Many programmers laud the Rust language for providing “good” error diagnostics. All languages should strive to provide insightful diagnostics but in this section we will see how trait resolution can easily leave developers with little debugging information.
Traits in Rust are interesting for several reasons. The language is gaining popularity in “the mainstream” and developers come to Rust from dynamically-typed languages, such as Python and JavaScript, or from traditional low-level systems languages like C. Rust may be the first strong statically-typed language they learn and their first encounter with traits. The trait system provides a flexible semantic compared to Haskell ’98, but with added flexibility comes undecidability. 25 Rust developers struggle to debug trait errors especially in the face of complex trait systems that may overflow. For example, Diesel is a object relational mapper and query builder for Rust that uses traits extensively for static safety. Currently, five of the twenty discussion pages are questions related to trait errors. 6 Traits are a key abstraction in Rust and many other language features rely on them, closures, async, and thread safety to name a few. On a quest for strong static guarantees, many trait-heavy crates are emerging in the Rust ecosystem, forcing developers to confront complex systems of traits to do something as simple as matrix multiplication. There is sufficient evidence to suggest that trait errors can be hard to debug—by newcomers and experts alike.
A Good Diagnostic
fn main() {
print_ln(vec![1, 2, 3]); // "[1, 2, 3]"
print_ln(vec![1.618, 3.14]); // Whoops!
}Our interest now turns towards diagnostic messages. Specifically what is meant by the terms “good” and “poor,” and what distinguishes the two. Exploring first with the current example, shown in
error[E0277]: the trait bound `f32: ToString` is not satisfied
|
40 | print_ln(vec![1.618f32, 3.14])
| -------- ^^^^^^^^^^^^^^^^^^^^ the trait `ToString` is not implemented for `f32`, which is required by `Vec<f32>: ToString`
| |
| required by a bound introduced by this call
|
= help: the trait `ToString` is implemented for `i32`
note: required for `Vec<f32>` to implement `ToString`
|
17 | impl<T> ToString for Vec<T>
| ^^^^^^^^ ^^^^^^
18 | where
19 | T: ToString,
| -------- unsatisfied trait bound introduced here
note: required by a bound in `print_ln`
|
32 | fn print_ln<T>(v: T)
| -------- required by a bound in this function
33 | where
34 | T: ToString
| ^^^^^^^^ required by this bound in `print_ln`We label this as a good diagnostic message. Good diagnostics help developers accomplish a task, usually by revealing a hole in their mental model or answering a question. Given a developer’s expectation for type \(U\) to implement trait \(T\), potentially via transitive implementors \(A, B, C, \ldots\), a diagnostic should explain where it failed in that chain. To accomplish this task there are two essential components for a diagnostic to provide: the root cause, and its provenance.
Specific root cause The principle message reported: “trait bound f32: ToString not satisfied” is as specific as it gets. The compiler could have reported “the bound Vec<f32>: ToString was not satisfied,” but this isn’t as specific. The more specific the reported root cause, the faster developers can address the issue. There isn’t however a precise definition of root cause. As previously mentioned, diagnostics should help uncover holes in a developer’s mental model. Therefore, the root cause is a function of the developer’s mental model of the program and not solely the program per se. In general we cannot expect a computer-generated diagnostic to always point to the root cause because it lacks access to the developer’s mental model.
Full provenance The full provenance should answer the question “Why is this bound required?” Developers should not be left unknowing of where a trait bound came from. Good diagnostics provide this provenance. f32: ToString was required because it’s a condition on the implementation block for Vec<T>: ToString. Furthermore, Vec<T> was required by the condition on the call to print_ln. In the diagnostic Rust has laid out this path for developers to follow.
A Poor Diagnostic
For decades programmers have laughed—and cried—at pages of unreadable diagnostics spewed by the C++ and Java compilers. Compilers have the Herculean task of packaging complex failures into digestable and helpful diagnostics—a task that increases in complexity as type systems become more sophisticated. Modern type-checking involves proof trees and SMT queries; a type error can often be a single bit response “No,” leaving a frustrated programmer to trial-and-error debugging. In this section we will see how a system of traits can leave Rust programmers in a similar situation.
Axum is the most popular web framework in Rust that touts its focus on “ergonomics.” Using Axum is easy. To demonstrate, our second running example will use Axum to create a simple web. Servers provide various routes, and each route has a message handler. Creating a handler is as easy as: defining an asynchronous function that has 0–16 “extractors” as parameters and returns a type convertable into a web response—simple. Shown in
async fn login(
name: String, pwd: Bytes)
-> String {
if is_valid_user(&name, &pwd).await {
format!("Welcome, {name}")
} else {
"Invalid credentials".to_string()
}
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/login", post(login));
let listener = TcpListener::bind("0.0.0.0:3000")
.await.unwrap();
axum::serve(listener, app)
.await.unwrap();
}use axum::{
body::Bytes,
routing::post,
Router
};
use tokio::net::TcpListener;
async fn is_valid_user(
name: &str, pwd: &Bytes
) -> bool { true }This example demonstrates all the necessary pieces of a handler. Extractors are doing some heavy lifting, the types encode how an incoming message is parsed into separated parts, and their explanation, till now, was surface level. One tricky “gotcha” with extractors is that the first \(n - 1\) parameter types must implement the trait FromRequestParts, and the \(n^{th}\) must implement the trait FromRequest. This distinction helps Axum peel off parts of a message, then consume the rest with the last parameter. Types implementing FromRequestParts do the peeling. Types implementing FromRequest do the consuming.
As stated, strings must come last. A String does not implement FromRequestParts. It does however implement FromRequest and can be used to consume the remainder of a message. With this knowledge, in an error diagnostic we should hope the principle message is: “String does not implement FromRequestParts.” Instead, we get the paragraph shown in
error[E0277]: the trait bound `fn(String, axum::body::Bytes) -> impl Future<Output = String> {login}: Handler<_, _>` is not satisfied
|
22 | let app = Router::new().route("/login", post(login));
| ---- ^^^^^ the trait `Handler<_, _>` is not implemented for fn item `fn(String, axum::body::Bytes) -> impl Future<Output = String> {login}`
| |
| required by a bound introduced by this call
|
= help: the following other types implement trait `Handler<T, S>`:
<Layered<L, H, T, S> as Handler<T, S>>
<MethodRouter<S> as Handler<(), S>>Programmers have a mental model. The diagnostic has said “your function is not a handler,” but as the programmer I intended for it to be. A good diagnostic should provide information on why my mental model was violated. I want login to be a handler. It isn’t, but how can I figure out where I went wrong? The first criterion for a good diagnostic message is violated. The compiler has not provided the root cause, anywhere. There is some provenance, but not full provenance to the root cause. The Axum diagnostic has failed the criteria and is therefore a poor diagnostic.
There is an explanation for why Rust provided a good diagnostic in the first example and not in the second. This explanation lies within trait resolution, the process by which Rust decides which trait implementations will be called at runtime.