What is “bad” Rust?#

When we say that a snippet of code is “bad” Rust, it’s ambiguous.

We might on the one hand mean that it is “invalid” Rust, like the following function (standing on its own in a module):

fn foo(bar: u32) -> u32 {
    bar + baz // but baz is never declared...
}

In this situation, a rule of the programming language has been violated. The compiler stops compiling and does not output a binary. In fact, it has to stop compiling, because this is not a Rust program. It might resemble one, but it in fact does not make any sense, because it is violating one of the extra-syntactic constraints that text has to have to be a Rust program.

What would it even mean, to access a variable that’s not declared? When you write a variable access, the compiler issues an access to the corresponding register or location in memory. When a variable is undeclared, no such location exists. The compiler couldn’t compile this code if it wanted to!

On the other hand, there’s this sort of “bad Rust” as well:

fn foo(bar: bool) -> &'static str {
    match bar == false {
        true => "false",
        false => "true",
    }
}

This code is – as the kids say – cringe. Whatever this code is trying to do, it should not be done this way. But for all its flaws, it’s definitely “good” Rust in a validity sense: the compiler knows exactly what to do to output a binary from it, and will do so with no complaints. Whatever is “bad” about this code – and it’s a lot – is bad from a human perspective only; the computer doesn’t even notice. It’s bad idiomatic Rust, not erroneous invalid Rust, and it’s bad because humans prefer not to structure their concepts this way.

So now we have a nice little dichotomy of problems with a Rust program. On the one hand, we have errors, where the compiler will not – cannot, even – produce an output. And on the other hand, we have idiomatic failures. It’s a nice neat tidy distinction that a lot of people make, but in the context of Rust – and with most programming languages – it’s actually problematic, because problems with programs, like gender or political views, don’t actually quite form a tidy binary. And as with gender and politics, oversimplifying types of “bad Rust” into a binary, even conceptually, can lead to practical problems.

I am, of course, talking about warnings and linter errors – those rules that if you violate them, it won’t necessarily cause the compiler to reject the program, but it may, depending on its settings. I’m also talking about things like safety rules, where if you dereference pointers the compiler will normally reject your program, but it can be told not to on a block-by-block basis.

Here’s an example of that, for Rust:

fn foo() -> u32 {
    return 3;
    println!("Not reached!");
}

The compiler knows that that the println can’t be called, and it makes a point to tell the user about it:

warning: unreachable statement

But more on those later. For right now, we’ll continue to try and brush these warnings under the rug.

The Binary Error Model#

I call the philosophical framework I am criticizing the “binary error” model, and before I start picking apart at it and denouncing it, I’d like to spend some time explaining what I mean by it, and why it’s appealing.

So to talk about “the binary error model,” as I’ve termed it, we’ll start by talking about why it exists, what problem it’s trying to solve. It’s trying to distinguish between a notion of the programming language in itself, as a platonic ideal almost, versus the other things that surround it – like a reference implementation, or a set of community norms. What would belong in a formal specification, and what not? What would have to be the same for another compiler to also be a Rust compiler?

In the “binary error model,” Rust, or any programming language, is a set of valid programs and their semantics. You could look at it as being analogous to a Rust function with this signature:

fn rust_programming_language(program: SourceTree) -> Option<Semantics>;

SourceTree in this context is a directory hierarchy of properly organized Rust code at some level of organization, maybe a crate. Semantics is a little harder to define – it’s an abstract notion of what the program “does,” a representation of the platonic essence of what the program should output (meaning, in this context, any observable behavior) given a set of inputs (meaning, in this context, any information the program can observe).

So this definition is to say, the Rust programming language, in general, can be thought of, philosophically, as a function from source trees to specifications of concrete behavior. Since this isn’t an actual Rust function, we can handwave those specifications a bit, and discuss them in English or a formal model of our choice.

And this is a coherent way to talk about Rust, a philosophical abstraction with practical applications. For example, if we were comparing two Rust compilers, trying to find out if they implemented “the same programming languages,” we could use this model as our criterion.

So, to find out whether two Rust implementations both implement the same programming language, we use this function signature as our guide: Given the same source tree, do they output programs with the same semantics, the same concrete interaction with the outside world?

There are a lot of things that can be different between implementations:

  • Do the programs, as compiled by these two different implementations, print out the same values when given the same inputs
  • Do the programs write the same data to the disk?
  • Do they panic in the same situations?
  • Do they have the same FFI characteristics to interact with a C library?
  • Do they have the same asymptotic complexity? (For a systems programming language, we definitely want to include this under “semantics”)
  • Do they have the same memory model for internal inter-thread interactions?
  • Do they make the same safety guarantees?
  • Do they accept and reject the same set of programs?
  • Do they print the same exact error messages?
  • Do they issue warnings on the same set of programs?
  • Are the two compilers invoked by the same command?
  • Is one of the compilers actually an interpreter?
  • Do they target the same processor architecture?
  • Do they output the exact same binaries?
  • Do they run with exactly equal performance?

Obviously, different implementations will differ in some of these ways. But we do need some way of defining whether two compilers both implement Rust, rather than one implementing Rust and one implementing Go, or one implementing Rust and the other one not quite succeeding at implementing Rust.

In the model, as we’ve defined it, the question comes down to whether accepted programs have the same semantics (but not form) and whether the set of accepted programs are the same. This means that, of the above questions, they stop mattering after “do they accept and reject the same set of programs?” That is where the binary error model draws the line.

To apply this model, the relevant part of a compiler is that it implements something like this:

fn rust_compiler(program: SourceTree) -> Option<CompiledProgram>;

And then, you could compare two compiled programs based on their semantics.

This model could also be useful for writing a formal specification of the Rust programming language (no, “the compiler itself” doesn’t count as a specification), and for programming languages that have a formal, written specification, it is couched in terms of something similar to this model – but not necessarily exactly.

Warnings and Errors#

Let’s take another look at our abstract “function signature” for the Rust programming language:

fn rust_programming_language(program: SourceTree) -> Option<Semantics>;

We have so far been glossing over a feature of the return type, Option. But that is what makes this particular model the “binary error” model, and that’s what I’m going to be criticizing, so let’s discuss it now.

Some source trees are not Rust programs. Some are, in fact, Go programs, or directories full of plain text files, or random binary data. Some, on the other hand, are almost Rust programs, like the example from above:

fn foo(bar: u32) -> u32 {
    bar + baz // but baz is never declared...
}

This model treats all of these programs equally. From the perspective of this abstract function, these all return the same value, None. Which means, from the perspective of this philosophical perspective, all of these are the same: not a valid Rust program.

If we’re comparing two implementations of Rust, this model therefore considers these statements to be irrelevancies:

  • Do they generate the same error messages?
  • Are their error messages equally relevant to the problem?
  • Are their error messages equally comprehensible to a beginner programmer?

These things, however, are still relevant:

  • Do they reject the same source trees?

In fact, a single program accepted by one and not by the other would make these two compilers implementations of different programming languages.

And what about warnings? This abstract function signature barely has room for errors, flattening them all to None. The complexities of the ways in which a Rust program might be bad are simplified to a binary: it is or is not a valid Rust program. Warnings are rounded to “it is valid.”

So in the “binary error” model, where the “return value” of the abstract function for the programming language is just Option<Semantics>, this function falls into the “valid Rust” side of the binary:

fn foo() -> i32 {
    let Foo = 3;
    Foo
}

This is considered to be the case, even though the standard Rust compiler outputs a warning for it:

warning: variable `Foo` should have a snake case name
 --> test.rs:2:9
  |
2 |     let Foo = 3;
  |         ^^^ help: convert the identifier to snake case (notice the capitalization): `foo`
  |
  = note: `#[warn(non_snake_case)]` on by default

warning: 1 warning emitted

So what’s going on here?

Well, in point of fact, our compiler implementation does not implement Option<CompilerError> as its conceptual return value. Its contract looks more like this:

fn rust_compiler(program: SourceTree) ->
    (Result<CompiledProgram, Vec<ErrorMessage>>, Vec<Warning>);

But when we compare the compiler to other compilers in the “binary error” model, we pretend instead the compiler was wrapped in this wrapper:

fn rust_compiler_for_comparison(program: SourceTree) -> Option<CompiledProgram> {
    let res = rust_compiler(program);
    let (res, _) = res; // strip warnings
    res.ok() // flatten errors, did it compile or not?
}

In this model, only the parts that are part of our original rust_language function truly are part of the Rust programming language. Only the rules that would cause every hypothetical compiler to reject the program are part of the programming language. This warning is “just the compiler’s opinion, man.”

It’s as if the compiler had two jobs: compiling the Rust programming language (defined as including a binary distinction between valid and invalid programs) and separately a linter, which tells you the compiler-writer’s opinions about what might be considered wrong with the code.

And this is a self-consistent way to think about Rust and about programming languages. It has practical applications: It gives you a definition of when two compilers implement the “same” programming language, and it allows you to define a formal specification for Rust – or to imagine an abstract formal specification, if you so choose, and use this notion to think about how your Rust code might fare under alternative implementations of the programming language.

Alternatives to the “Binary Error Model”#

There is no coherent way to say that this way of thinking about Rust is wrong, per se. It is a philosophical perspective, a definition of what concepts (like type safety) are part of the “programming language” and the “programming language specification” (even if none has been written) and what concepts are not, what concepts (like using snake case) are just opinions and conventions outside of the scope of the programming language.

But on the other hand, we are not forced to assume this model. As it is a definition of what is part of the “programming language,” we are free to use a different operating definition. As it is a scope for what goes in the “programming language specification,” the Rust community is free to write a formal specification with different scope.

And I think we should, when that time comes, use a different scope. I think that the people in charge of writing the spec come to it, they will use a different scope rather than strictly following the definitions explained here. Because even though the “binary error model” isn’t wrong, per se, I think it is, nevertheless, harmful.

I not only think if a formal Rust specification is written, it should not use this model. I think people should not assume this model. I think it will lead to mistakes in your thinking. I also think that, if you do assume this model specifically in Rust, you have to do a lot more mental work that can be saved by asserting a different model.

So what’s the alternative? Well, our original definition of a programming language did two things. It determined if the program was valid (a binary up-down decision), and it mapped each valid program to its semantics.

An alternative model would not make validity so binary. If we do this in the most straight-forward way, we get something like this:

fn rust_programming_language(program: SourceTree) ->
    (Result<Semantics, Vec<Errors>>, Vec<Warning>);

This loses a few of the nice properties that we had in the previous definition. “Valid Rust programs” is no longer a straight-forward set. Instead, we have a potential multiplicity of sets distinguished by this definition:

  • Programs that compile
  • Programs that compile without warnings
  • Programs that compile without a specific warning we may care about
  • Programs that don’t compile but only have one error
  • Programs that don’t compile but only have one category of error

Also, this definition imposes more on the writers of alternative implementations. Suddenly, a compiler is only a valid Rust compiler if it outputs the exact same list of errors and warnings, given an input program.

This seems to me a little too strict. I don’t think the exact wording of an error or warning should necessarily matter or be part of a programming language spec. And compilers regularly stop compiling after experiencing too many errors (where too many can sometimes be one), and implementations would reasonably differ about which errors they would output before giving up.

But I think it’s a good starting point, and in any case much better than the binary-error Option<Semantics> model. Part of the benefit of Rust as a programming language is how much work has gone into its warnings. For an alternative implementation to claim to be Rust without having the same warning system would strike me as extremely misleading. Warnings – obligatory warnings – should be included in any language spec.

Many important Rust safety features are actually warnings. Ignoring #[must_use] is technically a warning – just set to #[deny] by default. A function that has dead code after a return statement: this is a warning, but also a serious correctness issue.

Rust Warnings are Complicated#

And of course any Rust implementation would have to include warnings. Just as C (in practice) has a #warning directive, which causes the compiler to issue warnings, Rust has a number of annotations that control the issuance of warnings.

For example, if we add an annotation to our function from before:

#[deny(non_snake_case)]
fn foo() -> i32 {
    let Foo = 3;
    Foo
}

… the warning becomes an error:

error: variable `Foo` should have a snake case name
 --> test.rs:3:9
  |
3 |     let Foo = 3;
  |         ^^^ help: convert the identifier to snake case (notice the capitalization): `foo`
  |
note: the lint level is defined here
 --> test.rs:1:8
  |
1 | #[deny(non_snake_case)]
  |        ^^^^^^^^^^^^^^

error: aborting due to previous error

Any Rust specification, even one with the binary error model, would therefore have to include:

  • The rules about snake case (variables should have snake case)
  • The rules about annotations (so that #[deny(...)] triggers an error)

This means that, even if we did imagine a specification where only errors were in scope, rules for warnings would have to also be in that specification, because they can be configured to become errors. And at that point, why not also specify in the specification that the warnings are obligatory?

Especially because we can also say #[warn(...)] as a tolerance level for these configurable rules. What do we say about #[warn(...)] in the spec if warnings are out of scope?

The Other Side of the Binary#

Now that I’ve criticized the “binary error” model from the warnings side, I also want to address the notion that all errors are created equal. Errors are different from each other.

First off, there’s an obvious distinction between syntax errors and semantic errors. This is kind of boring and obvious, but it adds some nuance into the idea that invalid Rust is simply “not Rust,” and it comes up in practice sometimes.

As I write my code, I sometimes run cargo fmt as part of my editing workflow. Usually, this helps me read my own code better for further editing, and usually, this works even if my code is full of errors – it might even help me find and understand the errors. But sometimes, my code has a relatively superficial, syntactic error, like a missing }, and cargo fmt can’t even help me. This sends me into a little bit of a panic, but I’m usually also glad I didn’t keep working longer with such a problem.

If a Rust specification wanted to include formatting tools in its scope, it could conceivably make a formal distinction between syntax and semantic errors.

More interesting, however, are errors that don’t have to be errors, where the compiler could keep compiling, but it chooses not to.

We have the obvious example, where the error is configurable, where it’s actually a warning that’s just been set to #[deny(...)] as a lint level.

But we also have things like lifetime errors, which cannot be disabled. Or the rule against dereferencing a pointer outside an unsafe block. The Rust compiler could, if it wanted to, simply allow those things. We could do something like:

#[unsafe_allow(lifetime_mismatches)]

The compiler would then output a program, which would then exhibit undefined behavior – or not. It would then be potentially unsound – or not.

This is not included in Rust, but it’s theoretically possible, unlike referring to a variable that doesn’t exist, where there is no reasonable interpretation of what the code should do.

On the border is things that C++ allows, but are arguably non-sensical like referring to a variable that doesn’t exist. If a function returns u32, and you reach the end of the function, that’s non-sensical, right?

fn foo() -> u32 { }

But depending on the ABI, you can just not output the code that sets the return value, and perhaps not even output the code that returns from the function. This is definitely undefined behavior, but C++ will often allow it, sometimes without even a warning.

Unsafety as Always-On Warnings#

As an aside, the #[allow] and #[deny] annotations are very similar to how unsafe works. We could imagine an alternative world where there was no unsafe keyword for blocks. Instead of writing:

unsafe { *ptr }

… we could instead imagine a Rust where this is written as:

#[allow(unsafe)]
*ptr

Basically, using operations like dereference (*ptr) are disallowed in Rust by default, but can be allowed. They are disallowed because, like Rust that is warned about, they are indications that the programmer likely made a mistake. But like Rust that is warned about, the programmer can make explicit that they are using the construct on purpose.

Given that unsafe/safety, one of Rust’s core features, works in a way very similar to warnings, should make us take seriously the importance of warnings. It would have been just as valid from a safety point of view to use literally the same mechanism with #[allow] and #[deny], but I think safety is such an important category of possible mistakes it’s probably for the best that it has its own special syntax.

Take-Aways#

So why am I writing all of this, besides thinking it’s all an interesting mental exercise?

I don’t think the authors of any future Rust spec would actually err in such a way as to not discuss warnings at all. But I think it’s important to understand the theoretical implications.

But I also know that people do think in terms of the hypothetical Rust specification which only accepts or rejects programs. I recently saw someone write that capitalization conventions, such as snake case, are not part of the Rust programming language. They meant that according to the “binary error” model that we discussed above, which they implicitly subscribed to, using snake case or not will never change whether your program is a valid Rust program, and therefore, the entire convention is not part of Rust.

But even if we ignore the fact that a Rust compiler needs to know about this convention in case #[deny] is used, this assumes a definition of Rust programming language and Rust specification that uses the “binary error” model.

And while that is one way to think about Rust, it’s not a very good one, and I would say it’s not a very useful one. And more fundamentally, you don’t have to. You don’t have to use this philosophical framework where only rules that cause compilation failures are part of the programming language.

So I don’t think it’s fair to say “in Haskell, variable name case is significant, and in Rust, it is not, it is only a convention and not part of the programming language.” I think it’s more fair to say “in Haskell, case conventions are mandatory, violations are errors, and they are used to disambiguate the syntax. In Rust, they are non-fatal warnings by default, and the compiler can still process Rust with incorrect case, and in some situations has to.” Or, more simply, “in Haskell, case convention violations are errors, but in Rust, they’re just warnings.” But, in both Haskell and Rust, capitalization conventions are part of the programming language. In both, the compiler has to know about them, and enforces them in at least some situations.

This may seem like a nitpick, but I think using definitions of “programming language” vs “convention” can make the “convention” stuff seem less important than it should be. I think that if you think that way, and were writing an alternative implementation of Rust, you might give yourself permission to not care about the warnings. You might be less likely to add a policy to use -Werror, or require clippy to pass in your CI.

If someone with that attitude were writing the language spec – which I don’t think they would be, but if they were – they might underspecify the things that make Rust the useful tool that it is. Programming is about contracts, and as far as I’m concerned, the warnings are part of the Rust compiler’s contract. And a compiler should not be allowed to call itself a Rust compiler if it doesn’t follow it.

Snake case for variables is part of the Rust programming language, as I define the Rust programming language, and – I think – as most of the community defines it. Certainly it is part of “the Rust programming language” as that phrase is used in common parlance, and it is one of many features that make Rust special. If there is to be a specification, it should be part of the Rust specification. I understand that if you use the “binary error” model to define what a programming language is, and what a programming specification should be, you don’t get this result. But I just don’t think you should be using that model, and I think it does matter whether you do, even though it is a philosophical perspective that cannot be disproven.

Of course, Rust will probably not ever have a single monolithic specification mediated by ISO or an equivalent. It will certainly continue to be a community organization, with many different standards and specifications, perhaps one for a compiler with basic features, another for a compiler that fully supports errors, another for formatters like cargo fmt. Each of these specifications will delineate different sets of source trees: source trees with the syntax of Rust, source trees without errors, source trees without warnings, etc.

Just like the notion of a “programming language” doesn’t have to be a single set, a single binary between valid and invalid, the notion of a specification also needn’t be so monolithic.