Skip to content
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

Add guard and guard.let #637

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft

Conversation

jackfirth
Copy link
Collaborator

@jackfirth jackfirth commented Mar 21, 2025

This pull request adds guard statements (as seen in Swift and Rust) in the form of two new control flow utility macros, guard and guard.let. They're definition sequence macros that give straight-line code the ability to escape early if a condition isn't met. Their semantics are the same as in my guard package for Racket, with the exception that define/guard and guarded-block aren't needed. As an example, here are two versions of a list_equals(xs, ys) function, one with guard statements and one without:

fun list_equals(xs :: List, ys :: List):
  cond
  | xs == []: ys == []
  | ys == []: #false
  | ~else:
      let [x, & rest_xs] = xs
      let [y, & rest_ys] = ys
      x == y && list_equals(rest_xs, rest_ys)
fun list_equals(xs :: List, ys :: List):
  guard.let [x, & rest_xs] = xs | ys == []
  guard.let [y, & rest_ys] = ys | #false
  x == y && list_equals(rest_xs, rest_ys)

The guard form checks that an expression is true; otherwise, it short-circuits the enclosing block and evaluates the given alternative. The guard.let form checks that a pattern matches an expression or else short-circuits. They're equivalent to putting the remainder of the block in an enclosing if or a match expression. Here is a longer piece of example code that uses a mix of guard and guard.letstatements:

fun foo(x, y):
  guard.let x :: Int = x | #false
  guard.let y :: Int = y | #false

  println("looks like the types are right")
  println("so far so good")

  guard x > 0 | #false
  guard y > 0 | #false

  println("both values positive")

  guard x > y
  | println("x must be greater than y")
    #false

  guard.let delta :: Int = x - y
  | println("this is supposed to be impossible")
    println("compiler bug?")
    #false

  delta

Tests and documentation are not yet included, as I wanted to get feedback on the proposal first.



defn.sequence_macro 'guard $condition ... | $failure_body
$success_body':
Copy link

@distractedlambda distractedlambda Mar 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does $success_body need a ..., or does defn.sequence_macro do something where a plain trailing $-binding implicitly matches the remainder of input?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The latter. $success_body here matches the remainder of the enclosing block.

@distractedlambda
Copy link

I really like the look of this (especially as someone who's used Rust and Swift a fair bit). It also hadn't even occurred to me to use defn.sequence_macro for this (I had imagined some sort of escape-continuation-based thing, but this seems much better).

One thing I'm still slightly wrestling with is that it does use the purely syntactic notion of a defn-or-expr sequence for what constitutes the code to short-circuit. This is almost certainly for the best, since it keeps it easy to understand how guard will behave in combination with other macros... I guess it just feels a little strange when I try to analogize it to early-return in Swift/Rust/Zig, which cares only about function boundaries (but then for that sorta thing, explicit escape continuations are probably the right answer for Rhombus).

@samth
Copy link
Member

samth commented Mar 21, 2025

One thing that would be really nice would be to have a way of producing a failure result automatically based on static information for the enclosing context, and then having ? do that as well, similar to how it works in Rust. I'm not sure if that's possible currently though.

@rfindler
Copy link
Member

This is a really nice idea!

One thing that would be really nice would be to have a way of producing a failure result automatically based on static information for the enclosing context, and then having ? do that as well, similar to how it works in Rust. I'm not sure if that's possible currently though.

Ah, that would be great. I get hung up on the syntax of the examples with this extra "guard" stuff. But if there was a specific sentinel value that these bindings worked with somehow and you could write patterns that explicitly didn't include that sentinel and then that would mean "abort to the enclosing block" but doing it all statically, not via (escaping) continuations, I think that would make the notation look very nice. And DrRacket/Emacs/VSCode could offer you some kind of feedback when you've done that so you could easily see the shortcircuiting potential.

@distractedlambda
Copy link

One thing that would be really nice would be to have a way of producing a failure result automatically based on static information for the enclosing context, and then having ? do that as well, similar to how it works in Rust. I'm not sure if that's possible currently though.

I like the idea of having a syntactically-lightweight error-handling mechanism that "synthesizes" the aborting path, but my feeling is that that makes more sense as a separate mechanism / construct. Though I might just be biased by analogizing to Rust/Swift/Zig (where constructs like guard or if let are distinct from try).

I also think there may be a question there of "how far to escape". In Rust/Swift/Zig, the try construct (which is postfix-? in Rust, much to my annoyance) is hard-wired to escape out of the enclosing function, and I think that's probably the right behavior for the majority of uses. I could imagine that working semi-statically by having a "current-escape-continuation-that-try-should-call" tracked as a syntax parameter, potentially with every fun form automatically introducing one of those at the top. Though, we'd want to be sure that the compiler removes the overhead of these when they're not actually used.

@mflatt
Copy link
Member

mflatt commented Mar 23, 2025

I also think there may be a question there of "how far to escape". In Rust/Swift/Zig, the try construct (which is postfix-? in Rust, much to my annoyance) is hard-wired to escape out of the enclosing function, and I think that's probably the right behavior for the majority of uses.

Unless I misunderstand, this doesn't sound like an approach that would work well for Rhombus. Things that look like nested-block forms may actually be implemented with fun via macro expansion. For example, the body of a check form is under fun. We could have different kinds of funs, but the ease of converting <expr> to (fun (): <expr>))() without thinking too hard about it is probably something we don't want to lose from Scheme. There's also a question of tail calls. The body of let/cc is not in tail position, so it wold be bad for every function to wrap one around its body; maybe there's a variant of let/ec that would do the right thing, but I'm not immediately sure.

Along the lines of functional-versus-imperative style, I think I wouldn't want to include a guard form that tries to jump out to a function boundary, even if macros and tail calls were not a problem. Continuations/gotos are difficult to reason about, and I doubt that I'd want an implicit goto point at the start of every function.

A block-based guard form makes sense to me, and I'm ok with guard in Rhombus being inspired by Swift and Rust without being the same thing. If there's a different name than guard with a closer precedent, that would be good, but I don't happen to know one.

@distractedlambda
Copy link

Unless I misunderstand, this doesn't sound like an approach that would work well for Rhombus.

I hadn't considered implicit introduction of funs when I wrote that, but I agree fully with your reasoning. My original reasoning for why function boundaries seemed "more right" was that it would avoid having to wrap nested block expressions in more guards or trys just to fully bubble up an error... though it's not like function boundaries are always the right granularity for that, either.

FWIW, limited forms of goto are regaining popularity in mainstream languages. Both Rust and Zig let you attach lexically-scoped labels to block expressions, and to break out of such an expression (supplying a value for the expression result) regardless of nesting depth, as long as you don't cross a function boundary. Kotlin goes further by allowing you also cross function boundaries in certain situations (specifically, you can cross the boundary of a lambda when you have already forced it to be inlined).

@mflatt
Copy link
Member

mflatt commented Mar 23, 2025

Rust and Zig let you attach lexically-scoped labels to block expressions, and to break out of such an expression

Something like let/cc (with a better name) would be a good idea to add to Rhombus. Something that explicitly declares a named point that might be jumped to — and where I can easy find all uses of the name — is certainly ok.

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

Successfully merging this pull request may close these issues.

5 participants