[Pitch] `With` functions in the standard library

You mutated it when you assigned $0. The warning I was proposing would fire if there were no mutating operations on the closure parameter in the body.

Exactly. It arguably reads a bit better, using this with, but it first requires knowledge & understanding of this construct, so it makes the language / stdlib a little bit more complicated. Only a little, but non-zero.

It's also potentially less efficient than alternatives (e.g. initialisers). It feels a bit regressive in that sense, like we're going back to [[SKLabelNode alloc] init].

Another equivalent way to do this today is:

users[userId].properties["node"] = {
    var node = SKLabelNode()
    node.text = "Hello, world!"
    node.fontColor = .red
    node
}()

It also has a potentially unintuitive quirk (the semi-implicit return of node), but overall it's not much different - in length, complexity, or functionality - to the with version.

The above form is also more amenable to factoring out into a standalone function. Implicit in a lot of this discussion is a stylistic preference for heavily inlining things, which I actually kind of go along with, but it might be worth stopping for a second and considering that aspect of the motivation.

To reiterate, I'm not particularly against something like this "with" proposal even for these cases, I'm just not enthused by it. It feels like it's missing a point somewhere; that there might be a better solution.

To be absolutely clear, did I understand the context of your proposal correctly, in that the warning would be emitted because SKNode().with { $0.fontColor = .red } doesn’t formally mutate $0 due to SKNode being a reference type?

The purpose of my reply was to illustrate how generalizing with to a lens can enable a rewrite that not only avoids the warning but also reduces the number of exclusivity checks. Is that not a useful operation for reference types as well, to reduce ARC traffic?

Yeah, which I raised as a possible solution if people are concerned about confusion around whether with here is modifying a copy of a value or operating on the same object referenced by the operand.

Yes, but what you propose is formally mutating, so it wouldn't be impeded by such a warning if it were implemented.

1 Like

I'd really love this alternative to be considered.

In Obj-C we were able to call setters two ways:

    object.property = 42
    [object setProperty:42]

The two were by and large equivalent. We can do a similar thing in Swift to allow fluent style code pattern without the pain of writing the needed boilerplate manually.

users[userId].properties["node"] = SKLabelNode()
    .text("Hello, world!")
    .fontColor(.red)

I believe this could work out of the box without any opting in (or with some minimal opt-in marker at the type level). If there's a user provided method with the matching signature – it will be called instead.

This seems to work equally well for both reference and value types.

1 Like

I resisted bringing this up earlier because it's a popular pattern in Java which makes me sad. However, the main problem with it there is all the boilerplate required - that is, all the manually-defined modifier functions for every property.

Iff a Swift version of this didn't require any such boilerplate, then I think it's attractive.

One would have to think through all the possibilities, though. e.g. what if SKLabelNode actually has a func text(_ foo: String) {} method? Does that block the use of this syntax for setting text? Does an implicit modifier still get created, overloading the text function? Etc.

1 Like

I would think that inventing such a with extension is exactly the opposite, i.e. not introducing a new pattern, but just something that comes in handy in some situations, maybe when you fetch something to put it into another structure and want to change it ”inline” without much ceremony. So on the contrary introducing with frees you from needing such a pattern.

I very well like the idea of with (although I would use another name) and I think one should not fear a “misuse” of it in some strange patterns. (Many language features can be misused, I for example use classes too much instead of structs, because I come from the Java world :wink:, but still classes are a good thing.)

So personally, I would welcome a more “down-to-earth debate” about a practical tool.

Regardless of the niche but stubborn push for such "fluent" pattern, the latter has been already largely dismissed in the thread, that is instead focused on the semantics of the a function that takes a regular closure that allows mutating of the input, the naming, the possibility of it being an automatic member for all types instead of a free function, et cetera.

I'd still suggest to read the whole thing :smile:

The "builder" pattern is a bad idea, it was conceived in the context of languages with limited features and no currying, and it shouldn't be supported by Swift with some esoteric language feature. This conversation is not really about that: it's instead about ergonomic mutation of types with var properties and mutating functions. It's exactly about the replacement of something like:

let newFoo: Foo = {
  var m_foo = get.this.foo()
  m_foo.bar = "yello"
  m_foo.baz = 42
  return m_foo
}()

with something like

let newFoo = get.this.foo().with {
  $0.bar = "yello"
  $0.baz = 42
}

that clearly has higher clarity, and it's focused on what actually matters, eliminating most of the noise and unnecessary syntax.

1 Like

Just to be clear, the pitch we submitted uses a magic extension on Any, not a free function.

1 Like

I'd be surprised if it's actually a show-stopper or anything like it, but nonetheless it would be good if someone did some analysis into the cost of an initialiser vs this with (or similar) pattern, as part of this pitch.

Initialisers can be inlined today (as far as I've seen, at least for trivial value types), but for non-frozen types shouldn't be because it imposes a de facto freeze on the type. For resilient libraries, I mean - but that includes all of Apple's OS libraries.

So this with pattern is going to incur the cost of actually initialising all the object's memory, and then reinitialise some portion of it, at extra cost.

For higher-level code (e.g. UIKit, SwiftUI) that's arguably insignificant, but for lower-level code, maybe not so much.

I'm not certain there's a performance difference - calling an initialiser with many arguments isn't free either - but it'd be interesting to see how it shakes out.

You seem to be making an implicit assumption that the compiler can expose arbitrary mutable stored properties to the caller, substituting the caller-provided value for that done by the initializer. For resilient types, this is certainly not possible for multiple reasons: the ABI does not expose stored properties any differently from computed ones, and the type may actually require default initialization to a certain value for correctness, even if the value is immediately replaced. For non-resilient types, making the initializer inlineable enables the optimizer to eliminate redundant initialization when safe.

In both cases, your proposal simply decays down to initializing the type as normal, and then assigning to its mutable properties.

1 Like

Am I? I thought I was assuming the exact opposite.

Can you clarify what you mean, perhaps with example code?

Sorry, I must have been confused. I thought you were cosigning @dmt’s arbitrary-initializer idea.

I don’t really find the arguments against with being a free function compelling enough to introduce this magical method. One of the arguments is that it’s not idiomatic, but I don’t see how that can be the case since the API guidelines explicitly recommends free functions for when the argument is an unbounded type. It also matches all the other withXXX functions and I can’t think of any methods that start with with; so for me it’s the more idiomatic choice.

i don’t buy the fluency argument either as both read similarly to me.

The discoverability aspect makes sense, but it has issues too. It pollutes the autocomplete namespace with a method that for the most part will only be called at initialization. It’s also hard to imagine a user discovering the method naturally when they’re trying to initialize the type.

5 Likes

I would like to point out that when consume is complete, one will be able to do the following, which has the same number of lines:

var fooCopy = get.this.foo()
fooCopy.bar = "yello"
fooCopy.baz = 42             // done modifying here
let newFoo = consume fooCopy // can't use fooCopy after this

To be fair this is not currently possible for non-bitwise-copyable types, but it will become possible before long; you can do it now with Array, as long as the var is not a global variable. The one thing I see so far that is an improvement over the copy-then-consume pattern I suggest is Joe's suggestion of a warning when there is no mutation.

2 Likes

The other big advantage of with() is that it avoids copy-on-write by taking exclusive borrow of the value, which can be a massive performance improvement.

IIRC in the fullness of time you could achieve this by making your initial binding an inout var, but with() does it all for you with familiar lexical scoping.

Edit: Actually, I don’t think it’s as simple as inout var. You need to move the value into mutable storage, since you can’t mutably borrow from an immutable binding.

1 Like

If you want to have exclusivity during mutation, then you can consume the original binding. But then all you would have done is changed the name for the value, after mutations.

1 Like

What about this use case, a result builder:

let a = MyStuff() {
    get.this.foo().with { $0.bar = "yello"; $0.baz = 42 }
    get.another.foo()
}

or as an argument:

doIt(using: get.this.foo().with { $0.bar = "yello"; $0.baz = 42 })

Nice and short.

This would help to avoid using fooCopy again after mutation, but it's still verbose, noisy, and one could easily forget to add consume, so I don't see it as a good alternative to .with.

2 Likes

I think it would be nice to consume mutable, but I think this pitch is about more about not breaking out of an expression. Especially in context of if/switch expressions, but it can also disrupt the flow of your functions if you need to do a lot of ad-hoc programming in expression-heavy code. I think reducing the number of lines of code is a non-goal.