carbon-lang icon indicating copy to clipboard operation
carbon-lang copied to clipboard

Design idea: patterns as control flow conditions

Open geoffromer opened this issue 8 months ago • 5 comments

Languages that support pattern matching often have ways of combining it with if/else control flow, and we've repeatedly discussed adding such features to Carbon, but don't yet have an accepted design for them.

if let

The most prominent such feature is using a pattern as the condition of an if statement, such as if let in Rust, and it has repeatedly come up as a desirable feature for Carbon.

One notable design question here is what the syntax should be. The most obvious options I see are: A. if let (x: i32, y) = Foo() { ... } B. if (let (x: i32, y) = Foo()) { ... } C. if let ((x: i32, y) = Foo()) { ... }

Option A is closest to Rust's syntax, and is arguably the most readable, but it risks ambiguity about where the body block begins, because { can also occur within the initializer expression. Even if it's not formally ambiguous, it raises many of the same problems as optional semicolons (which we rejected in p2665).

Option B consists of allowing a let declaration take the place of the condition expression, which is both an advantage and a disadvantage: it's superficially intuitive and easy to remember, but that appearance may be misleading -- it suggests that if and let are orthogonal language constructs that are being composed in this code, which isn't at all the case.

Option C is in some ways the inverse of option B: it violates syntactic expectations about let, so readers are less likely to mistakenly think they understand it, but more likely to be confused by it.

The proposal should also address whether this syntax works with else if and while, whether it works with var in place of let, and whether it works with expression-if (my recommendation would be "yes", "yes", and "no", respectively).

let ... else

Some languages support another form of conditional pattern matching, where bindings in the pattern are added to the enclosing scope (just like with unconditional let) instead of starting a new scope, and an attached else block contains the code to execute if the pattern does not match. To prevent those bindings from being accessed when the pattern does not match, the code must ensure (and typically the compiler enforces) that the end of the else block is unreachable, for example by having it unconditionally return, break, or terminate the program. Rust's let ... else and Swift's guard ... else are examples of this kind of construct.

The early-exit requirement makes this feature more complex than if let, but it allows the "happy path" (where all matches succeed) to be written without excessive nesting, which makes it a good fit for error handling.

The precedents of Swift and Rust point toward a syntax like guard let (x: i32, y) = Foo() else { ... }, but as with if let, there is a risk of ambiguity or near-ambiguity, because the else { tokens that mark the end of the condition can also occur within it, as part of the initializer expression.

A proposal for this feature would also need to address how (if at all) the compiler enforces the requirement that control not reach the end of the else block, which may be a tricky balance between expressivity and implementation complexity. It may also rely on other not-yet-designed language features, such as an empty/no-return type.

In light of that additional complexity, it may make sense to defer this feature to a separate proposal after if let, but we should at least keep this feature in mind when we're solving the syntax problem for if let.

geoffromer avatar Mar 11 '25 00:03 geoffromer

Thanks for making this issue! Thinking about it myself, I worry that the parentheses imply unconditional tuple destructuring. What happens when you pattern match against, say, a struct pattern?

if let {.x: auto, .y: auto} = Foo() {
  // Foo() returned a struct with x and y members
}

Or perhaps even matching against a value? In this case there's neither parens nor curly braces

class C { var val: i32; }
var c: C = { .val = 0 };
if let c = Bar() {
  // Bar() is equal to c
}
if let true = GetBool() {
  // GetBool() returned true
}

I know that's a little silly to write when you could use a regular if and a double equals check, but I can imagine it coming up in template generics and other forms of meta programming where you're trying to make something that fits all cases.

I also wonder if instead of "else" which happens later syntactically, if there's a syntax where you know from the beginning that the false case is what you care about. For example, we could do if not let :)

if not let (a: auto, b: auto) = GetTuple() {
  // GetTuple() did not match the pattern
}

And of course, control flow would have to exit before the end of the braces as you pointed out above.

CJ-Johnson avatar Mar 13 '25 20:03 CJ-Johnson

You know, something bothers me about using = (single equals) for conditional pattern matching. It seems like we should reserve it for irrefutable patterns (guaranteed initialization) and instead have a different symbol for refutable patterns.

What about <=?

if let foo: i32 <= GetSomething() {
  // GetSomething() returned an i32 which is now bound to foo
}

if not let bar: i32 <= GetSomething() {
  // GetSomething() did not return an i32, bar is inaccessible in
  // this context and the user must return/break/etc
}

if let true <= GetBool() {
  // GetBool() was true
}

CJ-Johnson avatar Mar 13 '25 21:03 CJ-Johnson

Or perhaps, since that already means "less than or equal to" in other contexts, we could do <-? Or maybe ~= as in "maybe equals"?

CJ-Johnson avatar Mar 13 '25 21:03 CJ-Johnson

Regarding option A (optional parentheses), this question compares with semicolons in #2665, but #623 was even more specific with if x { [and so might be closely entwined with any decision allowing things like if let (v: auto) = x {].

jonmeow avatar Mar 13 '25 21:03 jonmeow

I worry that the parentheses imply unconditional tuple destructuring. What happens when you pattern match against, say, a struct pattern?

My intent with all three options is that the innermost parentheses represent tuple destructuring because that's what I happened to choose for my example; you can use any other pattern syntax in that position. The outer parentheses in options B and C are mandatory, and do not represent tuple destructuring. This whole issue seems like a bigger problem for match; here we have a mandatory = inside the parentheses to disambiguate (not to mention the let in option B).

I also wonder if instead of "else" which happens later syntactically, if there's a syntax where you know from the beginning that the false case is what you care about. For example, we could do if not let :)

if not let (a: auto, b: auto) = GetTuple() {
  // GetTuple() did not match the pattern
}

And of course, control flow would have to exit before the end of the braces as you pointed out above.

This syntax is specifically designed for situations where the false case is not what you care about, it's something you want to get out of the way. It's true that the false case is what transfers control to the nested block, but I think let ... else { does a good job of making that clear, specifically because there is no leading if.

At least in my own experience as a reader, the most important cues for reading a construct are the introducer token and the overall visual "shape" of the construct (e.g. the fact that it's followed by an indented code block), so the combination of the let introducer and the presence of a nested block makes this hard to mistake for anything else. if not let ... {, on the other hand, has the same introducer token and the same shape as if let ... {, which I feel like makes them too hard to tell apart, considering that they have completely different variable scoping behavior.

If we want to make sure this construct is identifiable "from the beginning", we should give it a distinct introducer token. For example, Swift spells this guard let ... else {. If we want something with a more conditional flavor, I suppose we could do something like unless let ... {, but that makes me nervous because I don't know of any precedent for spelling this construct without an else before the {.

geoffromer avatar Mar 14 '25 16:03 geoffromer

In light of the summit, has this moved at all? @geoffromer

CJ-Johnson avatar Jul 29 '25 19:07 CJ-Johnson

I don't think the summit really touched on this.

geoffromer avatar Jul 30 '25 16:07 geoffromer