Debug Trait
Debug Trait
Level 4 — Error Handling & Generics A trait allowing types to be formatted using
{:?}(developer-facing output).
1. Prerequisites
- Trait — The contract being implemented.
- Derive Macro — How you get this trait for free 99% of the time.
println!/format!— The macros that consume this trait.
2. Term Category
Rust-specific (the print enabler): In dynamic languages like JavaScript or Python, if you console.log() a custom object, the language will aggressively try to print it (often resulting in unhelpful output like [object Object]). Rust strictly refuses to print any custom type unless you explicitly declare how it should be converted into text. The Debug trait is how you make that declaration.
3. Explanation
(1) Design Motivation — "Why did we design this?"
When debugging code, developers constantly need to print variables to the console to inspect their current state.
Because Rust is strictly typed, the println! macro can't just guess how to convert a custom struct into a string. The Debug trait is the specific contract that provides that conversion logic.
It is designed entirely for developers, not end-users. This means the output doesn't have to be pretty or localized; it just has to be technically accurate, showing the exact struct name and the raw values of all its internal fields. Because the implementation is so predictable, the compiler can write it for you automatically.
(2) Reality Metaphor
Imagine you find a strange machine part on the floor of an assembly plant (your custom Struct). You take it to the foreman and ask, "What is this?" (calling println!).
If the part has no labels, the foreman can't help you (the compiler throws an error).
The Debug trait is the technical spec sticker on the back of the part. It lists the exact serial number, dimensions, and raw material composition. It's not pretty enough to put on a marketing brochure for a customer, but it's exactly what an engineer needs to debug a problem.
(3) Rust Code Examples
Short Snippet (The Free Implementation)
You almost always use the #[derive] macro to get Debug for free. You print it using the special {:?} placeholder.
// Ask the compiler to write the Debug trait for us
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect = Rectangle { width: 30, height: 50 };
// Use {:?} to trigger the Debug trait
println!("The rectangle is {:?}", rect);
// Output: The rectangle is Rectangle { width: 30, height: 50 }
// Use {:#?} for "pretty-printing" (adds line breaks and indents)
println!("The rectangle is {:#?}", rect);
/* Output:
The rectangle is Rectangle {
width: 30,
height: 50,
}
*/
}
Fuller Example (Manual Implementation)
Why would you ever write Debug manually instead of using the macro? Usually, to hide sensitive information from your server logs!
use std::fmt;
struct User {
username: String,
password_hash: String,
}
// We write it manually so we don't accidentally log the password!
impl fmt::Debug for User {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("User")
.field("username", &self.username)
.field("password_hash", &"********") // Redacted!
.finish()
}
}
fn main() {
let u = User {
username: String::from("alice99"),
password_hash: String::from("12345_qwerty"),
};
println!("{:?}", u);
// Output: User { username: "alice99", password_hash: "********" }
}
4. Common Mistakes & Pitfalls
Mistake 1: Misunderstanding Debug Trait Scoping and Lifecycle Rules
The mistake: Assuming Debug 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("debug_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("debug_trait_data");
s // Ownership of the String is transferred directly to the caller
}
Mistake 2: Mutating Debug Trait State Without Exclusive Ownership or mut Borrowing
The mistake: Attempting to mutate data associated with Debug 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 Debug Trait Across Threads Without Send / Sync Guards
The mistake: Sharing non-thread-safe Debug 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: Production-Grade Security Masking Wrapper (Sensitive<T>)
Scenario:
In high-security enterprise systems (payment processing, OAuth authentication services), inadvertently emitting sensitive data (passwords, JWT tokens, private keys) into application logs introduces severe vulnerability risks (OWASP A09). Using #[derive(Debug)] prints all internal fields in plain text.
Task:
- Define a generic wrapper type
Sensitive<T>(pub T). Crucially, do not requireT: fmt::Debugon the generic struct definition or on theimpl<T> fmt::Debug for Sensitive<T>block. - Implement
fmt::Debugmanually forSensitive<T>:- When formatted using standard specifier
{:?}, outputSensitive("[REDACTED]"). - When formatted using alternate specifier
{:#?}(f.alternate()), output non-sensitive structural metadata:Sensitive { type: "<type_name>", size_bytes: <size> }usingstd::any::type_name::<T>()andstd::mem::size_of::<T>().
- When formatted using standard specifier
- Provide an explicit method
pub fn expose(&self) -> ExposedDebug<'_, T>onSensitive<T>(which requiresT: fmt::Debug) to outputExposed("...")when developers explicitly opt in during local diagnostic debugging. - Construct a
DatabaseConfigstruct containinghost: String,port: u16, andauth_token: Sensitive<String>derivingDebugand demonstrate that secret values remain redacted in container structs. - Create unit tests with explicit assertions (
assert_eq!,assert!,assert_ne!,matches!).
Answer
Implementation
use std::any::type_name;
use std::fmt;
use std::mem::size_of;
/// A generic zero-cost security wrapper that redacts inner sensitive values during Debug formatting.
pub struct Sensitive<T>(pub T);
impl<T> Sensitive<T> {
/// Constructs a new sensitive wrapper around the payload.
pub fn new(val: T) -> Self {
Sensitive(val)
}
/// Expressly opts into formatting the inner secret value for localized debugging.
pub fn expose(&self) -> ExposedDebug<'_, T> {
ExposedDebug(&self.0)
}
}
// Notice: T is NOT bounded by fmt::Debug! Any T can be wrapped and safely printed as redacted.
impl<T> fmt::Debug for Sensitive<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if f.alternate() {
f.debug_struct("Sensitive")
.field("type", &type_name::<T>())
.field("size_bytes", &size_of::<T>())
.finish()
} else {
f.debug_tuple("Sensitive")
.field(&"[REDACTED]")
.finish()
}
}
}
/// Auxiliary helper returned by `expose()` to print raw inner secrets when requested.
pub struct ExposedDebug<'a, T>(&'a T);
impl<'a, T: fmt::Debug> fmt::Debug for ExposedDebug<'a, T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("Exposed")
.field(&self.0)
.finish()
}
}
/// Production configuration struct demonstrating container debug integration.
#[derive(Debug)]
pub struct DatabaseConfig {
pub host: String,
pub port: u16,
pub auth_token: Sensitive<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sensitive_standard_debug_redaction() {
let secret = Sensitive::new(String::from("super_secret_jwt_token_12345"));
let output = format!("{:?}", secret);
assert_eq!(output, "Sensitive(\"[REDACTED]\")");
assert!(!output.contains("super_secret_jwt_token_12345"));
}
#[test]
fn test_sensitive_alternate_debug_metadata() {
let secret = Sensitive::new(42u64);
let output = format!("{:#?}", secret);
assert_ne!(output, "Sensitive(\"[REDACTED]\")");
assert!(output.contains("u64"));
assert!(output.contains("size_bytes: 8"));
}
#[test]
fn test_sensitive_exposed_opt_in() {
let secret = Sensitive::new(String::from("db_password"));
let exposed = format!("{:?}", secret.expose());
assert_eq!(exposed, "Exposed(\"db_password\")");
assert!(matches!(secret.0.as_str(), "db_password"));
}
#[test]
fn test_database_config_struct_formatting() {
let config = DatabaseConfig {
host: String::from("localhost"),
port: 5432,
auth_token: Sensitive::new(String::from("p@ssword123")),
};
let debug_output = format!("{:?}", config);
assert!(debug_output.contains("host: \"localhost\""));
assert!(debug_output.contains("port: 5432"));
assert!(debug_output.contains("auth_token: Sensitive(\"[REDACTED]\")"));
assert!(!debug_output.contains("p@ssword123"));
}
}
Technical Explanation
- Step-by-Step Implementation Detail:
Sensitive<T>wraps arbitrary values of typeT. By leavingTunbounded inimpl<T> fmt::Debug for Sensitive<T>, types that do not implementfmt::Debug(such as un-annotated third-party structs or closures) can still be stored insideSensitiveand formatted safely without triggering compiler errors.- Inside
fmt(),f.alternate()inspects whether the{:#?}flag was used. When true, it delegates tof.debug_struct("Sensitive")to output structural metadata (type_name::<T>()andsize_of::<T>()). Otherwise,f.debug_tuple("Sensitive")emits the redacted literal"[REDACTED]". - The
.expose()method provides an explicit opt-in escape hatch returning a lightweight borrow adapterExposedDebug<'a, T>, which enforcesT: fmt::Debugto print raw inner state.
- Language Invariants & Ownership:
Sensitive<T>is a transparent single-field tuple struct with layout identical toT. Formatting calls take&selfimmutably, preserving Rust's shared borrowing guarantees.ExposedDebug<'a, T>holds a non-owning borrow&'a Twith lifetime'atied to&self, ensuring no heap allocations or string clones occur during explicit debug formatting.
- Monomorphization & Performance:
- The compiler monomorphizes
impl<T> fmt::Debug for Sensitive<T>for each unique concrete typeT. Becausesize_of::<T>()andtype_name::<T>()evaluate to constants, metadata formatting overhead is minimal and involves zero dynamic allocation on standard paths.
- The compiler monomorphizes
- Edge Cases:
- Structs deriving
Debug(likeDatabaseConfig) automatically delegate field printing toSensitive<T>'sfmtmethod, ensuring nested structures never accidentally leak sensitive fields into log aggregation systems.
- Structs deriving
Exercise 2: Zero-Copy Binary Buffer & Canonical HexDump Formatter (HexDump<'a>)
Scenario:
In high-performance networking stack development, binary deserializers, and hardware communication protocols, raw byte slices (&[u8]) must be inspected during troubleshooting. Default Rust slice formatting ({:?}) outputs comma-separated decimal integers ([222, 173, 190, 239]), which is unreadable, slow, and cannot be easily visually cross-referenced with Wireshark packet captures or hexadecimal memory views.
Task:
- Create a zero-copy wrapper type
HexDump<'a>(pub &'a [u8]). - Implement
fmt::DebugforHexDump<'a>:- For empty slices (
self.0.is_empty()), outputHexDump[]. - Standard formatting (
{:?}) outputs compact single-line hex byte values:HexDump[de ad be ef 00 ff]. - Alternate formatting (
{:#?}) outputs a canonical multi-line hex dump table with line byte offsets (0000:), hex representation formatted in 16-byte chunks, and printable ASCII graphics side-by-side (non-printable bytes rendered as.).
- For empty slices (
- Ensure zero string dynamic memory allocations occur during formatting by writing directly to
f: &mut fmt::Formatter. - Include unit tests with explicit assertions (
assert_eq!,assert!,assert_ne!,matches!).
Answer
Implementation
use std::fmt;
/// Zero-copy adapter for formatting byte slices into hexadecimal visual representations.
pub struct HexDump<'a>(pub &'a [u8]);
impl<'a> fmt::Debug for HexDump<'a> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let bytes = self.0;
if bytes.is_empty() {
return write!(f, "HexDump[]");
}
if f.alternate() {
// Canonical multi-line hex dump with offset and ASCII column
writeln!(f, "HexDump ({} bytes):", bytes.len())?;
for (offset, chunk) in bytes.chunks(16).enumerate() {
write!(f, " {:04x}: ", offset * 16)?;
// Format 16 hex byte slots with padding alignment
for i in 0..16 {
if i < chunk.len() {
write!(f, "{:02x} ", chunk[i])?;
} else {
write!(f, " ")?;
}
}
write!(f, " |")?;
// Printable ASCII column output
for &b in chunk {
if b.is_ascii_graphic() || b == b' ' {
write!(f, "{}", b as char)?;
} else {
write!(f, ".")?;
}
}
writeln!(f, "|")?;
}
Ok(())
} else {
// Compact single-line output
write!(f, "HexDump[")?;
for (i, &b) in bytes.iter().enumerate() {
if i > 0 {
write!(f, " ")?;
}
write!(f, "{:02x}", b)?;
}
write!(f, "]")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hexdump_empty_slice() {
let empty: &[u8] = &[];
let dump = HexDump(empty);
assert_eq!(format!("{:?}", dump), "HexDump[]");
assert_eq!(format!("{:#?}", dump), "HexDump[]");
}
#[test]
fn test_hexdump_compact_single_line() {
let data = [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0xFF];
let dump = HexDump(&data);
let output = format!("{:?}", dump);
assert_eq!(output, "HexDump[de ad be ef 00 ff]");
assert!(!output.contains('\n'));
}
#[test]
fn test_hexdump_alternate_multiline_table() {
let data = b"Hello, World!\x00\x01\x02";
let dump = HexDump(data);
let output = format!("{:#?}", dump);
assert_ne!(output, format!("{:?}", dump));
assert!(output.contains("HexDump (16 bytes):"));
assert!(output.contains("0000: 48 65 6c 6c 6f 2c 20 57 6f 72 6c 64 21 00 01 02"));
assert!(output.contains("|Hello, World!...|"));
}
#[test]
fn test_hexdump_chunk_boundary_and_matches() {
let data = [0x41; 17]; // 17 bytes to cross the 16-byte boundary line
let dump = HexDump(&data);
let output = format!("{:#?}", dump);
assert!(output.contains("0000:"));
assert!(output.contains("0010: 41"));
assert!(matches!(dump.0.len(), 17));
}
}
Technical Explanation
- Step-by-Step Implementation Detail:
HexDump<'a>wraps&'a [u8]. When formatting with{:?}, it streams bytes sequentially viawrite!(f, "{:02x}", b)with space separators.- When
{:#?}(f.alternate()) is active,bytes.chunks(16)divides the slice into 16-byte slices. Each line outputs:- A 4-digit zero-padded hexadecimal offset (
{:04x}). - 16 hex byte columns, padded with 3 spaces when the final line chunk contains fewer than 16 bytes.
- An ASCII column where
b.is_ascii_graphic() || b == b' 'checks printable characters and renders unprintable bytes as..
- A 4-digit zero-padded hexadecimal offset (
- Language Invariants & Ownership:
HexDump<'a>borrows byte buffers zero-copy for lifetime'a. No byte vectors or intermediary strings are cloned or allocated on the heap during printing.
- Formatting Stream Performance:
- Direct iteration writing to
fmt::Formatteravoids string allocation. The buffer uses formatting macros (write!,writeln!) which append directly to the target output stream (e.g.stdout,Stringbuffer informat!).
- Direct iteration writing to
- Edge Cases:
- Slices of length 0 return immediately with
HexDump[]. - Slices whose length is not a multiple of 16 cleanly pad remaining hex columns so the ASCII column stays aligned across lines.
- Slices of length 0 return immediately with
Exercise 3: Dynamic Dispatch Diagnostics for Trait Object Pipelines (Box<dyn Plugin>)
Scenario:
An extensible microservices backend processes telemetry events through dynamically registered plugin pipelines (Vec<Box<dyn Plugin>>). Because plugins are registered at runtime, the concrete plugin types cannot be resolved at compile time via generics and monomorphization. Diagnostic logging must dynamically inspect each stage in the pipeline without dynamic type casting or manual formatting boilerplate.
Task:
- Define a
Plugintrait with afmt::Debugsuper-trait bound, providingname(&self) -> &strandis_active(&self) -> bool. - Implement concrete plugin types:
RateLimiter(max_rps: u32,current_count: u32) andEventFilter(allowed_topics: Vec<String>,dropped_count: u64). - Construct a
Pipelinestruct holdingname: Stringandplugins: Vec<Box<dyn Plugin>>. - Implement
fmt::Debugmanually forPipelineusingf.debug_struct()andf.debug_list(), dynamically dispatchingDebugformatting across&dyn Plugintrait objects. - Create unit tests with explicit assertions (
assert_eq!,assert!,assert_ne!,matches!).
Answer
Implementation
use std::fmt;
/// Super-trait defining a dynamic plugin capable of diagnostic inspection.
pub trait Plugin: fmt::Debug {
fn name(&self) -> &str;
fn is_active(&self) -> bool;
}
#[derive(Debug)]
pub struct RateLimiter {
pub max_rps: u32,
pub current_count: u32,
}
impl Plugin for RateLimiter {
fn name(&self) -> &str {
"RateLimiter"
}
fn is_active(&self) -> bool {
self.current_count < self.max_rps
}
}
#[derive(Debug)]
pub struct EventFilter {
pub allowed_topics: Vec<String>,
pub dropped_count: u64,
}
impl Plugin for EventFilter {
fn name(&self) -> &str {
"EventFilter"
}
fn is_active(&self) -> bool {
!self.allowed_topics.is_empty()
}
}
/// Dynamic pipeline carrying runtime-registered plugin trait objects.
pub struct Pipeline {
pub name: String,
pub plugins: Vec<Box<dyn Plugin>>,
}
impl fmt::Debug for Pipeline {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut builder = f.debug_struct("Pipeline");
builder.field("name", &self.name);
// Helper zero-allocation struct to format plugins list via vtable dynamic dispatch
struct PluginsDebug<'a>(&'a [Box<dyn Plugin>]);
impl<'a> fmt::Debug for PluginsDebug<'a> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut list = f.debug_list();
for plugin in self.0 {
// Dispatch fmt::Debug formatting through the trait object vtable
list.entry(&format_args!("{}: {:?}", plugin.name(), plugin));
}
list.finish()
}
}
builder.field("plugins", &PluginsDebug(&self.plugins));
builder.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pipeline_empty_plugins() {
let pipeline = Pipeline {
name: String::from("empty_stage"),
plugins: vec![],
};
let debug_str = format!("{:?}", pipeline);
assert_eq!(debug_str, "Pipeline { name: \"empty_stage\", plugins: [] }");
}
#[test]
fn test_pipeline_dynamic_dispatch_formatting() {
let limiter = Box::new(RateLimiter {
max_rps: 1000,
current_count: 42,
});
let filter = Box::new(EventFilter {
allowed_topics: vec![String::from("auth"), String::from("billing")],
dropped_count: 3,
});
let pipeline = Pipeline {
name: String::from("ingress_pipeline"),
plugins: vec![limiter, filter],
};
let output = format!("{:?}", pipeline);
assert!(output.contains("name: \"ingress_pipeline\""));
assert!(output.contains("RateLimiter: RateLimiter { max_rps: 1000, current_count: 42 }"));
assert!(output.contains("EventFilter: EventFilter { allowed_topics: [\"auth\", \"billing\"], dropped_count: 3 }"));
assert_ne!(output, "Pipeline { name: \"ingress_pipeline\", plugins: [] }");
}
#[test]
fn test_plugin_trait_methods_and_matches() {
let limiter: Box<dyn Plugin> = Box::new(RateLimiter {
max_rps: 10,
current_count: 2,
});
assert_eq!(limiter.name(), "RateLimiter");
assert!(limiter.is_active());
assert!(matches!(limiter.name(), "RateLimiter"));
}
}
Technical Explanation
- Step-by-Step Implementation Detail:
pub trait Plugin: fmt::Debugestablishesfmt::Debugas a super-trait requirement. Any type implementingPluginmust also implementfmt::Debug.- In
Pipeline::fmt,f.debug_struct("Pipeline")starts building a structured representation. A nested lifetime-bound helper structPluginsDebug<'a>(&'a [Box<dyn Plugin>])is created. - Inside
PluginsDebug::fmt,f.debug_list()creates a list formatter. Iterating overself.0callsplugin.name()and formatspluginusingformat_args!("{}: {:?}", plugin.name(), plugin).format_args!constructs a zero-allocationfmt::Argumentsstruct on the stack.
- Vtable Dispatch & Trait Objects:
Box<dyn Plugin>is a fat pointer consisting of a data pointer (pointing to the concrete struct allocation likeRateLimiter) and a vtable pointer (pointing to virtual function pointers includingPlugin::nameandfmt::Debug::fmt).- When
format_args!printspluginvia{:?}, Rust performs dynamic dispatch through the vtable to execute the concrete struct's debug implementation.
- Language Invariants & Object Safety:
- The
Plugintrait is object-safe because all methods (name,is_active) take&selfby reference and do not returnSelfby value or contain generic type parameters.
- The
- Edge Cases:
- An empty
pluginsvector producesplugins: []without allocating memory or raising formatting errors.
- An empty
6. Related Terms
DisplayTrait — The user-facing counterpart toDebug.- Derive Macro — The mechanism used to generate
Debugautomatically. dbg!Macro — Related concept:dbg!Macro.std::error::ErrorTrait &Box<dyn Error>— Related concept:std::error::ErrorTrait &Box<dyn Error>.assert!/assert_eq!/assert_ne!— Related concept:assert!/assert_eq!/assert_ne!.
7. Key Takeaways
Debugis a trait that allows a type to be printed using the{:?}format specifier.- The
{:#?}specifier "pretty-prints" the output (adds line breaks and indentation for large structs). - The
dbg!(my_var)macro is an incredible shortcut that prints the file name, line number, variable name, and theDebugoutput, and then returns the variable back so you can use it in-line. - You should almost always automatically
#[derive(Debug)]on every single struct and enum you create.