[Pitch] `With` functions in the standard library

The "wrong" thing is that you should use var properties for structs that have a memberwise initializer (which is the case for the Person struct in your code, because it's generated automatically): please check the explanation that I posted earlier.

Then, you could consider using the operator that I proposed to do that copy-and-update automatically for all structs, instead of needing a with function specific for each type.

As I mentioned, Swift automatic enforcing of value semantics allows to create structs with mutable properties, that are actually immutable (because they're values) but that can worked with as if they're mutable: this is one of the most important Swift features, but it's often not well understood. Please PM me if you want to discuss this more, as not to derail the thread :wink:.

EDIT: in fact, another "wrong" thing, I think, is the fact that your with function could be mutating, instead of returning a new value. Structs with var properties and mutating functions are the foundational components that allow to leverage Swift enforcing of value semantics.

Well, this is different. In fact more powerful than what the pitch proposes. If this version of "with" is generated automatically (e.g. only when it is used) it could be a powerful feature as it handles "let's" as well. Among these:

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

// proposed A:
let components = with(another) {
    $0.path = "foo"
    $0.password = "bar"
}

// proposed B:
let components = another.with {
    $0.path = "foo"
    $0.password = "bar"
}

// LLC alternative:
let components = another.with(
    path: "foo",
    password: "bar"
)

the last option looks quite appealing, especially if it handles Optional parameters correctly:

with(path: "foo") // path will be overridden
with(path: nil) // path will be overridden (to nil)
with(somethingElse: ...) // path will stay what it is

This would indeed be possible with an @attached(member) macro that adds the with(prop1:prop2:) member function

This suffers all the same limitations of the builder pattern, with no way to call arbitrary mutating methods or derive one property from another property. I have used it for a UIColor extension that adjusts HSL values, and it's generally a good fit for types where there aren't mutable properties to begin with (although that could be argued to be an underlying API design problem)

extension UIColor {
  func with(hue: CGFloat? = nil, saturation: CGFloat? = nil, brightness: CGFloat? = nil, alpha: CGFloat? = nil) -> UIColor {
    ...
  }
}
1 Like

I think this is possible, no?

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

To what self refers here, to the original another instance?

it's still pretty limited if compared to a closure, where you can use existing Swift constructs to express the mutation logic

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