5

New in iOS 15, we can form a Swift AttributedString like this:

var att = AttributedString("Howdy")
att.font = UIFont(name:"Arial-BoldMT", size:15)
att.foregroundColor = UIColor(red:0.251, green:0.000, blue:0.502, alpha:1)
print(att)

Cool, but there's another way. Instead of successive imperative property setting, we can make an attribute dictionary by way of an AttributeContainer, chaining modifier functions to the AttributeContainer to form the dictionary:

let att2 = AttributedString("Howdy",
    attributes: AttributeContainer()
        .font(UIFont(name:"Arial-BoldMT", size:15)!)
        .foregroundColor(UIColor(red:0.251, green:0.000, blue:0.502, alpha:1))
    )
print(att2)

(In real life I'd say .init() instead of AttributeContainer().)

So my question is, how does this work syntactically under the hood? We seem to have here a DSL where we can chain what look like function calls based on the names of the attribute keys. Behind the scenes, there seems to be some combination of dynamic member lookup, callAsFunction, and perhaps some sort of intermediate builder object. I can see that every callAsFunction call is returning the AttributeContainer, which is clearly how the chaining works. But just how would we write our own object that behaves syntactically the way AttributeContainer behaves?

matt
  • 515,959
  • 87
  • 875
  • 1,141
  • 2
    Just wondering why "In real life I'd say .init() instead of AttributeContainer()"? – aheze Jun 27 '21 at 21:31
  • 1
    @aheze It's the accepted style. But for purposes of the question I wanted to be clear about what this object is, as it may be new to many readers. – matt Jun 27 '21 at 21:38
  • 1
    I've made DSLs in the past similar to this. I can't verify this is what they're doing, but the way I did it is by making `.font` return a temporary `@dynamicCallable` struct. It stores the parent (AttributeContainer), and the key path ('\.font'). When it's called with `()`, the parameter passed is used to do `parent[keyPath: keyPath] = theParamValue; return parent`. Then the call to `foregroundColor` repeats the process. – Alexander Jun 27 '21 at 22:03
  • 1
    The problem is that this implementation approach is a leaky abstraction. You could call `init().font` and observe the intermediate helper struct – Alexander Jun 27 '21 at 22:04
  • @Alexander Nice move. I can see in fact that the builder generic is resolved to the particular attribute (`.font` in your example), thus dictating what sort of value it can be called with. – matt Jun 27 '21 at 22:23
  • 1
    @Alexander I know we're sort of just guessing what _they_ do, but I was careful not to ask that; I just want to know what I would do to make the language behave similarly. Thus it seems to me you could usefully enter an actual answer without treading into "opinion" territory. :) – matt Jun 27 '21 at 23:33

1 Answers1

2

I've made DSLs in the past similar to this.

I can't verify this is exactly what they're doing, but I can describe the way I achieved a similar DSL syntax.

My builder object would have methods like .font and .color return a temporary @dynamicCallable struct. These structs would store their parent build (by analogy, the AttributeContainer), and the keypath they were called originated from (\.font, \.color, etc.). (I don't remember if I used proper keypaths or strings. I can check later and get back to you.)

The implementation of callAsFunction would look something like:

func callAsFunction(_ someParam: SomeType) -> AttributeContainer {
    parent[keyPath: keyPath] = someParam
    return parent // for further chaining in the fluent interface.
}

Subsequent calls such as .foregroundColor would then repeat that same process.

Here's a bare-bones example:

@dynamicMemberLookup struct DictBuilder<Value> {
    struct Helper<Value> {
        let key: String
        var parent: DictBuilder<Value>
        
        func callAsFunction(_ value: Value) -> DictBuilder<Value> {
            var copy = parent
            copy.dict[key] = value
            return copy
        }
    }
    
    var dict = [String: Value]()
    
    subscript(dynamicMember key: String) -> Helper<Value> {
        return DictBuilder.Helper(key: key, parent: self)
    }
}

let dict = DictBuilder<Int>()
    .a(1)
    .b(2)
    .c(3)
    .dict
    
print(dict)

IIRC, you can some generic magic and keypaths (instead of strings) to return different type per keypath, whose callAsFunciton could require arguments of different type, which can be enforced at compile time.

You can use @dynamicCallable instead of @dynamicMemberLookup+callAsFunction, but I don't think worked with the trick I just mentioned.

Alexander
  • 59,041
  • 12
  • 98
  • 151
  • Yeah, cool-o-rama. Let me know if you can supply more actual code. Or I could try to work it out on my own I suppose. :) – matt Jun 28 '21 at 01:23
  • I edited in a quick and dirty example. I'm afraid I don't have access to the codebase that had the DSL I mentioned – Alexander Jun 28 '21 at 02:07