PrePitch: Implementing the Buildable pattern for Structs

Problem

It's considered "best practice" when creating model or state structs to make all members immutable 'let' variables and construct them whole using initializers.

struct Address {
    let address1: String
    let address2: String?
    let city: String
    let state: String
    let zipcode: String
    let country: String
}
func example1() {
    let address = Address(
        address1: "10 E West St",
        address2: nil,
        city: "New York",
        state: "NY",
        zipcode: "10101",
        country: "US"
    )
}

Unfortunately, however, there usually comes a time when we need to update the state or model with new information.

Currently in Swift, that either means we change all of our properties to 'var' with all of the tradeoffs those entail, or we need to create brand new structs by hand, copying all of the old values along with the new values.

func example2() {
    let newAddress = Address(
        address1: "20 W East Ave ",
        address2: "Apt 12",
        city: address.city,
        state: address.state,
        zipcode: address.zipcode,
        country: address.country
    )
}

This is somewhat tedious to write and more than a little error prone, and the potential for error increases each and every time you need to update a different value.

The Builder Pattern

One solution to making our structs and models easier to use is implementing a Builder pattern which makes updating individual members much easier.

func example3() {
    let newAddress = address
        .address1("20 W East Ave ")
        .address2("Apt 12")
}

Here the result of each of the above address functions is a new constant struct with the address updated. Simple, and much easier to use than reinitializing the struct as shown in example2.

That said, actually implementing the Builder pattern in Swift is incredibly mind-numbing and involves a ton of boilerplate code:

extension Address {
    func address1(address1: String) -> Address {
        return Address(
            address1: address1,
            address2: address2,
            city: city,
            state: state,
            zipcode: zipcode,
            country: country
        )
    }
    func address2(address2: String) -> Address {
        return Address(
            address1: address1,
            address2: address2,
            city: city,
            state: state,
            zipcode: zipcode,
            country: country
        )
    }
    func city(city: String) -> Address {
        return Address(
            address1: address1,
            address2: address2,
            city: city,
            state: state,
            zipcode: zipcode,
            country: country
        )
    }
    func zipcode(address1: String) -> Address {
        return Address(
            address1: address1,
            address2: address2,
            city: city,
            state: state,
            zipcode: zipcode,
            country: country
        )
    }
    func country(country: String) -> Address {
        return Address(
            address1: address1,
            address2: address2,
            city: city,
            state: state,
            zipcode: zipcode,
            country: country
        )
    }
}

As shown, implementing the Builder pattern for complex structs often becomes a huge copy and paste jobs and can become a breeding ground for bugs. (Like forgetting to change the parameter name in the zipcode function above.)

And heaven help you if at a later date your parameter order changes or you need to add new values.

Solution? Buildable

So what if we let the computer do what it does best and manage all of the above boilerplate for us?

Image the following:

struct Address: Buildable {
    let address1: String
    let address2: String?
    let city: String
    let state: String
    let zipcode: String
    let country: String
}

func example4() {
    let newAddress = address
        .address1("20 W East Ave ")
        .address2("Apt 12")
}

This is akin to what Codable does, when it writes the boring code for us.

Implementation

Behind the scenes the actual implementation could write all of the above code for us, macro-style, or if we wanted to be more efficient the compiler could simply do a copy-on-write and update the appropriate value for us, eliminating the need to perform each initialization steps and all of the attendant code.

The compiler could even notice that we're stringing builders together and optimize things such that we only do one copy with the various updates.

Wrap Up

So that's the Buildable pitch. Clearer code. Smaller code. No boilerplate, No copy and paste. And less potential for error.

What do you think?

1 Like

I've never heard about it. What are the pros of it? I think all the code I've seen uses vars in the type definition and prevents mutation by using let for the individual instances.

18 Likes

One issue is that these models (which typically come from APIs) and states are designed to be created, updated and/or changed after the fact, which means that by default the instances can't be 'let' constants.

As to the best practice aspect, see: https://en.wikipedia.org/wiki/Immutable_object or https://hackernoon.com/5-benefits-of-immutable-objects-worth-considering-for-your-next-project-f98e7e85b6ac.

This seems somewhat similar to something @Erica_Sadun looked at previously. I'm not really sure where that left off, but there's Circling back to `with`.

5 Likes

Swift structs are immutable objects, even if they use var everywhere. There's no difference between

struct Foo {
  var value: Int
}
var foo = Foo(value: 0)
foo.value += 1

and

struct Foo {
  let value: Int
}
var foo = Foo(value: 0)
foo = Foo(value: foo.value + 1)

Both make a new Foo struct. You can see when using didSet property observers, the whole struct is being set even if you change only one field.

12 Likes

It would be nice to have some sugar here, but the builder pattern is not it. Some form of with that's been presented before would be better, or even just allowing instance values as default parameters would be nice.

6 Likes

I never heard of it, and usually opt for having every property as var (as long as the type has value semantics)

If you use vars, then this update can be done by simply setting the property to the new value.
If the issue is that you have a constant, and you want to generate a new constant from it, you can use various utilities already discussed before in the previous replies.
One example can be:

public func updated <T> (_ value: T, with update: (inout T) -> Void) -> T {
	var editable = value
	update(&editable)
	return editable
}

with something like this, your problem comes out to:

struct Address {
    var address1: String
    var address2: String?
    var city: String
    var state: String
    var zipcode: String
    var country: String
}

let newAddress = updated(address, with: {
    $0.address1 = "20 W East Ave "
    $0.address2 = "Apt 12"
}
8 Likes

I’ll have to disagree with that sentiment even though I’m absolutely a fan of functional and stateless programming. structs make it easy to “lock down” properties whenever the aggregate value (or any level with value semantics above) is declared constant. This means that even though we declare vars, Swift still gives us immutability guarantees if the outer aggregate value is immutable (by let or by being part of an immutable aggregate). Swift goes even further and calls property observers at every level up to the object level (if applicable) for every change to any aggregate value at any level. Most other languages don’t have these features and semantics and there it absolutely makes sense to declare as much as possible immutable.

A good reason, however, to declare constant properties in a struct type is when the property’s value depends on some calculation or has semantics (like error throwing) that fit much better as part of an initialiser, and thus when it isn’t sensible to be able to assign it separate from this initialiser call. The parts of an address usually can be assigned separately (unless the initialiser validates its properties with some intra-property invariants). Most of my structs, at least, are quite simple though and don’t have complicated relationships between the properties or intra-property invariants.

For the same reason, and it’s a little off-topic, I’d love for enums to get autosynthesised properties for some or all associated values, e.g., for associated values that occur in every case.

I’m not arguing against a feature facilitating the Builder pattern though, but Swift already has more functional/statelessness tools than most other languages, and the Builder pattern looks like something that patches those other languages’ deficiencies. :stuck_out_tongue:

11 Likes

I, too, have to dispute this premise. If a struct is basically a "bag of properties" which can be independently changed, there's no reason to make the individual properties immutable. If a struct's properties are interdependent in such a way that certain combinations of values would be invalid, it may make sense to make the properties immutable, but then you would need to write builder methods by hand—the automatic ones would allow the creation of instances with invalid combinations of values.

This is different from classes, where it does often make sense to make properties immutable because several different variables can refer to the same instance. That can't really happen with structs because each variable has its own copy of the struct.

9 Likes

One issue with the proposed syntax is that it would introduce an overload for each property. This might be reason enough to choose with.

1 Like

We already have compiler-generated key paths that can be used for this purpose and require no changes to the language. Introducing machinery to leverage those key paths in the standard library (like with) might be a nice addition, though.

1 Like

To be fair to the OP, those key paths aren't writable if the underlying property is let.

Sure, though it might make more sense to explore extending non-writable key paths with builder semantics than add compiler-generated builder methods.

1 Like

If a property is appropriate to be individually modifiable by a builder, though, you don't get anything from making it a let. It would be great to provide standard library APIs to make copying-with-updates more expressive, but we don't need to invent new mechanisms to enable that.

21 Likes

And if you allow Buildable in extensions, or give key paths the equivalent power, you lose the ability to enforce invariants on let properties through initializers.

(Even with same-file restrictions, I worry about people slapping a Buildable onto an existing type “for convenience” without regarding initializer behaviour – the danger of mutable codebases. :upside_down_face:)

1 Like

Is this in fact true? Where is this stated? This was stated in a thread I started last year (Revelation (?) about mutating struct methods - #11 by Ponyboy47) where it was again asserted, but by the end of the thread that assertion was taken back. It seems to be a common assertion, but I've never seen it in any official documentation.

Are you asking about the underlying implementation or the semantic model/observable behaviour?

Interesting answers. I have to admit that the "best practice" comment grew out of what was perceived to be "best practices" here in our shop and actually implemented in several projects.

But as ctxppc indicates above, and after researching the subject to a greater extent, I have to agree that this appears to be a port to Swift of what was considered to be a best practice in another language. And one done without regard to the additional safety mechanisms provided by Swift.

Really, the bottom line (as indicated by Joe) is that making a struct Buildable is really no different from making the struct members vars. And that in doing so you lose the ability to fine-grain your protections. As in:

struct Address {
    let id: String
    var address1: String
    var address2: String?
    var city: String
    var state: String
    var zipcode: String
    var country: String
}

In the above address everything is updatable and capable of being passed back to the server... except for the database record id, protected by let.

So given the above, I think I'm going to pull the plug on the pitch. Thanks to any and all who participated.

9 Likes

The thing is, it depends on what init you make available. In this snippet I can still create Address values that have all the same value apart from id, and it's no different than having var id and using that to change it.
The only reason you'd ever want a let is if you actually don't give a way of changing it, e.g. with an explicit init

1 Like

Hadn't seen this pattern before. Thanks for sharing!