Is this not solvable by producing an error if there is an ambiguity? Adding x:
would cause the break x
to produce an error because of the new ambiguity with the value named x
. I like some of the upsides of using break
that @Val describes, and I’m also dubious of how it reads to use “break” in a situation where the meaning is akin to “return”. But the argument you provided (that it produces unacceptable unpredictability when modifying code) didn’t make sense to me (yet), given the option of diagnosing ambiguity with an error.
That would make existing valid code an error anywhere a label has the same spelling as any identifier in scope at that point in the code by putting them in the same namespace. You could try to limit the error to only contexts inside an if/switch/do expression, but then the diagnostic would have the same action-at-a-distance problems. And regardless, a reader would not actually be able to know what sort of control flow statement break foo
is without context.
This is basically like wondering whether we could “solve” a design where if
, guard
, switch
, and do
are all spelled if
—not sure why this is being entertained 450 replies into a thread about making the language more joyful to write.
If I understand this pitch correctly, it would allow me avoid writing this code:
let data: Data
let response: URLResponse
do {
(data, response) = try await URLSession.shared.data(for: request)
} catch {
throw RequestError.failedToLoadData(error)
}
And instead I could write this much more readable and convenient code:
let (data, response) = do {
try await URLSession.shared.data(for: request)
} catch {
throw RequestError.failedToLoadData(error)
}
Do I understand that right?
For this aspect alone, I'm supporting this pitch. I'm not so sure about the introduction of a then
keyword though, I don't find it very intuitive on first read of the proposal. The aspect I'm most interested in is the allowance of do-expressions. It might be added in a much narrower proposal. I pitched another change here to solve the same problem:
This isn't the pitch for do-expressions. This thread's exploring an extension that would allow the blocks to contain more than a single expression.
This absolutely is the pitch for do
expressions:
I do understand the confusion. I wish there was a proposal that was more narrowed down to do-expressions. I was confused, too, see here. do
expression only seem to be a side aspect.
To me this proposal adds little value with a weird syntax. Its main problem is that the new keyword then
doesn't read naturally. It looks like a linguistic syntax error, especially when then
is interweaved with else
.
Below I propose an enhanced syntax, which is a bit more general and easier to read. I use special symbols which may conflict with the parser. Please consider mostly the introduced features, not the exact symbols.
The main purpose of the new "branch expression" (BE) syntax is to define a "branch value" without repeating the lvalue or an auxiliary constant/variable name. In this respect, a BE looks more like a functional expression, since it tries to anonymize the intermediate variables.
The following code is very similar to the example in the original proposal:
switch scalar.value {
case 0..<0x80: longLValue = 1
case 0x80..<0x0800: longLValue = 2
case 0x0800..<0x1_0000: longLValue = 3
default:
log("this is unexpected, investigate this")
longLValue = 4
}
The purpose is to avoid the repetition of longLValue
, which might be a long string containing subscripts, and also to avoid introducing a new intermediate constant/variable. The abbreviated code shall look as a single assignment, with one composite expression as rvalue.
As already commented, replacing longLValue =
with return
is a NO-GO, but it worths mentioning a few similarities. If the above code was the body of a closure it would look like:
switch scalar.value {
case 0..<0x80: return 1
case 0x80..<0x0800: return 2
case 0x0800..<0x1_0000: return 3
default:
log("this is unexpected, investigate this")
return 4
}
The return value is anonymous. Once we hit a return
, we set the return value and we skip the rest of the code. The return
keyword has the meaning "set and done". (In a pure function, it also has the equivalent but more relaxed meaning: "set the return value and you may skip the rest; you can continue if you want, but nothing is going to change, since the return value is already set and it cannot be overwritten or unset, so all subsequent return
are not effective".)
The above "set and done" semantics are exactly what is needed in place of longLValue =
in the original code, or in a more complex BE body.
Principles of the BE syntax:
- A BE is introduced with the special symbol
.=
. This makes it clear that the rvalue has special semantics for the inner.=
symbols. - The lvalue of the wanted value and the assignent operator are replaced with
.=
, which can be spelled as "set and done". - Effectively the lvalue is pulled before the BE and it is factored out.
- The BE is a single
if
/switch
/do
/for
/while
expression, but its inner body may be complex (as complex as the body of a closure). - All BE expressions (not only
if
) may have anelse {}
part. Theelse
body is executed in case of a miss (no.=
is executed in the main body).
The above example would be abbreviated as:
longLValue .= switch scalar.value {
case 0..<0x80: .= 1
case 0x80..<0x0800: .= 2
case 0x0800..<0x1_0000: .= 3
default:
log("this is unexpected, investigate this")
.= 4
}
As it is common in Swift, .=
may be omitted if it can be inferred (as in the first 3 cases above).
Visually the .=
symbol acts like a continuation marker. The outer .=
continues to the applicable inner .=
, depending on the conditions. Writing the code is as easy as thinking of the quivalent closure and replacing return
with .=
. When reading the full syntax, .=
is interpretted as "set and done". Reading the simplified code after reducing .=
, is more challenging (and often more natural), as with other simplifications in Swift.
Another example:
let first: Int? .= for i in 0..<a.count {
if found(a[i]) { .= i }
} else {
.= nil
}
An interesting extension is that an optional lvalue can take its default nil
value in case of a BE miss, therefore the else
part above can be omitted.
The "set and done" semantics is a good fit to "first-of" type of algorithms, e.g., finding the first index, as in the example above. It is not such a good fit to "full-scan" algorithms which need to update a state, e.g., finding the max value.
Another interesting extension is nested BE's. I don't think that this feature worths the effort, and I don't recommend it, but it worths considering it as an exercise. The inner body of a BE can be any body, which could contain another (nested) BE. We can use the symbol ..=
for the inner anonymous value, which allows full flexibility on how the two anonymous variables are set. This flexibility introduces some new challenges, e.g., which part of the code to skip if the inner value is set first. (Another approach is to restrict the syntax like in nested closures, where each return
has a well-defined context.)
Regarding the inner .=
symbol: of course it can be replaced with another symbol or another keyword. I would prefer to have a symbol which allows an extension to nested BE's (just as a possibility, even if it is never implemented).
Regarding the outer .=
symbol: maybe it can be replaced by =
, if the compiler can recognize that the rvalue is a BE. In general I don't like this approach. The rvalue is something different than what we had before. Using the same syntax for the assignment just hides this information. We pretend that nothing changed, although there are new semantics. This approach quite often fires back, since it over-simplifies syntax by adding confusion (and by the way this is a favorable approach in Swift).
A more conventional syntax:
- Replace the outer
.=
with=
- Replace the inner
.=
withbind
BE can be pronounced "binding expression".
The second example above becomes:
let first: Int? = for i in 0..<a.count {
if found(a[i]) { bind i }
}
Why though? It seems to have been rejected because it was assumed to conflict with regular use of return
, but that's not actually the case in practice.
If the grammar do <statement>
wraps the statement in a closure and invokes it, then any return
within the statement is just returning from the implied closure. It doesn't change the semantics of return
.
You never encounter a situation where you need a branch or case to return (from the containing function) and evaluate to something locally (within the function), as returning (anything) precludes evaluating to anything locally.
let foo = if predicate {
return // return from function (`foo` is never initialized)
} else {
do { // this invocation is a single, boolean expression
if x { log("x") } else { log("y") }
return true
}
}
From syntax point of view it should be ok, and it won't be difficult for the compiler to parse it (maybe I am wrong), but for human readers it will be very confusing. An in-place closure also contains return
, but over the years human readers developed the ability to easily recognize the closure pattern. A binding expression is a new pattern, and maybe no more difficult for new programmers to accept it, but for experienced programmers it is more prone to confusion compared to in-place closure. Essentially it is an unnecessary re-learning process for how to quickly recognize the meaning of return
.
Anyway this Pitch is history.
Using do
to introduce IIFEs has been done before, and it's very simple grammar: do <block>
is equivalent to <block>()
.
Anyway this Pitch is history.
Fair enough. Using do
as an IIFE prefix has other usecases, but they're always hard to articulate, because it's useful in lots of little ways.
A “do” section is not a closure, it just hides variables, is used for error handling, and with this pitch it would be used instead of a closure to calculate a result.
That's why I began with if.