Allow optional unwrapping in where clause

The where clause in for loops currently allows us to test a certain condition while iterating a sequence to filter out the elements that don't satisfy that condition, which is cool because it lets us avoid nesting, i. e. instead of

let numbers: [Int] = [1, 4, 5, 2, 9, 13, 7, 14, 40, 11]
for number in numbers {
    if isPrime(number) {
        print("\(number) is prime")
    }
}

we can write

let numbers: [Int] = [1, 4, 5, 2, 9, 13, 7, 14, 40, 11]
for number in numbers where isPrime(number) {
    print("\(number) is prime")
}

However, I often find myself in need to filter out the elements that are nil or have an optional property that is nil, and somehow process the non-nil values otherwise. Consider this example:

struct User {
    var name: String
    var birthday: DateComponents
    var profilePic: UIImage?
    var hasBirthdayToday: Bool { /* ... */ }
}

func addBirthdayCap(to profilePic: UIImage) { /* ... */ }

/* ... */

for user in users where user.hasBirthdayToday {
    if let profilePic = user.profilePic {
        addBirthdayCap(to: profilePic)
    }
}

As you can see, we have a nested if, the where clause cannot help us in this case.

I propose to extend the syntax of where clause everywhere except type constraints (see "Where "where" may be used?"), so that we could write

for user in users where user.hasBirthdayToday, let profilePic = user.profilePic {
    addBirthdayCap(to: profilePic)
}

What do you think?

3 Likes

I personally have nothing agains it. It will be an alternative for:

users.lazy
  .filter { $0.hasBirthdayToday }
  .compactMap { $0.profilePic }
  .forEach(addBirthdayCap(to:))

PS: On the second thought I'm not sure if we should allow non-boolean expressions as part of the where clause, which kind of would not make any sense.

Maybe it will be even more readable when we need to use both user and profilePic values. Compare:

var birthdayCapOwners = [User]()
for user in users where user.hasBirthdayToday, let profilePic = user.profilePic {
    addBirthdayCap(to: profilePic)
    birthdayCapOwners.append(user)
}

and

users.lazy
    .filter { $0.hasBirthdayToday }
    .compactMap { user in user.profilePic.map { profilePic in (user, profilePic) } }
    .forEach { user, profilePic in
        addBirthdayCap(to: profilePic)
        birthdayCapOwners.append(user)
    }

The last example can be also writen as following:

users.lazy
  .filter { $0.hasBirthdayToday }
  .forEach { user in
    user.profilePic.map {
      addBirthdayCap(to: $0)
      birthdayCapOwners.append(user)
    }
  }
1 Like

Since it's already allowed as the condition in an if statement, let unwrapped = optional is essentially some sort of contextual boolean expression.

Well but a where clause should stay only for boolean expressions in my opinion. The pitched feature may potentially look something like this instead for ... in ... where ... if ... { ... }. I'm not saying I'm in favor of the latter but I'm not sure we should break the simplicity of the where clause, because then we should be able to unwrap optionals in every possible where clause (except in places such as extension ... where ... { ... }.

That said, I'm neutral on it's addition in the language. ;)

I'm not sure it was a good idea to have it be (roughly) a boolean expression in an if statement either, but I can't say at this point that it would be inconsistent or strange for it to also be boolean in some similar contexts.

1 Like

I feel like this should be moved into the binding side of the for loop. Maybe we should just allow for multiple patterns to be allowed on for loops?

for let thing, // first thing in binding list is always the thing from iterator
    let something = thing.optionalProperty,
    case let .someCase(binded) = thing.someEnum 
    in collection where binded == 2 {
 }
3 Likes

There's still nesting in both your and my example, and it would be great if we could avoid it.

It will break tons of code, so I guess this is a no go.

Besides, when we look at a for loop, we generally want to see what we're iterating over first, not what we are filtering out.

This will also introduce an inconsistency with the switch operator, in which we'd be still using the where clause.

Why? The pitched syntax is not source breaking in my eyes but completely additive. I'm not sure about for something in vs. for let something in though.

How will expanding the allowed syntax of for-in break code? It would certainly be a major addition, but I don't think we currently allow anything that would conflict with this.

And this is what you're getting. This is just pattern matching. It's basically what happens now on the lhs of the for loop, but we only allow a single pattern to be bound. By opening the number/kind of bindings allowed we're just allowing the for-in more expressivity.

Oh, I misunderstood you.

But still, even if we do this for for loops, what do we do with the switch, do-catch, etc. operators that support where clause too? What I'm proposing is to allow binding in all kinds of where clauses except type constraints. (I've edited the post to be more clear on this.)

I feel where is the wrong place for this kind of behavior. Generally where is used to express some kind of restrictedness to the thing it's modifying. Opening it up to allow variable binding feels like the wrong path to go.

1 Like

Now on the third thought I'd vote against it because it will just make other language extensions like the following one unnecessarily complicated to read and understand if not completely impossible, just because any potential where clause could also be followed by bindings.

I'd rather prefer to pursue something like Erik posted above.

FWIW you can get something that's pretty close already:

for case let (user, pic?) in users.map({ ($0, $0.profilePic) }) where user.hasBirthdayToday {
  addBirthdayCap(to: pic)
  birthdayCapOwners.append(user)
}

It probably doesn't scale very well with more binding though.

Also, it probably helps to see the pattern matching of for-in in practice.

This is actually the full desugared form of a for-in

for case let index in 0..<10 { }

And lets show probably the most common pattern matching done in Swift: matching optionals

// this is still the identifier pattern! nils are not unwrapped
for case let thing in [1, nil, 3] { } 

// now lets start matching optionals
for case let .some(thing) in [1, nil, 3] {}  // No sugar, not really the optional pattern
for case let thing? in [1, nil, 3] {}  // the optional pattern

Optional isn't actually special with for loops, this is also perfectly fine:

enum Thing {
  case one
  case two
}

for case .two in [Thing.one, Thing.two] {
  print("a two")
}

Once you see that, I think it's pretty obvious that, at least with regards to for-in, putting pattern matching in the where seems a bit suspect when the lhs already does some pattern matching.

4 Likes

I'm not totally sure why the where syntax exists on for-in— it seems like sugar for a limited kind of guard statement just inside the loop, and makes it less obvious what happens.

That said, if we have the ability to filter the elements in a loop, I think it would make sense be able to include anything that can appear in a guard statement:

for user in users {
    guard user.hasBirthdayToday,
        let pic = user.profilePic,
        let url = pic.url,
        isValid(url)
    else { continue }
    
    addBirthdayCap(to: pic, with: url, for: user)
}

vs

for user in users where
    user.hasBirthdayToday,
    let pic = user.profilePic,
    let url = pic.url,
    isValid(url)
{    
    addBirthdayCap(to: pic, with: url, for: user)
}
2 Likes

Yeah, there was a proposal long ago to remove where from for-in loops, but it was rejected (thankfully.) I do have mixed feelings about the name. (Part of me thinks it should have been renamed guard)

I think the problem I have with having optional binding on the where is that given for-in's ability to already do pattern matching on the LHS, you would now have a blur between checking a boolean, and matching a pattern on a construct that can already do pattern matching elsewhere. Why not just remove where and merge its functionality with the LHS? That would bring it inline with guard. And because where is settled at this point we can't do that.

So I think the question is do we expand where to match what guard can do, or allow multiple patterns to be matched on the LHS of the for loop (or do nothing of course.)

And could the expression to the right of the in do some pattern matching too, solving SE-0231 Optional Iteration? And if so, can it be done in a general way that is consistent with other pattern related stuff in the language? I started a thread to discuss whether Swift's patterns could be more uniform here.