Undefined behaviour: C++ and Rust

Both languages have undefined behaviour, and they disagree about who is responsible for it. In C++ a program that returns the address of a local compiles, warns if the warning is enabled and does whatever the machine does; in Rust the same program is refused by the compiler, and the programmer who really wants the raw pointer has to write unsafe and accept the contract.

The task in C++

The function below creates a number on the stack and returns its address. The object dies at the closing brace and the address it leaves behind points at a frame that no longer exists, which the standard calls undefined behaviour and the machine calls an accident waiting to happen.

#include <iostream> int* make() { int value = 42; return &value; } int main() { int* pointer = make(); std::cout << *pointer << '\n'; }

g++ -std=c++23 -Wall -Wextra dangling.cpp -o dangling
./dangling > /dev/null; echo "status $?"

dangling.cpp: In function 'int* make()':
dangling.cpp:6:12: warning: address of local variable 'value' returned [-Wreturn-local-addr]
    6 | return &value;
      | ^~~~~~
dangling.cpp:5:9: note: declared here
    5 | int value = 42;
      | ^~~~~
status 139
Segmentation fault (core dumped)

The compiler saw the mistake and said so, because -Wall includes this warning, and the binary was produced anyway. The status is 139, which is the shell reporting a segmentation fault from a process that did not exit. On another machine, or after a change to the function above, the same program prints a number, and that number is not 42 by any rule of the language.

A program that crashes is the friendly case. The same mistake in a function called once at the end of a long job usually reads a value that is still in the same stack slot, prints a plausible number and leaves a test passing for months. That is the shape of undefined behaviour that costs the most: not the crash, the silence.

The point is not that C++ is broken; it is that the language leaves the check to a warning and the warning to a flag, and the program that ships may not have been built with the flag. The C++ lesson for this ground is pointers and addresses.

The same task in Rust

The same function in Rust has to say how long the reference lives, and the only answer that would allow the return is 'static. The body then tries to return a reference to a local, and the borrow checker refuses the function at its definition.

fn make() -> &'static i32 { let value = 42; &value } fn main() { println!("{}", make()); }

rustc --edition 2024 refused.rs

error[E0515]: cannot return reference to local variable `value`
 --> refused.rs:3:5
  |
 3 | &value
  | ^^^^^^ 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 error names the reference and the function, and it is the same error a program gets when it tries to return a slice of a local buffer, a reference to a field of a local struct, or a string built in a function. The rule is one rule, and the compiler applies it to every return type that borrows.

The fix is not a cast but a change of ownership: the function returns the number, and the caller owns it.

fn make() -> i32 { 42 } fn main() { println!("{}", make()); }

rustc --edition 2024 safe.rs
./safe

42

The copy is four bytes and the compiler decides when it is needed; a large value would be returned by move instead, and the address of the local would never escape. The Rust lesson for this ground is unsafe, raw pointers and undefined behaviour.

What differs and why

Who checks the address: in C++ the type carries no lifetime, so the compiler cannot tell a valid pointer from a stale one, and the warning is a courtesy of the optimizer's analysis. In Rust the lifetime is part of the type of every reference, and the checker compares it with the scope of the value before any code is generated.

What the standard promises: both languages call the dereference of a dangling pointer undefined behaviour, which means the program has no meaning at all and the optimizer may assume it never happens. Rust adds a layer in front of that promise: safe code cannot produce the dangling pointer, so the undefined behaviour is confined to blocks the programmer marked.

What the optimizer does with it: a C++ compiler that proves a pointer is dangling is allowed to delete the branch that would have used it, and a program that seemed to work in a debug build can change behaviour in a release build. The same rule holds in Rust, and it is why the unsafe block is a promise the programmer makes to the optimizer rather than a switch that turns checking off.

The habit that breaks

The habit is to reach for the raw pointer when the borrow checker refuses something. Rust has raw pointers and an unsafe block, and the program below compiles, runs and prints the value of a box that was already freed.

fn main() { let pointer: *const i32 = Box::into_raw(Box::new(42)); unsafe { drop(Box::from_raw(pointer as *mut i32)); println!("{}", *pointer); } }

rustc --edition 2024 unsafe.rs
./unsafe; echo "status $?"

42
status 0

The output looks perfect and means nothing: the memory was returned to the allocator, the read is undefined behaviour, and the value 42 is what happened to be left in the freed block on this run. The compiler cannot report it, because the unsafe block told the compiler that the programmer had checked the contract, and the contract of a raw pointer includes its lifetime.

The difference from the C++ program is not that Rust prevents this bug; it is that Rust requires a word in the source that says the check was skipped, and that word is what a reviewer searches for. The C program with the same bug has no such word and no such search.

The Rust lesson is unsafe, raw pointers and undefined behaviour and the C++ lesson is pointers and addresses. The previous page in this set is Memory layout.

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

Article author: Arthur Isaev