Extend `condition-list` abilities

This is only a tiny pitch. If someone would like to pick it up and do a formal proposal and implementation, feel free to just go for it.

Having had used the fairly new but rather small Verse programming language for a few month, I started to like the ability to be able to create intermediate constants and variables as well as executing regular functions within the condition-list of the if statement.

I started to wonder why Swift does not have such fairly convenient capabilities. Unlike Verse, Swift does not treat throwing errors as conditions. This is okay and I'm not pitching to change that.

Here are an example of what I would like to be permitted in Swift.

Before:

if condition1 {
  var someVariable = ...
  // do computation using `condition1` and mutate `someVariable`
  callSideEffect(on: &someVariable)
  callSideEffect()

  if condition2 {
    ...
  } else {
    // execute failure branch (mimic A again)
  }
} else {
  // A: execute failure branch
}
After:
if 
  condition1,
  var someVariable = ..., // NEW (non-condition)
  callSideEffect(on: &someVariable), // NEW (non-condition)
  callSideEffect(), // NEW (non-condition)
  condition2
{
  ...
} else {
  // A: execute failure branch
}

To oversimplify things, treat regular let and vars as non-conditionals. This is a bit similar to how we can create local constants in a results builder, which aren't immediately consumed by it.

This idea would expand the condition-lists of if, guard and while.

2 Likes

Doesn't this do the same thing?

func callSideEffect(_ value: inout Int) { value += 1 }

if true,
   case var foo = 1,
   case _ = callSideEffect(&foo),
   foo >= 2
{
    print("zap")
}
3 Likes

What would happen if you specify a conditionless condition-list? A compiler error?

To oversimplify things, treat regular let and vars as non-conditionals.

By regular do you mean non-optional?

TBH I don't like this proposal since I feel like it can only make things harder to read. I've long since started to use , as a synonym for && in these places, and I don't think I am alone. It also feels like it could be easy to accidentally hide a condition among a list of non-conditions e.g.

if condition1,
   var someVariable = ...,
   callSideEffect(on: &someVariable),
   condition2, // HIDDEN
   callSideEffect(),
 { ... }

EDIT: I suppose my example is functionally different than what you posted because of short-circuiting, which makes me understand your comparison to result-builders a bit better.

2 Likes

Refactoring the condition-list into a separate function allows us to streamline the code into a single if/else branch structure.

func shouldRun() -> Bool {
    guard condition else { return false }
    var someVariable = ...
    callSideEffect(on: &someVariable)
    callSideEffect()
    return condition2
}

if shouldRun() {
  ...
} else {
  // execute failure branch
}
4 Likes

I didn't even know case can do these already. So basically this, but without the need to write case or case _.

I would assume so, the condition list should have at least one condition in it to evaluate which branch it would take in case of if and guard statements.

Fully understandable. However personally I've run into this wall many times where I needed an intermediate constant to extract a value instead of doing it in concrete branch body of an if statement.

Sure one can see this as burying things into the same list, but I don't think it's "that" bad.

It's certainly possible and there are many ways to write things differently, but this paricular example would require extra overhead or boilerplate code, depending who you ask.

It's a fairly lightweight pitch. The community if free to do whatever with it and even reject it. Personally I would love this addition though. :slight_smile:

1 Like

Let's look a the above example of moving the whole condition into a dedicated function again:

Some cons that I personally see with this:

  • it's not possible to return multiple values
  • even if it was possible to return values, all will remain immutable and the success branch will not be able to work in such way

While the following example is an extreme oversimplification it's one example that comes to my mind when this could actually shine.

if let nonOptionalNum = optionalNum, test(nonOptionalNum + someNum) {
  // we have potentially to perform the same operation yet again as we
  // want to use that result
  let summedNumber = nonOptionalNum + someNum
}

Instead we could simplify this into:

if 
  var nonOptionalNum = optionalNum, // condition_1: mutable unwrapped optional value,
  nonOptionalNum += someNum, // discardable intermediate computation
  test(nonOptionalNum) // condition_2
{
  // `nonOptionalNum` is already the sum, no extra operation needed
}

PS: It seems to be possible to achieve using case, but it's not a very great experience.

if var nonOptionalNum = optionalNum,
   case nonOptionalNum = nonOptionalNum + someNum,
   test(nonOptionalNum)
{
  // ...
}

Indeed, that's a lengthier example, but solvable using the same technique, without a new language feature.

func value(_ optionalNum: Int?) -> Int? { // or non-optional but throwing
  // in an cumbersome imperative way
  guard let nonOptionalNum = optionalNum else { return nil }
  let sum = nonOptionalSum + someNum
  guard test(sum) else { return nil }
  return sum
  // or in a concise functional way
  return optionalSum.map { $0 + someNum }.flatMap { test($0) ? $0 : nil }
}

if let sum = value(optionalNum) {
  ...
}
1 Like

And again it shows how much extra overhead it requires. On top of that, this example only uses a single value for the branch and you even need to wrap the value back into an optional. As soon as you start expanding the number of values you want to use in the success branch, the complexity will quickly ramp up.

Sure things are solvable, and so is error propagation through Result an alternative solution for typed throws, yet we will very likely have both ways. Something that has already a solution does not imply that it doesn't need a different one, especially as it removes unnecessary obstacles. ;)

There would be complications. E.g. foo() returning a generic type, will that work differently if the actual return type happens to be Void vs Int vs Bool vs Optional?

Same question for this one (regardless of whether SomeTypeHere is generic or a non generic type):

@discardable func callSideEffect() -> SomeTypeHere { ... }

Now when follow the code like this:

if let p = foo(), bar(), var x = baz() { ... }

we know what the flow would be without looking at the functions signatures (foo() and baz() has to return Optional and bar() Bool). With your proposal the return types could be arbitrary and code would flow dramatically different depending upon the actual return types. This is not a show stopper per se, but will result in a serious language complication.

2 Likes

Personally I wouldn't say it's that complicated as it's already well understood that a condition has to be an optional unrapping or a boolean. So if your function call returns a boolean (like bar()) it will be a condition unless you bind it to a constant or a variable. Returning a discardable optional is not a conditional as optionals require a binding or pattern matching.

@discardable
func generic<T>(_ t: T) -> T { t }

@discardable
func throwingFunc() throws -> Int { 42 }

if 
  generic(true), // a condition
  let value = generic(true), // not a condition
  let unwrappedInt = generic(42 as Int?), // a condition
  generic(42 as Int?), // not a condition as it does not bind the optional
  generic(42 as Int?) != nil, // a boolean condition
  generic(()), // not a condition as it returns `Void`
  let value2 = try? throwingFunc(), // a condition
  try? throwingFunc(), // not a condition as the optional value has not binding
  try throwingFunc() // not a condition, error caught by the parent scope
{ ... }

It seems fundamentally weird to have a non-conditional subexpression in a conditional expression. It's harder on readers as then more context is required to understand the control flow. e.g. how does one know that callSideEffect doesn't actually influence control flow? You have to look up its definition - and hope it never changes out from under you to return a boolean.

I fear this might become (if adopted) one of those legacy warts of the language, constantly confusing newcomers. It has that feelโ€ฆ that we'll be forever explaining to people "yeah, that was this thing we did years ago because we thought it was convenient even though it doesn't make much logical sense and it's hard to read, and now we can't remove it because of backwards compatibility".

Additionally, multi-line or otherwise complex if conditions are already challenging to parse (as a human). Some of the alternative forms already shown - such as moving the conditional to a dedicated function - are generally good ideas anyway.

If the main practical issue is the duplication of 'else' path logic - and it's important to short-circuit conditional evaluation - that can be addressed in various other ways too. e.g.:

var allGood = condition1

if allGood {
    var someVariable = ...
    // do computation using `condition1` and mutate `someVariable`
    callSideEffect(on: &someVariable)
    callSideEffect()

    allGood = condition2
}

if !allGood {
    // A: execute failure branch
}
4 Likes

You can do this with case conditions today, using unconditional pattern matches:

if 
  condition1,
  case var someVariable = ..., // NEW (non-condition)
  case _ = callSideEffect(on: &someVariable), // NEW (non-condition)
  case _ = callSideEffect(), // NEW (non-condition)
  condition2
{

Unfortunately let and var as conditions are already taken to mean optional unwrapping, as are bare expressions, so we wouldn't be able to retrofit those syntaxes without adding some type-contextual behavior.

1 Like

Right, but (a) you have the case prefix to signify that something unusual is going on, and (b) how often does anyone actually use it?

I occasionally reach for if case - typically for some value-binding pattern - but usually end up disappointed because it's difficult to use (especially in if conditionals as opposed to switch conditionals) and it's usually easier and clearer to just move the binding outside the if statement's conditional.

1 Like

The only time I would recommend doing something like that is if there is a temporary value that depends on a previous condition which is then used in one or more subsequent conditions, as in:

if case .foo(let a, let b) = step1(),
  case let c = function(a, b),
  c.foo != c.bar {
}

in which case the alternative would be a double-nested condition like

if case .foo(let a, let b) = step1() {
  let c = function(a, b)
  if c.foo != c.bar { 
    ...
  }
}

or something more gnarly if there is also an else clause. In other situations I agree it would be better to factor the bindings out of the condition altogether. But my point is that there is already a syntax to express this, the situations where you absolutely need it are relatively rare, and the proposed alternative syntax is already taken, so to me that makes it difficult to justify a breaking language change on purely aesthetic grounds.

6 Likes

You can also use this funny contraption:

if ({
  guard condition1 else { return false }
  var someVariable = ...
  callSideEffect(on: &someVariable)
  callSideEffect()
  return condition2
}()) {
  ...
} else {
  // A: execute failure branch
}
3 Likes

:slight_smile: The parens:

if ({ skipped }()) {
    ...
}

around the call expression looked strange and excessive to me โ€“ I tried without them:

if { skipped }() {
    ...
}

but got a compilation error. I don't immediately see why are they required in this case according to grammar, highlighting the relevant bits: