Merits of the "unless" keyword

Got me thinking that a 5-year old is less likely to use "unless" compared to adults. In fact "unless" comes much later in life compared to "if".

Negation never doesn't have a mental tax :slight_smile:

4 Likes

Personally, as someone whose eyes are getting older, I find I'm having to avoid using ! for negation, as it's just not visible enough.

I'd much prefer we had a clear, visible way to express negation that isn't == false.

This entire suggestion is preference, not a mandate that you use it. As with all expressive or syntactic additions to the language: you don't have to use it in your own code.

1 Like

As someone who spent 25 years writing code in a language that has an unless control-flow keyword, I'd like to say that I found it useful, clarifying, and enjoyable. In my experience, the use of unless was not a bigger source of bugs than its plain-old if sibling, and I vastly preferred unless to the if (!(…)) { … } alternative.

@michelf's point about how it'd work with conditional bindings in Swift is worth considering, but it seems surmountable to me.

4 Likes

Also, the variable would have to [also] be bound inside the unless block, otherwise it's inconsistent. In the above example, in the lines after the unless you have to treat message as Optional still - because while maybe it's non-nil and the unless was skipped, maybe it is nil and the unless ran, but it clearly doesn't do anything to ensure message is non-nil. The binding is technically pointless (it'd be clearer to just do if nil == message).

Alternatively, unless with bindings could require that those bindings be satisfied by the unless block. e.g.:

unless let message {
    message = "No message provided."
}

// `message` exists here and is non-nil.

That then relates to my prior point about whether this is better than simply using optional chaining (the ?? operator).

let message = getMessage() ?? "No message provided."

One of the downsides with optional chaining is that - while in some cases it's super succinct and elegant - if you want / need to do anything non-trivial within the chain, it quickly becomes unwieldy. Sometimes you can abstract things out into functions (or similar), so that your core variable assignment w/ optional chaining remains comprehensible, but sometimes that's awkward (e.g. you have a lot of local state that's needed within the expression, so you have to use nested functions). Other times you just want to do something temporary - e.g. insert a print call for debugging - and it's a bit annoying to have to do a substantial refactor for that.

func backupPath() {
    let token = UUID()
    log.notice("No cached token available, created new one: \(token)")
    return token
}

let token = getCachedToken() ?? createToken()

So, maybe unless is useful for those cases.

unless let token = preferredPath() {
    token = UUID()
    log.notice("No cached token available, created new one: \(token)")
}

To be clear, it's largely aesthetic / stylistic, not something that provides unique functionality. That's not to say it's worthless - this may be the difference between clear code and confusing code.

1 Like

I think the vision aspect of this strongly favours not instead of unless, since not can be used anywhere inside a boolean expression, whereas unless merely opens an 'inverted' conditional block.

I'm (mildly) inclined the same way, and I'm trying to understand my own feelings on that. I think it comes to down to explicit comparison against true and false being more difficult to read and more likely to be misunderstood.

if loggedIn { … }

…is substantially quicker and easier to correctly interpret (for me) than:

if loggedIn == true { … }

(even moreso for the inverse forms, with !loggedIn / == false)

This might seem baffling or trite to some folks, especially in isolated examples like this, but remember that in real world conditions you're seeing countless conditional expressions, often much more complicated ones and with complex surrounding code, and you're usually expected to read & understand them perfectly, almost incidentally while doing other things. Even a very small readability problem becomes significant under those conditions.

That all said, I could be convinced this 'difficulty' is not universal and that I might even be in a small minority in this regard. I'd be a bit surprised, though.

I think this also relates to the "clear code is plain [English] code" axiom, that Swift has adopted quite strongly since its inception (following the lead of Objective-C, among other languages well-reputed in this respect). Code that reads like a plain, simple English sentence is far more likely to be correctly interpreted than typical C/C++ code.

if loggedIn and not hasSessionToken() { … }

vs:

if loggedIn == true && !hasSessionToken() { … }

The ideal English version being: "If [the user is] logged-in but does not have a session token…"

2 Likes

It looks like Swift is turning into a natural language with thousands of words. I'm starting to love old good Pascal again. It has a small number of simple rules that can express anything.

1 Like

If your priority is brevity, Swift is not your ideal language, because that is explicitly not Swift's priority.

Per the Swift Fundamentals:

  • Clarity is more important than brevity. Although Swift code can be compact, it is a non-goal to enable the smallest possible code with the fewest characters. Brevity in Swift code, where it occurs, is a side-effect of the strong type system and features that naturally reduce boilerplate.

Swift 1.0 was my ideal language, modern swift is indeed not.

EDIT (addition): BTW Pascal is expressive, not brief.

1 Like

Actually it's an interesting use case, I can give a real-life example where you want to create and execute a task only once but any number of callers should get the same result with or without actually waiting, i.e. as a form of caching of say network calls. Here, task is a property of a singleton-actor:

if task == nil {
    task = Task {
        // task body
    }
}

return try await task!.value

becomes:

unless let task {
    task = Task {
        // task body
    }
}

return try await task.value

which is a bit more elegant and expressive.

However, this means the task after the unless block is its local copy, while the unless block itself deals with the global/property/whatever. But other than that should be doable I think in terms of compiler checks?

1 Like

I suspect unless let … could get rid of quite a few cases of either redundant checks (guard let … when you actually know the variable is already non-nil) or force-unwraps (which are dangerous because the code might change one day to break the expectation, and you might only find out in production rather than at compile time).

This line of function exploration makes me think unless should not permit else clauses; it should be modelled after guard, instead. As noted earlier, if…else already serves the same purpose - merely with the block orders reversed - and is easier to comprehend.

Note though that there are other possible solutions for the optional binding problem, e.g.:

token ??= do { … }

(building on the do expressions idea that's been pitched before)

Though that example has quite a bit more 'magic' in it (the reader and the compiler must understand that if the do expression returns a non-optional value, token is non-optional following that line, etc).

1 Like

Modern Swift is not my ideal language, because it's not the most balanced one.
lang_traits
Go ahead, keep adding new keywords. The balance will only get worse.

1 Like

I'm saying it's impractical otherwise.

If unless x { … } is effectively just an alias for if !(x) { … } then i cannot be treated as non-optional after the unless let i … because every path - including where i is nil - flows through. So the binding is pointless and perhaps confusing - normally if you see let i like that it means you can treat i as non-optional in at least some of the subsequent code (either inside the if block, or for the rest of the scope after guard).

If instead we say that unless let i { … } requires i to be non-nil by the time the next statement is reached - i.e. you have to explicitly set it to a non-nil value inside the unless clause - then i can be treated as non-optional for the rest of the scope. Which is sensible and potentially useful.

In that sense, it's very much like guard simply without the requirement that the rest of the scope not execute if the condition fails.

There's also the option to require a stricter version of this rule, beyond just for optional unwrapping - e.g. it could be that the unless condition must pass by the end of the unless clause. e.g.:

unless loggedIn {
    await logIn()
} // ❌ `loggedIn` must be true before exiting the unless clause.

unless loggedIn {
    await logIn()
    loggedIn = true
} // ✅

While that is technically more consistent and therefore makes the syntax technically simpler, I think it might be going too far. But, perhaps I'm just not yet aware of how that'd be useful. Or maybe it would be better served by a different keyword (dunno what - ensure or somesuch, maybe?).

I just don't understand why people try to push it to C++ level complexity. C++ is already a good example.

1 Like

Okay, we’re done here.

10 Likes