From 1170ce1682fa2315ea49ab46e745fcc2116d82a9 Mon Sep 17 00:00:00 2001 From: gavinleroy Date: Wed, 28 Aug 2024 10:03:51 -0400 Subject: [PATCH] Condense tutorial --- book/src/SUMMARY.md | 2 +- book/src/trait-debugging-101.md | 221 +++++--------------------------- 2 files changed, 32 insertions(+), 191 deletions(-) diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 67f93d9..a3162f2 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -2,7 +2,7 @@ [Introduction](README.md) -- [Whirlwind Tour](whirlwind.md) + - [Trait Debugging 101](trait-debugging-101.md) diff --git a/book/src/trait-debugging-101.md b/book/src/trait-debugging-101.md index e367420..df1f36f 100644 --- a/book/src/trait-debugging-101.md +++ b/book/src/trait-debugging-101.md @@ -1,177 +1,18 @@ # Trait Debugging 101 -Traits are a pervasive language feature in Rust: Copying, printing, indexing, multiplying, and more common operations use the Rust trait system. As developers use more of the language, and utilize the many available published crates, they will inevitably encounter more traits. Popular crates in the Rust ecosystem use traits to achieve richer type safety, such as the Diesel crate that uses traits to turn invalid SQL queries into type errors. Impressive! - -Static checks and type safety is great, but compiler errors can become increasingly complex alongside the types and traits you use. This guide serves to teach "trait debugging" in Rust, using a new tool, Argus, developed by the [Cognitive Engineering Lab](https://cel.cs.brown.edu/) at Brown University. - -```admonish info -If you're already familiar with the process of trait solving, [click here](#your-first-web-server) to dive straight into start debugging. - -If you're **participating** in a user study with us, please read this section. -``` - -Traits define units of *shared behavior* and serve a similar purpose as Java interfaces. In Java, you would declare an interface with a given name, and list functions the trait implementors must provide. Below we define a `Year`, simply a wrapper around an integer, and implement `Comparable` for a `Year`. This means we can compare years to other years, nothing else. - -```java -interface Comparable { - int compareTo(T o); -} - -class Year implements Comparable { - private int rawYear; - - int compareTo(Year that) { - // ... implementation elided ... - } -} -``` - -The equivalent Rust program looks similar, though one difference is how we declare `Year` an implementor of `Comparable`. - -```rust -trait Comparable { - fn compare_to(&self, o: &T) -> i32; -} - -struct Year { - raw_year: i32, -} - -impl Comparable for Year { - fn compare_to(&self, o: &Year) -> i32 { - // ... implementation elided ... - } -} -``` - -Notice that we *separate* the trait implementation and struct definition. In Java we both defined the class `Year` and declared it an implementor at the same time. In Rust we defined the struct `Year` and separately provided its `Comparable` implementation. The separation between definition and trait implementation means that for the compiler to answer the question "Does `Year` implement `Comparable`?" it must *search* for the relevant impl block. If no block exists, or if multiple blocks exist, this is a type error. The component responsible for finding trait implementations is the *trait solver.* - -## An Overview of The Trait Solver - -The trait solver has a single goal: respond to questions about types implementing traits. These so called "questions" come from trait bounds, which look like this in Rust: `Year: Comparable`. This trait bound says that `Year` must implement `Comparable`. In this post trait bounds read "does `Year` implement `Comparable`?" We do this because the trait solver is only responsible for answering questions, not determining if an 'No' answer becomes a type error. Here's some examples of questions the trait solver can get, and how the solver would respond. - -- Does `Year: Comparable` hold? *Response: No* - -- Does `Year: Sized` hold? *Response: Yes* - -- Does `Year: Comparable` hold? *Response: Yes* - -- Does `Year: Comparable` hold? *Response: Maybe, if `T = Year`* - -If the response is *maybe*, as in the third example above, the compiler will perform extra inference to check if `T = Year`, if it isn't, then it may become a type error. Maybe responses are out of scope for this tutorial so we won't mention them again. After reading this section you should know how the Rust trait solver determines its response to a question. - -### A simple algorithm for trait solving - -The steps to respond to `Ty: Trait` proceed as follows. - -1. For each implementation for `Trait` - - ```rust - impl Trait for T - where - Constraint_0, - Constraint_1, - ... - Constraint_N, - ``` - - if `T` and `Ty` *unify*, proceed, otherwise respond no. - - ```admonish note - You may not know what we mean with "if they unify," so let's go over some examples. Two types unify in the trait solver if we can instantiate type variables such that they are equivalent. - - - `String` and `Year` don't unify - - `T` and `Year` unify because `T` is a type variable. So if `T = Year`, then they're equivalent - - `Vec` and `Vec` unify if `T = String` - - `Vec` and `Vec` unify if `U = T` - ``` - -2. For each `Constrint_i`, restart the process with `Constrint_i` as the question. Respond with *yes* if all constraint responses are *yes*. - -3. Respond with *yes* if exactly one impl block responded with *yes*. - -Notice that these steps are recursive. When checking a where clause constraint the trait solver calls itself. The prosaic version is a bit nebulous, let's consider the diagramatic version instead. Consider the `Comparable` trait, we shall extend the previous program with one more impl block. - -```rust -trait Comparable { - fn compare_to(&self, o: &T) -> i32; -} - -struct Year { - raw_year: i32, -} - -// We can compare Years to Years -impl Comparable for Year { - fn compare_to(&self, o: &Year) -> i32 { - // ... implementation elided ... - } -} - -// NEW: we can compare two values IFF -// they can both be converted to strings -impl Comparable for T -where - T: ToString, - U: ToString, -{ - fn compare_to(&self, o: &U) -> i32 { - // ... implementation elided ... - } -} - -``` - -Let's consider how the trait solver would go about responding to the question `Year: Comparable`. - -```mermaid -graph TD; - root["Year: Comparable<Year>"] - implPar["impl Comparable<U> for T\nwhere\n    T: ToString\n    U: ToString"] - toStr0["❌ Year: ToString"] - toStr1["❌ Year: ToString"] - implCon["✔ impl Comparable<Year> for Year"] - - root -.-> implPar - implPar --T = Year--> toStr0 - implPar --U = Year--> toStr1 - root -.-> implCon - - classDef default fill:#fafafa, stroke-width:3px, text-align:left - classDef cssSuccess stroke:green - classDef cssFailure stroke:red - - class root,implCon cssSuccess - class implPar,toStr0,toStr1 cssFailure - - linkStyle 0,1,2 stroke:red - linkStyle 3 stroke:green, stroke-width:4px -``` - -Dotted lines represent an **Or** relationship between parent and child. That is, exactly one of the child blocks needs to respond with 'yes.' We see these lines coming from the root question and extending to the impl blocks. Impl blocks always form an Or relationship with their parents. This models step 3 in the listed algorithm because one of the impl blocks must match, no more and no fewer. - -Solid lines represent **And** relationships between parent and child. That is, every one of the child blocks needs to respond with 'yes.' We see these lines coming from impl blocks and extending to the constraints. Constraints always form an And relationship with their parent impl block. Or rather, all constraints in a where clause must hold. - -```admonish faq -**Why does the trait solver check the impl block for `T` when there exists one directly for `Year`?** - -The trait solver must consider all potentially matching impl blocks. Because `T` unifies with `Year`, the trait solver must check this block as well. Remember, if multiple impls work then this *is also* an error: ambiguous trait usage. Exactly one impl block must match for there to be a success. -``` - -A neat pattern to observe is that a question always has an impl block as a child, with a dotted line. We can never have two questions in a row; you shouldn't answer a question with a question! Impl blocks always have a question as a child, with a solid line. If you follow a specific path in the tree the pattern of relationships will be "Or, And, Or, And, Or, And, …" - -The tree diagram above actually has a name, it's called the *search tree*. Search trees represent the execution of the trait solver! Just as you may have traced your own programs to debug strange behavior, we can trace the trait solver to help us understand why a particular outcome occurred. Search trees are the core data structure used in the Argus trait debugger, let's size up to a real example and see how we can use Argus to do some trait debugging. +Traits are a pervasive language feature in Rust: Copying, printing, indexing, multiplying, and more common operations use the Rust trait system. As you use more of the language, and utilize the numerous published crates, you will inevitably encounter more traits. Popular crates in the Rust ecosystem use traits to achieve strong type safety, such as the Diesel crate that relies on traits to turn invalid SQL queries into type errors. Impressive! +Unfortunately, traits also obfuscate type errors. Compiler diagnostics become increasingly complex alongside the types and traits used. This guide demonstrates *trait debugging* in Rust using a new tool, Argus, developed by the [Cognitive Engineering Lab](https://cel.cs.brown.edu/) at Brown University. ## Your First Web Server -Axum is a popular Rust web application framework and we're going to use it to build a web server. Here's our starting code. +[Axum](https://docs.rs/axum/latest/axum/) is a popular Rust web application framework, a great example of how traits can obfuscate type errors. We will use Axum to build a web server, and Argus to debug the trait errors; here's our starting code. ```rust,ignore,compile_fail {{#include ../../examples/hello-server/src/main.rs:4:}} ``` -Unfortunately, our server does not work! Rust provides the following error diagnostic +Oh no, our server doesn't type check. Surely, the error diagnostic will tell us why--- ```text error[E0277]: the trait bound `fn(LoginAttempt) -> bool {login}: Handler<_, _>` is not satisfied @@ -188,37 +29,35 @@ error[E0277]: the trait bound `fn(LoginAttempt) -> bool {login}: Handler<_, _>` note: required by a bound in `axum::routing::get` ``` -The above diagnostic, in a long-winded way, tells us that the function `login` does not implement `Handler`. As the authors, we *intended* to use `login` as a handler, so I'm stumped why it doesn't. +in a long-winded way the diagnostic has said "`login` does not implement `Handler`." But as the authors we *intended* for `login` to be a handler. The diagnostic hasn't provided much specific information as to why the code doesn't type check. ```admonish note Going forward we will write `{login}` to abbreviate the type of `login`, `fn(LoginAttempt) -> bool`, which is far too verbose to repeat over and over. ``` -When the compiler diagnostic says "trait `Bleh` is not implemented for type `Blah`", that's a great opportunity to use Argus. +When the error diagnostic says "trait bound `Bleh` is not satisfied", it's a great opportunity to use Argus. - +## Down the Search Tree -## Through the Search Tree +In Rust we write type definitions and trait implementations separately---we refer to trait implementations as "impl blocks." The inner compiler component called the *trait solver* is responsible for answering queries like "Does `{login}` implement `Handler`?" such queries appear as trait bounds in the source code. The trait solver searches through the impl blocks trying to find whether or not the trait bound holds. -The diagnostic from rustc isn't totally useless; we learned that the type `fn(LoginAttempt) -> bool`, the signature of `login`, should implement the Axum trait `Handler`. Additionally, we know the call `get(login)` introduced the bound. Both pieces of information help us get started, but fail to answer why `login` *doesn't* implement `Handler`. Argus helps debug these types of trait errors by providing an interactive interface for the search tree. +In this post we will be using the *search tree* a data structure produced by the trait solver that describes how it searched impl blocks, and why---or why not---a particular trait bound holds. ````admonish example -Here's an illustrative diagram of the Axum-error search tree. Argus provides the search tree in a different format, similar to a directory tree, as you shall see later. +Here's an illustrative diagram of the Axum-error search tree. Argus provides the search tree in a different format, similar to a directory tree, as you shall see further on. ```mermaid --- title: Search tree produced by the Axum trait error --- graph TD - root["{login}: Handler<_, _>"] + root["{login}: Handler"] implRespH["impl Handler for T\nwhere\n    T: IntoResponse"] intoResp["{login}: IntoResponse"] - implH["impl Handler for F\nwhere\n    F: FnOnce(T1) -> Fut,\n    Fut: Future + Send,\n    T1: FromRequest"] + implH["impl Handler for F\nwhere\n    F: FnOnce(T1) -> Fut,\n    Fut: Future + Send,\n    T1: FromRequest"] isFunc["{login}: FnOnce(LoginAttempt) -> bool"] boolFut["bool: Future"] - loginARqst["LoginAttempt: FromRequest<_, _>"] + loginARqst["LoginAttempt: FromRequest"] root -.-> implRespH implRespH --T = {login}--> intoResp @@ -238,12 +77,16 @@ graph TD ``` > We elide trivial bounds to declutter the diagram. Don't panic if you open the Argus panel and see some bounds not shown here. +Dotted lines represent an **Or** relationship between parent and child. That is, exactly one of the child blocks needs to hold---outlined in green. We see dotted lines coming from the root bound and extending to the impl blocks. Impl blocks always form an Or relationship with their parent. + +Solid lines represent **And** relationships between parent and child. That is, every one of the child blocks needs to hold. We see solid lines coming from impl blocks and extending to the nested constraints. Constraints always form an And relationship with their parent impl block. + Traversing the tree from root to leaf is what's referred to as "Top-Down" in the Argus extension. This view represents the full search tree, in other words, *how* the trait solver responded to the query. ```` -Notice that the Rust compiler diagnostic mentions the *root question*, `{login}: Handler<_, _>`, instead of the failing nodes at the tree leaves. The compiler is conservative, when presented with multiple failures in different impls, it defaults to reporting their parent. In the diagram there are two potentially matching impl blocks. There's one for a function with a single argument, and there's one for things that implement `IntoResponse` directly (i.e., static responses that don't need input). Because there's more than one potentially matching impl block, Rust can't decide which is the actual error. +Notice that the Rust compiler diagnostic mentions the *root bound*, `{login}: Handler<_, _>`, instead of the more specific failing bounds at the tree leaves. The compiler is conservative, when presented with multiple failures in different impls, it defaults to reporting their parent. In the diagram there are two potentially matching impl blocks. There's one for a function with a single argument, and there's one for things that implement `IntoResponse` directly---i.e., static responses that don't need input. Because there's more than one potentially matching impl block Rust can't decide which is the actual error, so it reports the parent node of all errors. -Now let's walk through the search tree as presented in Argus' Top-Down view. +We developed Argus so you can identify the *specific failures* that led to a particular trait error; Argus can provide more specific details on a trait error than Rust is willing to summarize in a single text-based diagnostic message. Let's walk through the search tree as presented in Argus' Top-Down view. ![Search tree initial bound](assets/axum-hello-server/top-down-root-highlighted.png =600x center) @@ -263,7 +106,7 @@ In order to use this impl block to satisfy the root trait bound, `{login}: Handl F = fn(LoginAttempt) -> bool ``` - The type parameters `T1` and `Fut` unify with `LoginAttempt` and `bool`. (The bounds `Clone`, `Send`, and `'static` are also checked successfully.) + The type parameters `T1` and `Fut` unify with `LoginAttempt` and `bool`. The bounds `Clone`, `Send`, and `'static` are also checked successfully. 3. Does `Fut` implement `Future`? Hmmmm, no it doesn't. In step 2 we said that `Fut = bool`, but booleans aren't futures. @@ -273,7 +116,9 @@ This failing bound tells us that the output of the function needs to be a future The screenshots included so far of the trait search tree are from the Top-Down view in Argus. This means we view the search just as Rust performed it: We started at the root question `{login}: Handler<_, _>`, descended into the impl blocks, and found the failing where-clause in a tree leaf. This failing leaf is highlighted in the above image in red. There's a second failing bound, but we'll come back to that in the next section. The insight is that errors are *leaves* in the search tree, so the Top-Down view doesn't show you the errors first but rather the full trait solving process. -What if you want to see the errors first? Lucky for you, Argus provides a second view of the tree called the Bottom-Up view. The Bottom-Up view starts at the error leaves, and expanding node children traverses up the tree towards the root. This view prioritizes showing you errors. Below demonstrates the Bottom-Up view. +## Up the Search Tree + +What if you want to see the errors first? Argus provides a second view of the tree called the Bottom-Up view. The Bottom-Up view starts at the error leaves and expanding node children traverses up the tree towards the root. This view prioritizes showing you errors first. ````admonish example The Bottom-Up view is the *inverted* search tree. You start at the leaves and traverse to the root. Here's the bottom-up version of the Axum error search tree. @@ -283,13 +128,13 @@ The Bottom-Up view is the *inverted* search tree. You start at the leaves and tr title: Bottom-Up view of the search tree. --- graph TD - root["{login}: Handler<_, _>"] + root["{login}: Handler"] implRespH["impl Handler for T\nwhere\n    T: IntoResponse"] intoResp["{login}: IntoResponse"] - implH["impl Handler for F\nwhere\n    F: FnOnce(T1) -> Fut,\n    Fut: Future + Send,\n    T1: FromRequest"] + implH["impl Handler for F\nwhere\n    F: FnOnce(T1) -> Fut,\n    Fut: Future + Send,\n    T1: FromRequest"] isFunc["{login}: FnOnce(LoginAttempt) -> bool"] boolFut["bool: Future"] - loginARqst["LoginAttempt: FromRequest<_, _>"] + loginARqst["LoginAttempt: FromRequest"] implRespH -.-> root @@ -309,7 +154,7 @@ graph TD linkStyle 3 stroke:green, stroke-width:4px, color:green ``` -Argus sorts the failing leaves in the Bottom-Up view by which are "most-likely" the root cause of the error. No tool is perfect, and Argus can be wrong! If you click on "Other failures," which appears below the first shown failure, Argus provides you the full list of failures. +Argus sorts the failing leaves in the Bottom-Up view by which are "most-likely" the root cause of the error. No tool is perfect, and Argus can be wrong! If you click on "Other failures," which appears below the first shown failure, Argus provides you a full list. ```` @@ -318,9 +163,7 @@ Argus sorts the failing leaves in the Bottom-Up view by which are "most-likely" -The above demonstrates that Argus identifies `bool: Future` as a root cause of the overall failure, but also the second failure `LoginAttempt: FromRequestParts<_, _>`. The note icon in the Bottom-Up view indicates that the two failures must be resolved together. - -## Fixing Trait Bounds with Argus +The above demonstrates that Argus identifies `bool: Future` as a root cause of the overall failure in addition to the second failure: `LoginAttempt: FromRequestParts<_, _>`. The note icon in the Bottom-Up view indicates that the two failures must be resolved together if you want to us the function as a handler.