Skip to content

Instantly share code, notes, and snippets.

@jckarter
Created January 13, 2016 22:03
Show Gist options
  • Save jckarter/50b838e7f036fe85eaa3 to your computer and use it in GitHub Desktop.
Save jckarter/50b838e7f036fe85eaa3 to your computer and use it in GitHub Desktop.
Property behavior declarations

Property Behaviors

Introduction

There are property implementation patterns that come up repeatedly. Rather than hardcode a fixed set of patterns into the compiler, we should provide a general "property behavior" mechanism to allow these patterns to be defined as libraries.

Motivation

We've tried to accommodate several important patterns for properties with targeted language support, but this support has been narrow in scope and utility. For instance, Swift 1 and 2 provide lazy properties as a primitive language feature, since lazy initialization is common and is often necessary to avoid having properties be exposed as Optional. Without this language support, it takes a lot of boilerplate to get the same effect:

class Foo {
  // lazy var foo = 1738
  private var _foo: Int?
  var foo: Int {
    get {
      if let value = _foo { return value }
      let initialValue = 1738
      _foo = initialValue
      return initialValue
    }
    set {
      _foo = newValue
    }
  }
}

Building lazy into the language has several disadvantages. It makes the language and compiler more complex and less orthogonal. It's also inflexible; there are many variations on lazy initialization that make sense, but we wouldn't want to hardcode language support for all of them. For instance, some applications may want the lazy initialization to be synchronized, but lazy only provides single-threaded initialization. The standard implementation of lazy is also problematic for value types. A lazy getter must be mutating, which means it can't be accessed from an immutable value. Inline storage is also suboptimal for many memoization tasks, since the cache cannot be reused across copies of the value. A value-oriented memoized property implementation might look very different, using a class instance to store the cached value out-of-line in order to avoid mutation of the value itself. Lazy properties are also unable to surface any additional operations over a regular property, such as to reset a lazy property's storage to be recomputed again.

There are important property patterns outside of lazy initialization. It often makes sense to have "delayed", once-assignable-then-immutable properties to support multi-phase initialization:

class Foo {
  let immediatelyInitialized = "foo"
  var _initializedLater: String?

  // We want initializedLater to present like a non-optional 'let' to user code;
  // it can only be assigned once, and can't be accessed before being assigned.
  var initializedLater: String {
    get { return _initializedLater! }
    set {
      assert(_initializedLater == nil)
      _initializedLater = newValue
    }
  }
}

Implicitly-unwrapped optionals allow this in a pinch, but give up a lot of safety compared to a non-optional 'let'. Using IUO for multi-phase initialization gives up both immutability and nil-safety.

We also have other application-specific property features like didSet/willSet and array addressors that add language complexity for limited functionality. Beyond what we've baked into the language already, there's a seemingly endless set of common property behaviors, including resetting, synchronized access, and various kinds of proxying, all begging for language attention to eliminate their boilerplate.

Proposed solution

I suggest we allow for property behaviors to be implemented within the language. A var or let declaration can specify its behaviors in square brackets after the keyword:

var [lazy] foo = 1738

which implements the property foo in a way described by the property behavior declaration for lazy:

var behavior lazy<Value>: Value {
  var value: Value? = nil
  deferred initializer: Value
  get {
    if let value = value {
      return value
    }
    let initialValue = initializer
    value = initialValue
    return initialValue
  }
  set {
    value = newValue
  }
}

Property behaviors can control the storage, initialization, and access of affected properties, obviating the need for special language support for lazy, observers, addressors, and other special-case property features. Property behaviors can also provide additional operations on properties, such as clear-ing a lazy property, accessed with property.behavior syntax:

extension lazy {
  mutating func clear() {
    value = nil
  }
}

foo.lazy.clear()

Examples

Before describing the detailed design, I'll run through some examples of potential applications for behaviors.

Lazy

The current lazy property feature can be reimplemented as a property behavior.

// Property behaviors are declared using the `var behavior` keyword cluster.
public var behavior lazy<Value>: Value {
  // Behaviors can declare storage that backs the property.
  private var value: Value?

  // Behaviors can declare that properties using the behavior require
  // a `deferred initializer` expression. When deferred, the
  // initializer expression is assumed to be evaluated after
  // initialization of the containing value, which allows it to refer
  // to `self`. If declared, `initializer` is bound in accessors and
  // methods of the behavior.
  deferred initializer: Value

  // Behaviors can declare initialization logic for the storage.
  // (Stored properties can also be initialized in-line.)
  init() {
    value = nil
  }

  // Inline initializers should also be supported, so `var value: Value? = nil`
  // would work.

  // Behaviors can declare accessors that implement the property.
  get {
    if let value = value {
      return value
    }
    //
    value = initializer
  }
  set {
    value = newValue
  }

  // Behaviors can also declare methods to attach to the property.
  public mutating func clear() {
    value = nil
  }
}

Properties declared with the lazy behavior are backed by the Optional-typed storage and accessors from the behavior:

var [lazy] x = 1738 // Allocates an Int? behind the scenes, inited to nil
print(x) // Invokes the `lazy` getter, initializing the property
x = 679 // Invokes the `lazy` setter

Visible members of the behavior can also be accessed under property.behavior:

x.lazy.clear() // Invokes `lazy`'s `clear` method

Memoization

Variations of lazy can be implemented that are more appropriate for certain situations. For instance, here's a memoized behavior that stores the cached value indirectly, making it suitable for immutable value types:

public var behavior memoized<Value>: Value {
  public class MemoizationBox<Value> {
    var value: Value? = nil
    public func getOrEvaluate(fn: () -> Value) -> Value {
      if let value = value { return value }
      // Perform the initialization in a thread-safe way.
      // Implementation of 'sync' not shown here.
      return sync {
        let initialValue = fn()
        value = initialValue
        return initialValue
      }
    }
    func clear() {
      value = nil
    }
  }

  let storage: MemoizationBox<Value> = MemoizationBox()

  deferred initializer: Value

  get {
    return storage.getOrEvaluate { initializer }
  }

  public func clear() {
    storage.clear()
  }
}

which can then be used like this:

struct Location {
  let street, city, postalCode: String

  let [memoized] address = "\(street)\n\(city) \(postalCode)"
}

Delayed Initialization

A property behavior can model "delayed" initialization behavior, where the DI rules for properties are enforced dynamically rather than at compile time. This can avoid the need for implicitly-unwrapped optionals in multi-phase initialization use cases. We can implement both a mutable variant, which allows for reassignment like a var:

public var behavior delayedMutable<Value>: Value {
  private var value: Value? = nil

  get {
    guard let value = value else {
      fatalError("property accessed before being initialized")
    }
    return value
  }
  set {
    value = newValue
  }

  // Perform an explicit initialization, trapping if the
  // value is already initialized.
  public mutating func initialize(initialValue: Value) {
    if let _ = value {
      fatalError("property initialized twice")
    }
    value = initialValue
  }
}

and an immutable variant, which only allows a single initialization like a let:

public var behavior delayedImmutable<Value>: Value {
  private var value: Value? = nil

  get {
    guard let value = value else {
      fatalError("property accessed before being initialized")
    }
    return value
  }

  // Perform an explicit initialization, trapping if the
  // value is already initialized.
  public mutating func initialize(initialValue: Value) {
    if let _ = value {
      fatalError("property initialized twice")
    }
    value = initialValue
  }
}

This enables multi-phase initialization, like this:

class Foo {
  var [delayedImmutable] x: Int

  init() {
    // We don't know "x" yet, and we don't have to set it
  }

  func initializeX(x: Int) {
    self.x.delayedImmutable.initialize(x) // Will crash if 'self.x' is already initialized
  }

  func getX() -> Int {
    return x // Will crash if 'self.x' wasn't initialized
  }
}

Resettable properties

There's a common pattern in Cocoa where properties are used as optional customization points, but can be reset to nil to fall back to a non-public default value. In Swift, properties that follow this pattern currently must be imported as ImplicitlyUnwrappedOptional, even though the property can only be set to nil. If expressed as a behavior, the reset operation can be decoupled from the type, allowing the property to be exported as non-optional:

public var behavior resettable<Value>: Value {
  // A behavior can declare an *eager* initializer, which is available
  // during initialization of the property, but which cannot refer to
  // `self`.
  eager initializer: Value

  var value: Value = initializer

  get {
    return value
  }
  set {
    value = newValue
  }

  // Reset the property to its original initialized value.
  mutating func reset() {
    value = initializer
  }
}

For example:

var [resettable] foo: Int = 22
print(foo) // => 22
foo = 44
print(foo) // => 44
foo.resettable.reset()
print(foo) // => 22

Property Observers

A property behavior can also replicate the built-in behavior of didSet/willSet observers, by declaring support for custom accessors:

public var behavior observed<Value>: Value {
  // For composability, a behavior can declare a `base` property. The
  // base can be inherited from another behavior or an overridden
  // superclass property; if neither of those are provided, a fresh stored
  // property is generated in the containing scope.
  base var value: Value

  // A behavior can also declare accessors, the implementations of which
  // must be provided by property declarations using the behavior.
  // The behavior may provide a default implementation of
  // the accessors, in order to make them optional.

  // The willSet accessor, invoked before the property is updated. The
  // default does nothing.
  accessor willSet(newValue: Value) { }

  // The didSet accessor, invoked before the property is updated. The
  // default does nothing.
  accessor didSet(oldValue: Value) { }

  get {
    return value
  }

  set {
    willSet(newValue)
    let oldValue = value
    value = newValue
    didSet(oldValue)
  }
}

Because the observed behavior declares a base property, it can also be composed on top of other behaviors:

var [lazy, observed] observedLazy = expensiveExpression() {
  didSet { print("\(oldValue) => \(observedLazy)") }
}

or applied in subclasses that override base class properties, like didSet /willSet today:

class Foo {
  var foo: Int
}

class NoisyFoo: Foo {
  override var [observed] foo: Int {
    didSet { print("\(oldValue) => \(foo)") }
  }
}

A common complaint with didSet/willSet is that the observers fire on every write, not only ones that cause a real change. A behavior that supports a didChange accessor, which only gets invoked if the property value really changed to a value not equal to the old value, can be implemented as a new behavior:

public var behavior changeObserved<Value: Equatable>: Value {
  base var value: Value

  mutating accessor didChange(oldValue: Value) { }

  get {
    return value
  }
  set {
    let oldValue = value
    value = newValue
    if oldValue != newValue {
      didChange(oldValue)
    }
  }
}

For example:

var [changeObserved] x = 1 {
  didChange { print("\(oldValue) => \(x)") }
}

x = 1 // Prints nothing
x = 2 // Prints 1 => 2

Synchronized Property Access

Objective-C supports atomic properties, which take a lock on get and set to synchronize accesses to a property. This is occasionally useful, and it can be brought to Swift as a behavior. The real implementation of atomic properties in ObjC uses a global bank of locks, but for illustrative purposes (and to demonstrate referring to self) I'll use a per-object lock instead:

// A class that owns a mutex that can be used to synchronize access to its
// properties.
public protocol Synchronizable: class {
  func withLock<R>(@noescape body: () -> R) -> R
}

// Behaviors can refer to a property's containing type using
// `Self` (including to impose constraints on it).
public var behavior synchronized<Value where Self: Synchronizable>: Value {
  base var value: Value

  get {
    return self.withLock {
      return value
    }
  }
  set {
    self.withLock {
      value = newValue
    }
  }
}

For example:

// `Synchronizable` conformances not presented here
class SharedState: Synchronizable {
  var [synchronized] attributes: [String: Any] = [:]
}

class LazySharedState: Synchronizable {
  var [lazy, synchronized] attributes: [String: Any]
    = loadStateFromDisk()
}

class State: NSObject {
  var attributes: [String: Any]
}

class SynchronizedState: State, Synchronizable {
  override var [synchronized] attributes: [String: Any]
}

NSCopying

Many Cocoa classes implement value-like objects that require explicit copying. Swift currently provides an @NSCopying attribute for properties to give them behavior like Objective-C's @property(copy), invoking the copy method on new objects when the property is set. We can turn this into a behavior:

public var behavior copying<Value: NSCopying>: Value {
  base var value: Value

  get {
    return value
  }
  set {
    // Copy the value on reassignment.
    value = newValue.copy()
  }
}

This is a small sampling of the possibilities of behaviors. Let's look at the proposed design in detail:

Detailed design

Property behavior declarations

A property behavior declaration is introduced by the var behavior contextual keyword cluster, which declares the (possibly generic) type of properties supported by the behavior.

property-behavior-decl ::=
  attribute* decl-modifier*
  'var' 'behavior' identifier generic-param-list? ':' type '{'
    property-behavior-member-decl*
  '}'

Inside the behavior declaration, standard initializer, property, method, and nested type declarations are allowed, as are core accessor declarations —get and set. Additional contextual declarations are supported for initializer requirement declarations, base property declarations, and accessor requirement declarations:

property-behavior-member-decl ::= decl
property-behavior-member-decl ::= core-accessor-decl // get, set
property-behavior-member-decl ::= property-behavior-initializer-decl
property-behavior-member-decl ::= property-behavior-base-property-decl
property-behavior-member-decl ::= property-behavior-accessor-decl // accessor foo(...)

Bindings within Behavior Declarations

Definitions within behaviors can refer to other members of the behavior by unqualified lookup, or if disambiguation is necessary, by qualified lookup on the behavior:

var behavior foo: Int {
  var x: Int

  init() {
    x = 1738
  }

  mutating func update(x: Int) {
    foo.x = x // Disambiguate reference to behavior storage
  }
}

If the behavior includes an initializer requirement declaration, then initializer is bound as a computed get-only property that evaluates the property's initializer expression:

var behavior foo<Value>: Value {
  deferred initializer: Value

  get {
    return initializer
  }
}

If the behavior includes accessor requirement declarations, then the declared accessor names are bound as functions with labeled arguments:

var behavior fakeComputed<Value>: Value {
  accessor get() -> Value
  mutating accessor set(newValue: Value)

  get {
    return get()
  }
  set {
    set(newValue: newValue)
  }
}

Note that the behavior's own core accessor implementations are not referenceable this way.

Inside a behavior declaration, self is implicitly bound to the value that contains the property instantiated using this behavior. For a freestanding property at global or local scope, this will be the empty tuple (), and for a static or class property, this will be the metatype. Within the behavior declaration, the type of self is abstract and represented by the implicit generic type parameter Self. Constraints can be placed on Self in the generic signature of the behavior, to make protocol members available on self:

protocol Fungible {
  typealias Fungus
  func funge() -> Fungus
}

var behavior runcible<Value where Self: Fungible, Self.Fungus == Value>: Value {
  get {
    return self.funge()
  }
}

Lookup within self is not implicit within behaviors and must always be explicit, since unqualified lookup refers to the behavior's own members. self is immutable except in mutating methods, where it is considered an inout parameter unless the Self type has a class constraint. self cannot be accessed within inline initializers of the behavior's storage or in init declarations, since these may run during the container's own initialization phase.

Nested Types in Behaviors

Behavior declarations may nest type declarations as a namespacing mechanism. As with other type declarations, the nested type cannot reference members from its enclosing behavior.

Properties and Methods in Behaviors

Behaviors may include property and method declarations. Any storage produced by behavior properties is expanded into the containing scope of a property using the behavior.

var behavior runcible: Int {
  var x: Int = 0
  let y: String = ""
}
var [runcible] a: Int

// expands to:

var `a.runcible.x`: Int
let `a.runcible.y`: String
var a: Int { ... }

For public behaviors, this is inherently fragile, so adding or removing storage is a breaking change. Resilience can be achieved by using a resilient type as storage. The instantiated properties must also be of types that are visible to potential users of the behavior, meaning that public behaviors must use storage with types that are either public or internal-with-availability, similar to the restrictions on inlineable functions.

The properties and methods of the behavior are accessible from properties using the behavior, if they have sufficient visibility.

var behavior runcible: Int {
  private var x: Int = 0
  var y: String = ""

  func foo() {}
}

// In a different file...

var [runcible] a: Int
_ = a.runcible.x // Error, runcible.x is private
_ = a.runcible.y // OK
a.runcible.foo() // OK

Method and computed property implementations have only immutable access to self and their storage by default, unless they are mutating. (As with computed properties, setters are mutating by default unless explicitly marked nonmutating).

Base Property

A behavior may declare no more than one base property, which can be used to compose and override with behaviors. A base property is declared using the contextual base modifier on a var or let declaration:

property-behavior-base-property-decl ::=
  attribute* (decl-modifier | 'base')* core-property-decl

// core-property-decl refers to the existing property-decl syntax without
// `attribute* decl-modifier*` prefix

The base property must have the same type as the behavior. When a behavior declares a base property, a property using the behavior by itself instantiates the base property as a stored property, passing through any initialization:

var behavior hasBase: Int {
  base var value: Int
}

var [hasBase] x: Int = 0 // `x.hasBase.base` instantiated as stored property
var [hasBase] y: Int // `y.hasBase.base` instantiated as stored property
y = 0 // Late initialization of `y`

A property may use multiple behaviors, if all behaviors after the first use a base property. Each behavior sees the result of the previous behavior as its base.

var behavior root: Int { ... }

var [root, hasBase] z: Int
// Instantiates:
//   var [root] `z.hasBase.value`: Int
// as the base for:
//   var [hasBase] z: Int

If a subclass override-s a property, it may apply behaviors to the overridden property, which will be used as its base.

class Foo {
  var x: Int = 0
}
class Bar: Foo {
  override var [hasBase] x: Int // uses `super.x` as `x.hasBase.value`
}

Inside a behavior declaration, the base property declaration cannot have an inline initializer or be initialized in an init declaration, since the underlying base property may not be owned by the behavior instantiation. A base property declaration also may not appear with an initializer requirement declaration.

init in Behaviors

The storage of a behavior must be initialized, either by inline initialization, or by an init declaration within the initializer:

var behavior inlineInitialized: Int {
  var x: Int = 0 // initialized inline
  get { return x }
}

var behavior initInitialized: Int {
  var x: Int
  get { return x }

  init() {
    x = 0
  }
}

Behaviors can contain at most one init declaration, which must take no parameters. This init declaration cannot take a visibility modifier; it is always as visible as the behavior itself. Neither inline initializers nor init declaration bodies may reference self, since they will be executed during the initializing of a property's containing value. For the same reason, an init also may not refer to the base property, if any, nor may it invoke any accessor requirements or deferred initializer requirements.

Accessor Requirement Declarations

An accessor requirement declaration specifies that a behavior requires any property declared to use the behavior to provide an accessor implementation. An accessor requirement declaration is introduced by the contextual accessor keyword:

property-behavior-accessor-decl ::=
  attribute* decl-modifier*
  'accessor' identifier function-signature function-body?

An accessor requirement declaration looks like, and serves a similar role to, a function requirement declaration in a protocol. A property using the behavior must supply an implementation for each of its accessor requirements. The accessor names (with labeled arguments) are bound as functions within the behavior declaration:

// Reinvent computed properties
var behavior computed<Value>: Value {
  accessor get() -> Value
  mutating accessor set(newValue: Value)

  get { return get() }
  set { set(newValue: newValue) }
}

var [computed] foo: Int {
  get {
    return 0
  }
  set {
    // Parameter gets the name 'newValue' from the accessor requirement
    // by default, as with built-in accessors today.
    print(newValue)
  }
}

var [computed] bar: Int {
  get {
    return 0
  }
  set(myNewValue) {
    // Parameter name can be overridden as well
    print(myNewValue)
  }
}

Accessor requirements can be made optional by specifying a default implementation:

// Reinvent property observers
var behavior observed<Value>: Value {
  base var value: Value

  mutating accessor willSet(newValue: Value) {
    // do nothing by default
  }
  mutating accessor didSet(oldValue: Value) {
    // do nothing by default
  }

  get {
    return value
  }
  set {
    willSet(newValue: newValue)
    let oldValue = value
    value = newValue
    didSet(oldValue: oldValue)
  }
}

Accessor requirements cannot take visibility modifiers; they are always as visible as the behavior itself.

Like methods, accessors are not allowed to mutate the storage of the behavior or self unless declared mutating. Mutating accessors can only be invoked by the behavior from other mutating contexts.

Initializer Requirement Declarations

A behavior can require an inline initializer expression with an initializer requirement declaration, specified by the contextual initializer keyword:

property-behavior-initializer-decl ::=
  ('eager' | 'deferred') 'initializer' ':' type

The type of initializer must match the type of the behavior. The initializer requirement must be declared as eager or deferred, indicating where in the initialization process the initializer expression is used:

  • An eager initializer is used during the initialization of the behavior's state. An eager initializer can be used in the init implementation of a behavior, or in the inline initializer of one or more of its instantiated stored properties. A property using the behavior cannot refer to self within its initializer expression, as in a normal stored property.

    var behavior eagerInit: Int {
      eager initializer: Int
    
      var value: Int
    
      init() {
        value = initializer // Allowed for eager initializer
      }
    
      get {
        return value + initializer
      }
    }
    
    class Foo {
      var [eagerInit] foo: Int = self.bar // Not allowed
      var bar: Int
    }
  • A deferred initializer is used only after the initialization of the behavior's state. A deferred initializer cannot be referenced until the behavior's storage is initialized. A property using the behavior can refer to self within its initializer expression, as one would expect a lazy property to be able to.

    var behavior deferredInit: Int {
      eager initializer: Int
    
      var value: Int
    
      init() {
        value = initializer // Not allowed
      }
    
      get {
        return value + initializer
      }
    }
    
    class Foo {
      var [deferredInit] foo: Int = self.bar // OK
      var bar: Int
    }

When a behavior declares an initializer requirement, the name initializer is bound within the behavior declaration as a computed get-only property that evaluates the property's initializer expression. A property behavior with a base property member cannot require an initializer. A property behavior with an initializer requirement also cannot be applied to a property declaration with a destructuring pattern.

var [deferredInit] (a, b) = tuple // Error

Initializer requirements do not take visibility modifiers; they are always as visible as the behavior.

Core Accessor Declarations

The behavior implements the property by defining its core accessors, get and optionally set. If a behavior only provides a getter, it produces read-only properties; if it provides both a getter and setter, it produces mutable properties (though properties that instantiate the behavior may still control the visibility of their setters). It is an error if a behavior declaration does not provide at least a getter.

Using Behaviors in Property Declarations

Property declarations gain the ability to declare behaviors, with arbitrary accessors:

property-decl ::= attribute* decl-modifier* core-property-decl
core-property-decl ::=
  ('var' | 'let') behaviors? pattern-binding
  ((',' pattern-binding)+ | accessors)?
behaviors ::= '[' visibility? decl-ref (',' visibility? decl-ref)* ']'
pattern-binding ::= var-pattern (':' type)? inline-initializer?
inline-initializer ::= '=' expr
accessors ::= '{' accessor+ '}' | brace-stmt // see notes about disambiguation
accessor ::= decl-modifier* decl-ref accessor-args? brace-stmt
accessor-args ::= '(' identifier (',' identifier)* ')'

For example:

public var [behavior1, public behavior2] prop: Int {
  accessor1 { body() }
  behavior1.accessor2(arg) { body() }
  behavior2.accessor3 { body() }
}

If multiple properties are declared in the same declaration, the behaviors apply to every declared property. let properties cannot use behaviors in this proposal.

When a property is declared with behaviors, the behaviors are instantiated left-to-right, with each instantiated property used as the base property of the next behavior instantiation. It is an error if any of the behaviors after the first don't declare a base property, or if the behavior cannot be instantiated for the property's type or self type. If the behaviors require accessors, the implementations for those accessors are taken from the property's accessor declarations, matching by name. If two behaviors require accessors of the same name, the accessor definitions can disambiguate using qualified names. If an accessor requirement takes parameters, but the definition in for the property does not explicitly name parameters, the parameter labels from the behavior's accessor requirement declaration are implicitly bound by default.

var behavior foo: Int {
  accessor bar(arg: Int)
  get { return 0 }
}

var [foo] x: Int {
  bar { print(arg) } // `arg` implicitly bound
}

var [foo] x: Int {
  bar(myArg) { print(myArg) } // `arg` explicitly bound to `myArg`
}

If any accessor definition in the property does not match up to a behavior requirement, it is an error.

To preserve the shorthand for get-only computed properties, if the accessor declaration consists of code like a function body, that code is used as the implementation of a single accessor named get:

var [foo] x: Int {
  return 0
}

// is sugar for:

var [foo] x: Int {
  get {
    return 0
  }
}

The parser resolves the ambiguity between implicit getter and named accessor syntax by reading ahead for identifier ('.' identifier)* ('(' .* ')') '{', favoring parsing as a named accessor declaration if the lookahead succeeds.

If a property with behaviors declares an inline initializer, it behaves as follows:

  • If the first behavior has an initializer requirement, the initializer expression is bound as the initializer when instantiating the behavior. An initializer requirement in a behavior can only be satisfied if the declaration is not destructuring.
  • If the first behavior declares a base property, and the base property is not satisfied by override-ing a super property, then the initializer expression is used to initialize the storage of the default stored base property.
  • Otherwise, it is an error to provide an inline initializer.
var behavior noInitializer: Int {
  get { return 0 }
}

var behavior initializerReqt: Int {
  deferred initializer: Int
  get { return 0 }
}

var behavior baseProp: Int {
  base var value: Int
  get { return 0 }
}

var [baseProp] x = 0 // OK, base property initialized to 0
var [initializerReqt] y = 0 // OK, satisfies initializer requirement
var [initializerReqt] (a, b) = tuple // Error, behavior can't destructure
var [noInitializer] z = 0 // Error, behavior doesn't support initializer

If a behavior instantiates a default stored base property, it may be an error to leave out the initializer, if the property is not initialized before use.

Accessing Behavior Members on Properties

A behavior's properties and methods can be accessed on properties using the behavior under property.behavior:

var behavior foo: Int {
  var storage: Int = 0
  func method() { }
  get { return storage }
}

var [foo] x: Int
print(x.foo.storage)
x.foo.method()

To access a behavior member, code must have visibility of both the property's behavior, and the behavior's member. Behaviors are private by default, unless declared with a higher visibility. A behavior cannot be more visible than the property it applies to.

// foo.swift
var behavior foo: Int {
  private var storage: Int = 0
  func method() { }
  get { return storage }
}

// bar.swift
var [foo] bar: Int
var [internal foo] internalFoo: Int
var [public foo] publicFoo: Int // Error, behavior more visible than property

_ = bar.foo.storage // Error, `storage` is private to behavior
bar.foo.method() // OK

// bas.swift
bar.foo.method() // Error, `foo` behavior is private
internalFoo.foo.method() // OK

Methods, properties, and nested types within the behavior can be accessed. It is not allowed to access a behavior's init declaration, initializer or accessor requirements, or core accessors from outside the behavior declaration.

Extensions on Behaviors

Property behaviors should support extensions, to allow new methods or computed properties to be added, or to provide categorization:

var behavior foo: Int {
  base var value: Int
  get {
    return value
  }
}

extension foo {
  mutating func inc() {
    value += 1
  }
}

As with struct or enum extensions, behavior extensions cannot introduce additional storage. Since behaviors are not types, behavior extensions cannot declare protocol conformances. Behavior extensions can however constrain the generic parameters of the behavior, including Self, to make the members available only on a subset of property and container types.

Impact on existing code

By itself, this is (almost, see below) an additive feature that doesn't impact existing code. However, it potentially obsoletes lazy, willSet/didSet, and @NSCopying as hardcoded language features. We could grandfather these in, but my preference would be to phase them out by migrating them to library-based property behavior implementations. (Removing them should be its own separate proposal, though.)

One potentially breaking change is the disambiguation rule for implicit getter accessors. The rule favors parsing as a named accessor, which would break code that intends to define a getter whose first statement uses trailing closure syntax:

var foo: Int {
  doSomething { // parsed as named `doSomething` accessor
  }
  return 0
}

The potential impact is amplified if qualified accessor names with explicit arguments are considered:

var foo: Int {
  x.doSomething(arg) { // still parsed as named `x.doSomething` accessor
  }
  return 0
}

which strikes me as problematic. Ideas for a better disambiguation rule are welcome. An alternative to consider would be requiring non-core accessor implementations to be decorated with an accessor keyword, like the requirement declarations are in the behavior:

It's also worth exploring whether property behaviors could replace the "addressor" mechanism used by the standard library to implement Array efficiently. It'd be great if the language only needed to expose the core conservative access pattern (get/set/materializeForSet) and let all variations be implemented as library features. Note that superseding didSet/willSet and addressors completely would require being able to apply behaviors to subscripts in addition to properties, which seems like a reasonable generalization.

Alternatives considered

Using a protocol (formal or not) instead of a new declaration

A previous iteration of this proposal used an informal instantiation protocol for property behaviors, desugaring a behavior into function calls, so that:

var [lazy] foo = 1738

would act as sugar for something like this:

var `foo.lazy` = lazy(var: Int.self, initializer: { 1738 })
var foo: Int {
  get {
    return `foo.lazy`[varIn: self,
                      initializer: { 1738 }]
  }
  set {
    `foo.lazy`[varIn: self,
               initializer: { 1738 }] = newValue
  }
}

There are a few disadvantages to this approach:

  • Behaviors would pollute the namespace, potentially with multiple global functions and/or types.
  • In practice, it would require every behavior to be implemented using a new (usually generic) type, which introduces runtime overhead for the type's metadata structures.
  • The property behavior logic ends up less clear, being encoded in unspecialized language constructs.
  • Determining the capabilities of a behavior relied on function overload resolution, which can be fiddly, and would require a lot of special case diagnostic work to get good, property-oriented error messages out of.
  • Without severely complicating the informal protocol, it would be difficult to support eager vs. deferred initializers, or allowing mutating access to self concurrently with the property's own storage without violating inout aliasing rules. The code generation for standalone behavior decls can hide this complexity.

Making property behaviors a distinct declaration undeniably increases the language size, but the demand for something like behaviors is clearly there. In return for a new declaration, we get better namespacing, more efficient code generation, clearer, more descriptive code for their implementation, and more expressive power with better diagnostics. I argue that the complexity can pay for itself, today by eliminating several special-case language features, and potentially in the future by generalizing to other kinds of behaviors (or being subsumed by an all-encompassing macro system). For instance, a future func behavior could conceivably provide Python decorator-like behavior for transforming function bodies.

Declaration syntax

Alternatives to the proposed var [behavior] propertyName syntax include:

  • A different set of brackets, var (behavior) propertyName or var {behavior} propertyName. Parens have the problem of being ambiguous with a tuple var declaration, requiring lookahead to resolve. Square brackets also work better with other declarations behaviors could be extended to apply to in the future, such as subscripts or functions
  • An attribute, such as @behavior(lazy) or behavior(lazy) var. This is the most conservative answer, but is clunky.
  • Use the behavior function name directly as an attribute, so that e.g. @lazy works.
  • Use a new keyword, as in var x: T by behavior.
  • Something on the right side of the colon, such as var x: lazy(T). To me this reads like lazy(T) is a type of some kind, which it really isn't.
  • Something following the property name, such as var x«lazy»: T or var x¶lazy: T (picking your favorite ASCII characters to replace «»¶). One nice thing about this approach is that it suggests self.x«lazy» as a declaration-follows-use way of accessing the backing property.

Syntax for accessing the backing property

The proposal suggests x.behaviorName for accessing the underlying backing property of var (behaviorName) x. The main disadvantage of this is that it complicates name lookup, which must be aware of the behavior in order to resolve the name, and is potentially ambiguous, since the behavior name could of course also be the name of a member of the property's type. Some alternatives to consider:

  • Reserving a keyword and syntactic form to refer to the backing property, such as foo.x.behavior or foo.behavior(x). The problems with this are that reserving a keyword is undesirable, and that behavior is a vague term that requires more context for a reader to understand what's going on. If we support multiple behaviors on a property, it also doesn't provide a mechanism to distinguish between behaviors.
  • Something following the property name, such a foo.x«lazy» or foo.x¶lazy (choosing your favorite ASCII substitution for «»¶).
  • Doing member lookup in both the property's type and its behaviors (favoring the declared property when there are conflicts). If foo is known to be lazy, it's attractive for foo.clear() to Just Work without additional syntax. This has the usual ambiguity problems of overloading, of course; if the behavior's members are shadowed by the fronting type, something would be necessary to disambiguate.

Core accessors for property behavior implementation

The proposal as written specifies get and set as the only accessors that can be used to implement a property behavior. For efficiency, we may want to also allow the standard library to use addressors to implement standard behaviors, though it would be nice to design a fundamental core accessor model that subsumes addressors too. It'd also be a reasonable extension to allow a property behavior declaration to itself instantiate behaviors, and define itself in terms of those behaviors' accessors.

Behaviors for immutable let properties

A previous revision of this proposal allowed for behaviors to apply to let properties. Since we don't have an effects system (yet?), let behavior implementations have the potential to invalidate the immutability assumptions expected of let properties, and it would be the programmer's responsibility to maintain them. We don't support computed lets for the same reason, so I suggest leaving lets out of property behaviors for now. let behaviors could be added in the future when we have a comprehensive design for immutable computed properties and/or functions.

TODO

Resilience rules for behaviors: IMO, storage for behaviors should be fragile, since otherwise they require the equivalent of type metadata to abstract their layout across resilience boundaries. Other optimizations are possible depending on how much flexibility we want to allow in property behavior evolution. Some questions to answer include:

  • Can a behavior add new accessor requirements (with defaults)?
  • Can a behavior introduce references to self if it doesn't already use it?
  • Can a behavior freely change its private, non-stored-property members?
  • Can a behavior relax its generic constraints, on either the property type or on Self?

Some fundamental constraints:

  • A behavior can't resiliently add, take away, or change the eagerness of an initializer requirement.
  • A behavior can't change whether it has a base property.
  • A behavior can't remove accessor requirements.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment