Implicit Fluent Chaining

Pitch idea: Implicit Fluent Chaining

Motivation

Builder-style configuration is common in Swift but requires mechanical boilerplate for every settable property:

// value types:
extension S {
    consuming func foo(_ v: Int) -> Self {
        var copy = self; copy.foo = v; return copy
    }
}

// reference types:
extension C {
    // reference types can use the value-type form above, or this simpler version:
    func foo(_ v: Int) -> Self {
        self.foo = v; return self
    }
}

This scales linearly with property count — each setter is a real function declaration that emits a symbol in the binary (notably in debug builds, where the compiler can't strip or inline it away) — and the pattern is repeated across SwiftUI-style APIs, configuration objects, and DSLs.

Note: this pitch addresses cases where a memberwise initializer (auto-synthesized or manual) is not available or not suitable — e.g., types with many optional/defaulted or computed properties, reference types (which don't get memberwise inits), types from modules you don't own, or APIs where partial/incremental configuration is the point (à la SwiftUI modifiers). When S(foo: 1, bar: 2) works and reads well, it remains the right tool.

Proposed Solution

For any settable property foo: T, allow instance.foo(value) at the call site to mean "produce an instance with foo set to value" (as if the above boilerplate were present, although not necessarily by having the associated function stubs present in the resulting binary):

let s: S = S().foo(1).bar(2)
let c: C = C().foo(1).bar(2)

The compiler picks semantics based on the type:

  • Value types: consuming copy-and-mutate, returning a copy.
  • Reference types: in-place mutation, returning self.

Nothing is added to the type's declared API surface; the call syntax is enabled directly by the settable property.

Because the synthesized form is pure call-site sugar — s.foo(1).bar(2) lowers directly to the equivalent sequence of assignments — no foo(_:) or bar(_:) function symbol is emitted in the binary at any optimization level. This is an actual difference from the hand-written version, not just an aesthetic one.

Compatibility

If a method foo(_:) callable with the given argument is visible (on the type, an extension, or a conformed protocol), the user-declared method wins unconditionally. The synthesized form only activates when no such method exists.

Alternatives considered

Alternative 1. Do not have this feature

The user site is not too bad:

var s: S = S(); s.foo = 1; s.bar = 2
// tolerate the rest of the scope seeing this variable as "var"

For reference types it's even simpler:

let c: C = C()
c.foo = 1
c.bar = 2

Note that this approach has these drawbacks:

  • It will typically not be written on a single line (e.g. due to user coding style guides discouraging semicolons), hence it usually takes more vertical space in source.
  • In the case of value types, users will have to tolerate the binding being var instead of let, which is undesirable (because it weakens local reasoning about immutability, signals to readers that the value may be mutated later when it won't be, and prevents the compiler from enforcing that the value stays unchanged after construction).
  • It's not DRY — the variable name is repeated on every line.
  • For more complex cases an extra variable will have to be introduced just to hold the intermediate value, polluting the surrounding scope and forcing the reader to track a name that exists only for setup purposes:
users[id].properties[1] = C()
users[id].properties[1].foo = 1
users[id].properties[1].bar = 2

let c = C()
c.foo = 1
c.bar = 2
users[id].properties[1] = c
  • As in other alternatives, the use site is heavier and not quite as nice as in the proposal.

Alternative 2. Use Kotlin-inspired apply function

func apply<T>(_ v: T, _ execute: (T) -> T) -> T {
    execute(v)
}

Usage:

// value types:
let s = apply(S()) {
    var copy = $0
    copy.foo = 1
    copy.bar = 2
    return copy
}

// reference types
let c = apply(C()) {
    $0.foo = 1
    $0.bar = 2
    return $0
}
  • The use site is heavier and not quite as nice here as in the proposal.

Alternative 3. Use an ad hoc closure

let s: S = {
    var s = S()
    s.foo = 1
    s.bar = 2
    return s
}()
  • The use site is heavier and not quite as nice here as in the proposal.

See Also

The idea of this pitch was inspired by the discussion in Calendar init PR, although it by no means replaces that PR, for which the conventional "init" approach seems ideal.

We now live in a post-macro world. Why not investigate that first? (ie: @ChainedMembers, @Fluent)

Edit: I understand the point about the functions not being in the binary. I just think syntax sugar like this should be of the past when a macro is a suitable solution.

1 Like

I see we're circling back to probably the second-most discussed topic in Swift Evolution history--perhaps we'll be able to boost the number of comments beyond that for implicit last return this time? :wink:

There are sure to be many more relevant threads, but it gets harder to dig up as we go further back; as early as 2016, it was already said:

2 Likes

SwiftUI is not an example of this chaining style; you aren't setting properties by chaining view modifiers together, aside from the rare exception like Text. They're essentially equivalent to nested initializers.

VStack {
  Text("text")
}
.onTapGesture {
  print("tapped")
}

is essentially

ModifiedContent<
   VStack<
     Text
   >,
   TapGestureModifier
 >(
   content: VStack<
     Text
   >(
     _tree: _VariadicView.Tree<
       _VStackLayout,
       Text
     >(
       root: _VStackLayout(
         alignment: HorizontalAlignment(
           key: AlignmentKey(
             bits: 2
           )
         ),
         spacing: nil
       ),
       content: Text(
         storage: .anyTextStorage(
           <LocalizedTextStorage: 0x00000001059713b0>: "text"
         ),
         modifiers: []
       )
     )
   ),
   modifier: TapGestureModifier(
     count: 1,
     action: (Function)
   )
 )

So I don't think they're really comparable. Unless you want to extend this proposal to allow breaking initializers into modifier functions? Which is a sort of currying I guess?

3 Likes

I think this pitch conflates a couple concepts that are only loosely related thanks to past OOP inertia:

  • Builder pattern: The "builder pattern" isn't just about fluent chaining. It's fundamentally about wanting to provide an API to initialize complex values (let's say, loosely defined as "it has a lot of fields") by breaking it up into a sequence of operations. Builders are often fluent as a convenience to allow objects to be constructed in a single expression, but there's no requirement that they be.

    There are two main reasons languages like Java historically reached for builder patterns which don't apply to Swift:

    1. Functions/constructors don't take labeled arguments, so code that would construct an object in one shot isn't very readable. Builder methods effectively provide the "label" for each argument. Swift already has labeled arguments.†
    2. Languages like Java that only provide reference types still want to guarantee that certain values are immutable after construction. The builder provides broken down mutating operations that at the end yield an (often) immutable object. For value types in Swift, mutability is a property of the binding, not of the type, so if you want a value to be immutable, you just use let.
  • Fluent chaining: As @Jon_Shier points out above, SwiftUI modifiers are fluent but they're not an example of a builder pattern. Most modifier methods return entirely new values that wrap their receivers; they do not modify the same value nor is there an explicit "build" operation at the end. But even if they did always return the same type, if we just consider value types, then whether you get a "new value" out of a fluent chain or a "modified value" is inconsequential. You can't actually observe the difference.

My first inclination when I see someone reach for the traditional OO builder pattern in Swift is that they're holding the language wrong. I think macros in a package would be a better way to explore this design space.

† I'll concede that even with labeled arguments, there is still an order imposed on those arguments, so one advantage of the builder pattern is that it removes that constraint from call sites and fields can be initialized typically in whatever order you want. But that's possible in Swift as well—you just create the empty value and then mutate it by invoking its property setters directly.

15 Likes