Display Trait
Display Trait
Level 4 — Error Handling & Generics A trait allowing types to be formatted using
{}(user-facing output) — cannot be derived.
1. Prerequisites
- Trait — The contract being manually implemented.
println!/format!— The macros that consume this trait.DebugTrait — The developer-facing counterpart.
2. Term Category
Rust-specific (the marketing brochure): While Debug shows the raw, ugly technical truth of a data structure, Display is the trait used to format data exactly how you want a non-programmer (or an end-user) to see it. It is the trait that powers the standard {} print syntax.
3. Explanation
(1) Design Motivation — "Why did we design this?"
Imagine you have a Money struct containing an amount field and a currency field.
If you use Debug, it prints: Money { amount: 100, currency: "USD" }.
An end-user looking at a shopping cart receipt doesn't want to see that! They want to see $100.00.
Rust designed the Display trait so you can manually write the code that transforms your struct into that beautiful, business-logic-specific string.
Because "beauty" and "business logic" are highly subjective, the compiler has absolutely no idea how you want your struct to look. Do you want $100, or 100 USD, or 100.00$? Because the compiler cannot guess your business logic, Display can NEVER be derived. You must always write it manually.
(2) Reality Metaphor
If Debug is the technical serial-number sticker on the back of a flat-screen TV, Display is the glossy marketing brochure handed to the customer in the store.
An automated factory (the #[derive] macro) can print the serial number sticker automatically because it's purely mechanical: just list the parts. But an automated factory cannot write a compelling, human-readable marketing brochure. A human being has to sit down and write that brochure manually.
(3) Rust Code Examples
Short Snippet (Writing the Brochure)
Notice that the signature for implementing Display is exactly the same as implementing Debug. You use write! to push your formatted string into the provided Formatter.
use std::fmt;
struct Money {
amount: u32,
currency: String,
}
// We MUST write this manually. No macros allowed!
impl fmt::Display for Money {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// We decide exactly how it looks to the end-user!
write!(f, "{} {}", self.amount, self.currency)
}
}
fn main() {
let price = Money { amount: 250, currency: String::from("EUR") };
// We use `{}` for Display, not `{:?}`!
println!("The total cost is {}", price);
// Output: The total cost is 250 EUR
}
Fuller Example (The Magic of .to_string())
Implementing Display gives you an incredible hidden superpower. The moment you implement Display for a struct, the Rust compiler secretly implements the .to_string() method for that struct for free!
use std::fmt;
struct User {
first_name: String,
last_name: String,
}
impl fmt::Display for User {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {}", self.first_name, self.last_name)
}
}
fn main() {
let my_user = User {
first_name: String::from("John"),
last_name: String::from("Doe")
};
// MAGIC! Because we wrote Display, we instantly get `.to_string()` for free!
let full_name: String = my_user.to_string();
println!("Saved string: {}", full_name); // Prints: Saved string: John Doe
}
4. Common Mistakes & Pitfalls
Mistake 1: Misunderstanding Display Trait Scoping and Lifecycle Rules
The mistake: Assuming Display Trait instances remain valid beyond their declaring scope block or across asynchronous boundaries without explicit lifetime tracking.
Why it's wrong: Rust strictly enforces lexical scope boundaries and non-lexical lifetimes (NLL) at compile time. Accessing dropped values or failing to handle variable drop order results in compiler errors such as E0597 or E0382.
Incorrect:
fn get_ref() -> &str {
let s = String::from("display_trait_data");
&s // ❌ Error E0106/E0515: returns a reference to data owned by the current function
}
Fix:
fn get_string() -> String {
let s = String::from("display_trait_data");
s // Ownership of the String is transferred directly to the caller
}
Mistake 2: Mutating Display Trait State Without Exclusive Ownership or mut Borrowing
The mistake: Attempting to mutate data associated with Display Trait through an immutable reference &T or without specifying mut in variable declarations.
Why it's wrong: Rust's aliasing XOR mutability rule (&T for shared immutable access, &mut T for exclusive mutable access) prohibits mutating state through shared references unless interior mutability patterns (e.g. RefCell, Mutex) are explicitly used.
Incorrect:
fn update_val(data: &i32) {
// *data += 1; // ❌ Error E0594: cannot assign to `*data`, which is behind a `&` reference
}
Fix:
fn update_val(data: &mut i32) {
*data += 1; // Correct: exclusive mutable reference permits mutation
}
Mistake 3: Concurrent Access to Display Trait Across Threads Without Send / Sync Guards
The mistake: Sharing non-thread-safe Display Trait instances across OS threads via std::thread::spawn.
Why it's wrong: Types that do not implement Send or Sync marker traits cannot safely cross thread boundaries. The compiler prevents data races by raising compile errors E0277 (trait Send is not implemented).
Incorrect:
use std::rc::Rc;
use std::thread;
let rc = Rc::new(42);
// thread::spawn(move || { println!("{}", rc); }); // ❌ Error E0277: `Rc` cannot be sent between threads safely
Fix:
use std::sync::Arc;
use std::thread;
let arc = Arc::new(42);
thread::spawn(move || {
println!("{}", arc); // Correct: `Arc` implements `Send` and `Sync`
});
5. Practice Exercises
Exercise 1: Zero-Allocation Audit Record Formatter with Dynamic Specifier Flags
Scenario:
In a high-frequency financial backend, log formatting must be zero-allocation to prevent memory fragmentation and latency spikes. You are building a telemetry library and need to implement std::fmt::Display for an AuditRecord struct representing financial transactions.
Requirements:
- Define an enum
AuditStatus:SuccessFailed(String)PendingImplementstd::fmt::DisplayforAuditStatusrendering"SUCCESS","FAILED: <reason>", or"PENDING".
- Define a struct
AuditRecord:id:u64action:&'static stramount:f64status:AuditStatus
- Implement
std::fmt::DisplayforAuditRecord:- Default format pattern:
"[AUDIT #<id>] <action> - <status> ($<amount>)". - Read precision from
f.precision()if passed via format specifier (e.g.{:.4}). Default to2decimal places if omitted. - Stream formatted output directly to
fmt::Formatterusingwrite!without creating intermediate heapStringallocations.
- Default format pattern:
- Include comprehensive unit tests with
#[cfg(test)] mod testsusingassert_eq!,assert!,assert_ne!, andmatches!.
Answer
Implementation
use std::fmt;
#[derive(Debug, Clone, PartialEq)]
pub enum AuditStatus {
Success,
Failed(String),
Pending,
}
impl fmt::Display for AuditStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AuditStatus::Success => write!(f, "SUCCESS"),
AuditStatus::Failed(reason) => write!(f, "FAILED: {}", reason),
AuditStatus::Pending => write!(f, "PENDING"),
}
}
}
pub struct AuditRecord {
pub id: u64,
pub action: &'static str,
pub amount: f64,
pub status: AuditStatus,
}
impl fmt::Display for AuditRecord {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let precision = f.precision().unwrap_or(2);
write!(
f,
"[AUDIT #{}] {} - {} (${:.1$})",
self.id, self.action, self.status, self.amount, precision
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_audit_record_display_success() {
let record = AuditRecord {
id: 1001,
action: "TRANSFER",
amount: 2500.5,
status: AuditStatus::Success,
};
let formatted = record.to_string();
assert_eq!(formatted, "[AUDIT #1001] TRANSFER - SUCCESS ($2500.50)");
assert!(formatted.contains("SUCCESS"));
assert_ne!(formatted, "");
}
#[test]
fn test_audit_record_display_failed_and_precision() {
let record = AuditRecord {
id: 1002,
action: "WITHDRAWAL",
amount: 50.123456,
status: AuditStatus::Failed("Insufficient funds".to_string()),
};
let default_fmt = format!("{}", record);
assert_eq!(default_fmt, "[AUDIT #1002] WITHDRAWAL - FAILED: Insufficient funds ($50.12)");
let custom_precision = format!("{:.4}", record);
assert_eq!(custom_precision, "[AUDIT #1002] WITHDRAWAL - FAILED: Insufficient funds ($50.1235)");
assert!(matches!(record.status, AuditStatus::Failed(_)));
}
#[test]
fn test_audit_record_pending() {
let record = AuditRecord {
id: 1003,
action: "DEPOSIT",
amount: 100.0,
status: AuditStatus::Pending,
};
assert_eq!(record.to_string(), "[AUDIT #1003] DEPOSIT - PENDING ($100.00)");
assert!(matches!(record.status, AuditStatus::Pending));
}
}
Technical Explanation
- Zero-Allocation Formatting Architecture:
- The implementation streams bytes directly into
f(&mut fmt::Formatter<'_>), which acts as an I/O buffer abstraction. By writing directly tofwith thewrite!macro instead of callingformat!to produce temporaryStrings, no intermediate heap allocations occur during formatting.
- The implementation streams bytes directly into
- Dynamic Format Specifiers:
- Rust's
fmt::Formatterexposes options specified in macro calls. We queryf.precision()to extract precision specified by format strings like{:.4}. Using variable positional parameters inwrite!(f, "...", ..., precision)(using1$positional notation) forwards the dynamic precision to floating point numbers.
- Rust's
- Ownership and Lifetime Invariants:
fmt::Formatter<'_>carries an anonymous lifetime tied to the lifetime of the output buffer/stream.&selfis borrowed immutably, ensuring formatting operations never mutate the audited record state or take ownership of its fields.
- Compositional Display Trait Usage:
AuditRecordformats itsstatusfield using{}specifier, which invokesAuditStatus's ownDisplay::fmtimplementation. This modular composition guarantees separation of concerns across complex data models.
Exercise 2: Hierarchical Diagnostic Error Chain with Recursive Display & Trait Objects
Scenario:
Distributed database engines and API gateways require multi-tiered error reporting where low-level I/O failures bubble up through database queries and top-level HTTP request handlers. You are tasked with implementing std::fmt::Display and std::error::Error for a ChainableError type that wraps nested causes using dynamic trait objects (Box<dyn std::error::Error + Send + Sync + 'static>).
Requirements:
- Define
ChainableErrorcontaining:code:u32message:Stringcause:Option<Box<dyn std::error::Error + Send + Sync + 'static>>
- Implement
fmt::DisplayforChainableError:- Base message format:
"[ERROR-{code}] {message}". - If a
causeexists, recursively format each nested error on a new line with indented bullet points (" Caused by: {cause}"), scaling indentation level by depth. - Stream all lines directly to the formatter buffer without instantiating intermediate vectors of strings.
- Base message format:
- Implement
std::error::ErrorforChainableError, delegating.source()tocause. - Provide constructor helpers
new(code, message)andwith_cause(code, message, cause). - Include comprehensive unit tests with
#[cfg(test)] mod testsusingassert_eq!,assert!,assert_ne!, andmatches!.
Answer
Implementation
use std::error::Error;
use std::fmt;
#[derive(Debug)]
pub struct ChainableError {
pub code: u32,
pub message: String,
pub cause: Option<Box<dyn Error + Send + Sync + 'static>>,
}
impl ChainableError {
pub fn new(code: u32, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
cause: None,
}
}
pub fn with_cause<E>(code: u32, message: impl Into<String>, cause: E) -> Self
where
E: Error + Send + Sync + 'static,
{
Self {
code,
message: message.into(),
cause: Some(Box::new(cause)),
}
}
}
impl fmt::Display for ChainableError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[ERROR-{}] {}", self.code, self.message)?;
let mut curr_cause = self.cause.as_ref().map(|e| e.as_ref() as &dyn Error);
let mut indent_level = 1;
while let Some(err) = curr_cause {
let indent = " ".repeat(indent_level);
write!(f, "\n{}Caused by: {}", indent, err)?;
curr_cause = err.source();
indent_level += 1;
}
Ok(())
}
}
impl Error for ChainableError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
self.cause.as_ref().map(|e| e.as_ref() as &(dyn Error + 'static))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io;
#[test]
fn test_single_level_error_display() {
let err = ChainableError::new(500, "Internal Server Error");
assert_eq!(err.to_string(), "[ERROR-500] Internal Server Error");
assert!(err.source().is_none());
assert_ne!(err.code, 400);
}
#[test]
fn test_nested_error_chain_display() {
let io_err = io::Error::new(io::ErrorKind::ConnectionRefused, "port 8080 unreachable");
let db_err = ChainableError::with_cause(503, "Database connection failed", io_err);
let api_err = ChainableError::with_cause(500, "API Gateway pipeline error", db_err);
let formatted = api_err.to_string();
let expected = "[ERROR-500] API Gateway pipeline error\n Caused by: [ERROR-503] Database connection failed\n Caused by: port 8080 unreachable";
assert_eq!(formatted, expected);
assert!(api_err.source().is_some());
let source_err = api_err.source().unwrap();
let downcasted = source_err.downcast_ref::<ChainableError>();
assert!(matches!(downcasted, Some(e) if e.code == 503));
}
}
Technical Explanation
- Dynamic Dispatch via Trait Objects (
Box<dyn Error>):- The
causefield uses trait objectsdyn Error + Send + Sync + 'static. Trait objects perform dynamic dispatch through a vtable (virtual method table) containing pointers to the concrete error type'sDisplay::fmt,Debug::fmt, andError::sourcefunctions.
- The
- Recursive Source Chain Traversal:
- Standard library trait
std::error::Errorrequires supertraitDisplay(pub trait Error: Debug + Display). Infmt::Display, we iterate down the cause hierarchy by repeatedly querying.source(). Becausedyn ErrorimplementsDisplay, formattingerrvia{}delegates dynamically to the underlying error's implementation.
- Standard library trait
- Indentation and Direct Streaming:
- The formatting loop streams line breaks and indentation depth directly into
fviawrite!. The?operator propagatesfmt::Errorback up if the destination buffer runs out of space or encounters write failures.
- The formatting loop streams line breaks and indentation depth directly into
- Downcasting and Error Trait Integration:
- By implementing
Error::source(),ChainableErrorintegrates seamlessly with standard Rust error-handling ecosystems (anyhow,eyre, standard library diagnostics). The test demonstrates runtime type reflection via.downcast_ref::<ChainableError>(), validating trait object safety and pointer dereferencing.
- By implementing
Exercise 3: Monomorphized Generic Table Formatter with Dynamic Width Calculation
Scenario:
CLI observability tools (e.g. status dashboards, container monitors) need to format tabular metrics where each column's type implements Display. You must design a generic table renderer TableReport<T> where T: std::fmt::Display.
Requirements:
- Define
TableReport<T>containing:title:Stringheaders:Vec<String>rows:Vec<Vec<T>>
- Implement
fmt::DisplayforTableReport<T>with trait boundT: fmt::Display:- Compute column width per column dynamically by finding the max character length among headers and formatted cell values (
item.to_string().len()). - Format:
- Header title line:
=== <title> === - Header row with pipe borders:
| Header1 | Header2 | - Border separator:
+---------+---------+ - Data rows left-aligned to column widths:
| Value1 | Value2 |
- Header title line:
- Gracefully handle empty rows or mismatched column counts without panicking.
- Compute column width per column dynamically by finding the max character length among headers and formatted cell values (
- Include unit tests demonstrating static monomorphization over custom metric structs and primitive types, with tests using
assert_eq!,assert!,assert_ne!, andmatches!.
Answer
Implementation
use std::fmt;
pub struct TableReport<T> {
pub title: String,
pub headers: Vec<String>,
pub rows: Vec<Vec<T>>,
}
impl<T> TableReport<T> {
pub fn new(title: impl Into<String>, headers: Vec<String>, rows: Vec<Vec<T>>) -> Self {
Self {
title: title.into(),
headers,
rows,
}
}
}
impl<T: fmt::Display> fmt::Display for TableReport<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "=== {} ===", self.title)?;
if self.headers.is_empty() {
return Ok(());
}
let num_cols = self.headers.len();
let mut col_widths = vec![0; num_cols];
for (i, header) in self.headers.iter().enumerate() {
col_widths[i] = col_widths[i].max(header.len());
}
for row in &self.rows {
for (i, item) in row.iter().enumerate().take(num_cols) {
let item_len = item.to_string().len();
col_widths[i] = col_widths[i].max(item_len);
}
}
write!(f, "|")?;
for (i, header) in self.headers.iter().enumerate() {
write!(f, " {:<1$} |", header, col_widths[i])?;
}
writeln!(f)?;
write!(f, "+")?;
for width in &col_widths {
write!(f, "{}+", "-".repeat(width + 2))?;
}
writeln!(f)?;
for row in &self.rows {
write!(f, "|")?;
for i in 0..num_cols {
if let Some(item) = row.get(i) {
write!(f, " {:<1$} |", item, col_widths[i])?;
} else {
write!(f, " {:<1$} |", "", col_widths[i])?;
}
}
writeln!(f)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, PartialEq)]
struct Metric {
val: f64,
unit: &'static str,
}
impl fmt::Display for Metric {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:.1}{}", self.val, self.unit)
}
}
#[test]
fn test_table_report_display_custom_struct() {
let headers = vec!["ID".to_string(), "LATENCY".to_string()];
let rows = vec![
vec![
Metric { val: 101.0, unit: "" },
Metric { val: 12.5, unit: "ms" },
],
vec![
Metric { val: 102.0, unit: "" },
Metric { val: 145.2, unit: "ms" },
],
];
let table = TableReport::new("System Latency", headers, rows);
let output = table.to_string();
assert!(output.contains("=== System Latency ==="));
assert!(output.contains("| ID | LATENCY |"));
assert!(output.contains("+-------+---------+"));
assert!(output.contains("| 101.0 | 12.5ms |"));
assert!(output.contains("| 102.0 | 145.2ms |"));
assert_ne!(output, "");
}
#[test]
fn test_table_report_empty_and_mismatched_rows() {
let headers = vec!["KEY".to_string(), "VALUE".to_string()];
let rows: Vec<Vec<String>> = vec![
vec!["alpha".to_string()],
vec!["beta".to_string(), "100".to_string()],
];
let table = TableReport::new("Config Key-Value", headers, rows);
let rendered = table.to_string();
assert!(rendered.contains("| alpha | |"));
assert!(rendered.contains("| beta | 100 |"));
let empty_table: TableReport<i32> = TableReport::new("Empty", vec![], vec![]);
assert_eq!(empty_table.to_string(), "=== Empty ===\n");
assert!(matches!(empty_table.rows.first(), None));
}
}
Technical Explanation
- Generic Monomorphization (
TableReport<T>):- The implementation
impl<T: fmt::Display> fmt::Display for TableReport<T>uses static dispatch (monomorphization). During compilation, Rust generates specialized machine code for every concrete typeTused withTableReport(e.g.TableReport<Metric>,TableReport<String>,TableReport<i32>), eliminating virtual dispatch pointer overhead.
- The implementation
- Blanket Trait Integration (
ToString):- The helper calculation
item.to_string().len()relies on standard library blanket implementationimpl<T: Display> ToString for T. ImplementingDisplayautomatically synthesizes.to_string()for anyT.
- The helper calculation
- Dynamic Formatting Width Specifiers:
- In
write!(f, " {:<1$} |", item, col_widths[i]), the specifier{:<1$}instructs the formatter to left-align (<) the item and pad it with trailing spaces to match the width argument specified by index1$(col_widths[i]).
- In
- Defensive Edge Case Handling:
- Dynamic bounds checking via
row.get(i)prevents out-of-bounds panics when rendering sparse or uneven matrix rows, safely padding missing column values with empty spaces.
- Dynamic bounds checking via
6. Related Terms
DebugTrait — The developer-facing counterpart toDisplay.- String vs &str — The type you magically get for free (via the
.to_string()method) the moment you implement theDisplaytrait. println!/format!— Related concept:println!/format!.
7. Key Takeaways
Displayis for formatting data beautifully for end-users.- It is triggered by using the standard
{}placeholder in print macros. - You cannot derive
Display. You must always write the implementation manually because the compiler cannot guess your subjective formatting logic. - Once you implement
Display, your struct automatically gets a.to_string()method for free! (This happens because Rust's standard library has a blanket implementation: if something can be displayed to a screen, it can be converted to a String).