Let’s start where I started. Just thinking about if statements.
An if with an else can produce a value:
// C
int x = -5;
int abs_x = x > 0 ? x : -x;# Python
x = -5
abs_x = x if x > 0 else -x// Rust
let x = -5;
let abs_x = if x > 0 { x } else { -x };What about an if without an else?
// C
int x = -5;
int abs_x = x > 0 ? x; // Syntax error!# Python
x = -5
abs_x = x if x > 0 # Syntax error!// Rust
let x = -5;
let abs_x = if x > 0 { x }; // Type error -- evaluates to ()!No dice.
ifI realized that there is a meaningful value for if (x > 0) x,
though. It’s Option<i32>:
// Hypothetical-Lang
let x = -5;
let pos_x = if (x > 0) x; // = None
let y = 7;
let pos_y = if (y > 0) y; // = Some(7)(This hypothetical language will look a lot like Rust, but with different syntax
for if since we’re giving it a different semantics.)
In general, if the conditional evaluates to true then the if evalutes to
Some, otherwise it evaluates to None:
if (true) e ⟼ Some(e)
if (false) e ⟼ None
So the type of if is that it takes a bool and a T, and produces an
Option<T>:
if (bool) T : Option<T>
(That is to say: if expr_1 has type bool and expr_2 has type T, then
if (expr_1) expr_2 has type Option<T>.)
This generalized if could be used to perform an operation that’s only valid
when the conditional is true:
fn strip_prefix(string: &str, prefix: &str) -> Option<&str> {
if (string.starts_with(prefix)) &string[prefix.len()..]
}or in conjunction with a method like
filter_map:
let numbers = vec![9.0, -20.0, 16.0, -16.0];
let square_roots = numbers
.into_iter()
.filter_map(|x| if (x > 0) x.sqrt());
assert_eq!(square_roots.next(), Some(3.0));
assert_eq!(square_roots.next(), Some(4.0));
assert_eq!(square_roots.next(), None);elseThere’s a matching interpretation for else. It’s type is:
Option<T> else T : T
and its evaluation rules are that:
None else e ⟼ e
Some(x) else e ⟼ x
So it supplies a default for an option:
fn get_name(personal_info: &HashMap<String, String>) -> &str {
personal_info.get("name") else "Whoever you are"
}(This is exactly the behavior of Rust’s
unwrap_or_else(),
except that you don’t need to manually wrap the expression in a closure.)
Of course, you usually pair an else with an if. This continues to work
like you’d expect, as you can check using the type rules:
let abs_num = if (num > 0) num else -num;
-------- ---
bool i32
---------------- ----
Option<i32> i32
---------------------------
i32
Notice what we’ve done: we’ve turned both if and else into binary
operators.
and and orCan we go further? What about and and or – can they work on options? They
would take options, and return options.
or will take the first option if it’s Some, or otherwise take the second.
You could use it to try multiple options each of which may or may not succeed:
fn get_color(attrs: &HashMap<String, String>) -> Option<&str> {
attrs.get("color") or attrs.get("colour")
}and will take the second option, so long as the first option was Some.
You could use it to try something but only if a condition holds. For example,
if you wanted to parse a hex color like "#a52a2a" if a string starts with #,
or a color name like "brown" otherwise, you could write:
fn parse_color(color: &str) -> Option<Color> {
(color.starts_with("#") and parse_color_hex(color))
or parse_color_name(color)
}More formally, and and or would have the evaluation rules:
None and e ⟼ None
Some(x) and e ⟼ e
None or e ⟼ e
Some(x) or e ⟼ Some(x)
and the type rules:
Option<A> and Option<B> : Option<B>
Option<A> or Option<A> : Option<A>
(This makes and and or equivalent to Rust’s
and_then
and
or_else.)
If you have good mathematical intuition you might notice the asymmetry between
the type rules for and and or and suspect that we’re missing some sort of
generalization. Don’t worry, it will come.
But wait! You still need to be able to use and and or inside an if’s
condition, for example if (x >= 0 and x < len). That doesn’t work if and
produces an option!
There’s an easy fix, though. There’s an equivalence between booleans and options:
bool = Option<()>
true = Some(())
false = None
So we could eliminate booleans from our hypothetical language, and always use the equivalent options instead.
Now let’s imagine what a programming language without booleans might look like.
We just talked about replacing bool with Option<()>, because those types are
equivalent. But there’s a further equivalence with
results, as well:
Option<T> = Result<T, ()>
bool = Option<()>
= Result<(), ()>
If we’re going to use the equivalence between booleans and options, we might as well go all the way and use the equivalence between options and results. Results everywhere!
Since they’ll be so ubiquitous, let’s invent a short syntax for them. T ? E
can mean “either a successful value T, or an error E”. What Rust would call
Result<T, E>.
Then bool is a type alias for ()?(). Except… that looks like an ASCII
drawing of a fly’s face. So let’s call the unit value (and the unit type) nil
instead. Then we have:
bool = nil?nil
true = Ok(nil)
false = Err(nil)
These will be aliases built into the language.
Writing all of the type rules from before but using results instead of options, we have:
if (A?E) B : B?E
A?E else A : A
A?E and B?E : B?E
A?E or A?F : A?F
not A?E : E?A
(There’s the nice symmetry that was lacking before.)
And writing all of the evaluation rules:
if (Ok(x)) e ⟼ Ok(e)
if (Err(x)) e ⟼ Err(x)
Ok(x) else e ⟼ x
Err(x) else e ⟼ e
Ok(x) and e ⟼ e
Err(x) and e ⟼ Err(x)
Ok(x) or e ⟼ Ok(x)
Err(x) or e ⟼ e
not Ok(x) ⟼ Err(x)
not Err(x) ⟼ Ok(x)
(I’m not really sure whether if (A?E) B is the right type, or if if should
require its conditional’s error to be nil, like if (A?nil) B.)
Now we can start to imagine what conditionals in this language might look like.
else ifWe’ve replaced if and else with binary operators, so we should verify that
else if still works the way it’s supposed to:
let sign = if (n > 0)
1
else if (n < 0)
-1
else
0;It does, so long as else is right associative. That is, the grouping needs to
be like this:
if (n > 0) 1 else if (n < 0) -1 else 0
------------ -------------
--------------------
--------------------------------------
or ifSurprisingly there’s another way to write multi-way conditionals:
let sign = if (n > 0)
1
or if (n < 0)
-1
else
0;This behaves exactly the same as else if! It’s relying on the or binding
tighter than the else:
if (n > 0) 1 or if (n < 0) -1 else 0
------------ -------------
-----------------------------
------------------------------------
It took me a bit to get past my “WTF” reaction, but now I kind of like saying “or if”.
true and falseRemember that true and false are simply shorthands for Ok(nil) and
Err(nil). So you can write:
fn delete_file(path: FilePath) -> nil ? IOError {
if (not file_exists(path)) return true;
...
}and:
fn pop(vec: &mut Vec<i32>) -> i32 ? nil {
if (vec.len == 0) return false;
...
}isIt’s very useful to be able to bind a pattern inside a conditional. Rust uses
the syntax if let for this; let’s use the syntax is instead:
fn parse_and_clamp(s: &str, min: i32, max: i32) -> i32 ? nil {
if (parse_num(s) is Ok(n)) {
if (n < min) min
or if (n > max) max
else n
}
}try / elsePython lets you put an else clause after a for loop. The else clause runs
if the for loop doesn’t break. For example:
for elem in list:
if predicate(elem):
print("Found!", elem)
break
else:
print("Not found :-(")There’s a natural extension of this in our hypothetical language – a for loop
can break with a value, and prodcues Ok if it does:
fn find(list: &Vec<i32>, predicate: fn(i32) -> bool) -> i32 ? nil {
for elem in list {
if (predicate(elem)) break elem;
}
}This can be composed with our existing else construct:
fn find_with_default(
list: &Vec<i32>,
predicate: fn(i32) -> bool,
default: i32
) -> i32 {
for elem in list {
if (predicate(elem)) break elem;
} else default
}Oks and ErrsMany functions which would require wrapping their result in Ok or Err or
Some if they were written in Rust, no longer require that. For example,
parsing a boolean in Rust:
fn parse_bool(s: &str) -> Option<bool> {
if s == "true" {
Some(true)
} else if s == "false" {
Some(false)
} else {
None
}
}vs. in our hypothetical language:
fn parse_bool(s: &str) -> bool ? nil {
if (s == "true") true
or if (s == "false") false
}All the examples so far have been short. Let’s look at some real code, code from deep inside the guts of my personal projects. No need to understand what it does (I certainly don’t remember the details), we just need to rewrite it in this hypothetical language and see how it compares.
Here’s one snippet:
if !node.can_have_children(s) {
return None;
}
if let Some(last_child) = node.last_child(s) {
Some(Location(AtNode(last_child)))
} else {
Some(Location(BelowNode(node)))
}which can now be written as:
if (node.can_have_children(s)) {
if (node.last_child(s) is Ok(last_child))
Location(AtNode(last_child))
else
Location(BelowNode(node))
}And other example:
if let Some(menu) = &mut self.active_menu {
if let Some(key_prog) = menu.lookup(key) {
return Some(KeyLookupResult::KeyProg(key_prog));
}
if let Some(ch) = key.as_plain_char() {
if menu.execute(MenuSelectionCmd::Insert(ch)) {
return Some(KeyLookupResult::Redisplay);
}
}
} else {
let layer = self.composite_layer(doc_name);
let keymap = layer.keymaps.get(&KeymapLabel::Mode(mode));
if let Some(key_prog) = keymap.lookup(key, None) {
return Some(KeyLookupResult::KeyProg(key_prog));
}
if mode == Mode::Text {
if let Some(ch) = key.as_plain_char() {
return Some(KeyLookupResult::InsertChar(ch));
}
}
}
Nonewould be written as:
if (&mut self.active_menu is Ok(menu)) {
if (menu.lookup(key) is Ok(key_prog)) {
KeyLookupResult::KeyProg(key_prog)
} or if (key.as_plain_char() is Ok(ch)
and menu.execute(MenuSelectionCmd::Insert(ch))) {
KeyLookupResult::Redisplay
}
} else {
let layer = self.composite_layer(doc_name);
let keymap = layer.keymaps.get(&KeymapLabel::Mode(mode));
if (keymap.lookup(key, false) is Ok(key_prog)) {
KeyLookupResult::KeyProg(key_prog)
} or if (mode == Mode::Text and key.as_plain_char() is Ok(ch)) {
KeyLookupResult::InsertChar(ch)
}
}Notice that this loses the need for wrapping the results in Some and the need
for early return statements.
This sure seems to be a coherent design! It will certainly influence how I think about conditionals.
The closest thing I’ve seen is fallible
expressions
in Verse, but
those are pretty different because they (i) don’t assign a value to an if
without an else, and (ii) involve speculative execution. Let me know if you’ve
seen anything more similar.
UPDATES:
and and or to act on values other
than booleans, making them behave much like this post suggests.not a?e had type nil?nil. Commenter dpercy
points out that it could instead have type e?a. This seems so right that
I edited the post to make it so.or_else and friends,
but as built-in syntax.” Yup, exactly that. Plus the related realization that
if and else can be binary operators.Discussion was on r/programming and lobste.rs.