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.
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.
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()
Before describing the detailed design, I'll run through some examples of potential applications for behaviors.
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
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)"
}
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
}
}
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
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
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]
}
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:
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(...)
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.
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.
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
).
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.
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.
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.
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 toself
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 alazy
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.
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.
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.
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.
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.
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.
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 violatinginout
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.
Alternatives to the proposed var [behavior] propertyName
syntax include:
- A different set of brackets,
var (behavior) propertyName
orvar {behavior} propertyName
. Parens have the problem of being ambiguous with a tuplevar
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)
orbehavior(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 likelazy(T)
is a type of some kind, which it really isn't. - Something following the property name, such as
var x«lazy»: T
orvar x¶lazy: T
(picking your favorite ASCII characters to replace«»¶
). One nice thing about this approach is that it suggestsself.x«lazy»
as a declaration-follows-use way of 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
orfoo.behavior(x)
. The problems with this are that reserving a keyword is undesirable, and thatbehavior
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»
orfoo.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 belazy
, it's attractive forfoo.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.
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.
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 let
s for the
same reason, so I suggest leaving let
s out of property behaviors for now.
let behavior
s could be added in the future when we have a comprehensive
design for immutable computed properties and/or functions.
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.