[Pitch] Introduce `let-else` syntax as alternative for single expression `guard-let-else`

The question is really if having both is just overkill for some use cases.

You have presented your preferred layout. Everyone has their own preferences, of course.

I find it virtually unreadable though. I can't see where variables are declared, or what is declared, without parsing the whole chunk of code in my head. You can't skim that code to find something.

Ultimately, the most important piece of information is the variable name which is being declared. Consider that the "title" of the statement. I can go from that to details like constancy, errors etc. For me, having those "titles" clearly and consistently visible is important. Swift is the only language I have ever used that does not offer this option.

Pretty clear I am the only one though, so will call it a day on this one.

Update: Thought I would relayout your preferred style too, to show that even there, things become much more readable...

let store = sharedNotesStore else {
    throw Error.noSharedNotesStoreAvailable
}

let blobs: [SharedNote.Blob] = [.init("ABC")]
let data = section.propertyDictionary.json else { 
    throw Error.serializationFailure
}

let key = UUID().uuidString

let section = section(withIdentifier: noteIdentifier, data: data) else {
    throw Error.noSectionFoundForNote
}

let sharedNote = SharedNote(
    store: store, 
    section: section, 
    key: key, 
    blobs: blobs
)

Even with plenty of space, and a vertical orientation, removing the guards makes this code 10x easier to parse quickly. What is "section" again? Boom! You are right there. And I can immediately see it can potentially fail to be fetched, and throw.

Giving the "guard" keyword so much prominence is in my view a complete mistake. The correct thing to focus on is what is unique. It's the variable name. The thing you will be looking for 90% of the time.

3 Likes

A differing viewpoint: The important piece is interpreting the code - how the variable is initialized and used. The name of the variable should be documentation toward that purpose.

So there is a trade-off in people's minds - while this may slightly speed up tracking down where the variable is first initialized, it makes it significantly less clear that there is a failure condition present. This is especially obvious when using a single line - the "else" keyword is the only thing that visually differentiates a failure condition and a closure being passed to a function.

But if you can't even find where that happens, you are wasting time. I can find and determine the how much faster if the line is clearly 'labeled' by the variable name.

I see code as an executable document. Can you imagine trying to navigate a document in which people used language keywords as section titles. Eg every section of the document had the title 'Font'. To find the actual title, you had to dig into the paragraph text.

Although we all say these are subjective choices, in actual fact, they are not. They are fundamental parts of design. A language is best served by following the principles that have been developed in printed documents over centuries. A newspaper doesn't begin with typesetters command like "Column Block". It begins with what is most important, the unique title of the piece.

And so it is with code. A function begins with a short keyword, followed by a label. A variable declaration is best served by a short keyword β€” that bit they got right β€” followed by the title. You can then scan the code as fast as you scan the page of a newspaper.

Again, I point to the fact that failure conditions can be just as buried in Swift today as in this proposal. It's already possible to put a throw or early return deep into a nested if statement. In fact, you can throw just about anywhere. A 'try' is sometimes even more difficult to spot.

The proposal is not to remove the option to use an explicit guard if you prefer that, it's just to offer an abbreviated syntax for the most simple β€” and probably most prevalent β€” case. In this specific case, where you are dealing with a single condition, the else and throw/return is much easier to see than in general code. The guard is in that sense redundant, and distracts from readability for the reasons I mentioned above (code design).

From the comments so far, it seems that the dichotomy is whether statement-level or block-level code clarity is more important.

On one hand is your position that the block is more important. Seeing your variable declarations and having them stand out is more important than knowing that a guard statement is a precondition.

The other position is that it is important to know that a given statement is more than a mere assignment. In the case of guard, the "more" is a precondition, which if not met, does not allow the code to continue.

I find myself liking your pitch because it would allow me to alleviate one of the more annoying code organization headaches I face on a regular basis: interspersing preconditions (guard statements) with variable declarations. The reason this is necessary is the inability to include non-optional assignments in the expression clause of the guard. This means that variables which are non-optional, but which depend on an optional variable must be split into a guard followed by an assignment.

An alternate to your pitch might be to remove this restriction. Allow let and var assignments within a guard statement that are non-optional. This would allow me to have a single guard statement at the top which creates all the variables I will need to query and/or manipulate in the body of the code.

Other uses of guard, such as continuing a loop if a value can't be found in a lookup table for the current key, would be wholly-unaffected.

6 Likes

A little trick you can use to incorporate non-optional assignments is that you can prepend the clause with case. It is useful sometimes, e.g. guard let phoneNumber = person.phoneNumbers.first, case let areaCode = phoneNumber.areaCode, !areaCode.isEmpty else { return }.

(Not claiming that syntax is good or bad, but that is what is possible with the language currently.)

11 Likes

Interesting, I didn't know about this case let trick. How does that work? I thought case let would only work for types you can switch on.

Interesting. I was just thinking about that myself. A bit tangentially to this pitch, but I regularly have the same issue. Always wonder why the non-optional declaration can't just be included.

Did not know about this. Interesting workaround.

I too am not a big fan of this suggestion.

I think identifying the points of exit of program, function, ..., is more important than having a clean alignment of "let".

Furthermore the clean alignment of "let" is actively obfuscating the exit points.

I agree that with throws the exit is obvious at least when the app is running (which is not ideal). But with a simple return it will become a nightmare. A bit like a misplaced "," instead of ";" in certain languages.

5 Likes

I'm -1 on this because guard implies a required potential significant control flow change and this makes them very different than a simple let statement. I wouldn't want to lose the guard as it'd be too much easier to miss this fact, especially when reading someone else's code.

--

Here's a thought experiment: would you be ok with making the if in an if-let-else optional in the same way you're proposing for guard?
I'm guessing the answer is "no" (but that's an assumption and… :)).
The reason I wouldn't toss if there is the same reason guard shouldn't be tossed.

But on the off chance that you'd answer "yes!", then I get to the awkwardness of asking, "would if only be optional if it was an if let foo = ... statement but not if it was an if foo > 10 statement?" Because that being the case would seem very weird (especially in compound if statements where the first clause didn't have a let but subsequent ones did, e.g., if x > 10, let b = optb { ... } ).

In conclusion, like if, guard acts like a flow control statement and let is not. I think it's appropriate to have them be different and require the guard.

(fwiw, I mostly use guard grouped at the start of business logic as preconditions to that logic; perhaps some of that bit of Eiffel I played with stuck… :) ).

5 Likes

Someone had pointed me to https://goshdarnifcaseletsyntax.com which goes through the various uses of case let in Swift. The tip from @bzamayo is the last item on the page (which I must admit, I had never read far enough down the page before to see.)

I also noticed that you can write a guard statement as:

guard
let value = optionalValue else { return }

Although Xcode will automatically indent it as:

guard
    let value = optionalValue else { return }

So you'd need to fight the default indentation to get the let statements to line up.

I don't think let-else is a terrible idea, and at the same time I think the arguments against it (such as "if it's a flow control statement, it should look like a flow control statement") aren't terrible either.

However, I don't think that's the point.

The point is that Swift is an opinionated language. That is to say, Swift-the-language-design will choose which of two roughly similar forms it should implement, picking the one that's "better". Opinionated Swift would refuse to allow both forms unless there was a compelling need to do so.

In this case, there is no compelling need for both forms, and there is no consensus (so far) on which is better, and eliminating the guard form isn't an option because of the massive source breakage.

Therefore β€” my argument runs β€” there is no justifiable pathway to adding the guard-less form to Opinionated Swift.

7 Likes

I'm also -1 on this. Primary purpose of guard is visually marking early-exit block rather than binding a variable. We lose that visual marker by removing guard.

7 Likes

This seems to be a standard response, but there are countless other ways to return early. Eg an if block, a throw or return at just about any location .
If guard was the only early return possible, I might agree. But it is not, and this proposal is not even about removing the guard option. It’s about offering an alternative declaration format with an early return.

1 Like

Aside from the fact that one of the Swift goals is to prefer clarity over brevity, what I really don't particularly like about the proposed syntax is the fact that it subverts the reading flow. In order to understand if a let or var is assigning or binding an optional, you need to read until the end of the line (if the else is on the same line β€” the else may be on a different line, which would make it even worse).

1 Like

On the contrary. The whole idea is to improve the reading flow. As much as you might think you read code like a book, you don't really. You skim it. You skim to the line where that "x" variable is declared. You don't read line for line in general.

The guards obfuscate this "skimming". If the guard has several declarations, it is OK, because. you just indent your eyes for several lines in the skim. But for a single declaration, it can be quite jarring. See the original example to show what I mean. Very difficult to skim.

My feeling is that the whole idea is to improve the aesthetics of the code, not the reading flow. With "reading flow" I mean "understanding what the code does at a glance". With a guard let you know that the value assigned is unwrapped and you can totally skip the else clause if you are not interested in what happens if the condition is not met.
On the contrary, with the proposed sugar, you are forced to look for elses that may be present after an assignment. You cannot skip them in order to understand what the code does. You must read the assignment, then read the else and then go back to the assignment and think "oh, so that was an optional binding, the type of this variable is not Value?, it's Value".

5 Likes

Yea, this is also why many people tend to put control flows at the beginning of the line: guard, if, throw, return, try. While nothing stops you from doing

if cond { return }

it gets easier to miss as the condition grows bigger, albeit not significantly.

I'm a -1 as well here. The guard keyword is an important signal that there may be an abnormal departure from the rest of the flow. None of your guard statement are dependent on the standalone initializations, so there is no reason they need to be written before the guard statements.

This is how I would write the given example code to make it more readable and its intention clearer:

guard let store = sharedNotesStore else { throw Error.noSharedNotesStoreAvailable }
guard let data = section.propertyDictionary.json else { throw Error.serializationFailure }
guard let section = section(withIdentifier: noteIdentifier, data: data) else { throw Error.noSectionFoundForNote }

let blobs: [SharedNote.Blob] = [.init("ABC")]
let key = UUID().uuidString
let sharedNote = SharedNote(store: store, section: section, key: key, blobs: blobs)

-0.5, but I really do empathize the author's intention.
However, at the same time, I guess it is inevitable to have a benefit of guard, which is the clarity.
If you need to look at the end of lines to distinguish if it's unwrapping something or not, the balance of the clarity and the safeness would break and some people would regret in the end writing in that style.
I mean, if you always need to look at the end of lines (which all have different line width)... you know.

I suggest an alternative, let guard.

let guard data: Data = try? parseData() else...
// or
let data: Data = guard try? parseData() else...

The latter is much like async or await.


Although some people are suggesting to declare optionals first to group guards first though, that doesn't really solve this problem as there are cases that you need to use unwrapped value to declare non-optional value(b), then unwrap yet another optional value by using (b):

guard let key = getKey() else ...
let path = getPath(key: key)
guard let data = try? getData(path: path) else ... // <- You cannot declare this without `path`

And this is very problem the author would like to do something with, I suppose.

1 Like

As someone mentioned early in the thread, you can already do exactly that with, of all things, case let.

guard let key = getKey(), case let path = getPath(key: key), let data = try? getData(path: path) else ...

It looks weird at first, but since case let x = y is equivalent to

switch y {
case let x: // This matches everything
   ...
}

It makes sense.