DEV Community

Yufan Lou
Yufan Lou

Posted on

Else Before If In Rust

Jonathan Boccara has got another new idea in his Else Before If. I am honestly amazed at all the surprising thoughts he comes up with and how he bends C++ to make them work. He cares about things most don't.

In this case, the idea is about control flow. We have always dealt with edge cases with if statements, and it has always been edge cases first, general cases last. Let me quote his example:

if (edgeCase1)
{
    // deal with edge case 1
}
else if (edgeCase2)
{
    // deal with edge case 2
}
else
{
    // this is the main case
}

I want to note in addition that this is not only C++, but universal. We have always followed the convention that conditions are matched from top to bottom, be it cond in Lisp, match in ML, case in Ruby, etc.

This, despite that we usually come up with the normal case first, and the edge cases after. Despite that we understand the normal case first, and then we can understand the edge cases.

This is a similar problem to error handling. Errors, after all, are exceptional edge cases. For Java and JavaScript programmers, try ... catch ... is their very familiar friends.

try {
    // this is the main case
} catch (err) {
    // deal with edge case 1
} catch (edgeCase2) {
    // deal with edge case 1
}

Being designed specifically for error handling, try ... catch ... has other skills up its sleeves, but syntax-wise it is the same idea.

Jonathan proposes the following syntax:

normally
{
    // this is the main case
}
unless (edgeCase1)
{
    // deal with edge case 1
}
unless (edgeCase2)
{
    // deal with edge case 2
}

Without modifying the parser, there are some compromises to make. Instead of blocks, each case is instead a closure, and normally is a template function that encloses the unless blocks rather than leading them. I'll leave the end result and the detail of the template magic to the original post.

Short story, I quickly implemented the idea in Rust, and have since gained appreciation of the separation of macro and generic programming.

Applying Jonathan's strategy, representing the branches as closures, quickly bumps into a large obstacle. The way Jonathan uses the syntax is very C++: declare storage, put in value, display the value at storage:

std::string text;

normally
{
    text = "normal case";
}
unless (edgeCase1)
{
    text = "edge case 1";
}
unless (edgeCase2)
{
    text = "edge case 2";
}

std::cout << textToDisplay << '\n';

That means the storage text is referred to, and therefore borrowed in, all three branches. In addition, text is mutated in each branch. C++ doesn't care much, but Rust has a stand against shared mutation. It is a Rust compiler error for both the normal branch and the unless branches to refer to and mutate x.

Why? Because the Rust compiler doesn't know that only one of the branches will be executed. Unfortunately, we cannot tell it either, because Rust neither has the syntax for nor is capable of analyzing general control flow constraints like this. It is not a proof assistant. This is a trade-off among security, freedom of expression, and complexity of management.

On the other hand, the proposed syntax change is only a reordering of blocks. The change is not semantic, but purely syntactical. It is not some complicated data structure (like BTree) requiring verification for safety. Rust has a better tool for syntactic changes: macro.

macro_rules! normally {
    ( $norm_br:block $(unless ($cond:expr) $unle_br:block)* ) => {
        if (false) {}
        $(else if $cond $unle_br)*
        else $norm_br
    };
}

Playground

You can use the macro like this:

normally! {
    {
        x = "normal case".to_string();
    }
    unless (n == 10) {
        x = "unless case 1".to_string();
    }
    unless (n == 11) {
        x = "unless case 2".to_string();
    }
}

With the $norm_br:block $(unless ($cond:expr) $unle_br:block)* pattern, the macro matches $norm_br to the first block expression:

{
    x = "normal case".to_string();
}

$(unless ($cond:expr) $unle_br:block)* is a pattern group, with the * in the end indicating that we are looking for zero to many of it. Within the group, we look for unless keyword, after which is a pair of parenthesis enclosing an expression $cond representing the edge case, and the block expression to execute for that edge case.

So the macro sees unless and matches $cond to n == 10 and matches unle_br to:

{
    x = "unless case 1".to_string();
}

The macro does the same for the second unless block, and puts the matches into the example. Notably, $(else if $cond $unle_br)* repeats as many times as there are unless matches.

In the end the macro turns the normally! expression into this:

if (false) {}
else if n == 10 {
    x = "unless case 1".to_string();
}
else if n == 11 {
    x = "unless case 2".to_string();
}
else {
    x = "normal case".to_string();
}

I certainly feel this is simpler than the C++ template magic. C++ template feels like a compromise between a generic type system and a macro system. The mixture of concern really shows in its complexity in my opinion.


  1. Closure, lambda, they're the same thing.

  2. macro_rules! is the simpler way to write a macro in Rust, also called macro by example. The harder and more powerful way is procedural macro (proc_macro).

  3. unless is used in Ruby and Lisp to mean "if not", as in "Unless the egg is cooked, don't turn off the stove." It is also natural for "unless" in English to reject the sentence before, as in "Break two eggs, unless you don't have eggs, then pour 200cc of liquid egg." Natural language πŸ€·β€β™‚οΈ.


If you feel that you've got something from this post, I am glad! Please don't hesitate to comment or reach out.

If you feel that you've got enough that you'd like to donate, please donate to Wikimedia Foundation, to which I owe infinitely.

Top comments (5)

Collapse
 
jeikabu profile image
jeikabu

Interesting. Not entirely sure I agree, but interesting.

Thing is, the if branches aren't necessarily the "exceptions". Some compilers even optimize assuming the if branch is taken.

I guess it's a question if you write "if ok" vs "if ! ok".

Collapse
 
louy2 profile image
Yufan Lou

Yeah, I am not sure about it either, but it feels surprisingly ok.

Whether the branches mean "exceptions" in some sense may be less relevant than the fact that rarely do branches of a conditional in a real application get even chance of execution. So identifying the majority branch can be helpful, to other programmers and maybe to compilers.

That said, I've never heard of compiler optimization assuming a branch is taken. I only know about CPU doing branch predictions, but that's to prevent stalling the pipeline during branch execution. How does a compiler optimize based on branch prediction? The pipeline being the event loop?

Collapse
 
b0c1 profile image
b0c1

Ok, but why better it's than the pattern matching?

    let num = Some(4);

    match num {
        Some(x) if x < 5 => println!("less than five: {}", x),
        Some(x) => println!("{}", x),
        None => (),
    }

doc.rust-lang.org/book/ch18-03-pat...

So your example like this:

fn main() {
    let n = 11;
    let x = match n {
        10 => "unless case 1".to_string(),
        // Match several values
        11 => "unless case 2".to_string(),
        // Match an inclusive range
        _ => "normal case".to_string(),
    };

    println!("{}",x);
}
Collapse
 
louy2 profile image
Yufan Lou

The point is to promote the normal case to the top. In your code the normal case is at the bottom.

The merit of promoting the normal case to the top is it is more natural to read and write. It's a very subtle and subjective merit.

Collapse
 
b0c1 profile image
b0c1

Great, but disagree.
Usually, your "normal case" is the "fallback" method in the control statement, because the else statement is optional and not necessary.
Your normal case is unnatural, because:

  • you always expect a "normal" way
  • and when not run the normal way? you may need to check all condition
  • so you first need to skip the normal way and scroll down to all other condition

The try/catch "normal case" is different. In try/catch you will try to do something and if something wrong you create a fallback.

but this is only my opinion :D