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

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.

16 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

To clarify, as currently proposed, if/switch expressions do not have to be marked with try or await if the branches may throw or suspend, as those actions will be explicitly called out within the branches themselves with try/throw/await. For example, you can write:

func foo() throws -> Int {
  let x = if .random() {
    throw SomeError()
  } else {
    5
  }
  return x
}

and:

func foo() throws -> Int {
  let x = if .random() {
    try someThrowingFunction()
  } else {
    5
  }
  return x
}

without writing try if.

I thought this was mentioned in the proposal, but it seems like it isn't. It did however come up in the pitch thread.

If we were to enforce try if/await if, then I agree it might make sense to ban return, as there's no equivalent keyword to mark the expression. However I'm not convinced that we should enforce try if/await if, as IMO it would be unnecessary noise, especially for the cases currently being proposed (bindings, implicit returns, etc.). As such, banning return while allowing throw and try would seem odd to me, as they are all able to exit the function, and are just as explicit as each other.

I mean, this is true of any refactoring where you're taking code that was previously in a closure and bringing it into a parent function, I'm not convinced it's sufficient grounds to outright ban return in if/switch expressions.

That being said, I'm not against banning return for now and re-examining it when we consider allowing if/switch expressions in arbitrary positions (where IMO return may be more contentious).

4 Likes

I'm leaning towards requiring try and await to mark that if or switch is being used as an expression. Almost makes me wish there were a way to also mark it for non-throwing/async cases.

doesn’t that make it so it appears that the if is its own ‘returning scope” like a closure? I dunoo most of the code in this thread is already confusing enough l this may make it even worse.