SE-0380: `if` and `switch` expressions

An explicit return also has its readability issues. In some cases (i.e. closures) an explicit return is confusing since it returns from the closure and not the function.

Some ideas for expressions that look more like expressions

I think in the most common situations it wouldn't be too confusing, however making implicit returns more explicit by making it clear they are expressions might help. I see this as a bigger issue if extended to the last expression of a function or closure in the future.

Maybe match instead of switch or a different variant of if-expressions.

// match is expression-only, so it is obvious this returns a value
func foo(y: NumNames) -> Int { 
    match y {
        case .zero: 0
        default: 1
    }
}

/// some other indicator of a return value
func foo(y: NumNames) -> Int { 
     switch y {
        case .zero => 0
        default => 1
    }
}

// one return is better then many (although I don't care for it in common higher-order functions)
func foo(y: NumNames) -> Int { 
     return switch y {
        case .zero: 0
        default: 1
    }
}


Optional-chaining is not addressed by proposal

Optional-chaining is not addressed by this proposal. I think it makes sense to address that at the same time if we are going to re-invent the ternary operator. The proposed syntax will still require a lot of intermediary values to be explicitly stored in variables in many common situations. The if-expression alternatives below support chaining.

Use higher-order functions for if-expressions

The simplest solution would be to use higher-order functions for this.

func foo(y: NumNames) -> Int {
    when(y == .zero) { 0 } // the return type of this line is `Int?`
        ?? when(y == .one) { -1 }?.negate() // optional-chaining
        ?? 2 // else is handled by the nil-coalescing operator
}

Fix the ternary operator with a new type of conditional operator

Another option would be a better ternary-style operator that uses infix semantics and optional-chaining instead of nesting. The nesting readability is the common argument against ternary operators.

I like this idea, but I think it would require a change to precedencegroup for it not to feel weird. Currently, the condition will need to be put in parentheses due to operator precedence unless a special case is made like was done for assignment operators. It would basically need to add implicit parentheses to the comparison operator to the immediate left of the conditional operator because they are at the wrong precedence level. Changing the precedence of the nil-coalescing operator might also work, but that would be a very breaking changeā€“ I didn't explore this method.

precedencegroup ConditionalPrecedence {
    lowerThan: CastingPrecedence
    higherThan: NilCoalescingPrecedence
    associativity: none
    conditional: true // implicit parentheses around comparison operator to left of conditional operator (alternatively require parentheses around it)
}
infix operator ?=: ConditionalPrecedence
func ?=<Value>(lhs: Bool, rhs: Value) -> Value? {
    if lhs { return rhs } else { return nil }
}

This syntax avoids the major issues with the ternary operator since successive conditions are not nested. It is more versatile and Swifty with optional-chaining. This operator feels very analogous to the pattern match operator.

// already implementable
func foo(y: Int) -> SomeEnum {
    (y > 0) ?= .positive // the return type of this line is `SomeEnum?`
        ?? (y == 0) ?= .zero // successive conditionals not nested
        ?? .negative // `else` is handled by the nil-coalescing operator
        // or if nil-coalescing omitted, it returns an optional
}

// `precedencegroup` option to allow dropping the parentheses around the comparison operator
let msg = (Int.random(in: 0...10) < 5 ?= "less than 5")?.uppercased() 
    ?? "GREATER THAN 4"

It would be nice to see the ternary operator eventually removed since it is such a weird operator and a common source of ambiguity.

On the topic of explicit return keywords: how about then? Itā€™s distinct from other terms and fits with if/else in a sentence. e.g.

let foo = if bar {
    print("true")
    then 42
} else {
    print("false")
    then 7
}
6 Likes

This argument has never made any sense to me. How can a load bearing keyword affect readability? That's like saying if affects readability, since obviously x > y { } else { } is a condition. Previous removals of return explicitly called out the decrease in readability as a tradeoff to increase writability (and marketability) of new APIs like SwiftUI. Swift's return rules are rather straightforward and your example is exactly one of those cases: closures and functions are both returned from the same way. A closure inside a function should have no confusion except for the most junior of developers who are learning the rules for the first time. At worst extra verbosity is redundant, which makes reading take a bit longer but otherwise doesn't harm it, as the understandability is unhindered.

I'll throw a late review in here for posterity.

  • What is your evaluation of the proposal?

As proposed, -1. With the removal of return, perhaps a -0. Being able to drop a keyword and a few braces and parens really doesn't justify the increase in reasoning required for control flow. At least with return removed the control flow is easier to reason about, so I don't think there's active harm in that case. I still think it's a rather pointless change that isn't well motivated. And I find the "well, other languages have it, so we're not modern if we don't" truly bizarre given how often Swift has said adopting features from other languages is a non goal.

  • Is the problem being addressed significant enough to warrant a change to Swift?

No, it's not a problem at all. In fact, as proposed, it creates even worse problems even if you consider the original issue a problem in the first place.

  • Does this proposal fit well with the feel and direction of Swift?

Technically yes but really no. The core team has been a fan of removing explicit returns since it was required to get the syntax desired for SwiftUI, but it really doesn't fit with Swift's stated goals and the required use of explicit keywords in other areas of the language. As more implicit behavior has been adopted it's been used to justify future removals, leading to a line of circular reasoning. But I don't believe the core teams has properly articulated why some removals of some keywords are okay but others aren't. Until that's done I can't see a justification for continuing the pattern, especially in this case where the wins are so minimal.

  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

Kotlin, where I tend to stay away from it given the differences in its syntax vs. full conditions. It really seems to be used in that language only because it exists, not because it's actually superior. For anyone who doesn't work in Kotlin full time it's actually a bit painful since you have to step back and think about the differences. I really don't want Swift to see the same thing happen.

  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

Read pitches and proposals as well as the review thread.

10 Likes

I feel it affects readability in the context of higher-order functions since they look so much like statements, but obviously this is something that comes down to personal preferences. One can always use diligence to make sure the return is what it seems. I'm fine with it either way, but it feels like with implicit return of single-line functions Swift already set a precedent away from that way of thinking.

Like I said in my review, I find using Swift's existing implicit returns to justify more rather circular, as I don't recall any original justification for it other than "look how nice it looks with SwiftUI". Continuing down that path just makes the reasoning weaker and weaker.

In any case, higher order functions are exactly where explicit control flow and returns are most important, as it's easy to lose track of exactly what context you're in. It's the same reason nested ternaries are usually disallowed: they become unreadable.

1 Like

Maybe, but the return is already typically dropped by most Swift devs from higher-order functions when it is possible. In fact, limited implicit return was allowed here from the earliest days of Swift. It was part of the original design. This is the main use case I find myself wanting implicit return (that and property getters). I think losing track of the context you are in is a more important concern for other uses of closuresā€“ especially escaping closures.

I personally try to structure my higher-order function's closures to avoid early escape. In fact, I would love it if one could completely disable the return statement from higher-order functions. I feel that early escape using return is bad style since higher-order functions are meant to be read like expressions. I basically just use return from a final switch or if statement which this proposal will allow me to drop. Implicit return is also safer when the closure is non-escaping which is almost always the case for higher-order functions.

If for some reason I need to escape early, I usually take that as a sign that I shouldn't be using higher-order functions to solve the problem at hand and switch to imperative style programming. Swift is a multi-paradigm language after all!

I have a slight preference for implicit return coming more from a functional programming background (and a lot of Ruby too), but I can see why that is controversial to those coming from C-like languages.

EDIT: Clarified my thoughts. (Yeah, it took time until this felt right...)

After thinking things over, I am not convinced that the full generality of if and switch expressions would necessarily be worthwhile.

Notably, the proposed pattern of let x = if foo { f() } else { g() } is either short enough to fit on one line, in which case the ternary operator ?: is fine, or else it extends over multiple lines, in which case an immediately-executed closure may improve readability by grouping those lines within braces.

However, one place there could be a win here is in single-statement functions (and closures) consisting solely of an if or switch. We already have the rule that return can be omitted when there is only a single expression in a function or closure. We could extend that slightly to say:

When a function or closure contains only an if or switch statement, and each of its cases is a single expression, then return may be omitted from those cases.

In other words, if and switch would effectively become transparent to the single-expression return rule.

Making this change would address many of the common situations motivating the proposal, simply by allowing return to be omitted from functions and closures that comprise a control-flow statement with single-expression cases.

It would not directly affect complex assignments to variables, though as mentioned I am not convinced that if expressions would actually be a win there. It could, however, make the immediately-executed closure a bit more streamlined by omitting the returns.

Iā€™m not necessarily against the proposal, but I think this alternative might be a conceptually simpler way to achieve many of its aims.

8 Likes

I'm not exactly sure what Apple has in mind for this feature, but I would be fine if they limited this proposal to just non-escaping closures and property getters. Those are the only places I ever want to use implicit return from a switch or if statement anyway...

1 Like
1 Like

Off the cuff, here is an example that applies De Morganā€™s law to an expression using a hypothetical AST library:

func deMorganTransform(of expr: Expression) -> Expression {
    // Match `!(y <op> z)`
    guard
        let expr = expr as? UnaryExpression,
        expr.operator == .negation,
        let subexpr = expr.operand as? BinaryExpression
    else {
        return expr
    }

    // Look for `&&` or `||` in subexpr; anything else
    // leaves the whole original expression intact
    let negatedOperator: BinaryExpression.Operator =
        switch subexpr.operator {
            case .and: .or
            case .or: .and
            default:
                return expr  // āœ…
        }

    return BinaryExpression(
        operator: negatedOperator,
        lhs: UnaryExpression(.negation, operand: subexpr.lhs),
        rhs: UnaryExpression(.negation, operand: subexpr.rhs))
}

Without the mid-case return marked :white_check_mark:, the code has to undertake some mild gymnastics: eitherā€¦

  • ā€¦repeat the whole final return statement twice (once each for the and and or cases),
  • ā€¦or add a (maybe nested) helper function to factor out that repetition,
  • ā€¦or make negatedOperator optional, and then have two separate return cases afterwards,
  • ā€¦or etc.

The alternatives are all much less elegant. Not that this is the only way to write this function, butā€¦itā€™s a reasonable one, and the mid-expression return makes it a much better one.

Itā€™s also possible to construct examples using nested if/else trees, especially using if let bindings, where a mid-expression return is tidier than having to propagate the special case all the way to the top level of the expression and then deal with it.

The basic principle here is much like the failable initializer: a special case encountered mid-expression aborts the whole operation, and itā€™s easier to handle that special case where it occurs rather than create a forking path later.

(@Douglas_Gregor, I know youā€™re skeptical; am I swaying you at all?)

3 Likes

I'm not sure I agree that it's better. I suppose that with the benefit of the comment I could be prompted to inspect the switch statement more closely and notice the early exit, but I don't know that this is a pattern we would want to encourage, especially for a switch statement that might be longer than the one here. I think I'd prefer this to be structured something like:

private extension BinaryExpression.Operator {
  var deMorganNegation: Self? {
    switch subexpr.operator {
      case .and: .or
      case .or: .and
      default: nil
    }
}
...
func deMorganTransform(of expr: Expression) -> Expression {
  // Match `!(y <op> z)`
  guard
    let expr = expr as? UnaryExpression,
    expr.operator == .negation,
    let subexpr = expr.operand as? BinaryExpression,
    let negatedOperator = subexpr.operator.deMorganNegation
  else {
    return expr
  }

  return BinaryExpression(
    operator: negatedOperator,
    lhs: subexpr.lhs,
    rhs: subexpr.rhs
  )
}

so that the early return and failure conditions are made explicit at the top level

2 Likes

Even without @Jumhyn's larger restructuring, you could just return nil for the default and use the guard let, which is much nicer than a let with a return in it to me:

guard let negatedOperator: BinaryExpression.Operator =
        switch subexpr.operator {
            case .and: .or
            case .or: .and
            default: nil
        }
  else { return expr }

It occurs to me that this proposal may not support this structure. I agree with @Jumhyn's restructure anyway, as that definitely seems like a computed property.

2 Likes

I started writing that out but realized I wasnā€™t sure I could get behind an inline switch in a guard clause, or whether it was even supported as you note. :sweat_smile:

Sure, thereā€™s an argument for that. I personally donā€™t like how it splits these two pieces of tightly coupled logic so far apart: your deMorganNegation property doesnā€™t really have much of a useful meaning outside of that deMorganTransform function. Itā€™s one thought split into two places.

One could also do the same within the function, which was one of the alternatives I sketched out above:

func deMorganTransform(of expr: Expression) -> Expression {
    // Match `!(y <op> z)`
    guard
        let expr = expr as? UnaryExpression,
        expr.operator == .negation,
        let subexpr = expr.operand as? BinaryExpression
    else {
        return expr
    }

    // Look for `&&` or `||` in subexpr; anything else
    // leaves the whole original expression intact
    let negatedOperator: BinaryExpression.Operator? =
        switch subexpr.operator {
            case .and: .or
            case .or: .and
            default: nil
        }

    // šŸ˜•
    guard let negatedOperator = negatedOperator else {
        return expr
    }

    return BinaryExpression(
        operator: negatedOperator,
        lhs: subexpr.lhs,
        rhs: subexpr.rhs)
}

ā€¦but that sort of things gets increasingly confusing as (1) the conditional expression grows, or (2) the number of exceptional cases with different behaviors grows.

Neither is as nice to my eyes as my OP. That is of course a matter of taste, and de gustibus non disputandum est. Swift is a flexible language, and people use it to write code with all sorts of styles. Point is, itā€™s nice to have these choices. Thereā€™s no language feature that canā€™t be abused, but this one used judiciously can pay stylistic dividends.

1 Like

You could also pull the negation operation inside the deMorganTransform to keep it more tightly scoped if you really want to, at the cost of not having it be a private computed property.

func deMorganTransform(of expr: Expression) -> Expression {
    func negated(_ op: BinaryExpression.Operation) -> BinaryExpression.Operation? {
        switch op {
            case .and: .or
            case .or: .and
            default: nil
        }
    }

    // Match `!(y <op> z)`
    guard
        let expr = expr as? UnaryExpression,
        expr.operator == .negation,
        let subexpr = expr.operand as? BinaryExpression,
        let negatedOperator = negated(subexpr.operator)
    else {
        return expr
    }

    return BinaryExpression(
        operator: negatedOperator,
        lhs: subexpr.lhs,
        rhs: subexpr.rhs)
}

A further way to structure this is to have deMorganTransform(of:) return Expression?, so that it would then be used as let transformed = deMorganTransform(of: original) ?? original:

func deMorganTransform(of expr: Expression) -> Expression? {
    // Match `!(y <op> z)`
    guard
        let expr = expr as? UnaryExpression,
        expr.operator == .negation,
        let subexpr = expr.operand as? BinaryExpression
    else {
        return expr
    }

    // Look for `&&` or `||` in subexpr; anything else
    // leaves the whole original expression intact
    let negatedOperator: BinaryExpression.Operator? =
        switch subexpr.operator {
            case .and: .or
            case .or: .and
            default: nil
        }

    return negatedOperator.map {
        BinaryExpression(
            operator: $0,
            lhs: subexpr.lhs,
            rhs: subexpr.rhs)
    }
}

I think there are plentiful, decent choices without allowing return from mid-expression, and if it really ends up feeling restrictive in practice we could entertain a proposal in the future to allow for it. Given the ways in which I think it could end up being harmful to code readability, though, I'd prefer us to take that as a separate step.

Iā€˜m also in favor of allowing return in if and switch expressions because for me this feels a bit tidier and nicer as well for some programming patterns but I think this would be a good move to make for now. Just bring out minimal working feature and then look how the rest falls into place. I would definitely already be very happy even with very restricted control flow expressions.

Aside, since itā€™s really not important to the discussion.

Just because it did appear several times in this topic already and someone could actually copy this example:

If I remember my de Morganā€˜s rules correctly this should be

return BinaryExpression(
    operator: negatedOperator,
    lhs: UnaryExpression(operator: .negation, operand: subexpr.lhs),
    rhs: UnaryExpression(operator: .negation, operand: subexpr.rhs))

right?

2 Likes

Ha! Right you are, fixing.

+1 Just want to say I love this.

This is untrue ā€“ the proposal to omit returns in single-expression functions made no such call outs.

Omitting ceremony is about making both reading and writing code easier. Since Swift 1.0, long before SwiftUI or other APIs that you incorrectly claim were the only motivation for that proposal, you have been able to omit returns from closures. Why? Because doing so makes them easier both to read and to write. numbers.map { $0*2 } is more readable than numbers.map { return $0*2 }. Requiring a return is ceremony, and reduction of ceremony is better for both writing and reading code.

SE-0255 extended that logic to functions and computed properties. var startIndex: Index { _base.startIndex } is also more readable without the return, at least I think so.

This proposal takes that position further. The argument is that this code is more readable, as well as writable, than the equivalent where the return ceremony must be duplicated on each branch:

private func balance() -> Self {
    switch self {
    case let .node(.Black, .node(.Red, .node(.R, a, x, b), y, c), z, d):
        .node(.Red, .node(.Black,a,x,b),y,.node(.Black,c,z,d))
    case let .node(.Black, .node(.Red, a, x, .node(.Red, b, y, c)), z, d):
        .node(.Red, .node(.Black,a,x,b),y,.node(.Black,c,z,d))
    case let .node(.Black, a, x, .node(.Red, .node(.Red, b, y, c), z, d)):
        .node(.Red, .node(.Black,a,x,b),y,.node(.Black,c,z,d))
    case let .node(.Black, a, x, .node(.Red, b, y, .node(.Red, c, z, d))):
        .node(.Red, .node(.Black,a,x,b),y,.node(.Black,c,z,d))
    default:
        self
    }
}

Is this game changing? No. Is this a quality of life improvement that has strong demand from the community, and which is the baseline expectation for users of other modern languages? Absolutely.

Now this is of course a matter of taste and opinion. Some think the return improves readability, and would prefer to see it even on closures. Some might also think that semicolons at the end of lines, parenthesis around if conditions, and explicit types for variable declarations are also more clear. But those are not the aesthetics of Swift. Disagreeing is fine ā€“ but re-litigating settled disagreements, and imputing motivations for proposals as driven by marketing or specific frameworks not by a desire to improve the language is not fine.

15 Likes

If you don't want to relitigate, feel free to ignore and not reply. If you want to call out my dismissiveness and speculation on motives, feel free to message me directly, as that doesn't seem like something that needs to be in a review thread either.

1 Like