Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 Drop cannot be Copy, 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?