Skip to content

Should we have different syntax and semantics for statement and expression match? (or if let?) #114

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
osa1 opened this issue May 5, 2025 · 4 comments
Labels
language Language design syntax

Comments

@osa1
Copy link
Member

osa1 commented May 5, 2025

Right now we have one match syntax which can be used as an expression or statement.

When used as a statement not in return position, we allow the branches to have different types. See these tests for examples: https://github.com/fir-lang/fir/blob/eb2236a27a1730af97e7f17b0ddb1f1114c44fa3/tests/IfMatchStmtChecking.fir

In both exprs and stmts though we check exhaustiveness, and in runtime, if the expr or stmt doesn't match the scrutinee the interpreter panics.

I think in statement for we could allow not being exhaustive, i.e. add an implicit _: () at the end of every non-exhaustive match statement.

However a concern is that we'll have the same syntax for two things that work quite differently. So maybe a new keyword would be useful for the statement form, maybe switch.

Syntax-wise everything else other than the keyword should be the same.

The use case is similar to if let Some(x) = ... { ... } in Rust, in code like

fir/compiler/AstPrinter.fir

Lines 109 to 115 in cd69304

match extension:
Option.None: ()
Option.Some(extension):
if fields.len() == 0:
doc = Doc.str("..")
else:
doc += Doc.str(", ..")

Here ideally I want to be able to omit the Option.None case. With switch it would look like this:

switch extension:
    Option.Some(extension):
        if fields.len() == 0:
            doc = Doc.str("..")
        else:
            doc += Doc.str(", ..")

When we have just one branch Rust style if let can be more concise:

if let Option.Some(extension):
    if fields.len() == 0:
        doc = Doc.str("..")
    else:
        doc += Doc.str(", ..")

And if we add if let, we can add it in full generality (allow in conjunctions) which would make statement switch mostly redundant.

So maybe we just want if let?

@osa1 osa1 added syntax language Language design labels May 5, 2025
@osa1
Copy link
Member Author

osa1 commented May 6, 2025

It's probably worth reading rust-lang/rfcs#2260 before supporting if lets.

Or the merged version of the RFC: (not the same PR) rust-lang/rfcs#2497

(Note: I just realized we already support while let, I think I implemented that before we had for loops, as a way to iterate using Iterator.next)

There are some objections in rust-lang/rfcs#2260 (comment), and an alternative in the next comment: rust-lang/rfcs#2260 (comment). The alternative is basically Haskell's multi-way if, which I always liked in Haskell.

Also mentioned in the links above, apparently C# supports pattern matching in if statements: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/is

The main difference between let <pat> = <expr> and <expr> is <pat> is the order of <pat> and <expr>. Between these two, from my experience with if lets in Rust, I think the order in is would be preferred.

If we implement multi-way ifs, we should have a shorthand when we don't have multiple branches.

With multiple branches:

if:             # or `when`
    cond && expr is pat:
        ...

    cond || blah:
        ...

    Bool.True:  # or introduce a variable for `Bool.True`, similar to `otherwise` in Haskell
        ...

With just one branch, we should be able to avoid the extra indentation:

if cond && expr is pat:
    ...

Or we could call multi-branch version if and single-branch version when.

If we go with the is version, we may also want an is not version. Instead of !(expr is pat) this allows expr is not pat.

Or use is!, as in Dart.

In https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/patterns they show examples with patterns expr is not (pattern1 or pattern2).

I don't understand why they used and and or instead of reusing && and || for and- and or- patterns.
==> actually we do the same, we use | in or patterns instead of ||.

If we use is then it might make sense to use and and or also, to consistently use keywords in patterns and leave operators to expressions.

Also, is C# even real? They have relational patterns: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/patterns#relational-patterns which allows things like > 0 as patterns. Also property patterns: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/patterns#property-pattern this generalizes field patterns to properties, similar to view patterns in Haskell.

@osa1
Copy link
Member Author

osa1 commented May 6, 2025

Here's a plan:

  • We generalize if as multi-way/branch, as described above. (with the shorthand for one-way ifs)

    This can be done with very little effort, but it won't be too useful without the next change.

  • Add expr ::= <expr> is <pat>, with type Bool.

    When used in || and && expressions, they binders should be checked the same way as in and- and or- patterns.

    • In &&: check that left and right expressions bind disjoint set of binders.
    • In ||: check that left and right expressions bind the same set of binders with same types.

    These expressions then "propagate" bound variables to the parent expression. At the top-level, the bound variables will be available based on the scoping rules below.

    (We could not propagate binders in || expressions to keep things a bit simpler, and then allow binding different set of binders. E.g. (x is C.T1(a) && foo(a)) || (x is C.T2(a) && bar(a)) would be valid.)

    The scope of variables bound in <pat> depend on where the expr appears:

    • In match scrutinee: in branch guards and RHSs.
    • In a match guard: in the RHS.
    • In an if condition: in the RHS.
    • In while condition: in the body. (makes while let redundant, which we remove)
    • In expr1 && expr2: expr2 can refer to binders in expr1.

    In other places the binders are ignored.

    Precedence of is should be higher than &&, lower than everything else.

    With this new syntax it probably also makes sense to use or instead of | for or-patterns. With the new syntax we can now have this:

    if foo | bar is P1 | P2: ...
    
    # Parsed and desugared as:
    if (foo.__bitor(bar)) is (P1 | P2): ...
    

    Here | can appear both on the LHS and RHS, but it means different things. With or:

    if foo | bar is P1 or P2: ...
    

    Which reads slightly better I think.

    It's no big deal regardless as it's an hypothetical case, I don't think this will happen too much in practice.

In the Rust RFC people decided against this more general form, but I can't find what the problem is.

It's certainly not a simple feature, but I think it may worth it.

osa1 added a commit that referenced this issue May 6, 2025
Implements `is` expressions as specified in #114.
@osa1
Copy link
Member Author

osa1 commented May 6, 2025

23b667d replaces single branch matches in the compiler with is exprs.

@osa1
Copy link
Member Author

osa1 commented May 7, 2025

With is expressions we now have two different syntax for two different purposes:

  • match when you need exhaustiveness checking
  • if when you don't

In terms of expressivity, they're the same.

(Note: we haven't implemented guards in match alternatives yet.)

I'm still not 100% sure about the multi-branch if syntax. The main problem is that it adds one more level of indentation to the blocks. E.g.

if foo && bar is C(123):
    code
elif bar is A(x) && x.f():
    code

==>

if:
    foo && bar is C(123):
        code

    bar is A(x) && x.f():
        code

However this can also be considered a good thing, as you no longer have a reason to prefer if over match when match works.

Because "if: " is exactly one indentation level long, you can also do this:

if: foo && bar is C(123):
        code

    bar is A(x) && x.f():
        code

I'm not sure if this looks better or worse.

Note that we still need an else keyword, and expect it as the last branch of if to make if an expression rather than statement.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
language Language design syntax
Projects
None yet
Development

No branches or pull requests

1 participant