11.1 Defining Structs: Named, Tuple, and Unit Structs
Alright, let’s get our hands dirty with structs. Think of them as your custom data containers. You’re tired of juggling a dozen unrelated variables, so you bundle them up into one neat, named package. It’s the difference between carrying loose batteries, a bulb, and wires in your pocket versus just grabbing a flashlight. Structs are your flashlights.
The Named Struct: Your Go-To Data Workhorse
This is the classic, the one you’ll use 95% of the time. You give each piece of data a name and a type. It’s wonderfully self-documenting. Let’s define one for a concept we all understand: the profound misery of a poorly configured server.
struct Server {
hostname: String,
port: u16,
is_on_fire: bool,
uptime: std::time::Duration,
}
Here’s why this rules: the type system is now working for us. You can’t accidentally assign a String to port or a bool to uptime. The compiler becomes your very pedantic, very reliable pair-programming buddy. To create an instance of this struct, you use this delightfully straightforward syntax:
let my_server = Server {
hostname: String::from("localhost"),
port: 8080,
is_on_fire: true, // It's always on fire.
uptime: std::time::Duration::from_secs(10), // A new personal best!
};
Notice that we don’t have to specify the fields in the same order as the definition. The compiler matches the names, which is a fantastic quality-of-life feature. A crucial best practice: if your struct’s fields don’t need to be mutable, make the entire instance immutable with let instead of let mut. If you need mutability, you can then update fields using the dot syntax: my_server.is_on_fire = false; (after wishing really hard).
The Tuple Struct: The Anonymous, Focused One
Sometimes, naming every single field feels like overkill. You just want a simple bundle of data, but you want it to be its own distinct type, not just a generic tuple. Enter the tuple struct. It has a name for the whole type, but anonymous fields accessed by position.
struct Color(u8, u8, u8);
struct Point(i32, i32, i32);
You’d use these just like you would a tuple:
let black = Color(0, 0, 0);
let origin = Point(0, 0, 0);
println!("The red value is: {}", black.0);
The magic here is in type safety. A Color and a Point are both three integers, but they are different types. You can’t accidentally pass a Point to a function that expects a Color. The compiler will stop you, and you’ll be grateful. They’re perfect for thin wrappers around other types (called the “newtype pattern”), which we’ll absolutely dive into later. It’s a killer feature.
The Unit Struct: A Marker Without Data
This one seems absurd at first. A struct with no fields? What’s the point? It’s a data-less marker, and its power is purely at the type level and runtime reflection.
struct RadioactiveMarker;
struct Processed;
You create an instance just with its name:
let marker = RadioactiveMarker;
So, why? Imagine you’re building a state machine. You could track state with a string (“processed”) or a boolean, but then what happens when you add a third state? You have to refactor everything. Instead, you can use unit structs as type-level states.
fn handle_request(request: Request, _marker: Processed) -> Response {
// This function only accepts requests that have already been "processed"
// ... implementation
}
The Processed unit struct acts as a compile-time proof that the necessary previous steps were completed. You can’t call this function without providing that proof. It’s a zero-cost abstraction—it compiles away to nothing at runtime—but it makes your APIs incredibly robust and difficult to misuse. It’s the type system flexing its muscles.
The Devil in the Details: A Few Pitfalls
First, the “Field Init Shorthand” is a great bit of sugar. If you have variables with the exact same name as your struct’s fields, you don’t have to repeat yourself.
let hostname = String::from("production");
let port = 443;
let is_on_fire = false; // miraculously
let prod_server = Server {
hostname, // instead of hostname: hostname
port, // instead of port: port
is_on_fire,
uptime: std::time::Duration::from_secs(86400),
};
Second, remember ownership. In our Server struct, hostname is of type String, meaning it owns that data. If you try to create a Server with a string literal (a &str), it won’t work unless you convert it to an owned String first. This isn’t a flaw; it’s the compiler ensuring you’ve thought about the lifetime of that data. Do you need to own it, or just borrow it? For structs that need to stick around, owning their data is usually the right call.
Finally, update syntax is a lifesaver for duplication. Let’s say you want to create a new server instance based on my_server, but only change the port.
let server_two = Server {
port: 9090,
..my_server
};
This says “take all the other fields from my_server”. Be warned: this will often involve moving data (like the String in hostname), which will invalidate the original my_server instance unless the struct implements the Copy trait (which ours doesn’t, and most won’t). It’s powerful, but it transfers ownership.