Rust Ch.4: Borrowing and References
Outline
- 0:00 Introduction: the borrow checker's reputation as "the wall"
- 0:38 Reframe: it's just a compile-time reader-writer lock
- 2:13 Restaurant ticket analogy: move vs borrow
- 4:00 Java/Python references vs Rust's enforcement mechanism
- 4:45 The two laws: many readers (&T) or one writer (&mut T)
- 5:50 Java ReadWriteLock == Rust borrow checker (zero runtime cost)
- 7:05 The vector reallocation trap: dangling pointers in C++ vs compile error in Rust
- 9:58 Non-lexical lifetimes: from rigid scope rules to semantic flow analysis
- 11:55 String (owned, heap) vs &str (borrowed view, fat pointer)
- 13:53 The golden rule: accept &str for reads, String for ownership
- 16:15 Three strategies: restructure, clone, AI assist
- 19:10 Mindset shift: the compiler as a pedantic code reviewer
- 20:52 Preview: ch05 structs and methods, ch09 lifetimes puzzle
Transcript
0:00 You know, whenever backend engineers start talking about learning Rust, there's always this one specific moment. Oh, yeah, I know exactly what you're going to say. Right. It's the moment they encounter the absolute elephant in the room. I'm talking about the notorious Rust borrow checker. Yep. The wall. Exactly. The wall. It has this reputation as an insurmountable hurdle. Like the thing that makes incredibly smart senior developers just throw their hands up and rage quit. It totally does. Yeah. And it definitely carries that aura of intimidation.
0:31 Right. Because it can really feel like you have this uncompromising static analyzer just actively fighting you. Right. Rejecting code that you know for an absolute fact would run perfectly fine in another language. Exactly. But for you listening right now, especially since you're coming from a senior background in Java or Python, we are going to completely reframe this today. We really are. Because what if I told you the borrow checker isn't a monster at all? What if it's actually just a compile time reader, writer lock?
0:59 That is the perfect way to look at it. Because if you've built concurrent backend systems, you already know that concept inside and out. You literally already know this. You just don't know that you know it yet. I love that. That mental shift is everything. It takes a concurrency primitive that backend engineers use to manage threading at runtime. And it just shifts the entire enforcement of it to the exact moment you compile your code. So today's deep dive is focusing entirely on chapter four of Rust for backend engineers, which is all about borrowing and the borrow checker.
1:33 Right. And just to set our baseline here, we are assuming you were already caught up on the earlier chapter. Yeah, we're not going backwards today. Right. So we aren't rehashing the why Rust philosophy from chapter one or, you know, how cargo and the tool chain work from chapter two. And we're definitely assuming you remember the strict move semantics and the copy trait from chapter three. Exactly. We are building directly on that chapter three foundation. You already understand that passing a variable to a function by default moves ownership, right?
2:01 Right. It completely invalidates the original variable in the calling scope. Let's use the system resource analogy here instead of the usual, you know, handing over the car keys cliche. Okay, I'm listening. Think of ownership like a restaurant ticket system. If I hand the kitchen my only master ticket that's moving ownership, they process the order, cook the meal and then throw the ticket in the trash. The ticket is gone. You can't use it anymore. Exactly. But obviously in a real code base, sometimes I just need the kitchen to read the ticket, verify the order and give it right back to me so I can keep using it.
2:34 And that is exactly where borrowing comes in. Right. So, again, borrowing is Rust's mechanism for granting temporary access to a value without transferring that ultimate ownership. Without throwing the ticket away. Right. Instead of moving a value into a function and destroying it, you just lend the function a reference. So, mechanically, this is where the ampersand symbol comes into play, the ampersand. Yeah, the little and sign. Right. So, if I have a variable called, let's say, config data and I want a function to read it, I don't pass config data. I pass ampersand config data.
3:06 Exactly. The function receives it, executes its logic, and when it returns, your original config data variable is still perfectly valid in your calling scope. Because ownership never changed hands. Precisely. And the function's parameter signature reflects this, too. It will explicitly ask for ampersand config, meaning it expects a reference to the data, not the owned data itself. Okay, let me put on my Java or Python hat for a second, because I have to push back here. Go for it. In Java, when I pass an object to a method, I am always passing it by reference.
3:40 In Python, I am always passing a reference to the object. That's true. So, isn't this just the exact same thing we have been doing for 20 years? I mean, conceptually, you are passing a pointer to the data rather than copying the whole data structure, which feels identical. Right. It feels exactly the same. But the fundamental divergence here is the enforcement mechanism. In garbage-collected languages like Java or Python, the runtime keeps the underlying data alive on the heap as long as any reference to it exists anywhere in your application.
4:10 Because the garbage collector is constantly doing graph traversal to figure this out. Exactly. It's doing all that heavy lifting at runtime. But Rust doesn't have a garbage collector. None at all. Right. The memory is aggressively free to the exact millisecond the owner goes out of scope. Therefore, the compiler has to enforce uncompromising rules during the build step to ensure those references never, ever outlive the data they point to. And those rules are the famous two laws of borrowing. Let's unpack those.
4:39 Yeah. Let's do it. Law number one, you can have any number of immutable references to a value at the exact same time. Any number. Yeah. An immutable reference is written as just ampersand T. It lets you read the value, but you are structurally blocked from modifying it. Which totally makes intuitive sense. If I have a dozen different functions just reading the same cached configuration object, that's completely safe. Exactly. Nobody is mutating the state out from under anyone else. So what's law number two?
5:05 Don't wait. Law number two, you can have exactly one mutable reference to a value at a time. Just one. Just one. A mutable reference is written as ampersand mut T. It allows both reading and writing. But this exclusivity is absolute. Meaning? Meaning, while that single mutable reference exists in a given scope, no other references, not even immutable ones, are allowed to coexist. Wait, none at all? None. Zero. Okay. So if I synthesize that for the listener, you can have many readers or one writer, but never both at the same time.
5:39 That is the immovable law of the borrow checker. And that is exactly what I meant in the intro. As a Java dev, you are already reaching for a re-entrent-a-write lock when dealing with concurrent caches. Oh, all the time. You explicitly tell your code, hey, let a hundred threads read this cache simultaneously, but if one thread needs to update it, it acquires an exclusive write lock and everyone else blocks. Exactly. And Rust just takes that exact concurrency primitive and enforces it at compile time.
6:04 Instead of runtime. Right. Think about the performance implications of that. In Java or Python, managing that lock requires constant runtime overhead. Threads are pausing. Checking lock states in memory. Potentially context switching. It's expensive. Very. Rust achieves this exact same logical safety with zero runtime cost. It guarantees before your code ever even deploys that you will never have a data race. But this leads to a massive point of frustration for beginners. I can hear the listener asking, why is Rust being this aggressive?
6:37 Yeah, people get really mad about it. Because if I'm writing a simple, single-threaded script, I don't need a reader-writer lock. There are no threads to race. Why won't the compiler just let me have a mutable reference and an immutable reference at the same time? To understand the compiler's paranoia here, we have to look at how system memory actually behaves under the hood. Let's walk through the classic vector reallocation bug. Okay, set the stage for us. So think about how an array list in Java or a list in Python operates when it resizes.
7:05 You have a vector of integers. You create an immutable reference to the very first element of that vector. Okay, so in memory, I am holding a raw pointer that says, here is the exact memory address of item zero. Right, and that is perfectly legal under the rules. One immutable reference. Got it. Now imagine Rust allowed you to bypass the rules, and you simultaneously create a mutable reference to the vector itself. Through that mutable reference, you trigger a dot push to add a new element to the end of the vector.
7:37 But wait, the vector's current underlying memory block might be at full capacity? Exactly. When it hits capacity, it has to grow. It asks the operating system allocator for a brand new, significantly larger block of heat memory. So it moves everything. Yep. It copies all the existing integers over to this new location, adds your new element, and then it does something highly dangerous. It frees the old block of memory, handing it back to the OS. Oh, wow. And our first immutable reference, the pointer to item zero we made earlier, is completely oblivious to this whole process.
8:06 It has no idea. It is now pointing to freed memory. Here is where it gets really interesting for anyone coming from C++A. In C++A, that pointer is just quietly sitting there, pointing to garbage. Yep. The classic dangling pointer. Right. If you try to read from it later, you either get an immediate seg fault and crash the program, or worse, you silently read corrupted data that another process has written there. It is arguably the most insidious bug in systems programming, mostly because it often only crashes in production under specific high load conditions.
8:39 And in Python or Java, you are completely shielded from this because the garbage collector and the virtual machine heavily abstract the memory management away. Right. The ArrayList handles its internal pointers safely, but it comes at the cost of performance and memory overhead. But since Rust operates without a garbage collector, it has to guarantee that memory safety statically. Exactly. So instead of waiting for a midnight pager duty alert about a random seg fault, the compiler simply looks at your code and says, you're trying to hold an immutable reference to the data and simultaneously trying to take a mutable reference to push to it.
9:12 That violates many readers or one writer. Mm-hmm. So the bill just fails immediately. It completely eradicates the entire category of use after free bugs. That's actually incredible. It really is. It forces you to write structurally sound memory architectures. Okay. I understand the why now, but does the compiler really have to lock me out for the whole function block? What do you mean? Like, what if I'm done using that first reference on line 10, but my function doesn't end until line 50? Does that mutable lock just sit there paralyzing my code for 40 lines?
9:44 Because that sounds incredibly annoying. Well, in the earliest days of Rust, it actually was incredibly annoying. Really? Oh, yeah. The compiler used what were called lexical lifetimes. It strictly looked at the curly braces of your scope blocks. If you borrowed a variable, that borrow lived until the closing brace, no matter what. Wait, if the compiler is supposed to be this hyper-advanced analysis tool, why did early Rust have such a rudimentary problem? Why wasn't a smarter system there from day one?
10:11 Because doing anything more complex requires the compiler to build and analyze a complete control flow graph of your entire application. Oh, I see. Yeah. It has to trace every possible path your code could take if statements, loops, early returns, just to determine the absolute last moment a variable is actually accessed. Building that kind of graph analysis into a fast compiler is a massive computer science challenge. But they eventually solved it, right, with non-lexical lifetimes or NLL. They did. And it was a huge milestone.
10:43 NLL means the compiler now tracks when a reference is actually last used within the semantic flow of the code. So not just the curly braces. Right. If you borrow a reference on line 5 and use it for the final time on line 6, the borrow checker officially terminates that borrow right there on line 6. You are perfectly free to take a conflicting mutable reference on line 7. That drastically changes the developer experience. The compiler is actually mapping your intent, not just enforcing rigid syntax boundaries.
11:09 Exactly. It aligns the tooling with how an engineer naturally visualizes the lifecycle of their data. Okay. So if the compiler is this paranoid about tracking every single pointer and memory block, how does it handle text? Ah, strings. Yeah. Because in Java, I'm throwing strings around everywhere, combining them, passing them to functions, without giving memory allocation a single thought. This brings us to probably the most common stumbling block for a back-end engineer entering Rust. It's the fundamental divide between a string with a capital S and a string slice, written as ampersand string.
11:45 Let's pull those apart. Start with the capital S string. So a capital S string is an owned, heap-allocated, growable data structure. Under the hood, it basically consists of three distinct pieces of data sitting on your stack. Which are? A pointer to the raw bytes on the heap, a length indicating how much memory is currently used, and a capacity indicating how much heap space it is reserved. Okay. So it owns those bytes. Yes. Because it owns those bytes on the heap, it is entirely responsible for them.
12:11 When that string goes out of scope, Rust automatically drops the heap memory. It's essentially the text-specific version of the vector we just talked about. I can append to it, mutate it, as long as I have a mutable reference. Exactly. Now contrast that with the string slice, the ampersand str. Right. So a string slice, ampersand stride, is merely a view into a sequence of UTF-8 bytes that it does not own. Just a view. Yes. In memory, it's what we call a fat pointer. It only consists of two words on the stack, a pointer to where the text starts, and a length telling it how many bytes to read.
12:44 So it has no capacity. No capacity at all, because it cannot grow. It doesn't own the data, so when the ampersand str goes out of scope, nothing is freed. It's literally just a read-only window. Okay. So where do string literals fit into this? If I just type, let greeting, hello world, with quotes directly in my code. When you hard-code a string literal, those exact bytes are baked directly into your compiled binary executable. Oh, wow. Yeah. They get loaded into a read-only data segment of your system's memory when the program launches, and they live there for the entire duration of the process.
13:16 So you definitely don't own them. Exactly. And you certainly cannot mutate your own compiled binary at runtime. Therefore, the type of a string literal is inherently ampersand str eyes. It's just a pointer in a length looking directly into the binary. So what does this all mean for me? Like, if I'm designing a new API tomorrow or writing a helper function to parse some text, which type do I use for my function parameters? I've got own strings. I've got borrowed slices. I've got literals. How do I choose?
13:47 There is a definitive golden rule for daily coding and API design here. Give it to us. If your function only needs to read the text, accept an ampersand str as your parameter. Only accept a capital S string if your function actively needs to take ownership of the text. Like if I need to store it permanently inside a struct or send it across a thread boundary? Exactly. But I've heard people mention that automatic deref coercion makes this work seamlessly, but I honestly hate when things are just brushed off as compiler magic.
14:13 What is actually happening when I pass an own string into a function expecting an ampersand stride? It's totally not magic at all. It's a specific trait implementation. The capital S string type implements a built-in trait called deref. Okay. The compiler sees that your function wants an ampersand string, but you passed a capital S string. It automatically invokes the deref trait's logic. What does that logic do? It essentially says, extract my internal pointer and my current length and construct a temporary fat pointer and ampersand str to hand to the function.
14:46 That's brilliant. It allows callers to pass a hard-coded string literal, a slice of a larger string or full owned string, and the compiler just bridges the gap automatically. Exactly. Maximum flexibility. But if I force my API to accept a capital S string, I'm demanding ownership. I am forcing the caller to potentially allocate fresh heap memory and deep copy their data just to call my read-only function. Which is a massive performance failure. Right. So defaulting to ampersand str when reading makes your APIs universally flexible and completely eliminates unnecessary heap allocations.
15:20 You've got it perfectly. Okay. We've covered the architectural theory, the why behind the rules, and the memory layout of strings. Let's talk about the reality of writing the code. The fun part. Yeah. Because even knowing all these rules, every Rust developer gets their code rejected by the compiler. I'll admit, when I first started, my primary debugging strategy was what I call ampersand-driven development. Oh man, we have all been there. I would just randomly sprinkle ampersands or muts or asterisks onto my variables until the red squiggly lines in VS Code magically went away.
15:54 Every Rust developer has gone through that exact phase. But you quickly realize you need a systematic playbook when the terminal starts throwing complex lifetime errors at you. So let's build that playbook for the listener right now. What's strategy number one? Strategy one is simply restructuring your data flow. Because of the many readers or one writer rule, the vast majority of errors happen because immutable borrow and an immutable borrow are overlapping. Right. So by leaning into non-lexical lifetimes, the fix is often just reordering your statements.
16:25 Move your read operations so they completely finish before you initiate the mutable borrow. Or introduce a temporary variable to hold the output of a read, allowing the immutable borrow to drop before you mutate the core structure. So you are intentionally closing the window of the borrow as early as possible. That totally makes sense. Okay, strategy two. Strategy two is leveraging .clone. Oh, wow. Okay, I feel like I need to interject here because there is a palpable amount of guilt around cloning in the Rust community.
16:56 You read forums and people treat .clone like an absolute failure of engineering. They really do. But that guilt is entirely misplaced, especially during your first six months with the language. Why is that? Because calling .clone on a value bypasses the borrow checker by creating an entirely independent parallel universe of ownership. Yeah. It goes to the OS, requests new heat memory, and does a full deep copy of the data. And because you now have two completely separate allocations, the conflicting pointers simply cease to exist.
17:25 Exactly. The conflict is gone. But the criticism is that a deep copy has a severe runtime cost. It does require allocation and copying bytes, sure. But consider the perspective of a Java or Python developer. In those ecosystems, you are implicitly triggering heap allocations, deep copies, and garbage collector pressure constantly just to avoid unexpected reference mutation bugs. That's a great point. Rust doesn't hide that cost. It just forces you to type .clone so the performance hit is explicitly documented in your source code.
17:57 It's forcing honesty about the memory footprint. Exactly. In your early days, cloning strategically to bypass a confusing lifetime error and just keep your momentum going is perfectly acceptable. As you gain intuition for Rust's ownership patterns, you'll naturally architect your data flow to avoid needing it. That is incredibly reassuring. Restructure the flow or clone the data. What is strategy number three? Strategy three is utilizing AI assistance, specifically tools like Claude Code. Interesting. I mean, I use LLMs for boilerplate generation, but why are they particularly well-suited for fighting the borrow checker?
18:32 Usually, if you ask an LLM about complex architecture, it starts hallucinating. Well, AI struggles with ambiguous, dynamically typed code bases because the intent is often hidden. But Rust is the exact opposite. Rust compiler errors are incredibly structured and deterministic. Oh, because they're grounded in strict abstract syntax tree violations. Yes. When you paste your error into Claude Code, it doesn't have to guess what went wrong mechanically. The compiler explicitly tells it. Your job is just to describe your intent.
19:03 Like, I am trying to cache this database connection while simultaneously logging its state. Precisely. The AI can instantly map your human intent against the strict AST violation and provide the idiomatic structural pattern you are missing. So it translates the pedantic compiler error into actual architectural advice. If we connect this to the bigger picture, this fundamentally shifts your mindset. A borrow checker error isn't a punishment. It certainly feels like one sometimes. I know. But the compiler isn't judging your skills.
19:34 It is simply a highly pedantic code reviewer asking you to clarify the contract of your data. It's asking who exactly owns this memory at this specific line and who has the right to mutate it. When you stop looking for quick syntax hacks and start asking those architectural questions, you become a better programmer in any language. Like, you start seeing memory boundaries even when you go back to Python. You build a mental model of deterministic memory safety that elevates your entire engineering approach.
20:01 This has been awesome. We started this deep dive looking at the borrow checker as an insurmountable wall. But hopefully we've broken it down for you. It is merely a strict compile time reader-writer lock. That's all it is. By enforcing the law of many readers, or are exactly one writer, it systematically prevents entire classes of catastrophic bugs like those dangling pointers caused by vector reallocations that have plagued systems programming for decades. And with the evolution of non-lexical lifetimes, it analyzes your actual semantic flow, staying out of your way as much as statically possible.
20:37 And when you do hit a wall, you now have the strategies. You restructure to close the borrow window, you clone to create independent ownership, or you leverage AI to bridge the gap between your intent and the syntax. Looking ahead in your journey through the book, chapter five is going to cover structs and methods. If you love Python data classes, you're going to pick that up instantly. For sure. And then chapter six introduces enums and pattern matching, which honestly might ruin other languages for you because it is just that powerful.
21:07 It really is. But before we wrap up today, there is a lingering architectural puzzle I want you to think about. Ooh, what is it? Everything we discussed today regarding non-lexical lifetimes focused on tracking a reference within a single function block. Right, because the compiler can see the whole function, so it just maps the control flow. But what happens when you want to write a function that returns a borrowed reference back to the caller? Oh. How does the compiler guarantee how long that data will live out there in the wild across completely different scopes without seeing the rest of the application?
21:40 Wait, that breaks the whole control flow graph analysis. It does. It requires a completely different mechanism, which you will uncover when you reach lifetimes in depth in chapter nine. But let that puzzle marinate. Think about how you would solve that cross-function tracking if you were designing the compiler yourself. I love leaving on a cliffhanger. Next time that red squiggly line appears under your coat, take a breath. You aren't fighting a monster. You are just negotiating a reader-writer lock with a very pedantic compiler.
22:11 Keep building. Keep building.