[Pitch] Destructuring Assignment of Structs and Classes

Hi there! This is my first pitch so any feedback on the content, structure or presentation are very much welcome. I couldn't find any existing pitches on this topic though I'm sure it's been discussed. If there have been any material discussions on this topic feel free to point me to those threads. Thanks!

Introduction

Destructuring assignment is a language feature that allows you to extract multiple parts of a value and assign them to multiple variables within a single assignment statement. Swift already supports this feature for destructuring a tuple into multiple variables:

let (make, model, year) = ("Subaru", "Outback", 2017)

Which is equivalent to the following:

let car = ("Subaru", "Outback", 2017)
let make = car.0
let model = car.1
let year = car.2

The introduction of new variables is helpful as it gives more meaning to the values car.0 and car.1 which can be easy to mix up. You can see how destructuring syntax makes this pattern more ergonomic. While this is nice for tuples, destructuring is not currently supported for structs or classes which are very commonly used by Swift programmers.

The Pitch

I'd like to propose adding destructuring assignment for both structs and classes which would look something like this:

struct Car {
    let make: String
    let model: String
    let year: Int
}

let car = Car(make: "Subaru", model: "Outback", year: 2017)

let { make, model, year } = car

The last line would be desugared to the following:

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

The benefit here is slightly different than tuples as car.model is already well-named at the point of use, but if used frequently the car. part can become a bit noisy, especially with longer variable names. Pulling properties out into local variables is a fairly common way to get around this (in my experience) but requires redundant typing of the property/variable name:

let model = car.model

Destructuring allows the programmer to express this intent without the redundant keystrokes, and the savings scale linearly with the number of properties * the length of their names. This encourages the use of more descriptive variable / property names as well as extracting properties into local variables when used frequently. My opinion is that this is a net positive for both writing and reading Swift code.

There are a few key differences between destructuring structs vs tuples I'd like to point out. Destructuring a tuple is index-based and order matters, so the first variable declared on the left is assigned to tuple.0 the second variable on the left is assigned to tuple.1, and so forth. The destructuring of a struct/class is based on the names of the properties and order does not matter. This means the variable names must match the property names exactly. So the following line would still be valid in the example above:

let { year, model, make } = car

How ever the following would not compile:

let { manufacturer, model } = car //Error: Value of type 'Car' has no member 'manufacturer'

Additionally, I'm using curly brackets to wrap the variable names here (as opposed to parens for tuple destructuring) to notify the programmer that there are different rules involved. Structs/classes are opened/closed with {...} whereas tuple types are wrapped with (...), so I'm trying to reuse those visual indicators.

You do not have to extract every property in the assignment, you can extract whatever subset of values you'd like:

let { model, year } = car

Deeply Nested Values

Destructuring can be used to extract deeply nested values as well. Consider the following example:


struct Computer {
    let name: String
    let cpu: CPU
}


struct CPU {
    let speed: Double
    let numberOfCores: Int
}

let computer = Computer(
    name: "MacBook",
    cpu: CPU(speed: 2.8, numberOfCores: 8)
)

let { name, cpu: { numberOfCores } } = computer
// name: String
// numberOfCores: Int
// cpu is not introduced as a new variable, it is merely used to gain access to numberOfCores

Any time a nested value (like the CPU here) is being destructured, a new set of curly brackets is introduced for clarity.

Assigning Methods

Destructuring should work just as well for methods as it does for properties. The destructuring assignment is always desuraged to a normal assignment with dot notation. So if the Car struct had a method func start() { } method, this would also work:

let { start } = car 
// start: () -> ()

This would breakdown if start were generic, you'd need a way to annotate the variable on the left to provide the type, so we might simply disallow this. Discussion on this scenario is welcome, though I imagine that destructuring against generic functions would not be a common use case.

var vs let Declarations

Destructuring can be used with both var and let declarations. Whichever annotation is chosen is applied to all variables in the assignment.

var { make, model } = car

is the same as

var make = car.make
var model = car.model

Dynamic Member Lookup

If a struct or class is declared with @dynamicMemberLookup, destructuring assignment can be used against it with any arbitrary variable / property name. As a general rule, if the desugured code is valid, then the destructuring syntax is also valid.

Prior Art

Swift already supports destructuring for tuple values, so it already has a foothold in the language design. Because nominal types are used very commonly in Swift, it could be a nice addition to extend this feature to support them as well.

Destructuring assignment of object values has become incredibly popular in the JavaScript and TypeScript communities. While Swift and JS/TS are very different languages, they are not so dissimilar in the syntax for declaring variables and accessing properties, so some motivations may overlap in this area.

Not Included In This Pitch

This pitch solely covers property-based destructuring of structs and classes in assignment statements. It does not include destructuring of nominal types in function / closure parameter positions (like tuples in Swift and objects in JS), though this could be considered in a future pitch.

This pitch does not include index-based destructuring of Array values which is commonly found in other languages like JS and Python. In Swift, such a feature could trap on an invalid index so it would need to be handled with care.

Implementation

This feature could be implemented as an AST transformation in the compiler frontend. I don't yet have any experience contributing to the Swift compiler, so I don't have any more specifics at this time. If this feature is accepted, I would love to take a stab at implementing it with some guidance.

Effects on ABI Stability

This is purely a source level change so would not introduce any breaking changes to the ABI.

Effects on Source Stability

This feature is an additive change, so should not introduce any break any existing source files.

Effects on Compiler Performance

While {...} syntax overlaps with other scenarios (e.g. defining a closure), it's placement to the left of an assignment operator should preclude any ambiguity and minimize effects on compiler performance.

Alternatives Considered

The primary alternative to adding this feature would be not to add this feature. Maybe everyone is happy with the current state of things, and that's ok.

Thank you for reading this pitch! I look forward to reading your comments.

[Edit] fix some typos
[Edit] add sections for var vs let and compiler performance

19 Likes

@wtedst I'm not sure I understand the problem here. Your example would be re-written by the compiler as

let radius = circle.radius
let diameter = circle.diameter

It doesn't matter if these are stored or computed properties. It's just a more terse way of writing the variable declaration + dot notation access. Making one of these fields private in a future version of the library would break both methods of assignment, not just the destructuring one.

Perhaps I'm missing something. Could you kindly provide any more details on the issue you're describing?

7 Likes

As an additional note, I don't see any reason why this feature couldn't also be applied to protocols / existential values. For any valid variable declaration assigned to a property access expression, there is also a valid destructuring assignment that could be written.

3 Likes

Great stuff. :+1:

Although what you presented doesn't address this use case, this would allow for elimination of name redundancy with SwiftUI's Environment, given initializers that incorporate destructuring.

I'm not sure curly braces are the best syntax for it, but I don't care too much. I'll let others party in that bikeshed.

Eh, it seems like work is being put into getting that working, but it's in a buggy mid-process state. (I haven't been following this; maybe it's always behaved just like the following?)

let (a: Int, b) = (1, 2)
// Cannot find 'a' in scope
Int == 1 // true 🙃 I don't think anybody needs a labeled statement here.
b == 2 // true
1 Like

@Jessy Thanks for the input! I think you've uncovered a few issues here.

I'm not sure exactly what you mean about Environment, a code snippet would be helpful. But, in thinking about property wrappers, this statement is no longer true:

"For all var declarations assigned to a property access expression, there is also a valid destructuring assignment"

You would be able to use destructuring to get the projected value, but you couldn't access the property wrapper itself.

struct MyView: View {

   @State var carState = Car(make: "Subaru", model: "Outback", year: 2017)

   var body: some View {

      let { make } = carState // works
      let { $carState } = self  // won't compile
   }
}

The second line in body would give you the following error:

Cannot declare entity named '$carState'; the '$' prefix is reserved for implicitly-synthesized declarations.

So this proposal as is would not work when attempting to extract a property wrapper. This asymmetry around leading $ being valid for the wrapper name but not a variable name is rather annoying but I can see why it's necessary to differentiate between wrappers and wrapped values.


This is definitely surprising to me, and seems like a bug. But your motivation is sound and I think the destructuring in this proposal should also support such type annotations.

let { name: String, cpu: { numberOfCores: Int } } = ...

There may be some ambiguity around the colon here, sometimes it's introducing a type annotation and sometimes it's introducing a nested value. But I think the opening curly bracket for a nested value can be used to disambiguate the two cases.

Another item that should be considered in the pitch is when a type annotation is needed on a variable declaration to provide info to the constraint solver. I think this could be handled pretty simply like so:

let { make, model }: Car = funcWithGenericReturnType()
1 Like

Take the example at the top of the Environment documentation.

@Environment(\.colorScheme) var colorScheme: ColorScheme

We can already leave off the explicit type.

@Environment(\.colorScheme) var colorScheme

But we can't leave off that key path. The current version of the language has no mechanism for leaving it out of a call to this:

@inlinable public init(_ keyPath: KeyPath<EnvironmentValues, Value>)

I'm saying that, because your destructuring is, amongst other things, shorthand for using key paths, a fully-realized implementation of what you're proposing would allow us to simplify down to this:

@Environment var colorScheme

And code completion should work with that, even though I'm not sure that code completion is possible for any of your examples. Is it, in other environments? I don't bother destructuring much in Swift, partially because of the tedium of having to spell correctly or copy+paste.

1 Like

@anon9791410 Thanks, I see what you mean. I think this is a bit orthogonal to the pitch but it certainly would be nice.

Regarding code completion, it doesn't work the current version of Swift because tuple destructuring is totally based around the index and not any sort of name, even if the tuple has labels. So the variable name on the left is totally up to you, completion won't help you there.

But regarding this pitch, I definitely would want code completion to work in this case:

let { } = car

If I start typing between the curly braces, the compiler should auto suggest completions for the properties on car. This is exactly how it works in TypeScript, and it's very nice.

let { m /* suggestions for make or model*/ } = car

Perhaps it would help to see what exactly I'm seeing your pitch being a shorthand for. (This stuff is uncommon, but compiles.)

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

Those aren't key paths, but they could be individually.

let make = (\.make as (Car) -> String) (car)
3 Likes

@anon9791410 That's not exactly the generated code I had in mind, though it would work the same. I am thinking of it this way:

The following line

let { make, model, year } = car

is expanded to these lines before compiling the rest of the function:

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

This also bypasses the overhead of allocating a tuple and invoking a closure.

Editted this post to simplify.

I think this idea favors terseness over clarity, which is something Swift generally tries to avoid. Specifically, if a programmer without experience in ES6 were to come across this syntax, they would likely be confused. Worse, it would be hard to search the internet for what it means because this syntax is based on curly braces (in contrast to pattern matching, which — while it is a bit “magical” — has the case keyword so that it is easily searchable). I remember that I had trouble decoding this syntax when I first encountered it in JavaScript.

I think this use of curly braces is too JavaScript-y and not very Swift-y. In Swift, curly braces denote a change in scope, and that there is either a code block or a group of type declarations within it. The only exception to this I can think of is Unicode scalar escape syntax within a string literal (e.g. "\u{2665}"), which I consider to be a mistake (it would align better with the rest of Swift’s syntax if it had used parentheses instead of curly braces). This usage of curly braces is inconsistent with the rest of the language — especially since the declared variables “escape” the curly brackets.

I’m not sure if this syntactic sugar would be used often enough to justify its existence. If redundantly typing a variable’s name is an issue, perhaps it can be solved by creating a new variable with a smaller name within a local scopes, or by using a closure.

let car = aVariableWithAReallyLongNameThatRefersToACar
let make = car.make
let model = car.model
let year = car.year
let (make, model, year) = {
    return ($0.make, $0.model, $0.year)
}(car)

I realize you’ve expressed concerns about the overhead of invoking a closure and allocating a tuple, however, I’d be surprised if the compiler didn’t optimize this out (at least in optimized builds).

Have you thought about how this would work with pattern matching? Currently, every variable assignment in Swift is a pattern matching statement: usually an identifier pattern combined with a value-binding pattern or occasionally a wildcard pattern. The tuple destructuring syntax is actually a tuple pattern. Would this be an exception? Or would you be able to use this in other pattern matching contexts?

switch car {
case let { make } where make == "Porsche":
    print("Nice car!")
case let { year }:
    print("This car was made around \(2022 - year) years ago.")
}

Also, would variable reassignment be allowed? E.g.

var year = 2021
{ year } = car // without `let` or `var`

This would make it more analogous with the tuple destructuring pattern (which also allows for variable reassignment). However, that’ll also make it more confusing for people unfamiliar with the syntax. I’m not a compiler engineer, but this syntax might also be hard to implement in the compiler (and, more importantly, hard to implement diagnostics for) since { year } is also a valid closure.

10 Likes

@1-877-547-7272 Thanks for the detailed response! I think you make a lot of great points. No doubt my desire for this feature stems from my experience with ES6, and someone who isn't familiar with it might be confused. You could make the same argument that someone who doesn't have React experience will take longer to pick up SwiftUI. Ideally the best and most useful patterns win out, even if they're from another platform.

I think the curly brackets are intuitive in JS because they are used to define an object literal and also destructure it. The same is true for Swift tuple literals and tuple destructuring. The same cannot be said for Swift structs and { } destructuring. Admittedly, the syntax is very difficult to search for and there's no "jump to definition" capability here. I was confused at first as well, but now I'm very grateful I picked it up.

In terms of this syntax not being used enough to warrant it, I'm not sure I agree. Since it's introduction into JavaScript it's become nearly ubiquitous in modern codebases. Indeed the airbnb eslint config, one of if not the most widely used eslint configs, enables prefer-destructuring by default. That's a massive group of engineers who prefer this syntax over the alternative. Again I know that is a different ecosystem but Swift and JS are very similar in the realm of variable declaration and property access, so maybe folks would prefer it in Swift as well.

On the other hand, object destructuring is used very heavily in the function parameter positions in JS which is maybe where it creates the most value. The function can accept a large type but narrow it to only the fields it needs. This pitch does not allow for that.


The confusion of having { } look like a new scope but any variables declared within it escape into the enclosing scope could certainly be counter intuitive. I'm open to using other characters here but I think ( ) could also be confusing because it looks like tuple destructuring. It could be dangerous to confuse the two and think you're getting property matching variables but actually be getting index matching.

let ( make, model ) = car

You have no way of knowing if car is a tuple or struct at the point of use and can't determine if the behavior is correct. So I didn't want to overload the tuple destructuring syntax but I definitely acknowledge your concerns with let {...}.


This is seems like a lot of code for what it's doing and many of the words are duplicated. I'm not convinced the verbosity here creates more clarity, quite the opposite. In comparison:

let { make, model, year } = aVariableWithAReallyLongNameThatRefersToACar

This seems to strip away all of the noisy boilerplate for something clear and terse. All of the same words are present, but are only typed once. There's also no intermediate variable car polluting the rest of the scope.

Also, you could make the argument against tuple destructuring and just make everyone write multiple assignments. But Swift currently supports tuple destructuring, presumably because it creates some level of convenience. I think this convenience could be extended to nominal types which are used much more frequently (in my experience).


I would not recommend using this as a replacement because it would be too easy to switch the order of make and model and create a bug, since they have the same type. I would rather recommend the multiple assignments, but that is also prone to mismatching such as let model = car.make. The proposed destructuring assignment is the only thing I've seen on this thread that prevents a wrong name / same type mismatch. So it's more likely to be correct, not just more terse.


I wasn't aware the every variable assignment was a pattern match! That being the case, it seems this would have to work in pattern matching as well.

This seems sensible and useful to me and also analogous to how tuples can be used in matching. I'm all for it.


Yes, if it's allowed for tuples it should also be allowed for structs. Proposing that something analogous to existing behavior would be confusing doesn't ring true to me. If it's confusing for structs then it's confusing for tuples as well.


Under no circumstance that I'm aware of can you declare a closure on the left hand side of an assignment operator. Again you could make the same argument about (year) being a valid tuple or a destructuring, but that can be figured out by it's relation to an adjacent assignment operator if one exists. I'm sure there are other token sequences that are ambiguous without proper context.

Sorry for such a long post. I really wanted to address each of your points as you bring up some great concerns. After getting through all of them, I am still in favor of adding this feature. If I can summarize in a few words:

  1. Yes it's inspired by JS but it's proven to be an incredibly useful and popular approach to variable declarations. Perhaps the use of curly braces is not ideal, I'm open to other options.

  2. I believe it cuts down on boilerplate without sacrificing, and possibly even improving clarity.

  3. It reduces the chance of same type / wrong name mismatch bug which is apparent in multiple assignment statements or round tripping through tuple destructuring. So this syntax promotes correctness where those approaches fall short.

  4. It builds on the existing Swift syntax for tuple destructuring and follows the same patterns. If we see destructuring as fundamentally flawed and confusing then we do we already support it for tuples?

Thanks again for taking the time to reply to this pitch!

3 Likes

It seems to me to be of fairly limited utility because there is no way to give the local variables a different name to the object properties. This causes two major problems:

  • You can't destructure two different Cars at the same time because all the names will collide
  • The property name may not be a good name for a local variable, e.g. year might be a confusing name in local context.

Perhaps if there was an optional way to specify a different name for the local variable, perhaps inspired by function parameters having different external labels to their internal names, then I would see more value.

I'm also suspicious about giving a new meaning to the curly brackets, which might not technically be ambiguous now but can be confusing or conflict with a desired feature in future. We've already seen in this thread that using the colon for both specifying types in parameter lists and specifying labels in tuples means that let (a: Int, b) = (1, 2) has an unintuitive meaning (i.e. assigning 1 to a local variable called Int, shadowing the Int type).

I'll also note that, while not as convenient as a built in-language feature, with a small amount of manual work you can get most of the way there:

struct Car {
  let make: String
  let model: String
  let year: Int
  
  var properties: (make: String, model: String, year: Int) { (make, model, year) }
}

let (make, _, modelYear) = c.properties

which also suggests a possible alternative proposal where the properties come out in a fixed order but you can give them arbitrary local names.

5 Likes

Maybe the way to go is a TupleRepresentable protocol with init(tupleValue:) initializer and tupleValue property, which are automatically synthesized in some cases.

6 Likes

This was discussed in a previous thread, and our consensus was that the closure capture list syntax would work best for this. For example

final class C {}

struct Car {
    let make: String
    let model: String
    let year: Int
    let reference: C
}

let car = Car(make: "Honda", model: "E", year: 2021, reference: C())

let [make, model, year] = car

This would allow for binding to different names and also weak and unowned modifiers. This would also be less invasive to the parser I hope, since you'd be able to use parts of the grammar dedicated to capture lists:

var [weak weakReference = reference] = car

Alternatively, it could look closer to the dictionary syntax:

var [weak weakReference: reference] = car
9 Likes

wouldn't the "with" syntax be more concise and ergonomic to achieve the same goal?

var { year, model, make } = car
print(year)
print(model)
print(make)
year = 2021 // doesn't change car

vs

with car {
	print(.year)
	print(.model)
	print(.make)
	.year = 2021 // changes car
}
3 Likes

+1 for the ability to destruct instances based on their accessible properties (I wouldn't call it "struct and class" destructuring, you should be able to destruct anything, e.g. labeled tuples by their labels, enum instances by their computed properties or actor instances). +1 also for the curly braces syntax.

I'd like to point out some features that tuple/enum destructuring has in pattern matching, that could be mirrored to property destructuring as well. If Car, Generic and Cases are defined as

struct Car { let make, model: String; let year: Int }
struct Generic<T> { let a: T; let b: Int }
enum Cases { case foo(name: String, age: Int }
  • ability to specify var/let separately

    if case (let x, var y) = (1, 2) { … }
    
    if case { let make, var year } = car { … }
    
  • ability to specify a type to be matched

    func bar<T>(_ x: (T, T)) {
      if case (let x as Int, _) = x { … }
    }
    
    func baz<T>(_ x: Generic<T>) {
      if case { let a as Int } = x { … }
    }
    
  • ability to use different names

    if case .foo(name: let theName, age: var theAge) = cases { … }
    
    if case { year: var theYear, make: let theMake } = car { … }
    

This could be simplified to

switch car {
case { make: "Porsche" }:
  print("Nice car!")
}

I don't think type annotations should be supported in destructured assignments (they should be only available in patterns using as, mirroring what we can currently do with tuples). Tuple assignments do not support them and you're only allowed to annotate the type of the whole assignment:

let (a: theA, b: theB): (a: Int, b: Double) = (1, 2)

let { name: theName, cpu: { numberOfCores: theNumber } }: Machine = machine
1 Like

Thanks for sharing this! I'll need to read through the thread but now I'm learning towards square brackets / capture list over curly braces as the ability to rename the variable and also apply capture modifiers like weak would be great. On a quick scroll through the thread, I didn't see any code examples for extracting a deeply nested value, so we'll want to make sure that's considered.

I had initially shied away from square brackets because there is prior art for using them to destructure arrays, and this design would preclude ever adding that feature (at least with [ ] syntax). But that might be ok as array destructuring is far less common than with objects.

let [ first, second ] = [1] // second would trap here, that may not be obvious to some

let [ head, ...tail ] = [1, 2, 3] 
// head = 1, tail = [2, 3]
// this is common in JS / Python and would be nice to consider

I'm dubious of any design that uses tuples as the vehicle for destructuring. Because labels are not enforced as a part of a tuple's type, it's simply too easy to mix up two values that have the same type.

let (model, make) = car.tupleValue

This code would compile but would contain a bug if I got the ordering wrong or if the ordering of the synthesized tuple members was changed whatever reason.

I'm not opposed to with syntax in general, but I don't believe that introducing a new scope is more ergonomic than destructuring into the current scope. It also contains significant new syntax for .leadingDotNotation for non-static members.

Precisely! I had not generalized the idea when creating the pitch, but you are right that anything with properties should be able to be destructured and pattern matched in this way. Perhaps the right name for this feature is "property based destructuring".

This just shouldn't compile since arrays don't have a property named second, unless an extension with such property is in scope.

I would expect this to work though:

let [ first, last ] = [1]

binding both first and last to constants of type Int? having a value of .some(1).

@Max_Desiatov Apologies, I'm referring to prior art from JS which uses { } to destructure based on property names and [ ] to destructure arrays based on index. If we adopt [ ] for the former, we lose the ability to implement the latter, at least with the same syntax.

I think it's become obvious though, that { } for property destructuring in Swift doesn't make sense as curly braces are used for scoping and not much else.