[Pitch] `With` functions in the standard library

Yes you could do:

let components = another.with(
    path: "foo",
    identifier: another.identifier + 1
)

although that's a lot more boilerplate than $0.identifier += 1 in a closure.

But I was actually talking about something like this:

let components = another.with {
  $0.path = "some/long/path"
  $0.identifier = $0.path.split(separator: "/").last!
}

Obviously the original. To refer to another just use "another.xxx".

Sometimes "less is more"... If I want to do arbitrary mutation I can always do it by normal means. Plus "you win some you lose" some - if that version of "with" indeed works for let properties that's more powerful (at least in that regards). No, I am not buying your argument that "properties should always be vars unless..." although you are fully entitled to that personal opinion.

Unless identifier is let of course... in which case $0.identifier += 1 just won't work.

the equivalent would be:

let components = another.with(
  path: "some/long/path"
  identifier: another.path.split(separator: "/").last!
)

nice and clean IMHO.

Edit: Aha, I now see what you are doing... changing path first and using it next. My version above is not quite equivalent... the equivalent would be:

let path = "some/long/path" // or some long expression here
let components = another.with(
  path: path,
  identifier: path.split(separator: "/").last!
)

And if you say: "but that's a whole extra line!"... so is this:

func foo(bar: Int) {
    var bar = bar // this is also another line
}

This is also another line, that, IIRC we didn't have in Swift 1 (or 2?) which allowed var arguments... Sometimes "extra line" is not a deal breaker.

That would be a usage of self that is completely different from any other usage in Swift, and I doubt it could be even seriously considered in this pitch.

If you find $0 ugly, I'd suggest to scope that into a separate discussion, but I'm not sure that a pitch caused by a mere aesthetic preference would get much traction.

My argument is: it's pointless for them to be let, and it doesn't use actual useful Swift language features (foundational, I'd say). Not to mention that always using let will make code less maintainable because there will be no distinction from what should actually be let to enforce invariants, so it's also a point about good modeling.

I'm simply trying to build on top of the existing language, and compose its useful existing features.

I don't quite follow, what do you mean? Obviously in another.foo(self.bar) self refers to original, not another. That's always how it was and I am totally lost what do you mean.

Leaving that aside, what don't you like in this example:

let components = { var s0 = another
    s0.path = "foo"
    s0.password = "bar"
    return s0
}()

an extra return?

In regular Swift, the self in here

refers to the type in which the scope that contains that code is, but what @jayrhynas was referring to, I think, was to use the original identifier value of the another instance to derive the final mutated value, so I assumed that self meant "the original instance (that is, another)" there: sorry if I was mistaken. The point remains though: it would be clearly more noise and more boilerplate to repeat another and the property name twice... it seems self-evident to me.

obviously this

let components = { var s0 = another
    s0.path = "foo"
    s0.password = "bar"
    return s0
}()

is completely different from this

let components = another.with {
    $0.path = "foo"
    $0.password = "bar"
}

I will not waste my time pointing out all the differences again (just to mention another one, type inference will be faster because .with returns the exact same type of the value it's applied to), and I feel like I made a sufficiently compelling case for adding some syntactic sugar on top of the (idiomatic) former code, if you use it a lot (like me and many others).

On a side note: not even the idiomatic code would work with var properties.

1 Like

Assuming that the pack iteration pitch turns into an accepted proposal (and I understood the syntax well :sweat_smile:), I think .with could be composed with a function like the following:

func setting<Root, each Value>(_ keyPathAndNewValue: repeat (WritableKeyPath<Root, each Value>, each Value)) -> (inout Root) -> Void {
  { value in
    for keyPath, newValue in repeat (each keyPathAndNewValue) {
      value[keyPath: keyPath] = newValue
    }
  }
}

to enforce simple setting or properties with no closure via key paths, so something like:

let components = another.with(setting(
  (\.path, "foo"),
  (\.password, "bar")
))

This looks over engineering to me.

Maybe it's just me, but I fail to see how these two are "completely different":

let components = { var s0 = another
    s0.path = "foo"
    s0.password = "bar"
    return s0
}()

let components = another.with {
    $0.path = "foo"
    $0.password = "bar"
}

I just see some minor insignificant syntactic variations here.

OTOH, an ability to have a copy with one or few properties changed even if they are let properties could be a game changer:

struct S {
    let id: Int
    .... // 99 other fields
}

let another: S = ...
let new = another.with(id: another.id + 1)

if we want to pursue that opportunity, as doing this manually could indeed lead to a massive boilerplate.

IMO: supporting ergonomic construction of new values with arbitrarily updated lets is an anti-goal here. Either the property is subject to important invariants (in which case type authors must carefully consider how the property is meant to be initialized) or it isn't and it should just be a var.

12 Likes

This. let in structs is a strategic tool.

2 Likes

IMHO it could well be a goal. Expanding @LLC example:

struct Person {
    let first: String
    let last: String
    let age: Int

    init?(first: String, last: String, age: Int) {
        guard isValidFirstName(first) else { return nil }
        guard isValidLastName(first) else { return nil }
        guard isValidAge(age) else { return nil }
        self.first = first
        self.last = last
        self.age = age
    }
    
    func with(first: String? = nil, last: String? = nil, age: Int? = nil) -> Self? {
        Self.init(
            first: first ?? self.first,
            last: first ?? self.first,
            age: age ?? self.age
        )
    }
}

With enough fields writing this "with" manually results in quite large amount of boilerplate, besides it's error prone (there's a typo in the above implementation) and doesn't support overriding optional fields, something that built-in "with" could have provided:

    let clonedPerson = person.with(
        first: "another",
        // last is not provided → taken from person.last
        address: nil // provided → overrides address with nil
    )

Basically you want to re-call the initializer with some new values and some values from the existing instance. While I can see this being valuable in some cases, it only works if the initializer has the same signature as the generated initializer (but potentially failable). IMO this would be better implemented as macro that you can opt into rather than automatically be on all types (since not all types can participate anyway).

A minor comment. I don't think passing nil to address parameter explicitly works as you expected. It won't overide original address value.

1 Like

Having this available after opting-in rather than available for all types looks good to me.

That's part of the issue... With a "built-in" with (done by compiler or a macro or whatever) that could be possible. Pseudo code:

    // library type
    enum Optional3<T> {
        case absent
        case none // nil
        case some(T)
    }

    // compiler generated method
    func with(first: Optional3(String) = .absent, last = Optional3(String) = .absent, age: Optional3(Int) = .absent) -> Self? {
        Self.init(
            first: first == .absent ? self.first : first,
            last: first == .absent ? self.first : second,
            age: age == .absent ? self.age : age
        )
    }

    // user code
    let clonedPerson = person.with(
        first: "another", // literal, no need to say .some("another")
        // last is not provided → absent → taken from person.last
        address: nil, // or .none → provided → overrides address with nil
        previousAddress: .absent // ideally should be an error, like "`absent` is not available"
    )
1 Like

The value of .with syntax shows itself when you want to chain multiple mutations on one complex instance.

Imagine trying to provide a Mock class to an object for testing:

let urlSession = MockURLSession().with {
    $0.dataRequester = MockDataRequester().with {
         $0.resultValue = /*...*/
    }
}

let sut = SUT(urlSession: urlSession)

In this use-case it is very readable what we're doing... we're creating a MockURLSession with its dataRequester being mocked to return a specific resultValue. While doing it normally with closures add so much noise to the code:

let urlSession: URLSessionProtocol = {
    var mockSession = MockURLSession()
    session.dataRequester = {
        var mockDataRequester = MockDataRequester()
        mockDataRequester.resultValue = /*...*/
    }()
    return mockSession
}()

let sut = SUT(urlSession: urlSession)

I believe you can expand on this example and IMHO it will exponentially become unreadable.

1 Like

Although, that also works with the with free function syntax.

let urlSession = with(MockURLSession()) { session in
  session.dataRequester = with(MockDataRequester()) { requester in
    requester.resultValue = /* ... */
  }
}
2 Likes

I found it easier to compare different options when putting them together back to back:

// Option 1 (current)
let urlSession = {
    var session = MockURLSession()
    session.dataRequester = {
        var requester = MockDataRequester()
        requester.resultValue = /* ...*/
        return requester
    }()
    return session
}()
// Option 2 (proposed "v.with {...}")
let urlSession = MockURLSession().with {
    $0.dataRequester = MockDataRequester().with {
         $0.resultValue = /*... */
    }
}
// Option 3 (proposed "with(v) { ... }")
let urlSession = with(MockURLSession()) {
    $0.dataRequester = with(MockDataRequester()) {
        $0.resultValue = /* ... */
    }
}
// Option 4 (possible "v.with(...)")
let urlSession = MockURLSession().with(
    dataRequester: MockDataRequester().with(
        resultValue: /* ... */
    )
)

Option 4 looks most compact with the two additional traits to it, one positive and one negative:

  • positive: potential to work with "let" properties.
  • negative: inability to refer to "the thing" with is being called on (example below).
struct Foo {
    var value: Int
    func bar(_ v: Int) {
        print(v)
    }
}

// compact form
Foo(value: 42).bar(theThing.value) // no way to do this

// have to do this instead:
var theThing = Foo(value: 42)
theThing.bar(theThing.value)

I wonder if there are precedents in other languages to allow the compact form from the example.

We already have the “compact form”: it is spelled $0, and to emphasize again, the language steering group is not looking to replace or come up with another alternative to it.

Foo(value: 42).with { $0.bar($0.value) }
5 Likes

This feels related to Lenses. There’s an interesting YouTube video on the topic, and it’s been discussed on the forum before.

In the video. Brandon Williams develops a technique for creating variants of immutable data with concise syntax. It's very old, and can probably be made nicer with key paths

2 Likes

I don't think this potential exists. If in Swift you want to represent a type that

  • can be copied, and
  • while copying, one or more properties are updated to a new value

then Swift has already a solution, and it's simply using a struct and making those properties var: this is how this concept is expressed in the language, var properties in a struct mean this, and this is actually a powerful feature that's not present in many other languages, that instead need specialized syntax to represent the simple "copy + update" behavior (while Swift allows for more sophisticated code thanks to the automatic enforcing of value semantics), so failing to understand this critical point will make one not use Swift to its full potential.

As a personal note, in my first years in Swift I completely missed this point, so I get when one doesn't understand it (or stubbornly refuses it, due to familiarity with other languages), but when I "discovered" this feature, I fully embraced it, and then mine and my team's code got better, clearer, safer, faster to write et cetera.

Again, this is a major downside, that should compel us to discard that option altogether, in favor of the closure + $0 form, which is idiomatic, embedded in the language from the very start, far more flexible, and certainly more appropriate when considering a general extension to the language. Closures + $0 are here to stay, and I think they should be embraced an leveraged, instead of inventing a new language with constructs that would open the doors to a long stream of sub-pitches to add more features to it in order to make it on-par with the actual existing language.

Circling around this point due to an "allergy" to $0 (that's not even required, one could just spell the closure input explicitly with a Kotlin-style it, for example, or this, or x...) will not help the discussion, so I'd suggest again to instead think about another pitch to replace $0 with something else (or add another option in case of a single parameter).

1 Like

How would you do this example with vars?