Rust Ch.5: Structs and Methods
Outline
- 0:00 3 a.m. refund bug: valid code, wrong ID
- 1:18 Structs are where ownership touches real data
- 1:33 Dataclass / record analogy and the AND-type framing
- 1:59 Rigid memory layout and no partial initialization
- 2:38 The struct as one unified ownership boundary
- 3:46 Methods without classes and `impl` blocks
- 5:06 Receiver permissions: `&self`, `&mut self`, and consuming `self`
- 6:28 Associated functions and the `new` pattern
- 7:38 `derive(Debug, Clone)` and boilerplate removal
- 8:27 Tuple structs and the `UserId` vs `OrderId` bug
- 9:00 The newtype pattern
- 9:50 Compile-time protection against wrong IDs
- 10:16 Zero-cost abstractions
- 11:03 Next chapter: enums, OR-types, and pattern matching
Transcript
0:00 So imagine you're getting paged at like 3 a.m. Your backend just processed a $500 refund to a completely random user. Oh man, the absolute worst feeling. Right. It is a total nightmare. So you drag yourself out of bed, you pull up the logs, and you start tracing this transaction. And the database is perfectly fine. Of course it is. Yeah. The authorization middleware worked perfectly. The actual code you wrote is perfectly valid, Java or Python. So what actually happened? I'm guessing a parameter mixup.
0:31 Exactly. Deep in the payment processing logic, someone passed a UserId into a function parameter that was expecting an OrderId. Ah, yep. Classic. And because both of those IDs are, well, under the hood, they're just generic 64-bit integers, right? So the compiler happily let it happen. It just took a bucket of bits, handed it to the database, and initiated the transfer. It's exactly the kind of silent, catastrophic bug that keeps backend engineers up at night. Totally. And today we're going to talk about that.
0:58 We're going to look at how Rust makes that exact scenario a literal compile time impossibility. Because it really stems from a fundamental disconnect, you know, between how we think about our business domain and how the language actually stores data in memory. You need containers for all of that state. And in Rust, that container is the struct. Ownership rules become useful the moment you put them on a struct. We need a way to tell the compiler exactly what a specific piece of data looks like, how much memory it requires, and how it behaves when it moves through the system.
1:31 So let's establish that boundary. The source material compares a Rust struct to, like, a Python dataclass or a Java record. Yeah. It calls it an AND type. Right, an AND type. And my understanding is that this is a strict logical guarantee. Yes. There is no concept of, like, an empty object where you can just slap properties onto it dynamically later. Which you can totally do in Python. Well, Python will let you do whatever you want. But that strictness in Rust is the foundation of trustworthiness.
1:58 It's a very trustworthy memory management. When you define a struct, you are defining a rigid memory layout. So it's locked in. Completely locked in. The compiler physically will not let you instantiate a user without supplying a valid value for the name, the email, and the age simultaneously. Wow. So no partial initialization? None. There is no such thing as a partially instantiated struct in Rust. Which means, as a developer reading the code, I mean, I never have to guess if a field is a struct or not.
2:28 has been initialized yet. If the struct exists, its fields exist. Exactly. It removes an entire category of null reference errors. I want to tie this back to the ownership model because this is where the mental model really needs to click for back-end work. Sure. So if I have this strict, private, fully formed container, how does the borrow checker handle it? Does the struct act as a sort of unified ownership force field? A force field. I like that. Yeah. Like if a user struct owns a string for the name field, I don't have to worry about the name field getting dropped while the email field is still alive, right? That is the crucial insight.
3:02 Yes. The struct is one singular unified ownership block. Okay. That makes sense. And that unity is precisely what makes the borrow checker tractable for large scale enterprise programs. Because otherwise you'd be tracking a million tiny lifetimes. Exactly. You don't track the lifetime of the name and the email independently. You just track the user. Ah. So when the user instance goes out of the system, it's a user instance. Yeah. So when the user instance goes out of the system, it's a user instance. Yeah. So when the user instance goes out of the system, it's a user instance.
3:26 Every single field inside it is recursively dropped automatically. Nice. And when you move that user into a function by value, all of its fields move as a single indivisible bundle. It reduces the cognitive load dramatically. Because the struct boundary is the ownership boundary. Precisely. Okay. So we've established a unified memory boundary. Data is locked down, but data is really only half of that application. Right. You need it to actually do things. Exactly. Right. If I have a user struct, I eventually need that user to perform actions.
3:55 I need to update their email or format their display name for the front end or serialize them into a database query. Right. But the text explicitly states, Rust has no classes. We cannot put methods inside the struct definition. Nope. Not allowed. Why? I mean, why force this separation between data and logic? Because it clarifies what is actually happening at the hardware level. In traditional object-oriented languages, objects often carry around hidden overhead. Like virtual method tables. Exactly. vtables to figure out which method to run at runtime.
4:29 Rust says data is just data. A struct block just defines how bytes sit in RAM. Okay. So where does the behavior go? Behavior is defined in an entirely separate block called an impl block. Impl. Like I-M-P-L. Yes. Short for implementation. The compiler is what stitches the behavior to the data during compilation. Meaning your structs remain incredibly lightweight in memory. The receiver tells the compiler exactly what permissions the method is requesting over the struct's memory. I was looking at the three variations of this and honestly, it feels like a complete paradigm shift. It really is.
5:05 Let's say I write a method to get a user's display name. I would use ampersand self as the first parameter. Right. And that tells the compiler this method only needs to borrow the struct immutably. Like it can read all the fields, but it is physically barred from modifying anything. Precisely. It is a strictly mute-only view. Now suppose you write an update email method. Okay. For that, you use ampersand mute self. Where mute is short for mutable. Yes. That signals to the compiler that this method requires an exclusive mutable borrow.
5:37 It needs permission to actually change the internal state of those bytes. Makes perfect sense. But the third one is the one that really breaks your brain if you spend 10 years writing Java. Oh, the consuming self. Yes. If the first parameter is just self with no ampersand, the method actually takes full ownership of the struct. It consumes it. It eats it. Yeah. So if I call a method to convert my in-memory user into a database row representation, the original user struct is destroyed the moment that method finishes.
6:07 Gone. I literally cannot use it on the next line of code. The compiler will stop you cold. Yeah. And think about the architectural safety that provides. Right. Because you can't reuse stale data. Exactly. You can build state machines where data is fundamentally transformed. Okay. So we've successfully decoupled our memory layout, from our behavior. But that raises a mechanical question for me. Sure. Shoot. In an object-oriented language, the constructor keyword allocates memory and initializes state in one go.
6:35 If Rust separates these concepts and doesn't have a constructor keyword, how do we actually handle complex initialization? Especially if all the fields are private. You handle it through convention. Inside your impl block, you define an associated function. It's associated function. Yeah. It's simply a function that does not take a self-parameter as its first argument. It doesn't operate on an instance. It acts more like a static method in Java. Yeah. And by overwhelming community consensus, you name the primary initialization function new.
7:07 So I just call user colon colon new. Yep. But the text makes it clear there's no special compiler magic attached to the word new, right? Yeah. It's literally just a function that happens to return an instance of the struct. Exactly. There's no magic. But everything requires explicit manual implementation. I imagine backend engineers would quickly drown in border plate. It can get repetitive. Like writing a method to duplicate a struct or format it for a logging framework. How does Rust handle the repetitive stuff?
7:38 That brings us to the derive attribute. Derive. Yeah. It is a powerful macro system that sits right above your struct definition. If you need your struct to be printable for debugging logs, you simply add the derive Debug attribute. Yeah. Just writes it for me. Yes. And then in the compilation, Rust automatically inspects your struct's fields and writes the necessary formatting code for you. That's amazing. I can just tag it with derive Debug, Clone, and instantly my struct can be safely duplicated in memory and printed to the console when something crashes in production.
8:11 It keeps the core struct definition incredibly clean. It really does. Okay. So we've covered standard multi-field structs. We know how to encapsulate complex data and attach behavior to it. But I want to circle back to the third step, which is the 3AM bug we discussed at the very beginning. The UserId versus OrderId disaster. Exactly. The catastrophic mix up between a UserId and an OrderId. If these are both just single 64-bit integers, how do standard structs help us? Because building a massive struct with named fields just to hold one integer feels like complete overkill.
8:46 It would be. But Rust provides a specialized tool for this exact domain modeling problem, the tuple struct. A tuple struct. So that's a struct where the fields don't have names, right? Correct. You just access them by their position, like .0 or .1. Spot on. And the single most powerful application of tuple structs for backend engineering is the newtype pattern. New type pattern. This is how you solve the 3AM bug. Instead of passing around naked primitives, you define a tuple struct with exactly one unnamed field to wrap that primitive.
9:17 So I literally write struct user ed and inside parentheses, I put a u64. Yes. Then I write struct OrderId and put another U64. Yes, a U64 inside it. Exactly. I am essentially taking a generic integer, wrapping it in a nameless struct, and forcing the compiler to treat it as a completely unique identity. It's not a trick. You are elevating your business logic into the type system. Once you define those new types, the Rust compiler treats UserId and OrderId as fundamentally incompatible memory structures.
9:46 Even though they're the same bytes under the hood. Even though they're identical bytes. If your refund function expects an OrderId, and you accidentally pass it a UserId, the program physically will not compile. It just stops. It stops the logical error before you can even run the test suite, let alone deploy to production. But surely there's a performance cost to this. You think so, right? Yeah, if I have a system processing millions of transactions a second, and I'm constantly wrapping and unwrapping raw integers inside custom structs, doesn't that bloat the memory and slow down the execution?
10:15 That is the beauty of Rust's zero-cost abstractions. There is absolutely no performance penalty. Wait, really? Zero cost. Zero. Because the newtype wrapper contains no additional data or metadata, the compiler completely strips it away during the compilation process. Oh, wow. Yeah. At runtime, the machine code is just passing around raw 64-bit integers. You achieve absolute ironclad domain safety with exactly zero runtime cost. That is staggering. It transforms a generic bucket of bits into a strict domain concept, verified at compile time, for free.
10:51 For free! Feels like the ultimate high-leverage habit for an engineering team. When the type system understands the boundaries of your business logic, the compiler becomes an active partner in catching your architectural mistake. If structs are Rust's AND types, meaning a struct has this field and that field, next time we are diving into Rust's incredibly powerful OR types. Enums. And we're going to tackle enums, pattern matching, and the Result type for error handling, all in one massive deep dive, because they're really all interconnected facets of the same brilliant idea.
11:23 It is the chapter where the language really starts to shine for complex control flow. I am looking forward to it. Until next time.