Unit Struct
Unit Struct
Level 2 — Control Flow & Data Structures A struct with no fields, e.g.
struct Marker;. Used as a type-level tag.
1. Prerequisites
- Struct — The parent concept; a standard struct contains named data fields.
- Tuple Struct — A struct with unnamed data fields.
implBlock — (Future reference) This is where Unit Structs actually become useful, as it allows you to attach behavior to them.
2. Term Category
Rust-specific (mostly): While some Object-Oriented languages allow you to create "empty classes", Rust formalizes the Unit Struct as a distinct concept. It takes up absolutely zero memory at runtime and is heavily used in advanced Rust patterns (like the Typestate pattern) to enforce logic at compile time.
3. Explanation
(1) Design Motivation — "Why did we design this?"
If a Struct is designed to group data together, why would you ever want a struct that contains no data at all?
In Rust, data and behavior are strictly separated. Structs hold data, and impl blocks define behavior. Sometimes, you want to define behavior without actually needing to store any information. For example, you might want to create a Keyboard struct that implements a Typeable trait, but you don't care about storing the color or size of the keyboard in memory.
A Unit Struct solves this perfectly. It provides the strict Type Identity required by the compiler so you can attach functions and Traits to it, but it takes up exactly 0 bytes of memory. It vanishes completely when your program is compiled.
(2) Reality Metaphor
A Unit Struct is like a VIP Access Badge.
The badge itself doesn't contain any useful data. There is no barcode, no magnetic strip, no name, and no photo. It's literally just a blank piece of colored plastic.
However, simply possessing the badge grants you specific behaviors (the ability to walk past the bouncer into the VIP lounge). The value isn't in the data it holds; the value is entirely in its identity.
(3) Rust Code Examples
Short Snippet (Definition and Instantiation)
// Defining a Unit Struct.
// Notice there are no `{}` or `()`, just a semicolon.
struct DatabaseConnection;
fn main() {
// Instantiating a Unit Struct.
// Again, no brackets or parentheses required!
let conn = DatabaseConnection;
}
Fuller Example (Adding Behavior)
struct Greeter;
// We use an `impl` block to attach behavior to our empty struct.
impl Greeter {
fn say_hello(&self) {
println!("Hello! I take up 0 bytes of memory!");
}
}
fn main() {
let my_greeter = Greeter;
// We can call methods on it, even though it holds no data.
my_greeter.say_hello();
}
4. Common Mistakes & Pitfalls
Mistake 1: Adding curly braces or parentheses
The mistake: Trying to define or instantiate a Unit Struct using {} or ().
Why it's wrong: The defining characteristic of a Unit Struct is that it lacks those symbols entirely. It is just the keyword, the name, and a semicolon.
Incorrect:
struct Marker {}; // Adding unnecessary braces
let m = Marker(); // Trying to instantiate it like a function
Fix:
struct Marker;
let m = Marker;
Mistake 2: Mutating Unit Struct State Without Exclusive Ownership or mut Borrowing
The mistake: Attempting to mutate data associated with Unit Struct 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 Unit Struct Across Threads Without Send / Sync Guards
The mistake: Sharing non-thread-safe Unit Struct 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: Typestate Pattern for High-Performance Network Protocol Client
Scenario:
You are designing a high-reliability TCP protocol client for an industrial telemetry pipeline. The client connection can exist in three states: Disconnected, Connected, and Authenticated. To eliminate runtime state checks and prevent bugs (such as attempting to transmit data before authenticating), enforce state transitions at compile time using zero-sized Unit Structs as state markers with PhantomData<State>.
Requirements:
- Define three unit structs representing states:
Disconnected,Connected, andAuthenticated. - Define a generic struct
Connection<State>storing anendpoint: String,session_token: Option<String>, and_state: PhantomData<State>. - Implement
Connection::<Disconnected>::new(endpoint: impl Into<String>),connect(self)to transition toConnected,authenticate(self, token: impl Into<String>)to transition toAuthenticated,send_payload(&self, payload: &str)onAuthenticated, anddisconnect(self)returning toDisconnected. - Demonstrate that unit structs occupy
0bytes in memory (std::mem::size_of::<Disconnected>() == 0), guaranteeing zero memory overhead for type-level safety markers. - Provide a complete unit test module
#[cfg(test)] mod testswith explicit assertions (assert_eq!,assert!,assert_ne!,matches!).
Answer
Implementation
use std::marker::PhantomData;
// State markers defined as Zero-Sized Unit Structs
#[derive(Debug, PartialEq, Eq)]
pub struct Disconnected;
#[derive(Debug, PartialEq, Eq)]
pub struct Connected;
#[derive(Debug, PartialEq, Eq)]
pub struct Authenticated;
// Generic connection client parametric over state
pub struct Connection<State> {
endpoint: String,
session_token: Option<String>,
_state: PhantomData<State>,
}
impl Connection<Disconnected> {
pub fn new(endpoint: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
session_token: None,
_state: PhantomData,
}
}
// State transition consuming ownership of Disconnected state
pub fn connect(self) -> Connection<Connected> {
Connection {
endpoint: self.endpoint,
session_token: None,
_state: PhantomData,
}
}
}
impl Connection<Connected> {
// State transition consuming ownership of Connected state
pub fn authenticate(self, token: impl Into<String>) -> Connection<Authenticated> {
Connection {
endpoint: self.endpoint,
session_token: Some(token.into()),
_state: PhantomData,
}
}
}
impl Connection<Authenticated> {
pub fn send_payload(&self, payload: &str) -> String {
format!(
"[{}] Sent '{}' via token {}",
self.endpoint,
payload,
self.session_token.as_deref().unwrap_or("none")
)
}
pub fn disconnect(self) -> Connection<Disconnected> {
Connection {
endpoint: self.endpoint,
session_token: None,
_state: PhantomData,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_typestate_lifecycle() {
let conn = Connection::new("127.0.0.1:8080");
assert_eq!(std::mem::size_of::<Disconnected>(), 0);
assert_eq!(std::mem::size_of::<Connected>(), 0);
assert_eq!(std::mem::size_of::<Authenticated>(), 0);
// Transition: Disconnected -> Connected
let connected_conn = conn.connect();
// Transition: Connected -> Authenticated
let auth_conn = connected_conn.authenticate("secret_jwt_token_123");
let response = auth_conn.send_payload("PING");
assert_eq!(
response,
"[127.0.0.1:8080] Sent 'PING' via token secret_jwt_token_123"
);
assert!(response.contains("secret_jwt_token_123"));
// Transition: Authenticated -> Disconnected
let disconnected_conn = auth_conn.disconnect();
assert_eq!(disconnected_conn.endpoint, "127.0.0.1:8080");
assert_ne!(disconnected_conn.endpoint, "192.168.1.1");
}
#[test]
fn test_zero_sized_type_property() {
let disc = Disconnected;
let conn = Connected;
let auth = Authenticated;
assert_eq!(std::mem::size_of_val(&disc), 0);
assert_eq!(std::mem::size_of_val(&conn), 0);
assert_eq!(std::mem::size_of_val(&auth), 0);
let option_marker: Option<Disconnected> = None;
assert!(matches!(option_marker, None));
}
}
Technical Explanation
- Zero-Sized Types (ZST): The unit structs
Disconnected,Connected, andAuthenticatedtake up0bytes in memory. Rust compilers optimize ZST instances out entirely during LLVM code generation, creating zero runtime footprint. - Typestate Pattern Mechanics: By parameterizing
Connection<State>over unit structs, method availability is controlled at compile time viaimpl Connection<State>blocks. Attempting to callsend_payloadon aConnection<Disconnected>causes a compile error (E0599), moving invalid state transitions from runtime crashes to compile-time check failures. - Ownership and Move Semantics: State transition methods consume
selfby value (e.g.,fn connect(self)). Once transferred, the old instance in the prior state is moved and destroyed, preventing reuse or concurrent access in an invalid state (preventing use-after-move). PhantomDataRole: BecauseStateis only used as a type-level marker and not stored in data fields,PhantomData<State>informs the Rust compiler's type checker and variance engine thatConnectionowns logical stateStatewithout allocating memory for it.
Exercise 2: Zero-Cost Strategy Pattern for Telemetry Encoding
Scenario:
In a real-time event processing platform, log messages must be formatted and dispatched using different encoding strategies (JsonFormat, CompactTextFormat, CsvFormat). Instead of using dynamic trait objects (Box<dyn Formatter>) which introduce runtime vtable lookups and heap allocations, implement a zero-cost strategy pattern using Unit Structs and static trait dispatch.
Requirements:
- Define a trait
LogFormatterwithfn format_log(&self, timestamp: u64, message: &str) -> String. - Define three unit structs acting as stateless strategies:
JsonFormat,CompactTextFormat, andCsvFormat. - Implement
LogFormatterfor each unit struct strategy. - Define
TelemetryLogger<F: LogFormatter>holdingformatter: F. - Implement
TelemetryLogger::new(formatter: F)andlog(&self, timestamp: u64, message: &str) -> String. - Prove that storing unit struct strategies in
TelemetryLoggeradds zero bytes to the logger instance size (std::mem::size_of::<TelemetryLogger<JsonFormat>>() == 0). - Write complete unit tests in
#[cfg(test)] mod testsusing explicit assertions (assert_eq!,assert!,assert_ne!,matches!).
Answer
Implementation
// Strategy trait for log serialization
pub trait LogFormatter {
fn format_log(&self, timestamp: u64, message: &str) -> String;
}
// Unit structs acting as zero-sized strategy implementations
#[derive(Debug, Clone, Copy)]
pub struct JsonFormat;
#[derive(Debug, Clone, Copy)]
pub struct CompactTextFormat;
#[derive(Debug, Clone, Copy)]
pub struct CsvFormat;
impl LogFormatter for JsonFormat {
fn format_log(&self, timestamp: u64, message: &str) -> String {
format!(r#"{{"ts":{},"msg":"{}"}}"#, timestamp, message)
}
}
impl LogFormatter for CompactTextFormat {
fn format_log(&self, timestamp: u64, message: &str) -> String {
format!("[{}] {}", timestamp, message)
}
}
impl LogFormatter for CsvFormat {
fn format_log(&self, timestamp: u64, message: &str) -> String {
format!("{},\"{}\"", timestamp, message)
}
}
pub struct TelemetryLogger<F: LogFormatter> {
formatter: F,
}
impl<F: LogFormatter> TelemetryLogger<F> {
pub fn new(formatter: F) -> Self {
Self { formatter }
}
pub fn log(&self, timestamp: u64, message: &str) -> String {
self.formatter.format_log(timestamp, message)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_zero_cost_strategy_formatting() {
let json_logger = TelemetryLogger::new(JsonFormat);
let compact_logger = TelemetryLogger::new(CompactTextFormat);
let csv_logger = TelemetryLogger::new(CsvFormat);
assert_eq!(
json_logger.log(1620000000, "System boot"),
r#"{"ts":1620000000,"msg":"System boot"}"#
);
assert_eq!(
compact_logger.log(1620000000, "System boot"),
"[1620000000] System boot"
);
assert_eq!(
csv_logger.log(1620000000, "System boot"),
"1620000000,\"System boot\""
);
assert_ne!(
json_logger.log(1620000000, "System boot"),
csv_logger.log(1620000000, "System boot")
);
}
#[test]
fn test_strategy_memory_footprint() {
assert_eq!(std::mem::size_of::<JsonFormat>(), 0);
assert_eq!(std::mem::size_of::<CompactTextFormat>(), 0);
assert_eq!(std::mem::size_of::<CsvFormat>(), 0);
assert_eq!(std::mem::size_of::<TelemetryLogger<JsonFormat>>(), 0);
assert_eq!(std::mem::size_of::<TelemetryLogger<CompactTextFormat>>(), 0);
let fmt = JsonFormat;
assert!(matches!(fmt, JsonFormat));
}
}
Technical Explanation
- Static Trait Dispatch (Monomorphization): Generic type parameters (
TelemetryLogger<F>) cause Rust to generate concrete specialized code for each strategy type at compile time. BecauseJsonFormat,CompactTextFormat, andCsvFormatare Unit Structs, methods are directly inlineable without dynamic vtable lookups (dyn LogFormatter). - Zero-Overhead Strategy Pattern: Unlike object-oriented strategy patterns that require storing pointer handles to heap objects or trait vtables (which cost 16 bytes for fat pointers), unit struct strategies cost exactly
0bytes. The compiler replaces calls with direct static code execution. - Lifetimes and Pass-by-Value: Because Unit Structs carry no internal state or pointers, passing them by value (
JsonFormat) consumes zero register space. Unit structs easily deriveCopyandCloneat no performance penalty. - Edge Cases: Because unit structs contain no fields, instantiating
TelemetryLoggerrequires passing the unit struct instanceJsonFormat. The compiler completely optimizes out storage forformatter: Ffield insideTelemetryLogger<F>.
Exercise 3: Type-Safe Hardware Register Access Control via Capability Tokens
Scenario:
In embedded hardware driver design, memory-mapped registers possess explicit access permissions: ReadOnly (e.g. hardware status registers), WriteOnly (e.g. command trigger registers), and ReadWrite (e.g. configuration registers). Using Unit Structs as access capability tokens, build a type-safe register abstraction Register<T, AccessMode> that guarantees permission correctness at compile time.
Requirements:
- Define zero-sized unit structs representing access rights:
ReadOnly,WriteOnly, andReadWrite. - Define marker traits
ReadableandWritable. ImplementReadableforReadOnlyandReadWrite. ImplementWritableforWriteOnlyandReadWrite. - Define
Register<T: Copy, AccessMode>storingvalue: Tand_access: PhantomData<AccessMode>. - Implement
read(&self) -> Tconstrained onAccessMode: Readable. - Implement
write(&mut self, val: T)constrained onAccessMode: Writable. - Verify that
Register<T, AccessMode>size equalsstd::mem::size_of::<T>()exactly, proving unit struct markers add zero runtime size overhead. - Include comprehensive unit tests inside
#[cfg(test)] mod testsusing explicit assertions (assert_eq!,assert!,assert_ne!,matches!).
Answer
Implementation
use std::marker::PhantomData;
// Zero-sized capability token unit structs
#[derive(Debug, Clone, Copy)]
pub struct ReadOnly;
#[derive(Debug, Clone, Copy)]
pub struct WriteOnly;
#[derive(Debug, Clone, Copy)]
pub struct ReadWrite;
// Capability marker traits
pub trait Readable {}
pub trait Writable {}
impl Readable for ReadOnly {}
impl Readable for ReadWrite {}
impl Writable for WriteOnly {}
impl Writable for ReadWrite {}
// Type-safe hardware register abstraction
pub struct Register<T: Copy, AccessMode> {
value: T,
_access: PhantomData<AccessMode>,
}
impl<T: Copy, AccessMode> Register<T, AccessMode> {
pub fn new(initial_value: T) -> Self {
Self {
value: initial_value,
_access: PhantomData,
}
}
}
impl<T: Copy, AccessMode: Readable> Register<T, AccessMode> {
pub fn read(&self) -> T {
self.value
}
}
impl<T: Copy, AccessMode: Writable> Register<T, AccessMode> {
pub fn write(&mut self, val: T) {
self.value = val;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_register_access_permissions() {
let ro_reg: Register<u32, ReadOnly> = Register::new(0xDEAD_BEEF);
assert_eq!(ro_reg.read(), 0xDEAD_BEEF);
let mut wo_reg: Register<u32, WriteOnly> = Register::new(0);
wo_reg.write(0xCAFE_BABE);
// ro_reg.write(0); // ❌ Compile Error: trait bound `ReadOnly: Writable` is not satisfied
let mut rw_reg: Register<u32, ReadWrite> = Register::new(100);
assert_eq!(rw_reg.read(), 100);
rw_reg.write(200);
assert_eq!(rw_reg.read(), 200);
assert_ne!(rw_reg.read(), 100);
}
#[test]
fn test_register_zst_overhead() {
assert_eq!(std::mem::size_of::<ReadOnly>(), 0);
assert_eq!(std::mem::size_of::<WriteOnly>(), 0);
assert_eq!(std::mem::size_of::<ReadWrite>(), 0);
// Memory footprint of Register<u32, AccessMode> matches size_of::<u32>() exactly (4 bytes)
assert_eq!(std::mem::size_of::<Register<u32, ReadOnly>>(), 4);
assert_eq!(std::mem::size_of::<Register<u64, ReadWrite>>(), 8);
let cap: Option<ReadOnly> = Some(ReadOnly);
assert!(matches!(cap, Some(ReadOnly)));
}
}
Technical Explanation
- Capability Traits and Zero-Cost Access Control: By creating empty marker traits (
Readable,Writable) and implementing them on capability unit structs (ReadOnly,WriteOnly,ReadWrite), method availability onRegisteris strictly governed by trait bounds (AccessMode: Readable). Attempting to call.write()on aRegister<u32, ReadOnly>causes a compile error, eliminating invalid hardware bus operations before code reaches hardware. - Struct Layout and Memory Alignment: Rust's ABI rules specify that zero-sized fields (
PhantomData<AccessMode>) do not alter the struct layout, size, or alignment requirements ofRegister<T, AccessMode>. Thus,Register<u32, ReadOnly>is byte-identical in memory representation to a plainu32. - Safety and Low-Level Driver Invariants: In embedded systems where registers map directly to memory hardware addresses (
volatileMMIO pointers), Unit Struct markers allow developers to enforce safety constraints at compile time without paying any runtime penalty in code size, memory footprint, or execution cycles.
6. Related Terms
- Struct — The standard version that requires you to name every field.
- Tuple Struct — A struct with unnamed fields.
- Unit Type (
()) — Related concept: Unit Type (()). - Never Type (
!) — Related concept: Never Type (!). PhantomData<T>— Related concept:PhantomData<T>.
7. Key Takeaways
- A Unit Struct is defined simply with
struct Name;(no{}or()). - It takes up exactly 0 bytes of memory at runtime.
- You create an instance simply by typing its name:
let x = Name;. - It is primarily used when you need a custom Type to attach behavior to (via
implblocks or Traits), but you don't need to store any actual state.