Rust's Most Subtle Syntax
Psst. Hey kid. You wanna write confusing Rust? I’ve got just the thing.
Bring us let
and const
Quick rundown on let
and const
in Rust:
let
You use let to declare a new variable. Wow.
let x = 5;
This is in the form let PAT = EXPR;
, which makes it a bit more powerful than first seems.
// +---- this is a pattern. Same as the things you use in matches.
// |
// vvvvv
let (a, b) = (5, 10);
You can combine this with other parts of Rust to get some seriously convenient stuff.
// this is either Some("...") or None
let maybe_string: Option<String> = ..;
// here: `Some(value)` is our pattern. This is a "refutable" pattern, which is to say
// that it *might not match*.
//
// as such, we need to handle the case where it is refuted.
let Some(value) = maybe_string else {
panic!("die horribly")
};
const
Constants are variables that are calculated at compile time and embedded, literally, into what you compile.
const MY_VAR: &str = "heyyyyyyyy man";
const SECRET: i32 = 0x1234;
The embedding bit here isn’t relevant, but the gist of it is that you can’t change these variables. At all. They’re like “aliases” for values you’ll use throughout the program.
Unlike let
, const
declarations are of the form
const IDENT: TYPE = EXPR;
// vs.
let PAT = EXPR;
This means two things:
- a const declaration must declare its type (like
: i32
) - a const declaration CANNOT be a pattern. It can only be an identifier.
In practice, this means this just isn’t possible:
// (A, B) is a pattern, not an identifier
const (A, B): (i32, i32) = (5, 10);
Bring us confusion
Ok, now that we’re up to speed on let
and const
, lets start throwing in the weird stuff.
Firstly, const
declarations are hoisted. Remember hoisting? From javascript?
// this compiles, even though X is defined after Y.
const Y: i32 = X + X;
const X: i32 = 5;
This is a wonderful feature. For the same reason that we don’t want to have to care about the “order” our functions are declared, we don’t have to care about the order our consts are evaluated.
Secondly, you can declare constants anywhere.
fn oh_boy() -> i32 {
const X: i32 = 5;
return X;
}
Let’s combine these two features…
fn oh_boy() -> i32 {
return X;
const X: i32 = 5;
// ^ this compiles and works. No warning!
}
Now this is fun. If you work with programmers coming from Javascript and just learning Rust, this is an excellent one to baffle them with.
But ultimately, this is a harmless consequence of a great feature.
That’s boring. Let’s start writing harmful consequences.
Bring us match
Before we cover the worst bit, lets explain patterns a bit more.
// let PAT = EXPR;
let x = 5;
// in this case, `x` is a pattern. We check whether `5` can be put into `x`.
// This pattern always matches -- we can always put 5 into a variable called `x`.
// not all patterns have to match. For example:
let (5, x) = (a, b);
// the expression here only "matches" the pattern if a == 5.
//
// This is called a "refutable" pattern.
//
// In `let` declarations, refutable patterns need to have their "refused"
// case handled:
let (5, x) = (a, b) else { panic!() };
//
// ...otherwise you could have "conditionally existing" variables, which sucks.
Which brings us to match
. What is a match?
// match is a list of patterns and what to do if they match.
//
// match EXPR {
// PAT => EXPR
// PAT => EXPR
// ..
// }
match (a, b) {
(5, x) => {
// if (a,b) matches (5,x), this block is executed
},
(x, 5) => {
// same thing: if (a,b) matches (x, 5)..
},
(x, y) => {
// and this is our "catch all" pattern, the same way let (x,y) = (a,b) works.
}
}
Bring us pain
Confusing people is fun and all, but what about causing abject misery and real bugs?
This is - in my opinion - Rust’s most subtle syntax:
If there is one interesting line in this post it is this:
Rust’s most subtle syntax is that constants, themselves, are patterns.
This syntax adds for some nice ergonomics around matching:
let input: i32 = ..;
const GOOD: i32 = 1;
const BAD: i32 = 2;
match input {
// this checks that input == GOOD, because GOOD is a const.
GOOD => println!("input was 1"),
// this checks that input == BAD, because BAD is a const.
BAD => println!("input was 2"),
// this defines otherwise = input, and always matches...
otherwise => println!("input was {otherwise}"),
}
But capitalizing constants is just a convention. It’s just a compiler warning to not do it.
const good: i32 = 1;
const bad: i32 = 2;
match input {
// um...
good => {},
bad => {},
otherwise => {},
}
And now we have three branches that look the same, yet what they do depends on whether constants exist with those names!
Let’s get worse. What do you think happens here?
const GOOD: i32 = 1;
match input {
// typo...
GOD => println!("input was 1"),
otherwise => println!("input was not 1")
}
You will get a compiler warning here, but this code will always print input was 1
.
Or more realistically:
// whoops, accidentally commented out or deleted this import
// use crate::{SOME_GL_CONSTANT, OTHER_THING}
// uh oh!
match value {
SOME_GL_CONSTANT => ..,
OTHER_THING => ..,
_ => ..,
}
This trips people, especially when they try fancy things with enums.
enum MyEnum {
A, B, C
}
// this is how you would normally write this
match value {
MyEnum::A => ..,
MyEnum::B => ..,
MyEnum::C => ..,
}
// but you can write it like this.
use MyEnum::*;
match value {
A => {},
B => {},
C => {}
}
// and then, if you ever change MyEnum...
enum MyEnum { A, B, D, E };
use MyEnum::*;
// this still compiles!
match value {
A => {},
B => {},
C => {},
}
// `C` now ends up being a "catch all" pattern, as nothing like `C` is in scope.
// you're doing let C = value, which always matches!!!
Clippy has a bunch of rules warning you to not do this, because it gets people all the time.
But this can be made even more confusing. Check this out:
// binds x to 5, irrefutably...
let x = 5;
// ...wait...
const x: i32 = 4;
This code won’t compile, because const x
is a pattern, constants are hoisted, and now this code is evaluated as:
let 4 = 5;
// error[E0005]: refutable pattern in local binding
// --> src/main.rs:3:5
// |
// 3 | let x = 5;
// | ^
// | |
// | patterns `i32::MIN..=3_i32` and `5_i32..=i32::MAX` not covered
// | missing patterns are not covered because `x` is interpreted as a constant pattern, not a new variable
// | help: introduce a variable instead: `x_var`
// |
// = note: `let` bindings require an "irrefutable pattern", like a `struct` or an `enum` with only one variant
“expr equals 4” isn’t an irrefutable match, and you aren’t handling the case where it doesn’t. Sweet. Ok.
Annoying everyone around you
Let’s go further.
// let's say we have `maybe`, which is an Option<&str>. It might be some text,
// or it might be None.
let maybe_username: Option<&str> = ..;
// this is a common pattern in rust for one-line matches. If this thing matches Some(..)
// we can do something with the string.
if let Some(username) = maybe_username {
// so ok, this code will run if the username exists...
return username.to_uppercase();
}
// except, uh... Now that code will only run if 'username' matches Some("hey")
const username: &str = "hey";
The combination of constant-hoisting and constants-are-patterns allow you to write puzzling Rust code.
This isn’t a real issue
Realistically, the only reason this can be confusing is that you’re allowed to write let UPPERCASE
, and const lowercase
.
If creating a variable that started with a capital letter was a lint error, no confusion could happen; you could never accidentally bind something when trying to match an enum variant or constant.
But just to be clear, this is just a fun oddity of the language. Everyone writes constants with UPPERCASE
names, and all of the footguns here are big compiler warnings.
Now Everybody –
macro_rules! f {
($cond: expr) => {
if let Some(x) = $cond {
println!("i am some == {x}!");
} else {
println!("i am none");
}
}
}
fn main() {
f!(Some(100));
{
f!(Some(100));
return;
const x: i32 = 5;
}
}