[Pitch] Destructuring Assignment of Structs and Classes

This is a very interesting point independent of the discussion about the best syntax, perhaps important enough to be worth fleshing out before even delving into syntax.

In Swift, tuple elements, enum cases, and properties (whether computed or stored) all share the same . operator to access them on a value. However, they carry very different semantics:

  • This can be seen in the case of enums, where you can add static properties that can be accessed with the same syntax as though a case, but they absolutely aren't cases, as revealed readily when it comes to exhaustive switching.

  • In the same vein, when tuples conform to Hashable (the proposal is accepted but not yet implemented), you'll be able to access their hashValue, but that absolutely won't be a tuple element (unless you happen to label an element hashValue shadowing the protocol implementation). And with variadic generics (type sequences) on the horizon, we may be even closer to conforming tuples to arbitrary protocols.

In both of these cases, there is a clear notion of the "structure" part of "destructuring," or to use terminology more commonly used in Swift, what constitutes a tuple's or an enum's value.

We already have similar notions for structures, but it doesn't hinge on whether something is a stored property or not. Indeed, it is actively desirable for the author of a type to be able to switch a property from stored to computed without breaking user code. Rather, we encounter this issue in several places; given a value x of type T with value semantics:

  • What arguments do I need to pass to an initializer T.init(...) in order to construct another value that's interchangeable with x?
  • What are the 'salient' properties of x that are compared with my newly constructed value to determine equivalence? Relatedly, what are the properties of x that are fed into the hasher for Hashable conformance?
  • What are the keys of x that need to be encoded and decoded for the purposes of Codable conformance in order to roundtrip?
  • If the type is LosslessStringConvertible, what properties of x are required to come up with the converted string?

I think there may be a role here for a Value protocol that formalizes and makes consistent the answer to these questions for any given value type. For tuples and enums, there will be one and only one way to conform to Value and it can be synthesized. But for structs, we already know (from existing conformances to Equatable, Hashable, Codable, and LosslessStringConvertible) that the answer to these questions will not correspond to stored versus computed properties but depends on the semantics of the particular type. Indeed, for a given type, there may be stored properties (for example, private ones) that don't participate in some or any of these conformances and computed properties that do.

Now, returning to this pitch, I think we have a distinction to make here: Is the goal here to have a shorthand to access any property, or is it to destructure a value?

If one wishes to destructure a value, then the syntax should very much care that you get all the properties that are part of the "structure" of the value and none that aren't. We would want to work through what that means and whether it merits formalizing in some way with a protocol to which all destructurable values (including tuples and enums by default) should conform.

By contrast, if one wishes to have a shorthand to access any property (and this would include, for example, computed properties such as hashValue on tuples that conform to Hashable), then the syntax should very much not enforce any such thing. But these are, to my mind, two entirely separate--and in some ways incompatible--features.

14 Likes

I can't say I agree with any of that but you should certainly include it as the Motivation in your proposal.

3 Likes

i do this all the time. note that by raw character count this:

var v = currentVehicle
print(v.manufacturer)
print(v.model)
print(v.engineType)

is shorter than this:

var [manufacturer, model, engineType] = currentVehicle
print(manufacturer)
print(model)
print(engineType)

also the former version is more powerful as it allows working with more than one vehicle variable and also allows modifications (v.model = 2021). considering short functions (which i believe is a good thing) normally there is no confusion what a short variable name like "v" means.

note that you can use a single var / let for a number of variables.

otherwise i understand the motivation. a +1/2 from me.

2 Likes

@tera Totally fair points! Agreed that shorter variable names strike a balance of brevity, usefulness (e.g. support modifications), with a hit on readability. Short functions do alleviate that, so perhaps it's clear that more than one principle must be brought to bear to balance all of these concerns. Also v is an extreme example but even curVehic would be really hard to mistake (silly I know, but I've seen worse names in the wild).

I think that a single let / var can be used to declare multiple variables but not assign them to their initial values.

Overall great alternative solution to my problems, and I'll take the +1/2!

One solution could be to introduce a kind of self-map:

let car = Car()
let (model, make) = car.map { ($0.model, $0.make) }
2 Likes

For my part, I see the goal as being to pattern-match based on the contents of properties. I don’t see this being a complete destructuring as especially crucial.

13 Likes

I agree with Becca. The goal should be to make it easier to pattern match on the contents of properties.

I don't think the community is likely to ever accept anything sensitive to the declaration order of properties. The properties you're matching against should be named in the syntax.

Also, it's okay — maybe better! — for the syntax to not look like tuple destructuring. In general, patterns look like the "literal" syntax for creating a value of that kind of type; in this case, that would suggest that it look like a use of the memberwise initializer, although that has the disadvantage of requiring the type name to be spelled out, e.g.:

switch foo {
case Car(manufacturer: let manufacturer, model: let model, ...)
  where manufacturer != "Ford" || model != "Edsel":
}

Perhaps .init would be fine as an alternative. Similarly, it would be nice to avoid repeating the property names, which is the main source of verbosity in that example.

11 Likes

Would SE-0315 placeholder types work here?

3 Likes

I liked the pitch, as pitched, because it was clean. It would serve a similar purpose as https://forums.swift.org/t/lets-fix-if-let-syntax/. We've already got wordy syntax to do this. It doesn't solve new problems. The benefit is in the feature seeming like it's thinking what you're thinking, removing indirection.

Having to specify key paths and new variable names, as Becca and John have suggested, makes me not like the pitch nearly as much. It's a good option, but not a good default. Realistically, you're not going to need to rename things often. The names you pick will match whatever is after the last dot in the keypath. The idea is that you're digging in to a deeper indirected scope, and pulling out to the local scope, not fully remapping.

e.g. We want this, almost always…

convenience computer let (.name, .cpu.numberOfCores)

…not this, always:

convenience computer (let name: .name, let numberOfCores: .cpu.numberOfCores)

I don't encounter tuples like the following often, but you do need the remapping/renaming feature for them because index numbers can't be used as variables names.

let tuple = ("🐸", dog: "🐶", log: "🪵")
convenience tuple (var frog: .0, let .log)
1 Like

+1 <3

1 Like

This is why I thought it would be good to clarify goals here before delving into syntax.

For me, the Car(manufacturer...) notation makes a lot of sense for a destructuring feature that's intuitively about getting back all the salient properties of a value that one puts in, as a sort of counterpart to initializing.

But as a syntax for pattern matching on the contents of arbitrary properties, it's both heavy and kind of...odd. Consider: if case Double(isFinite: let x) = 42.0 { ... }. If this were some edge case, then sure, but we're talking about performing a plain-ol' pattern matching on the contents of a property using a feature whose primary purpose is for pattern matching on the contents of properties.

Now that we've clarified that many (most?) people on this thread just want less verbosity for let x = foo.x, it seems to me that this thread is boiling down to much the same issue as prior threads about shorthands for guard let x = x, etc. This comes up with such regularity that we ought to have some sort of nickname for it; I'll propose to refer to it as the x = x problem.

Perhaps we could look for some sort of generalizable solution to put the x = x problem to bed once and for all. For instance, we could come up with a sort of repetition mark (there isn't one used in English, unfortunately, but in CJK languages there's "々").

6 Likes

Wait, wait! I hope for more than a solution to the x = x problem. :-)

I'd like to do things like operate on a tree-shaped data structure made
from objects instead of enums. Off the cuff example in a variant of
Becca R-G's syntax,

func leftRotate(_ tree: BinaryTree) {
	(left: let l, _..., right: (left: let m, _..., right: let r)) = tree
	tree.left = BinaryTree(left: l, right: m)
	tree.right = r
}

Dave

2 Likes

For my part, that’s not really what I’m looking for. I’m most interested in refutable patterns in places where we use the case keyword. For this purpose, it’s important that the syntax include a subpattern that can either match or bind the property’s value. I think irrefutable patterns would also fall out of any reasonable design for the feature, and I think they’ll be very useful, but it’s not my primary focus.

8 Likes

So, I see the motivation for this pitch, though I don't know if any of the solutions proposed are actually worth the hassle.

The initial pitch has a simple and clean syntax (minus the curly brackets, which as other have mentioned are a no-go), but it has the problem of only letting you destructure a single instance of an entity in a scope.

The subsequent pitches allow for various things like different variable names, weak variables, nested destructuring, etc but have syntax that is, frankly, unwieldy and a little convoluted. You end up with something that is harder to read, harder to understand, and as a result harder to learn than the supposed "boilerplate" it is seeking to replace.

A lot of this does feel a bit like wanting code to be clever and terse over wanting code to be clean and readable. There is nothing really all that wrong with the below unless you deeply care about character count or line count (which you should never do)

let make = car.make
let model = car.model
let year = car.year

The above code is clear, it is simple, it is easy for both a novice and an expert to read and write. And on top of that it is fewer characters than some of the proposed solutions while being more flexible than the others. In a way it is already the best solution to the problem of destructuring an entity into variables.

I think we also need to think about how common the above code is. How often do we need to extract a large number of properties from a struct into local read only variables within a single scope? It's not often. Occasionally you may need quite a few vars but you're more likely to just modify the class or struct directly. The most likely place to need something like that is in guard let/if let statements, but it's rare you're doing that for multiple properties on a single entity.

In many ways the above code is more of a code smell, and it feels a bit like we're asking what perfume to use to disguise it rather than just eliminating the smell (breaking up the method or just foregoing the local variables in the first place). Then again it could just be my usual tendency for "Swift has too many features/too much syntax and should have a ridiculously high bar for new ones" :slightly_smiling_face:

16 Likes

wholeheartedly agree with this sentiment and the rest of what you've just said.

Within Swift, there is a much smaller and cleaner language struggling to get out!

3 Likes

Hi there, OP chiming in here. Thanks for all of the feedback and after reading your comments I no longer think this feature would be a good addition and won't be providing any more input (explanations below). However it was really fun and inspiring to post my first pitch and engage with all of you here, thanks for spitballing with me and I hope to provide more valuable pitches in the future.

While my motivation was not to make Swift like JS, I think my experience with JS heavily drove the motivation. I think there are things in the JS/TS that make this feature valuable that don't carry over to Swift. Just one example, historically React components have had a single state object and a single props object from which you must extract multiple values to render whereas in a SwiftUI view you can simply have multiple @State properties and multiple let properties for "props". The values are already split apart. The problems I've presented are really not that problematic or common in Swift, so this feature does not meet the high bar required for new syntax IMO.

The discussions on this thread quickly got out of scope from the original pitch (which was purely about assignment statements), specifically talking about switching on a struct (or similar property based value). After thinking about it, I don't think this feature makes much sense either. switch works very well for exhaustively handling a set of mutually exclusive cases (eg sum types), and product types / property based values don't really line up with this at all. I don't think we'd ever want to force exhaustivity for switching on / destructuring a struct because then adding a new property would be a breaking API change. As @wtedst mentioned in the first reply, we'd then have to start thinking about @frozen or @unknown for almost every type. I didn't quite understand their point at the time but with exhaustivity in mind it makes total sense.

If you'd like to to enumerate some property values, putting them in a tuple makes perfect sense. You've scoped what properties you'd like to handle, instead of having to think about the whole range of properties available, which can come from many different places outside the original type definition, eg extensions and protocol conformances with extensions.

switch (car.model, car.year) {
// very easy to exhaust these cases compared to any and everything that might exist on car
// in reality you probably want a very limited subset of the properties
}

Additionally, if-else works just as well switching on a struct value and with much less noise. Take the following example that was posted above:

Is this really that much better than:

if car.make == "Porsche" {
    print("Nice car!")
else {
    print("This car was made around \(2022 - car.year) years ago.")
}

It's less code overall and very easy to read. Rebinding the values to variables in the case statements provides very little value: being able to just write year instead of car.year is not a win given the amount of other keystrokes required to get there.

For all of those reasons I am now -1 on this pitch but please continue to iterate if you think there's something valuable lurking here.

Have a nice day!
-Pat

14 Likes

I honestly don't see any particular utility in the OP proposal. Being able to write something like this

let { make, model, year } = car

instead of

let make = car.make
let model = car.model
let year = car.year

doesn't seem, at least to me, to justify the surely substantial changes to the compiler, just for saving a couple of characters. AFAIK, Javascript "type system" is essentially structural, which is not a good match for Swift.

But the idea of destructuring structs for (exhaustive) patter matching purposes is one of the key missing features in Swift, in my opinion, and would be extremely useful, exactly for the same reasons why exhaustive pattern matching on enums is useful.

I would actually look a bit further ahead though: the actual missing feature in Swift, I think, is generalized pattern matching with variable binding. What I would actually like to write, one day, is something like:

let foo = "some interesting string"

switch foo {
case ("some ", let adjective, " string"):
  print(adjective) // prints "interesting"
default:
  break
}

struct Person {
  var name: String
  var age: Int
}

let bar = Person(name: "Foober", age: 42)

switch bar {
case (name: "Foober", age: let age):
  print("Foober is", age) // prints "Foober is 42"
case (name: _, age: let age):
  print("Someone is", age) // prints "Someone is 42"
}

This would be a 2-step process:

  • define a syntax to declare a pattern with variable binding;
  • synthesize patterns for common types and structures (possibly under a protocol conformance, like Matchable).

For example, in the Person and String cases, the syntax could be something like:

func ~=(pattern: (&name: String, &age: Int), value: Person) -> Bool {
  name &= value.name
  age &= value.age
  return value.name == name && value.age == age
}

func ~=(pattern: (&portions: Substring...), value: String) -> Bool {
  portions &= value
  return value == portions.reduce("", +)
}

Although the proposal has basically been withdrawn now, I just wanted to chime in on this because I think I've seen this argument a couple of times. "Saving a couple of characters" was never the point. It's about eliminating redundancy, which is not about terseness or brevity for its own sake, but rather reducing opportunities for error.

5 Likes

I think for nominal types, you'd want to be able to delegate the destructuring to the type (in the same way that construction is delegated to the type). I'm thinking of something like Scala's unapply function that's used for object extraction. That would also be useful for generalised parsing/regexes in the future.

I share what you said.

On the apple web site it is written:
"Swift is a robust and intuitive programming language...
Swift is easy to use..."

But nowadays it is already not so easy and intuitive for newcomers in many places. The problem we discuss is not so big in general, but solutions complicate the language. May be in some projects it is needed more often than in others. But those people can write about ten functions to achieve the goal, like this one:

func destr<T, A, B, C>(_ instance: T, _ body: (T) -> (A, B, C)) -> (A, B, C) { body(instance) }

let (model, year, brand) = destr(car) { ($0.model, $0.year, $0.brand) }

We can also write some other functions to make code shorter when we don't need destructuring:

func doWith<T>(_ instance: T, body: (T) -> Void) { body(instance) }

func mutate<T>(_ instance: inout T, body: (inout T) -> Void) { body(&instance) }

doWith(car) {
  print($0.model)
  print($0.brand)
}

mutate(&profile) {
  $0.name = "personName"
  $0.birthDate = "01.01.2021"
}

Almost always we don't need to destructure all properties of a class or struct, we need only several of them.

In my own experience the need of destructuring is not so often. In our codebase about 55k lines of code it can be used in less then 10 places.

1 Like
Terms of Service

Privacy Policy

Cookie Policy