[Pitch #3] Property wrappers (formerly known as Property Delegates)

Hi all,

Based on feedback from the core team, I've revised the property delegates proposal again, albeit under a more descriptive name "property wrappers". The updated proposal is at

swift-evolution/0258-property-wrappers.md at property-wrappers · DougGregor/swift-evolution · GitHub

There's revision history at the bottom, which can be summed up as:

  • The name of the feature has been changed from "property delegates" to "property wrappers" to better communicate how they work and avoid the existing uses of the term "delegate" in the Apple developer community

  • When a property wrapper type has a no-parameter init(), properties that use that wrapper type will be implicitly initialized via init().

  • Support for property wrapper composition has been added, using a "nesting" model.

  • A property with a wrapper can no longer have an explicit get or set declared, to match with the behavior of existing, similar features (lazy, @NSCopying).

  • A property with a wrapper does not need to be final.

  • Reduced the visibility of the synthesized storage property to private, and expanded upon potential future directions to making it more visible.

  • Removed the restriction banning property wrappers from having names that match the regular expression _*[a-z].*.

  • Codable, Hashable, and Equatable synthesis are now based on the backing storage properties, which is a simpler model that gives more control to the authors of property wrapper types.

    Doug

EDIT: I've opted to pare back the access-control part of the feature. The synthesized storage property will be private by default, and anything else related to access control is in Future Directions.

47 Likes

Bravo! I can't wait for a feature like this to be added to swift. I followed the earlier proposal closely and this evolution of it looks great! big +1

I am having a hard time with the nesting model

@Lazy @Copying var path = UIBezierPath()

what about something like

@(Lazy, Copying) or

@combo(Lazy, Copying)

2 Likes

Cannot wait to use this in Swift, huge thanks!

I was already in love with the property behavior from the beginning and having the implementation for that feature already available was just awesome. So just for fun I created my own propertyWrapper which verifies that you are accessing a particular instance variable on a specific queue, and I have a question related to this example at the bottom:

@propertyDelegate
struct AssertOnQueue<Value> {
    private var _value: Value
    private var _expectedQueue: DispatchQueue

    init(initialValue: Value, queue: DispatchQueue) {
        _value = initialValue
        _expectedQueue = queue
    }

    var value: Value {
        get {
            __dispatch_assert_queue(_expectedQueue)
            return _value
        }

        set {
            __dispatch_assert_queue_barrier(_expectedQueue)
            _value = newValue
        }
    }
}

Now you can assert when trying to use a property on the wrong queue, it even checks the barrier when you modify the variable! Here's how you use it.

class Blah {
    let internalQueue: DispatchQueue
    @AssertOnQueue private var strings: Set<String>

    init() {
        let queue = DispatchQueue(label: "InternalQueue", attributes: .concurrent)
        internalQueue = queue
        $strings = AssertOnQueue(initialValue: Set<String>(), queue: queue)
    }

    func addOnQueue(_ string: String) {
        internalQueue.async {
            self.strings.insert(string)
        }
    }

    func addOnQueueBarrier(_ string: String) {
        internalQueue.async(flags: .barrier) {
            self.strings.insert(string)
        }
    }

    func containsOnQueue(_ string: String) -> Bool {
        var contains = false
        internalQueue.sync {
            contains = self.strings.contains(string)
        }

        return contains
    }

    func addOffQueue(_ string: String) {
        strings.insert(string)
    }

    func containsOffQueue(_ string: String) -> Bool {
        return strings.contains(string)
    }
}

let blah = Blah()

print("contains hello: \(blah.containsOnQueue("hello"))")
blah.addOnQueueBarrier("hello")
print("contains hello: \(blah.containsOnQueue("hello"))")

// Crashes because this is not a barrier
blah.addOnQueue("Bonjour")

// Crashes because this is not on the queue
print("contains hello: \(blah.containsOffQueue("hello"))")

Given the proposed revisions, is there a way to improve the initialization of a @AssertOnQueue variable? Namely, I don't want to have a local variable to store the queue. The alternative would probably be to group all the ivars that are to be accessed on that queue in a nested objects instead.

1 Like

Keep in mind that these are very simple examples, you can have more complex property wrapper types with more than one generic type parameters which would require explicit types. I think because of that your alternatives would rather create more visual noise.

1 Like

I followed much of the earlier pitch threads, and I like the way this has shaped up. I really look forward to having the reference enclosing self issue figured out in the (near?) future.

4 Likes

So far I love the proposal. As it‘s mentioned in the proposal lazy as a keyword will stay. Can we maybe extend it like access modifier to lazy(wrapper) so we can get self into the property delegates when needed?!

This is a little hand wavey, Do you have examples to demonstrate your point?

@propertyWrapper
struct B<Value> {
  var value: Value
}

@propertyWrapper
struct C<T, R> {
  var value: T 
}

@propertyWrapper
struct IntDelegate {
  var value: Int
}

// Backwards order
@C<B<IntDelegate>, String> // requires explicit types because of second generic parameter
@B // `<IntDelegate>` is inferred from next property delegate attribute
@IntDelegate 
var foo: Int

// results to
var $foo: C<B<IntDelegate>, String>
var foo: Int {
  get { return $foo.value.value.value }
  set { $foo.value.value.value = newValue }
}

You basically suggest to do:

@(
  C<B<IntDelegate>, String>, 
  B,
  IntDelegate
)

@combo(
  C<B<IntDelegate>, String>, 
  B,
  IntDelegate
)

Why would I want this over this?

@C<B<IntDelegate>, String>
@B
@IntDelegate 

Property wrapper types are not #Name#<Value>, they can be non-generic, or a generic type with arbitrary number of generic types where one of them may or may not refer to value's type.

1 Like

Thanks for the examples.
I am not tied to any specific syntax but the proposed nesting to me feels unintuitive.

@C<B<IntDelegate>, String>

Why isnt the above sufficient with out having to be followed by @B and @IntDelegate ?

I discussed this issue with @Douglas_Gregor in the previous thread. The current design would not allow this (as far as I understood) but we could potentially make this possible if it becomes something that the majority of people would want to have. You can read the discussion here:

The main issue, is that the type of the main property which is baked by the wrapper is deeply nested inside the new composed wrapper type. The compiler must be extended to know how to 'unpack' deeply nested wrapper types to find a value which would have the same type as the property you wrap.

On that note,
I would rather see

@Lazy<Copying> be equal to

@Lazy<Copying> @Copying

My brain just won’t let me parse the below as Russian doll nesting.
@Lazy @Copying

I'm a little confused here:

@Lazy @Copying var object: Object

This is the same as:

@Lazy<Copying<Object>> 
@Copying<Object> 
var object: Object

In the end every property will have at most one property wrapper. In this particular case the wrapper will have type Lazy<Copying<Object>> .

edit: this was intended to be a reply to @Douglas_Gregor

I'm trying out the toolchain linked in the pitch, and I'm having trouble. It seems that accessing the synthesized wrapper property outside of the type causes the compiler to crash.

@propertyDelegate
struct Test<V> {
    var value: V

    init(initialValue: V) {
        value = initialValue
    }

    func test() {}
}

class C {
    @Test var a = 0
    @Test var b = ""

    init() {
        $a.test()     // <--- This one is fine
    }
}

let c = C()
c.$a.test()     // <--- Seg fault: 11
Stack dump:
0.	Program arguments: /Users/avi/Library/Developer/Toolchains/swift-PR-23701-300.xctoolchain/usr/bin/swift -frontend -c /Users/avi/Projects/CmdTest/CmdTest/Observable.swift -primary-file /Users/avi/Projects/CmdTest/CmdTest/main.swift /Users/avi/Projects/CmdTest/CmdTest/Observables.swift -emit-module-path /Users/avi/Library/Developer/Xcode/DerivedData/CmdTest-aaomfvgeclatyvbhhwovvxdgladw/Build/Intermediates.noindex/CmdTest.build/Debug/CmdTest.build/Objects-normal/x86_64/main~partial.swiftmodule -emit-module-doc-path /Users/avi/Library/Developer/Xcode/DerivedData/CmdTest-aaomfvgeclatyvbhhwovvxdgladw/Build/Intermediates.noindex/CmdTest.build/Debug/CmdTest.build/Objects-normal/x86_64/main~partial.swiftdoc -serialize-diagnostics-path /Users/avi/Library/Developer/Xcode/DerivedData/CmdTest-aaomfvgeclatyvbhhwovvxdgladw/Build/Intermediates.noindex/CmdTest.build/Debug/CmdTest.build/Objects-normal/x86_64/main.dia -emit-dependencies-path /Users/avi/Library/Developer/Xcode/DerivedData/CmdTest-aaomfvgeclatyvbhhwovvxdgladw/Build/Intermediates.noindex/CmdTest.build/Debug/CmdTest.build/Objects-normal/x86_64/main.d -emit-reference-dependencies-path /Users/avi/Library/Developer/Xcode/DerivedData/CmdTest-aaomfvgeclatyvbhhwovvxdgladw/Build/Intermediates.noindex/CmdTest.build/Debug/CmdTest.build/Objects-normal/x86_64/main.swiftdeps -target x86_64-apple-macosx10.14 -enable-objc-interop -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.14.sdk -I /Users/avi/Library/Developer/Xcode/DerivedData/CmdTest-aaomfvgeclatyvbhhwovvxdgladw/Build/Products/Debug -F /Users/avi/Library/Developer/Xcode/DerivedData/CmdTest-aaomfvgeclatyvbhhwovvxdgladw/Build/Products/Debug -enable-testing -g -module-cache-path /Users/avi/Library/Developer/Xcode/DerivedData/ModuleCache.noindex -swift-version 5 -enforce-exclusivity=checked -Onone -D DEBUG -serialize-debugging-options -enable-anonymous-context-mangled-names -Xcc -I/Users/avi/Library/Developer/Xcode/DerivedData/CmdTest-aaomfvgeclatyvbhhwovvxdgladw/Build/Intermediates.noindex/CmdTest.build/Debug/CmdTest.build/swift-overrides.hmap -Xcc -iquote -Xcc /Users/avi/Library/Developer/Xcode/DerivedData/CmdTest-aaomfvgeclatyvbhhwovvxdgladw/Build/Intermediates.noindex/CmdTest.build/Debug/CmdTest.build/CmdTest-generated-files.hmap -Xcc -I/Users/avi/Library/Developer/Xcode/DerivedData/CmdTest-aaomfvgeclatyvbhhwovvxdgladw/Build/Intermediates.noindex/CmdTest.build/Debug/CmdTest.build/CmdTest-own-target-headers.hmap -Xcc -I/Users/avi/Library/Developer/Xcode/DerivedData/CmdTest-aaomfvgeclatyvbhhwovvxdgladw/Build/Intermediates.noindex/CmdTest.build/Debug/CmdTest.build/CmdTest-all-target-headers.hmap -Xcc -iquote -Xcc /Users/avi/Library/Developer/Xcode/DerivedData/CmdTest-aaomfvgeclatyvbhhwovvxdgladw/Build/Intermediates.noindex/CmdTest.build/Debug/CmdTest.build/CmdTest-project-headers.hmap -Xcc -I/Users/avi/Library/Developer/Xcode/DerivedData/CmdTest-aaomfvgeclatyvbhhwovvxdgladw/Build/Products/Debug/include -Xcc -I/Users/avi/Library/Developer/Xcode/DerivedData/CmdTest-aaomfvgeclatyvbhhwovvxdgladw/Build/Intermediates.noindex/CmdTest.build/Debug/CmdTest.build/DerivedSources-normal/x86_64 -Xcc -I/Users/avi/Library/Developer/Xcode/DerivedData/CmdTest-aaomfvgeclatyvbhhwovvxdgladw/Build/Intermediates.noindex/CmdTest.build/Debug/CmdTest.build/DerivedSources/x86_64 -Xcc -I/Users/avi/Library/Developer/Xcode/DerivedData/CmdTest-aaomfvgeclatyvbhhwovvxdgladw/Build/Intermediates.noindex/CmdTest.build/Debug/CmdTest.build/DerivedSources -Xcc -DDEBUG=1 -Xcc -working-directory/Users/avi/Projects/CmdTest -module-name CmdTest -o /Users/avi/Library/Developer/Xcode/DerivedData/CmdTest-aaomfvgeclatyvbhhwovvxdgladw/Build/Intermediates.noindex/CmdTest.build/Debug/CmdTest.build/Objects-normal/x86_64/main.o -index-store-path /Users/avi/Library/Developer/Xcode/DerivedData/CmdTest-aaomfvgeclatyvbhhwovvxdgladw/Index/DataStore -index-system-modules 
0  swift                    0x000000010b9cc7b5 llvm::sys::PrintStackTrace(llvm::raw_ostream&) + 37
1  swift                    0x000000010b9cba75 llvm::sys::RunSignalHandlers() + 85
2  swift                    0x000000010b9ccd98 SignalHandler(int) + 264
3  libsystem_platform.dylib 0x00007fff65d99b5d _sigtramp + 29
4  libsystem_platform.dylib 000000000000000000 _sigtramp + 2586207424
5  swift                    0x00000001082ede3a SILGenLValue::visitMemberRefExpr(swift::MemberRefExpr*, swift::Lowering::SGFAccessKind, swift::Lowering::LValueOptions) + 890
6  swift                    0x00000001082e9a81 swift::ASTVisitor<SILGenLValue, swift::Lowering::LValue, void, void, void, void, void, swift::Lowering::SGFAccessKind, swift::Lowering::LValueOptions>::visit(swift::Expr*, swift::Lowering::SGFAccessKind, swift::Lowering::LValueOptions) + 305
7  swift                    0x00000001082e97e7 swift::Lowering::SILGenFunction::emitLValue(swift::Expr*, swift::Lowering::SGFAccessKind, swift::Lowering::LValueOptions) + 55
8  swift                    0x00000001082c977c swift::ASTVisitor<(anonymous namespace)::RValueEmitter, swift::Lowering::RValue, void, void, void, void, void, swift::Lowering::SGFContext>::visit(swift::Expr*, swift::Lowering::SGFContext) + 8092
9  swift                    0x00000001082bc6d9 swift::Lowering::SILGenFunction::emitRValueAsSingleValue(swift::Expr*, swift::Lowering::SGFContext) + 57
10 swift                    0x0000000108272ece (anonymous namespace)::ArgEmitter::emit(swift::Lowering::ArgumentSource&&, swift::Lowering::AbstractionPattern) + 1838
11 swift                    0x0000000108260ac6 (anonymous namespace)::ArgEmitter::emitSingleArg(swift::Lowering::ArgumentSource&&, swift::Lowering::AbstractionPattern) + 70
12 swift                    0x000000010827df43 (anonymous namespace)::ArgEmitter::emitPreparedArgs(swift::Lowering::PreparedArguments&&, swift::Lowering::AbstractionPattern) + 163
13 swift                    0x000000010827dc5a (anonymous namespace)::CallSite::emit(swift::Lowering::SILGenFunction&, swift::Lowering::AbstractionPattern, swift::CanTypeWrapper<swift::SILFunctionType>, (anonymous namespace)::ParamLowering&, llvm::SmallVectorImpl<swift::Lowering::ManagedValue>&, llvm::SmallVectorImpl<(anonymous namespace)::DelayedArgument>&, llvm::Optional<swift::ForeignErrorConvention> const&, swift::ImportAsMemberStatus) && + 730
14 swift                    0x000000010827d46b (anonymous namespace)::CallEmission::emitArgumentsForNormalApply(swift::CanTypeWrapper<swift::FunctionType>&, swift::Lowering::AbstractionPattern&, swift::CanTypeWrapper<swift::SILFunctionType>, llvm::Optional<swift::ForeignErrorConvention> const&, swift::ImportAsMemberStatus, llvm::SmallVectorImpl<swift::Lowering::ManagedValue>&, llvm::Optional<swift::SILLocation>&, swift::CanTypeWrapper<swift::FunctionType>&) + 1723
15 swift                    0x0000000108263d74 (anonymous namespace)::CallEmission::apply(swift::Lowering::SGFContext) + 2980
16 swift                    0x0000000108263080 swift::Lowering::SILGenFunction::emitApplyExpr(swift::ApplyExpr*, swift::Lowering::SGFContext) + 2464
17 swift                    0x00000001082c7836 swift::ASTVisitor<(anonymous namespace)::RValueEmitter, swift::Lowering::RValue, void, void, void, void, void, swift::Lowering::SGFContext>::visit(swift::Expr*, swift::Lowering::SGFContext) + 86
18 swift                    0x00000001082bcc01 swift::Lowering::SILGenFunction::emitIgnoredExpr(swift::Expr*) + 1137
19 swift                    0x0000000108257684 swift::Lowering::SILGenModule::visitTopLevelCodeDecl(swift::TopLevelCodeDecl*) + 564
20 swift                    0x0000000108257ed6 swift::Lowering::SILGenModule::emitSourceFile(swift::SourceFile*) + 822
21 swift                    0x0000000108258e85 swift::SILModule::constructSIL(swift::ModuleDecl*, swift::SILOptions&, swift::FileUnit*) + 293
22 swift                    0x00000001082593f9 swift::performSILGeneration(swift::FileUnit&, swift::SILOptions&) + 41
23 swift                    0x0000000107f59cbe performCompile(swift::CompilerInstance&, swift::CompilerInvocation&, llvm::ArrayRef<char const*>, int&, swift::FrontendObserver*, swift::UnifiedStatsReporter*) + 8398
24 swift                    0x0000000107f56b92 swift::performFrontend(llvm::ArrayRef<char const*>, char const*, void*, swift::FrontendObserver*) + 2978
25 swift                    0x0000000107efd918 main + 696
26 libdyld.dylib            0x00007fff65bae3d5 start + 1
27 libdyld.dylib            0x0000000000000047 start + 2588220531
error: Segmentation fault: 11
1 Like

Oh I see. Makes sense.
What I was trying to say is that I think the best vehicle for nesting is the same syntax we already use for generics.

@Lazy<Copying<Object>>

That’s the kind of nesting I can get onboard with. Can’t get more explicit than that though it would be nice if the inner most type could be inferred.

1 Like

The latest draft is looking great! If this was up for review I would enthusiastically support it.

There is one place where I think the proposal could be stronger in response to the core team's feedback regarding delegateValue (now called wrapperValue:

  • The Core Team feels that the proposal fails to justify delegateValue well enough for it to be evaluated. This feature was developed late in the pitch phase and added to the proposal without much discussion, and it adds a substantial amount of conceptual complexity. The proposal should explain the purpose of this feature well enough that the community (and Core Team) can meaningfully evaluate the proposed approach and consider alternatives to it.

This section of the proposal was beefed up with a more fleshed out example. However, the proposal also includes:

@propertyWrapper
struct UnsafeMutablePointer<Pointee> {
  var pointee: Pointee { ... }
  
  var value: Pointee {
    get { return pointee }
    set { pointee = newValue }
  }
}

With that wrapper in hand, an alternative to the LongTermStorage wrapper would be:

extension UnsafeMutablePointer {
  init(manager: StorageManager, initialValue: Pointee) {
    self = manager.allocate(Pointee.self)
    self.initialize(to: initialValue)
  }
}

let manager = StorageManager(...)

@UnsafeMutablePointer(manager: manager, initialValue: "Hello")
var someValue: String

I still think it would be nice to see the motivation for wrapperValue stated more directly. If the designs that rely on wrapperValue are sufficiently superior to the alternatives that do not require wrapperValue to motivate an extra layer of indirection, why is that the case? How are the tradeoffs being evaluated? Are there any use cases that do not have available alternatives to using wrapperValue?

I think wrapperValue is interesting and could be useful. I'm not trying to argue against it. If the core team is confident it is right, I trust their decision. But the motivation still feels a bit abstract ("you can hide the wrapper") which makes the design hard to evaluate - both in terms of whether the incremental complexity is warranted as well as whether we might come across use cases that would be better served by a different design. If the proposal can be strengthened in this area before going up for review again I think that would be good. I want to see it accepted in the next round of review!

5 Likes

@Douglas_Gregor is this restriction still necessary, or do you just want it to be lifted in the future and not in the current implementation?

  • A property with a wrapper that is declared within a class must be final and cannot override another property.

At least the first part is unnecessary I'd say.

@propertyWrapper
struct Wrapper<Value> {
  var value: Value
} 

class A {
  @Wrapper var b: Int
  var c: Int
  ...
}

class B: A {
  override var b: Int {
    didSet { /* this should be fine */ }
  }

  @Wrapper override var c: Int // this should be an error
}

I attempted to play around with wrapperValue, and nothing I tried would compile, or if it compiled, did not access wrapperValue. Could you provide a complete example and perhaps a more elaborate explanation of the feature?

Try to think of an example where you would use SE-0252 (Key Path Member Lookup). wrapperValue is very similar to that. In fact the proposal uses the same example types, because Lens is Ref, while Box is like Ref but with a little bit more magic though wrapperValue. In other words if wrapperValue is present on your property wrapper type, you can no longer access the wrapper type directly as it will be shadowed and re-routed to the wrapperValue and its type.