@dynamicMemberLookup via another @dynamicMemberLookup

On the example of AttributedString type it is not very clear how the dynamicMemberLookup chaining works from

@dynamicMemberLookup
struct AttributedString {
    subscript<K>(dynamicMember keyPath: Swift.KeyPath<AttributeDynamicLookup, K>) -> K.Value? where K: AttributedStringKey {
 // ...
    }
}

through

public extension AttributeDynamicLookup {
    subscript<T: AttributedStringKey>(dynamicMember keyPath: KeyPath<AttributeScopes.FoundationAttributes, T>) -> T {
        // ... <--- Not called!!!
    }
}

What's not clear here is that the second subscript is not called at all. How does it work?

var attr = AttributedString()
_ = attr.foo

// ...

extension AttributeDynamicLookup {
    subscript<T>(dynamicMember keyPath: KeyPath<AttributeScopes.MyAttributes, T>) -> T where T: AttributedStringKey {
        self[T.self] // <--- Not called!!!
    }
}

extension AttributeScopes {
    var my: AttributeScope.Type {
        MyAttributes.self
    }
    struct MyAttributes: AttributeScope {
        let foo: FooAttribute
    }
}

enum FooAttribute: AttributedStringKey {
    typealias Value = Int
    static let name = "MyAttribute"
}

// ...

@dynamicMemberLookup
struct AttributedString {
    subscript<K>(dynamicMember keyPath: Swift.KeyPath<AttributeDynamicLookup, K>) -> K.Value? where K: AttributedStringKey {
        self[K.self]
    }
    subscript<K>(_: K.Type) -> K.Value? where K: AttributedStringKey {
        // ...
    }
}

@dynamicMemberLookup
enum AttributeDynamicLookup {
    subscript<T>(_: T.Type) -> T where T: AttributedStringKey {
        fatalError()
    }
}

public enum AttributeScopes {
}

protocol AttributeScope {
}

public protocol AttributedStringKey {
    associatedtype Value: Hashable
    static var name: String { get }
}

I'm also trying to figure out how AttributedStringKeys work.

You can see the internal implementation of AttributeDynamicLookup here:

@dynamicMemberLookup @frozen
@available(macOS 12, iOS 15, tvOS 15, watchOS 8, *)
public enum AttributeDynamicLookup {
    public subscript<T: AttributedStringKey>(_: T.Type) -> T {
        get { fatalError("Called outside of a dynamicMemberLookup subscript overload") }
    }
}

You're correct that self[T.self] is never actually called. As shown above, if it were, it would crash at runtime.

Digging further into the implementation, it seems that under all the abstraction, AttributedStringKeys and their values are stored in a dictionary, keyed by the name property defined on each key:

https://github.com/swiftlang/swift-foundation/blob/bfc4580760814fe5f883c6694e07497cc8a26d9c/Sources/FoundationEssentials/AttributedString/AttributedStringAttributeStorage.swift#L100

As far as I can tell, the @dynamicMemberLookup and AttributeScope abstractions don't actually perform any logic themselves. Their only purpose seems to be creating a convenient DSL to resolve a concrete AttributedStringKey type via a KeyPath<Root, Value> (where Value is the AttributedStringKey). That type is then used to obtain the static name for storage and to cast the value to the correct type when retrieving it from the dictionary.

The purpose of the extensions on AttributeDynamicLookup is to provide additional entry points to the Root of the KeyPath that can be reused by AttributedString and the other types like AttributedSubstring, AttributeContainer, just by adding:

subscript<K>(dynamicMember keyPath: Swift.KeyPath<AttributeDynamicLookup, K>) -> K.Value? where K: AttributedStringKey {
   // ...
}
1 Like

This is how I understand the technique used by AttributedString.

First of all, @dynamicMemberLookup can be chained. This is a simple demo:

@dynamicMemberLookup
struct A {
    subscript<T>(dynamicMember keyPath: KeyPath<B, T>) -> String {
        fatalError()
    }
}

@dynamicMemberLookup
struct B {
    subscript<T>(dynamicMember keyPath: KeyPath<C, T>) -> Double {
        fatalError()
    }
}

enum C {
    var property: Int { fatalError() }
}

_ = A().property // of type String

As shown in the above code, when the compiler tries to type check the property access on A, it will find the subscript defined on A, it then will search if there is a valid \B.property, then further, it will find the subscript defined on B, and so on.

Note that the static information in the signatures alone is enough, that's why leaving all implementations as fatalErrors does not stop the code above from compiling. Also note that for a single @dynamicMemberLookup type, the return type of its subscript does not need to be the same with the Value part of its KeyPath parameter.

Building upon this, we just need a protocol to decouple our core logic for the users to customize, and use an associatedtype to retrieve the crucial type information we need, like this:

protocol Entry {
    associatedtype Value
}

@dynamicMemberLookup
struct A {
    subscript<T: Entry>(dynamicMember keyPath: KeyPath<B, T>) -> T.Value {
        // yay! everything we need is contained in T.self
    }
}

@dynamicMemberLookup
struct B {
    subscript<T: Entry>(dynamicMember keyPath: KeyPath<C, T>) -> T {
        // implementation not relevant
        fatalError()
    }
}

struct MyEntry: Entry {
    typealias Value = Int
}

enum C {
    var property: MyEntry { /* not relevant */ }
}

_ = A().property // of type MyEntry.Value, aka Int

The only constraint here is that the the T of any instance of KeyPath<B, T> (e.g: the type of B.property) needs to satisfy T: Entry - this is required by A's subscript. That's why the return type of B's subscript has its form above.

Now, just dance in the body of A's subscript.

2 Likes