[Pitch] `With` functions in the standard library

This is how this is usually done in Rust, but they have two advantages here: assignments automatically do the equivalent of consume and they can shadow the original variable name so you don't have to come up with a new name for "foo but it's immutable now".

with (either the magic member function or the free function) gives us both benefits: you don't have to manually consume the old variable, and you don't need to name it twice.

1 Like

Some allowances for implicit consume might be useful in Swift - not as unilaterally as in Rust, because they're still different languages with different principles - but based on context or somesuch. Maybe like type inference; "consumption inference".

On the latter point - being able to "freeze" a variable (whether by redefining it through shadowing or some other form) - that might be an appealing, orthogonal enhancement. It's not something I've often found myself wishing for, but it'd be interesting to see what it could mean and do if someone really thinks it through.

Is there currently a syntax that is .{} ?

Apologies for coming so late to this pitch.

I have some misgivings about adding this feature for a few reasons:

I'm uneasy about breaking the seal on adding a method on all types – this would be a big step in policy terms, and I think it should not be taken lightly. The first use case for it should be extremely compelling, and I am not sure this is compelling enough.

The use case for this with existing values makes me particularly uneasy. I find:

originalComponents.with { components in
  components.port? += 1
}

as a spelling for "create a copy of originalComponents, but with this value changed" very strange. This may be a matter of spelling – with is not at all a good name for this "copy and tweak" operation. Maybe a different name could be better – but I'm not sure a better one is readily available. And a different more verbose one would detract from the appeal with other use cases.

I definitely see the need for this feature in particular for initializing variables from an expression, especially global variables. It's definitely a gap, currently filled by immediately-executed closures. But I would prefer another path to solve this.

@hamishknight and I have been working on a proposal for syntax that would allow multi-statement if and switch branches to be expressions, through the introduction of a then keyword (draft implementation here):

let url = 
  if let hostname { 
    var components = URLComponents()
    components.scheme = "https"
    components.host = hostname
    components.path = "/c/evolution/18"
    then components.url
  }  else {
    log("no URL supplied, falling back to default")
    then defaultUrl
  }

This addresses one of the main future directions left by SE-0380.

Once this is done, another future direction becomes very appealing: do expressions:

let url = 
  do {
    try fetchUrl()
  } catch {
    log("something went wrong")
    then defaultUrl
  }

If do statements become expressions, then such expressions (with or without catch clauses) could also become the natural alternative to the immediately-executed-closure idiom, the main target motivation of this pitch.

Finally, I have a general wariness of implementing control flow using closures. The most important place where this was a mistake was the introduction of forEach, which is highly problematic due to its changing the meaning of return and continue versus it's for...in equivalent. This proposal doesn't have quite the same challenges, but in general if given a choice I would rather model things like this with real language control flow like a do block (if such a thing ends up being an expression anyway) than duplicating that functionality a second time with a library function (especially, as is the case here, a library function you cannot write without special accommodation from the compiler).

12 Likes

I see things the exact opposite way—initializing variables should not be hidden in the depths of an expression; after all, that's part of why we did away with assignments returning their value back and the ++/-- operators. On the other hand, producing a modified variant of an existing value in an expression is very useful, and something people have been asking for about as long as Swift has been around, and for value types, it doesn't really have the stink of embedded global side effects that other possible use cases for this formulation might. Making do blocks into expressions might be nice but doesn't do anything for this use case, since you'd still have to write:

let newComponents = do {
  var temp = oldComponents
  temp.port? = 1
  temp
}

in order to explicitly copy, modify, and return the value in the block.

7 Likes

Is forEach really that bad? The main complaint you have is that control flow in the closure works in a somewhat unintuitive way compared to a real for loop. However I would argue that users wouldn't expect continue or break to work in a closure argument to a method like they do in a real for loop. It can also allow for more brief code in some circumstances (although the API guidelines do say this is a non-goal)

for number in 1...5 { print(number) }
(1...5).forEach(print)

Modifying an existing value is probably the best use case for the with function, especially when composed in a chain of other mutations. The .with { ... } design compliments builder pattern APIs like SwiftUI very nicely.

Take this example I ran in to recently. I was working with a SwiftUI type that only has a func set(key: Key, value: Value) -> Self shaped API, but I had a dictionary of [Key: Value] I wanted to apply.

Today, you have to either define a one-off helper:

var body: some View {
  MyView()
    .modifierA()
    .modifierB()
    .values(dictionary) // one-off helper to apply values in dictionary to view
    .modifierC()

  Text("some other view")
}

extension MyView {
  func values(_ dictionary: [Key: Value]) -> Self {
    var copy = self

    for (key, value) in dictionary {
      copy.set(key: key, value: value)
    }

    return copy
}

or assign the view to a mutable property and write some really silly-looking code:

var body: some View {
  var view = MyView()
    .modifierA()
    .modifierB()

  // Without this very silly-looking closure, you get an error:
  // Closure containing control flow statement cannot be used with result builder 'ViewBuilder'
  let _ = ({
    // Now you can imperatively mutate the existing view in-place:
    for (key, value) in dictionary {
        view = view.set(key: key, value: value)
    }
  }())

  view
    .modifierC()

  Text("some other view")
}

but with a with function you can trivially do:

var body: some View {
  MyView()
    .modifierA()
    .modifierB()
    .with { view in
      for (key, value) in dictionary {
        view = view.set(key: key, value: value)
      }
    }
    .modifierC()
}
2 Likes

Could you please clarify what exactly makes you think so? Are you against allowing extensions on Any? I mean, there are things in the language today that's barely different. What's so special about Any?

extension Sendable {
  func foo() {}
}

func foo(self: some Any) {}

Can't wait to see it. Also, PTAL

I don't think forEach specifically is a problem (while it is handy in fluent style chains... wish it was returning it's object so I can put it not just at the end of the chain)... the same would happen with, say a custom while:

    whileDo(expression()) {
        ...
        return // returns from the closure!
    }

The real issue (IMHO) is closure syntax looking confusingly similar to control statements flow (which was the "goal", and that's questionable design choice precisely because it causes confusion). If the syntax was different there would be no confusion where return returns:

func foo() -> Int [
    for i in ... {
        return 1 // returns from foo()
    }
    forEach [ item in
        return 2 // returns from forEach's closure
    ]
    map(x) [ item in
        return 3 // returns from map's closure
    ]
    return 42 // returns from foo()
]

I fail to see how a do block with then (which I actually think is a very nice idea) addresses the actual issue pitched here.

The issue is "ergonomic copy + mutation of values with var properties and mutating functions", it's purely a syntactic sugar and lack of convenience matter.

This:

let newValue = do {
  var x = oldValue
  x.foo = "yello"
  x.bar = 42
  then x
}

is not that different from a immediately executed closure in terms of convenience and readability (it has the added power of control flow analysis though), and it's still far worse than

let newValue = oldValue.with {
  $0.foo = "yello"
  $0.bar = 42
}

I think we've come to a point where the case and utility for this feature have been clearly stated: many of us have used stuff like this (for example, the with free function, the Withable protocol, or in my case the &> operator) for many years, and I'm sure some people that never thought about this and stumbled upon this tread will start using this stuff, because it makes the code so much better and clearer when using value types with mutable members (which is a super power of Swift that everyone should adopt).

It was also clearly stated why .with is useful as an instance member. For example, because it can be used for values returned from chain expressions, that could also include optionals:

let newValue = path.to?.old.value?.with {
  $0.foo = "yello"
  $0.bar = 42
}

While I agree that .forEach, that probably makes sense in some limited cases, is in general a bad idea specifically because it's a worse version of for-in, that doesn't apply to all members that use closures, for example the very important .map, .flatMap. .filter et cetera: .with would be part of the same family.

2 Likes

I think what I failed to do is state up front that I don't think this particular use case sufficiently merits sugaring.

I totally agree that the mild sugaring makes some use cases slightly nicer. But I'm also not convinced it's enough to introduce a quirk into the language of a new magic member on Any. In a lot of cases I think with might end up being over-used when what you actually just want is:

var newValue = oldValue
newValue.foo = "yello"
newValue.bar = 42

which is the clearest of all. What is the goal of the sugar over doing this? Hoop-jumping to preserve let instead of var? Being able to use $0 instead of newValue for the assignments? This seems a bit dubious to me.

And I don't think with is a good name for what is being done here ("copy + mutation of a value") nor do I think there's a better name that doesn't start to give up some of the cuteness of the sugar that is supposedly the main driver. So we give sugar, but drop in clarity, which is not a good trade. Contrast this with if let x { } sugar where, yes, it's pure sugar, it's a natural intuitive extension of what it's sugaring.

I think instead of sugar, it is more important to focus on expressivity deficiencies of the language. Being able to initialize a value from a single expression (because it's a global you want to lazily initialize) is important, and currently only possible via the {}() kludge. I think this kludge is actually mildly harmful, because people see it and think that's how you initialize any variable e.g. you see a lot of let x = { singeExpression() }().

As to

let newValue = path.to?.old.value?.with {
  $0.foo = "yello"
  $0.bar = 42
}

I think we're now in edge case territory: the need to sugar (with a confusing name with) a long optional chain, while preserving its optionality. So now we have a competitor to Optional.map that's subtly different (it's "map on the end of a chain, with a mutable wrapped value"). I also suspect sugaring this case encourages the anti-pattern of preserving optionality longer than necessary (are you going to unwrap this value shortly after? why not unwrap the chain now with if var let instead?)

5 Likes

I have a feeling this pattern appears a lot in apps whose primary task is consuming JSON.

2 Likes

Your concern about introducing a method across all types is valid, but it's worth noting that this precedent has already been set by SE-0161, where keypath subscripts are defined as extensions on Any. I consider this seal already broken.

Indeed, the motivation section of the proposal explicitly highlights the goal of 'avoiding the mutable variable'. The topic has also been subject to prior discussions, where the pitch outlined several other compelling factors, including considerations related to visual grouping.

Additionally, there's precedence in other languages and their standard library functions pursuing similar objectives:

Much like the map operation on Sequence, I believe this proposal is more than mere syntactic sugar. It serves a vital purpose by providing a concise, immutable, and straightforward way to achieve a specific use-case while sidestepping temporary mutable states. There's no intermediate state, all mutations happen in the same "transaction" and the result is immediately encapsulated into an immutable let.

As for the naming concern, I understand your point. While the term with might not be the most intuitive when chained, I find inspiration in Kotlin's apply , and applied or mutated could potentially be Swiftier alternatives.

4 Likes

These are very different from what is being proposed. They require the object adopt an interface (IDisposable in C#, the __enter__ and __exit__ magic methods in Python), and are intended for objects that provide time-delimited access to a resource.

Move-only types and consuming bindings are Swift’s intended answer in this problem space. I have argued elsewhere that you could define with as a convenience around a borrow and consuming binding, but that is not what was originally proposed.

2 Likes

Is this proposal officially rejected by the core team?

No. I'm giving my personal opinions here – if they were feedback from the language steering group I'd flag them as being that (like Xiaodi did above).

2 Likes

Your points are valid, I appreciate the clarification. The examples I provided were indeed broader in nature and might not directly align with the proposal's focus. To address the topic more accurately, I found further references:

I'm starting to feel with is a term of art

OCaml also uses with for record updates, where { foo with bar = newBarValue } gives a copy of foo with the value of the bar field changed to newBarValue. This seems like a generalization of that idea, allowing any update to the copy instead of only field-by-field updates.

1 Like

It seems like this proposal and any "copy with updates" method would encourage mutable properties on types, which goes counter to Swift's focus on immutability as first default.

Not really. Swift focuses on value semantics. Almost any data structure will necessarily have var properties. For instance would you rather do this:

var point = Point()
// move point to the right by 2
point.x += 2

or this?

var point = Point()
point = Point(x: point.x + 2, y: point.y)
5 Likes