Death by Diagnostic

Many pro­gram­mers laud the Rust lan­guage for pro­vid­ing “good” error diag­nos­tics. All lan­guages should strive to pro­vide insight­ful diag­nos­tics but in this sec­tion we will see how trait res­o­lu­tion can eas­ily leave devel­op­ers with lit­tle debug­ging infor­ma­tion.

Traits in Rust are inter­est­ing for sev­eral rea­sons. The lan­guage is gain­ing pop­u­lar­ity in “the main­stream” and devel­op­ers come to Rust from dynam­i­cally-typed lan­guages, such as Python and JavaScript, or from tra­di­tional low-level sys­tems lan­guages like C. Rust may be the first strong sta­t­i­cally-typed lan­guage they learn and their first encounter with traits. The trait sys­tem pro­vides a flex­i­ble seman­tic com­pared to Haskell ’98, but with added flex­i­bil­ity comes unde­cid­abil­ity. 25 Rust devel­op­ers strug­gle to debug trait errors espe­cially in the face of com­plex trait sys­tems that may over­flow. For exam­ple, Diesel is a object rela­tional map­per and query builder for Rust that uses traits exten­sively for sta­tic safety. Cur­rently, five of the twenty dis­cus­sion pages are ques­tions related to trait errors. 6 Traits are a key abstrac­tion in Rust and many other lan­guage fea­tures rely on them, clo­sures, async, and thread safety to name a few. On a quest for strong sta­tic guar­an­tees, many trait-heavy crates are emerg­ing in the Rust ecosys­tem, forc­ing devel­op­ers to con­front com­plex sys­tems of traits to do some­thing as sim­ple as matrix mul­ti­pli­ca­tion. There is suf­fi­cient evi­dence to sug­gest that trait errors can be hard to debug—by new­com­ers 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 inter­est now turns towards diag­nos­tic mes­sages. Specif­i­cally what is meant by the terms “good” and “poor,” and what dis­tin­guishes the two. Explor­ing first with the cur­rent exam­ple, shown in print_ln is called with a vec­tor of float­ing-point num­bers (or f32 in Rust). This results in a type error because f32 does not imple­ment the ToString trait.

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`

Rust diag­nos­tics fol­low a par­tic­u­lar struc­ture. There’s a prin­ci­ple mes­sage, fol­lowed by a series of notes on the ori­gins of this prin­ci­ple. Often included are sug­ges­tions, or “help” mes­sages for what might fix the prob­lem. In the prin­ci­ple mes­sage states that the trait bound f32: ToString is not sat­is­fied. This unsat­is­fied bound is the root cause of the type error. Rust then sug­gests other types that do imple­ment ToString, in this case, i32, if per­haps that was your inten­tion.The prove­nance of trait bounds is impor­tant. Why does f32 need to imple­ment ToString? Well, the com­piler wants to use the imple­men­ta­tion rule for Vec<T> which requires the bound on f32 as a sub­con­di­tion.The final piece of prove­nance is where the ini­tial ToString bound was intro­duced. The con­di­tion on the instan­ti­ated generic type, T, when call­ing print_ln.

We label this as a good diag­nos­tic mes­sage. Good diag­nos­tics help devel­op­ers accom­plish a task, usu­ally by reveal­ing a hole in their men­tal model or answer­ing a ques­tion. Given a devel­oper’s expec­ta­tion for type \(U\) to imple­ment trait \(T\), poten­tially via tran­si­tive imple­men­tors \(A, B, C, \ldots\), a diag­nos­tic should explain where it failed in that chain. To accom­plish this task there are two essen­tial com­po­nents for a diag­nos­tic to pro­vide: the root cause, and its prove­nance.

Spe­cific root cause The prin­ci­ple mes­sage reported: “trait bound f32: ToString not sat­is­fied” is as spe­cific as it gets. The com­piler could have reported “the bound Vec<f32>: ToString was not sat­is­fied,” but this isn’t as spe­cific. The more spe­cific the reported root cause, the faster devel­op­ers can address the issue. There isn’t how­ever a pre­cise def­i­n­i­tion of root cause. As pre­vi­ously men­tioned, diag­nos­tics should help uncover holes in a devel­oper’s men­tal model. There­fore, the root cause is a func­tion of the devel­oper’s men­tal model of the pro­gram and not solely the pro­gram per se. In gen­eral we can­not expect a com­puter-gen­er­ated diag­nos­tic to always point to the root cause because it lacks access to the devel­oper’s men­tal model.

Full prove­nance The full prove­nance should answer the ques­tion “Why is this bound required?” Devel­op­ers should not be left unknow­ing of where a trait bound came from. Good diag­nos­tics pro­vide this prove­nance. f32: ToString was required because it’s a con­di­tion on the imple­men­ta­tion block for Vec<T>: ToString. Fur­ther­more, Vec<T> was required by the con­di­tion on the call to print_ln. In the diag­nos­tic Rust has laid out this path for devel­op­ers to fol­low.

A Poor Diagnostic

For decades pro­gram­mers have laughed—and cried—at pages of unread­able diag­nos­tics spewed by the C++ and Java com­pil­ers. Com­pil­ers have the Her­culean task of pack­ag­ing com­plex fail­ures into digestable and help­ful diag­nos­tics—a task that increases in com­plex­ity as type sys­tems become more sophis­ti­cated. Mod­ern type-check­ing involves proof trees and SMT queries; a type error can often be a sin­gle bit response “No,” leav­ing a frus­trated pro­gram­mer to trial-and-error debug­ging. In this sec­tion we will see how a sys­tem of traits can leave Rust pro­gram­mers in a sim­i­lar sit­u­a­tion.

Axum is the most pop­u­lar web frame­work in Rust that touts its focus on “ergonom­ics.” Using Axum is easy. To demon­strate, our sec­ond run­ning exam­ple will use Axum to cre­ate a sim­ple web. Servers pro­vide var­i­ous routes, and each route has a mes­sage han­dler. Cre­at­ing a han­dler is as easy as: defin­ing an asyn­chro­nous func­tion that has 0–16 “extrac­tors” as para­me­ters and returns a type con­vertable into a web response—sim­ple. Shown in is an exam­ple server that pro­vides a route for users to login to an arti­fi­cial appli­ca­tion.

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 }

There is a some boil­er­plate required to make a server, but the impor­tant piece is the asyn­chro­nous func­tion login. This is the han­dler for the /login route. This han­dler takes two extrac­tors. The inten­tion is that String will extract out a string from the incom­ing mes­sage, to be the user’s name. The sec­ond extrac­tor, Bytes, con­sumes the remain­der of the mes­sage that will be the user’s pass­word. (Encrypted of course.)The response type, String, is returned by the func­tion body—indi­cat­ing suc­cess or fail­ure. This code does not com­pile. When used as a han­dler para­me­ter String must come last. To under­stand why, we first need to dig into some of the Axum trait specifics.

This exam­ple demon­strates all the nec­es­sary pieces of a han­dler. Extrac­tors are doing some heavy lift­ing, the types encode how an incom­ing mes­sage is parsed into sep­a­rated parts, and their expla­na­tion, till now, was sur­face level. One tricky “gotcha” with extrac­tors is that the first \(n - 1\) para­me­ter types must imple­ment the trait FromRequestParts, and the \(n^{th}\) must imple­ment the trait FromRequest. This dis­tinc­tion helps Axum peel off parts of a mes­sage, then con­sume the rest with the last para­me­ter. Types imple­ment­ing FromRequestParts do the peel­ing. Types imple­ment­ing FromRequest do the con­sum­ing.

As stated, strings must come last. A String does not imple­ment FromRequestParts. It does how­ever imple­ment FromRequest and can be used to con­sume the remain­der of a mes­sage. With this knowl­edge, in an error diag­nos­tic we should hope the prin­ci­ple mes­sage is: “String does not imple­ment FromRequestParts.” Instead, we get the para­graph 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>>

The prin­ci­ple mes­sage pro­vides the unhelp­ful, in lay man’s terms, “login is not a Handler.” The impor­tant FromRequestParts trait makes no appear­ance.For­tu­nately, some prove­nance is pro­vided. This short prove­nance describes why login must imple­ment Handler, but again, the infor­ma­tion stops there and no deeper cause is men­tioned.

Pro­gram­mers have a men­tal model. The diag­nos­tic has said “your func­tion is not a han­dler,” but as the pro­gram­mer I intended for it to be. A good diag­nos­tic should pro­vide infor­ma­tion on why my men­tal model was vio­lated. I want login to be a han­dler. It isn’t, but how can I fig­ure out where I went wrong? The first cri­te­rion for a good diag­nos­tic mes­sage is vio­lated. The com­piler has not pro­vided the root cause, any­where. There is some prove­nance, but not full prove­nance to the root cause. The Axum diag­nos­tic has failed the cri­te­ria and is there­fore a poor diag­nos­tic.

There is an expla­na­tion for why Rust pro­vided a good diag­nos­tic in the first exam­ple and not in the sec­ond. This expla­na­tion lies within trait res­o­lu­tion, the process by which Rust decides which trait imple­men­ta­tions will be called at run­time.