The Anatomy of a Type
A class in Java or C# is one construct doing four jobs at once. It declares the shape of some data, it declares the operations on that data, it declares a position in an inheritance hierarchy, and it declares which interfaces are satisfied. Because the four arrive together, it is easy to go a whole career without noticing they are separable.
Rust separates them. A struct or an enum declares the shape of the data and
nothing else. An impl block declares operations. A second kind of impl block
declares that the type satisfies a trait. There is no hierarchy at all. This
chapter walks through a type from the outside in, in the order you would build
one, and points out where each of those jobs has gone.
The data declaration
Rust has two ways to build a type out of other types, and the distinction between them runs through every design decision in the language.
A struct is data held together. Every field exists at once, and the size of the struct is the sum of its fields:
#![allow(unused)]
fn main() {
struct Invoice {
number: u64,
customer: String,
total_cents: i64,
paid: bool,
}
}
An enum is a choice between alternatives. Exactly one variant is present, and the size of the enum is the size of its largest variant plus a small tag:
#![allow(unused)]
fn main() {
enum Payment {
Cash,
Card { last_four: String, authorization: u64 },
Check(u32),
}
}
Notice that Payment::Cash carries no data, Payment::Card carries named
fields, and Payment::Check carries an unnamed one. All three shapes are
available in both structs and enum variants, and picking between named and
unnamed fields is a readability question: name them unless the type is obvious
from context, which in practice means name them except for single-field wrappers.
Those two constructs, combined with each other to any depth, are how every data structure in Rust is built. There is no third mechanism and no escape hatch. When you find yourself wanting one, the answer is almost always an enum you have not thought of yet, which is what chapter 6 is about.
Coming from Java or C#
The enum you know is a fixed set of named constants, possibly with fields attached, all of the same shape. Rust’s enum is a different construct wearing the same keyword: each variant has its own shape, and the type is the union of them. The closest thing you have met is a sealed class hierarchy with a switch over subtypes, and the comparison is worth holding onto, because the designs that use a sealed hierarchy in Java are usually the designs that want an enum in Rust.
Fields are private, and private means something different
By default every field is private. Adding pub makes one visible:
#![allow(unused)]
fn main() {
pub struct Invoice {
pub number: u64,
customer: String,
total_cents: i64,
paid: bool,
}
}
The part that surprises people is the boundary that privacy is measured against.
In Java, private means private to the class. In Rust, private means private to
the module — so a struct’s private fields are readable and writable by any code
in the same file, including other types’ methods and free functions.
This sounds looser and works out stricter, because Rust modules are small and
explicit. The unit of encapsulation is the module rather than the type, which
means a set of types that genuinely need to cooperate can live in a module and
see each other’s internals, while the outside world sees only what is pub.
There is no friend, no package-private awkwardness, and no reflection to work
around it.
The practical consequence for design is that you should think of a module as the thing with an interface, not a type. Chapter 15 develops this.
Behavior lives in a separate block
Operations go in an impl block, which may be anywhere in the same crate as the
type and may be split into as many blocks as you like:
pub struct Invoice {
number: u64,
customer: String,
total_cents: i64,
paid: bool,
}
impl Invoice {
pub fn new(number: u64, customer: String, total_cents: i64) -> Invoice {
Invoice { number, customer, total_cents, paid: false }
}
pub fn number(&self) -> u64 {
self.number
}
pub fn is_overdue(&self, days_elapsed: u32) -> bool {
!self.paid && days_elapsed > 30
}
pub fn mark_paid(&mut self) {
self.paid = true;
}
}
fn main() {
let mut invoice = Invoice::new(1001, String::from("Acme"), 250_00);
println!("{} overdue: {}", invoice.number(), invoice.is_overdue(45));
invoice.mark_paid();
println!("overdue now: {}", invoice.is_overdue(45));
}
Two kinds of function appear here. new takes no self and is an associated
function, called as Invoice::new(...). The others take a form of self and
are methods, called as invoice.number(). The only difference is the receiver;
there is no separate static keyword and no separate declaration syntax.
Separating data from behavior has a consequence worth noticing. Because an impl
block is not part of the type declaration, the standard library’s types can gain
methods from your crate through traits, your types can gain methods from a
library’s traits, and a large type’s operations can be grouped into several
blocks by subject rather than piled into one class body. The declaration of what
an Invoice is stays four lines long no matter how much behavior accumulates.
Constructors are a convention, not a feature
Rust has no constructors. new is an ordinary associated function with a name
everyone agreed on, and nothing enforces its existence, its name, or its
signature.
This sounds like a gap and removes a whole category of problem. A constructor in
an object-oriented language runs against a partially built object: the fields
have default values, this exists and can escape, a virtual call can dispatch to
a subclass method that runs before the subclass’s own initialization, and the
object can be observed in a state the class’s invariants do not allow. Rust’s
version builds the value and then returns it, so there is no moment at which an
Invoice exists but is not yet an Invoice.
The consequences show up immediately. There is no null, so a field never holds
one. There is no default initialization, so the compiler rejects a struct literal
that omits a field. A type with private fields and no public constructor cannot
be built from outside its module at all, which makes “you may only obtain one of
these through parse” an enforceable rule rather than a documented wish.
Multiple constructors are just multiple functions, and the standard library’s
naming conventions are worth following. new is the obvious way to build one.
with_capacity, with_timeout and friends are variants taking configuration.
from_str, from_bytes and similar name what they convert from. A constructor
that can fail returns Result:
pub struct Percentage(f64);
impl Percentage {
pub fn new(value: f64) -> Result<Percentage, String> {
if (0.0..=100.0).contains(&value) {
Ok(Percentage(value))
} else {
Err(format!("{value} is not between 0 and 100"))
}
}
}
fn main() {
println!("{:?}", Percentage::new(45.0).map(|p| p.0));
println!("{:?}", Percentage::new(145.0).map(|p| p.0));
}
Once that function is the only way to obtain a Percentage, every Percentage
in the program is between zero and a hundred, and no downstream code needs to
check. That is the idea chapter 6 is built on.
Derives
Most types want a handful of standard capabilities, and asking for them by hand would be tedious, so the compiler will write them:
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Sku {
code: String,
revision: u8,
}
fn main() {
let a = Sku { code: String::from("A-1"), revision: 2 };
let b = a.clone();
println!("{a:?} == {b:?} is {}", a == b);
}
derive generates an implementation of a trait based on the fields. Debug
gives you {:?} formatting for logs and tests. Clone gives you explicit
duplication, field by field. PartialEq and Eq give equality, again field by
field. Hash lets the type be a key in a HashMap. PartialOrd and Ord give
ordering, comparing fields in declaration order — which means field order is
semantically significant on an ordered type, a detail that has surprised people.
Deriving is cheap enough that the temptation is to put all of them on everything.
Resist it slightly. Each derive is a public promise: deriving Clone says
duplicating this is meaningful, deriving PartialEq says two of these can be
compared and that comparing all fields is the right comparison. For a type where
one field is a cache or a generated identifier, field-by-field equality is the
wrong answer, and writing the implementation by hand is the right one.
Copy deserves a separate thought. Deriving it changes assignment from a move to
a duplication, which changes how every piece of code using the type behaves. It
is appropriate for small aggregates of numbers — a Point, a Rgb, a
Duration — and inappropriate for anything with an identity or a resource.
Destruction
The counterpart to construction is the Drop trait, and unlike a constructor it
really is a language feature, because the compiler calls it for you:
struct TempFile {
path: String,
}
impl Drop for TempFile {
fn drop(&mut self) {
println!("removing {}", self.path);
}
}
fn main() {
let _scratch = TempFile { path: String::from("/tmp/work.tmp") };
println!("doing work");
}
Drop order is deterministic and worth knowing: local variables are dropped in
reverse declaration order, struct fields in declaration order, and a value’s own
drop runs before its fields are dropped. A Vec drops its elements in order,
front to back.
Most types never implement Drop. The derived behavior — drop every field, which
recursively drops everything they own — is correct for anything that does not
manage a resource outside Rust’s knowledge. You write one when you hold a raw
handle, a lock, a connection, a temporary file, or anything else where “finished”
means an action rather than merely freeing memory. That pattern has a name, and
the TempFile above is an instance of it: a value whose destruction performs the
cleanup, so that no code path can skip it. Chapter 17 returns to it as the guard
pattern.
Note
A type that implements
Dropcannot beCopy, cannot have fields moved out of it individually, and has a few other restrictions. These all come from the same place: if destruction does something, the compiler must guarantee it happens once, on a complete value.
The newtype
The smallest structure in the language is a struct with one unnamed field:
#![allow(unused)]
fn main() {
struct Meters(f64);
struct Feet(f64);
}
At runtime a Meters is an f64 and nothing more: no header, no indirection, no
cost. At compile time it is a distinct type that cannot be passed where Feet is
expected, cannot be added to a bare f64 by accident, and can carry its own
methods and trait implementations.
This is one of the highest-value moves available in Rust and one of the most
underused by newcomers, because it looks like ceremony. It is not. Every
identifier in your program that is currently a String — user IDs, order
numbers, file paths, email addresses, SQL fragments — is a candidate, and every
one you wrap is a class of mix-up the compiler now catches. The Mars Climate
Orbiter was lost to a units mismatch that struct Newtons(f64) would have
caught at build time.
A type alias is not the same thing and does not do this:
#![allow(unused)]
fn main() {
type Meters = f64;
}
That introduces a name for f64 and nothing else. Every f64 is a Meters and
every Meters is an f64, so nothing is checked. Aliases are for shortening
long generic types, not for making distinctions.
Conclusion
A Rust type is assembled rather than declared in one piece. Data shape comes from
structs and enums, held together or chosen between. Behavior comes from impl
blocks that can be split, added later, and written in other crates through
traits. Visibility is measured against the module rather than the type.
Construction is an ordinary function returning a finished value, which is why
there is no partially built object and no null. Destruction is automatic,
ordered by structure, and can be given meaning by implementing Drop.
Two of these do more than they first appear to. A private field plus a fallible constructor makes an invariant that holds everywhere in the program, and a one-field newtype makes a distinction the compiler will police for free. The next chapter is about spending both of them deliberately.
Questions to think through
Pick a class you have written with a constructor that can throw. What does an instance look like between the first assignment and the throw, and who can observe it?
Field-by-field equality is right for some types and wrong for others. Find a type you know where two instances should be considered equal despite differing in some field, and say which field and why.
List the String fields in a type you own. How many of them are really
identifiers that should never be compared to, assigned from, or concatenated with
a different kind of identifier?