References and borrowing: C++ and Rust

A reference in C++ is an alias with no lifetime information. A reference in Rust is a borrow with a lifetime the compiler tracks, and the difference shows up exactly when a reference would outlive the value it points at. This page writes one function that reads through a const reference and writes through a mutable one, then writes the function that returns a reference to a local.

The task in C++

show takes the text by const reference and the counter by mutable reference. Neither argument is copied, and the counter is incremented in the caller. The call site passes the variables directly, with no ampersand, which is the part a Rust programmer has to unlearn.

The function prints the text twice with an increasing counter and then reads the size of the string in main to show that nothing was moved.

#include <iostream> #include <string> void show(const std::string& text, int& counter) { ++counter; std::cout << counter << ": " << text << '\n'; } int main() { std::string greeting = "hello"; int calls = 0; show(greeting, calls); show(greeting, calls); std::cout << "size " << greeting.size() << '\n'; }

g++ -std=c++23 -Wall -Wextra references.cpp -o references
./references

1: hello
2: hello
size 5

The output is two lines and a size. The signature says nothing about how long the references live, and the compiler trusts the caller to keep the string alive for the duration of the call.

The same task in Rust

The Rust version takes &String for the reader and &mut i32 for the writer, and the call site writes the ampersands. The increment needs a dereference, *counter += 1, because the reference is not transparent the way a C++ reference is.

The borrow of the string ends when the second call returns, so the size is read from the owner. The output is identical to the C++ one.

fn show(text: &String, counter: &mut i32) { *counter += 1; println!("{}: {text}", *counter); } fn main() { let greeting = String::from("hello"); let mut calls = 0; show(&greeting, &mut calls); show(&greeting, &mut calls); println!("size {}", greeting.len()); }

rustc --edition 2024 references.rs
./references

1: hello
2: hello
size 5

The two calls borrow the string immutably and the counter mutably, and the compiler checks that the borrows do not overlap. That is the same rule the lesson on mutable borrows explains in full.

What differs and why

Who owns the values: the caller in both languages. A reference never owns anything, and the callee borrows for the length of the call. The difference is that Rust records the borrow, so a reference that outlives its value is a compile error instead of undefined behaviour.

What the compiler rejects: C++ rejects nothing here, and the dangling reference that follows is only a warning when the compiler can see it. Rust rejects the same function with E0515 before it can be called: the reference would point at a local that is destroyed when the function returns.

What happens at run time: the C++ program reads a string that no longer exists. On this machine it crashes with SIGSEGV inside the stream operator, which is one of the better outcomes; the other is printing whatever the freed buffer now contains.

The habit that breaks

The habit is returning a reference to a local, or keeping one after the owner goes out of scope. The C++ compiler warns about the direct case and says nothing about the indirect one; Rust refuses the function outright, and the borrow checker refuses the indirect case at the call.

#include <iostream> #include <string> const std::string& make() { std::string local = "temporary"; return local; } int main() { std::cout << "dangling: " << make() << '\n'; }

g++ -std=c++23 -Wall -Wextra -g dangling.cpp -o dangling

dangling.cpp: In function 'const std::string& make()':
dangling.cpp:7:12: warning: reference to local variable 'local' returned [-Wreturn-local-addr]
7 | return local;
| ^~~~~
dangling.cpp:6:17: note: declared here
6 | std::string local = "temporary";
| ^~~~~

Program received signal SIGSEGV, Segmentation fault.

The same function in Rust needs a lifetime for its return type, and no lifetime can be satisfied by a reference to a local. The error names the local and the reference that would escape.

fn make() -> &'static String { let local = String::from("temporary"); &local } fn main() { println!("dangling: {}", make()); }

rustc --edition 2024 dangling.rs

error[E0515]: cannot return reference to local variable `local`
--> dangling.rs:3:5
|
3 | &local
| ^^^^^^ returns a reference to data owned by the current function

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0515`.

The C++ lesson for this ground is References, and the Rust lesson is Shared references. The rule that makes the Rust version checkable is the subject of Lifetimes and elision.

The C++ programs were built with g++ 15.2.0 and the Rust programs with rustc 1.96.1, and both were run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev