Multiple assignment with tuple doesn't work with optional chains

Why this works

struct Foo {
  var bar: Bar

  struct Bar {
    var baz: Int
  }
}

var foo1 = Foo(bar: .init(baz: 42))
var foo2 = Foo(bar: .init(baz: 43))

(foo1.bar.baz, foo2.bar.baz) = (1, 2)

but this doesn't?

struct Foo {
  var bar: Bar?

  struct Bar {
    var baz: Int
  }
}

var foo1 = Foo(bar: .init(baz: 42))
var foo2 = Foo(bar: .init(baz: 43))

(foo1.bar?.baz, foo2.bar?.baz) = (1, 2) // error: Cannot assign to immutable expression of type 'Int?'
2 Likes

what would happen to the assigned values if bar is nil?

Same thing (nothing) that happens when you write:

foo1.bar?.baz = 1 // compiles fine
1 Like

Things tend to break down with assignment after a ?, regardless of tupling. :dumpling:

foo1.bar? = .init(baz: 1) // compiles
(foo1.bar?, foo2.bar?) = (.init(baz: 1), .init(baz: 2)) // Cannot assign to immutable expression of type 'Foo.Bar?'
foo1.bar?.baz? = 1 // Cannot use optional chaining on non-optional value of type 'Int'
1 Like

But should it? What's the difference here between these two lines?

foo1.bar? = .init(baz: 1) 
foo1.bar = .init(baz: 1) 

IMHO the first line should be an error.

"? =" only makes (re)assignments to non-nil values.

3 Likes

Ah, I keep forgetting about this peculiar sequence (it feels very different compared to other operators). Is it in the swift book at all?

Sounds to me like it’s an implementation limitation that this usage of optional assignment isn’t supported, not a deliberate language design choice.

optional mutation is different from optional assignment, because mutations can report whether or not they were written via optional chaining:

return typeWithSmartSubscript[id]?.update(with: foo) ?? .rejected

almost every time i remember trying to assign through an optionally-chained property, it was because i was forgetting something important.

Technically you can check whether assignment was done or not:

struct Foo {
    var bar: Bar?

    struct Bar {
        var baz: Int?
    }
}

var foo1 = Foo(bar: nil)
var foo2 = Foo(bar: .init(baz: 42))

let done1 = (foo1.bar?.baz = 1) != nil
let done2 = (foo2.bar?.baz = 1) != nil
print(done1, done2) // false true

But it is rarely done this way (perhaps it's not even documented in the swift book).

Are you suggesting that instead of fixing the second line we should make the first line an error / warning?

foo1.bar?.baz = 1                       // ✅
(foo1.bar?.baz, foo2.bar?.baz) = (1, 2) // 🛑

Note it would be a breaking change if it becomes an error.

Can you do that? I thought the assignment operators return Void.

Apparently not always:

let done1: Void = foo1.bar?.baz = 1  // 🛑 Cannot convert value of type '()?' to specified type 'Void'
let done2: Void? = foo1.bar?.baz = 1 // ✅
var someX = 0
let done3: Void = someX = 1          // ✅
2 Likes

That's not just with =, but anything with assignment: true.

var variable: Optional = 0
(variable = 0) as Void
(variable? = 0) as Void // Cannot convert value of type '()?' to type 'Void' in coercion
precedencegroup 🎩PresidentPresentGroup🎁 {
  assignment: true
  higherThan: CastingPrecedence
}

infix operator ≟: 🎩PresidentPresentGroup🎁
func ≟ <🎩, 🎁>(_: 🎩, _: 🎁) { }

variable ≟ 0 as Void
variable? ≟ 0
variable? ≟ 0 as Void // Value of optional type 'Void?' must be unwrapped to a value of type 'Void'
1 Like

wow, I didn't know that

double wow, so an assignment that "silently fails" due to the nil-ness of the left-hand side returns Void?... it would be interesting if the value was also nil (internally, it's still ()). (CORRECTION: if no assignment took place the value resulting from the assignment is nil).

do you think this should be considered a bug then? how can I report it?

1 Like

Which value?

It's not a bug. The "scope" of an optional chain is defined to only propagate out from the base of a postfix expression and from the LHS of an assignment operator. That does not include tuples, so the compiler is correct to not allow this.

Now, since this syntax is currently invalid, we could always add it to the language. If you want, you can pitch it and try to take it through the evolution process. I don't think the Language Workgroup has ever taken a position against this idea specifically. I do personally have some concerns about creating inconsistencies between the treatment of tuples by optional chaining in different places, and I'm not sure it's clear enough to be worthwhile. That's that sort of thing can sometimes be answered in pitch.

But it's not a bug that this doesn't currently work.

9 Likes

I was wrong, the resulting value of the assignment is actually nil if no assignment took place:

struct Foo {
  var bar: Bar?

  struct Bar {
    var baz: Int
  }
}

var x = Foo(bar: nil)
var y = Foo(bar: .init(baz: 0))

let value1: Void? = x.bar?.baz = 42

switch value1 {
case .some:
  print("value1: some")

case .none:
  print("value1: none")
}

let value2: Void? = y.bar?.baz = 42

switch value2 {
case .some:
  print("value2: some")

case .none:
  print("value2: none")
}

// prints:
// value1: none
// value2: some

This might actually be interesting, because it tells, at value level, if an assignment took place or not.

1 Like

Maybe not, just an unexpected feature. People could rightfully expect this:

(foo1.bar?.baz, foo2.bar?.baz) = (1, 2)

to be equivalent to that:

foo1.bar?.baz = 1; foo2.bar?.baz = 2

and it is not.

As much as I think I probably never want to read this code

…is there another example in the language where == compiles but = does not, with a variable on the left? That's the aspect which makes it feel most wrong to me.

I use this type of assignment (without optionals) when I have some conditional branching that would produce different assignments for multiple variables in each branch. Consider this code

/// these are actual variables, they are not `let`, for example they can be properties of a class
var firstVariable: Int = 42
var secondVariable: String = "yello"

switch someCondition {
case firstCase:
  firstVariable = 43
  secondVariable = "O"

case secondCase:
  firstVariable = 44
  secondVariable = "M"

case thirdCase:
  firstVariable = 45
  secondVariable = "G"
}

the main issue I see with this is the fact that one could forget an assignment in one of the branches, and the code would compile. But in this case

(firstVariable, secondVariable) = {
  switch someCondition {
  case firstCase: return (43, "O") /// `return` will disappear in a future version of Swift
  case secondCase: return (44, "M")
  case thirdCase: return (45, "G")
  }
}()

the compiler will complain if cases are not homogeneous.

2 Likes