But then you run into a similar problem to the one that current switch expressions have (in that you need to refactor to add a single log
statement). In this case it's almost worse cause you have to touch every branch of the switch expression just to add a single log
statement (which you can at least avoid with the somewhat messy immediately-invoked closure workaround). Switches can have a lot of cases, especially in situations where switch expressions make a big difference.
I'm thinking that Xcode could issue a yellow warning and
offer to fix it for us by adding the keywords that are missing.
The longer the keyword, the more dramatic the change of
appearance would be to an extensive switch
.
The question then is if we want a sudden change of
appearance just because of a single log addition.
A quick addition to this everlasting thread: "emit"
instead of "then"
would look more appropriate:
let width = if scalar.value == 0x80 { 42 } else {
log("this is unexpected, investigate this")
emit 24
}
That is if we decide to not to use the bare last expression rule.
Doesn't requiring the keyword on every case just because one case needs it defeat the purpose of using a switch expression over a switch statement?
If you're going to have to add a then value
to the end of every case the moment you do it in one case then you might as well just replace:
let width = switch scalar.value {
case 0..<0x80: then 1
case 0x80..<0x0800: then 2
case 0x0800..<0x1_0000: then 3
default:
log("this is unexpected, investigate this")
then 4
}
With this:
let width: Int
switch scalar.value {
case 0..<0x80: width = 1
case 0x80..<0x0800: width = 2
case 0x0800..<0x1_0000: width = 3
default:
log("this is unexpected, investigate this")
width = 4
}
I think there would still be a marginal benefit in terms of visibly declaring author intent ("this switch
expression exists for the purpose of initializing width
" vs. "this switch
statement contains arbitrary logic, one of the side effects of which is that width
ends up initialized"), but I agree that it defeats much of what I see as the value of multi-statement if
/switch
expression branches (solve the issue of local changes requiring non-local refactoring/modifications).
Having thought about this a bit more I'd like to circle back to an idea that briefly occurred to me earlier in this thread:
We could use the last expression rule, but require that all unused values prior to the final unused value are of type Void
.
Thus, logging is enabled (because the output is Void
)...
let sfSymbolIdentifier =
switch deviceType {
case .mac: "laptopcomputer"
case .iPhone: "iphone"
default:
log("Unexpected device type...")
"questionmark.square.dashed"
}
...intermediate expressions are enabled because they do not result in unused values...
let accessibleRepos: [Repo] =
switch signedInEntity {
case .companyMember (let employee, let company):
///
let isAdmin: Bool =
company
.admins
.contains(employee.id)
///
employee
.personalRepos
.appending(company.publicRepos)
.appending(
isAdmin ? company.adminRepos : []
)
case .individual (let user):
user.personalRepos
}
...imperative control flow is fine because again, no unused values...
let backgroundColor =
switch colorScheme {
case .light: .white
case .dark:
var (r, g, b) = (0.0, 0.0, 0.0)
for value in myCollection {
// ... update rgb values
}
Color(r: r, g: g, b: b)
@unknown default: .white
}
...and in situations where you want to use a static member of the type but precede it with Void
-returning expressions that result in ambiguity, you resolve it the same way as we already have available to us with result builders which is separating the expressions using a semicolon...
let backgroundColor: Color =
switch colorScheme {
case .light: .white
case .dark: .charcoal
@unknown default:
log("Unrecognized color scheme: \(colorScheme)");
.white
}
With this approach we don't have to touch the return value when adding print statements before it, which is nice. We also can't change the return value by adding a line underneath the current return value, because that would make the previous return value an unused value that isn't Void
. This possibility was for me one of the most unsettling aspects of an unrestricted last-expression rule, although at the moment I can't thoroughly articulate why. This approach also generalizes to function contexts if we want it to. And we don't have to suffer the various implications of a new return-adjacent keyword.
Previously, I expressed concern that the lack of visual indication of whether or not a result builder is in effect combined with a last-expression-y rule would dramatically degrade readability, but I'm now leaning towards the idea that if a function's name does not make it clear whether or not it produces a value then that's the real source of confusion and is the real thing that should be changed to remove that confusion.
Here's a topsy-turvy approach.
Use last expression rule. If something has to be done after the last
expression, write keyword then
, followed by a single statement.
That single statement could, for example, be a do
, if
, or switch
.
let x = if condition {
1
then log()
} else {
2
}
let x = switch someEnum {
case .one: 1
case .two: 2
then do {
log()
print("done")
}
}
That sounds like it could be extended to return
e.g.
func foo() {
return 1
then log()
}
…and isn’t that essentially an alternate syntax for defer
?
With then
it is not necessary to include curly braces if there
is only one statement. If curly braces would always be required,
then consider the following example (that wouldn't require a new
keyword to be introduced):
let x = if condition {
do { before() }
1
do { after() }
} else {
2
}
(do
should not be allowed to be an expression in this case)
If it were possible to skip the curly braces when using do
,
the above example could simply be:
let x = if condition {
do before()
1
do after()
} else {
2
}
EDIT: I think that there could be some problems with the
scopes in my latest suggestion – and with any variables
created within those scopes. That is, variables intended
to be used for the assignment could become unavailable.
Before this pitch, I proposed using do-expressions for immediately invoked closures (instead of empty parens on the end of the block). So, these two lines would become equivalent:
let x = { ... }() // each closure contains a block that
let x = do { ... } // returns some value of a known type
I was told that this pitch basically does the same thing, but I'm not sure it does.
In that scheme, the closure would simply return values (using return
), like any other closure. It literally just desugars do {x}
to {x}()
(so there's no need for then
or emit
or whatever).
If what I wanted to do is incompatible with this pitch, I'd be happy to advocate for just adding this helper function (probably with a different name) to the standard library instead:
func iife<T>(_ closure: () -> T) -> T { closure() }
let x = iife { ... }
I just want to remove the unsightly (yet still easily overlooked) parens on the end of immediately invoked closures, and was directed to this pitch.
Sorry if this has been addressed already (I did try to read both pitches), but I don't understand why return
is an issue, if we have do-expressions. It solves the problem by allowing multiple statements in an expression:
let width = switch scalar.value {
case 0..<0x80: 1
case 0x80..<0x0800: 2
case 0x0800..<0x1_0000: 3
default: do {
log("this is unexpected, investigate this")
return 4
}
}
You don't need to introduce a new keyword.
In general, we either want to return from the function that contains the switch-expression (and don't want the case to evaluate to anything), or we want to return/emit a value from the switch-expression (and don't want to return from the function). We pretty much never need to do both within the same case/branch.
Besides, assuming we can nest if-expressions and switch-expressions recursively, then we could still "return or emit" from the same case (if we really wanted to):
let width = switch scalar.value {
case 0..<0x80: 1
case 0x80..<0x0800: 2
case 0x0800..<0x1_0000: 3
default:
if predicate { return /* from function */ }
else {
do {
log("this is unexpected, investigate this")
return 4 // make local `width` equal to `4`
}
}
}
Ultimately, this is sugar, so it doesn't need to handle every conceivable corner case. We can just fall back to the more general grammar (that we use now) as required.
If we're not able to reinterpret do-blocks as closures (as it would break existing do-blocks that contain return-statements that would become nested), all of this can be achieved (currently) with the iife
function I posted above. It just needs the right name:
let width = switch scalar.value {
case 0..<0x80: 1
case 0x80..<0x0800: 2
case 0x0800..<0x1_0000: 3
default: iife {
log("this is unexpected, investigate this")
return 4
}
}
Using do
with return
would break a bunch of stuff, and I think a helper function (probably named evaluate
) would be better anyway:
func evaluate<T>(_ closure: () -> T) -> T { closure() }
let width = switch scalar.value {
case 0..<0x80: 1
case 0x80..<0x0800: 2
case 0x0800..<0x1_0000: 3
default: evaluate {
log("this is unexpected, investigate this")
return 4
}
}
This doesn't require changing the language at all. IIUC, it handles the usecases in this pitch?? It removes the trailing parens from immediately invoked closures (which are unavoidable in Swift, as there's no other way to directly use statements to initialize default values in type definitions), and it's pretty enough to employ idioms that are common in languages that have IIFE sugar:
let foo: (Int) -> Int = evaluate {
let x = expensiveComputation()
return { y in x + y }
}
I was reminded of this proposal recently when I came across loop-expressions in Zig. Zig uses the break
keyword to return values in an expression. I know there was some discussion about using break
instead of then
, but not much. I believe this is a very interesting direction, as my only objection was the invention of a new keyword for a niche of syntax sugar.
break
also has the benefit of allowing for loop-expressions (then
doesn't seem to make sense in that context, but maybe that's just me). Another benefit is no parsing ambiguities, because break
is not a contextual keyword. Using break
here could also compose nicely with labeled blocks.
let x = if condition {
print("hello")
break 0
} else {
break 1
}
// Potential future direction:
var count = 0
let first = for item in collection {
count += 1
if item.isRelevantToMe {
break item
}
} else {
Item()
}
This cannot be done because break item
already means to break out of the block labeled item
, so it cannot also mean that the result of the expression should be item
. See the prior discussions for more elaboration.
But only if a labeled block “item” exists, right? There could be an error diagnosing the ambiguity. Unless I’m missing something, this seems like one of the more surmountable problems that were mentioned with regards to break.
It is completely out of the question for a programming language to deliberately choose to design a feature in such a way that someone typing x:
1000 lines up or moving some code into a loop can completely change the meaning of a statement such that instead of an expression being evaluated to a value it turns into a control flow statement that breaks out of an outer loop.
let width = switch scalar.value {
case 0..<0x80: 1
case 0x80..<0x0800: 2
case 0x0800..<0x1_0000: 3
default:
log("this is unexpected, investigate this")
then 4
}
The branch statement to the right of the =
represents the return value of the closure that is returned immediately, which continues to be bound by the variable or capture body(self
), to the left of the =
. This is similar to the semantics of the existing keyword continue
.
I tend to accept continue(value)
or its sugar form continue value
rather than a new contextual keyword that gets introduced just because the keyword appears in another language.
This also cannot be done because continue value
already means to continue on to the next iteration of a loop labeled value
.
If "Multi-statement if/switch/do expressions" is considered to be a loop that only executes once and must have a return value of the specified type, continue value
, which is similar in form to continue label
, can logically be considered to control the flow back to the beginning.
This makes more sense than break value
.
It would not be “similar in form,” it would be ambiguous, and therefore impossible because continue value
already means something and it’s not this.