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
};
}
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.
Closure, lambda, they're the same thing.
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
).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)
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".
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?
Ok, but why better it's than the pattern matching?
doc.rust-lang.org/book/ch18-03-pat...
So your example like this:
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.
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:
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