Variables and mutability: C++ and Rust
The first habit a C++ programmer carries into Rust is the wrong one about variables. In C++ a local variable is writable unless it is const, and a type conversion happens quietly when the target can hold the value. In Rust a binding is immutable unless it says mut, and no conversion happens unless it is written.
This page takes one little program that declares, changes, narrows and shadows a variable, writes it in both languages, and builds both versions with the real compilers on the machine named at the end. Then it shows the mistake that C++ accepts and Rust stops at compile time.
The task in C++
The program counts, reads a constant, narrows an int into an unsigned char, and shadows a std::string inside a block. The narrowing is the interesting line: assigning 300 to an eight-bit type is allowed, the value wraps, and only -Wconversion would mention it, which -Wall -Wextra does not enable.
The shadowing is a block, not a redeclaration: C++ does not allow two variables of one name in the same scope, so the inner name lives inside braces and the outer one reappears after them.
#include <iostream>
#include <string>
int main()
{
int count = 0;
count += 1;
const int limit = 10;
int small = 300;
unsigned char narrowed = small;
std::cout << "count: " << count << '\n';
std::cout << "limit: " << limit << '\n';
std::cout << "300 as unsigned char: " << static_cast<int>(narrowed) << '\n';
std::string name = "Rust";
{
std::string name = "C++";
std::cout << "inner: " << name << '\n';
}
std::cout << "outer: " << name << '\n';
}
g++ -std=c++23 -Wall -Wextra variables.cpp -o variables
./variables
count: 1
limit: 10
300 as unsigned char: 44
inner: C++
outer: Rust
No warning and no error: the compiler accepted the narrowing, and the value 44 is the low eight bits of 300. A reader who expected 300 in that variable has a bug that nothing in the toolchain pointed out.
The same task in Rust
The Rust version says let mut count because the counter is updated, declares the limit as a const with a type, and narrows with an explicit as u8. The narrowing is not an accident any more: the cast is in the source and a reader can see where the value loses information.
The shadowing is a second let in the same scope, which C++ forbids and Rust allows. The inner binding ends with the block, and the outer string is untouched, so the last line prints Rust again.
fn main() {
let mut count = 0;
count += 1;
const LIMIT: i32 = 10;
let small: i32 = 300;
let narrowed = small as u8;
println!("count: {count}");
println!("limit: {LIMIT}");
println!("300 as u8: {narrowed}");
let name = "Rust";
{
let name = "C++";
println!("inner: {name}");
}
println!("outer: {name}");
}
rustc --edition 2024 variables.rs
./variables
count: 1
limit: 10
300 as u8: 44
inner: C++
outer: Rust
The outputs differ in one label only, unsigned char against u8, because the two programs print different words. Every value is the same, including the wrapped 44.
What differs and why
Mutability is opt-in in Rust. The compiler does not check a convention or a comment; it checks the binding, and an assignment to a binding without mut is an error (E0384). In C++ the same code is always allowed, and the habit of making everything writable is the reason a large function is hard to reason about.
Shadowing replaces the C++ trick of a nested block. A new let with the same name hides the old binding and may change its type, which is how a value is turned into its parsed form without an invented name. The old value is still there and is dropped at the end of the scope, so the difference is visible in the destruction order as well.
Conversion is explicit. C++ converts an int to unsigned char as part of the assignment; Rust needs as, which truncates and never fails, or try_into, which returns an error when the value does not fit. The first is for bit-level work, the second is for input, and the type system makes you choose.
None of this costs anything at run time. The mutability check, the type check and the bounds of a conversion are compile-time facts, and the binary has the same instructions it would have in C++.
The habit that breaks
The habit is the uninitialised local. C++ compiles a read of an uninitialised int and warns when it can see the problem; the program then prints whatever the stack held, which is different on every run and on every build. The same program in Rust is a compile error, because a binding without a value may not be read.
#include <iostream>
int main()
{
int total;
std::cout << "total: " << total << '\n';
}
g++ -std=c++23 -Wall -Wextra uninit.cpp -o uninit
uninit.cpp: In function 'int main()':
uninit.cpp:6:31: warning: 'total' is used uninitialized [-Wuninitialized]
6 | std::cout << "total: " << total << '\n';
| ^~~~~
uninit.cpp:5:9: note: 'total' was declared here
5 | int total;
| ^~~~~
The warning is honest, and the program still builds and still runs. Its output is not shown here on purpose: it is whatever was on the stack, and a number printed once would teach the wrong lesson.
The Rust version is one line shorter and does not build at all. The error names the declaration, the read and the fix, and the fix is what the C++ version should have done in the first place.
fn main() {
let total: i32;
println!("{total}");
}
rustc --edition 2024 uninit.rs
error[E0381]: used binding `total` isn't initialized
--> uninit.rs:3:16
|
2 | let total: i32;
| ----- binding declared here but left uninitialized
3 | println!("{total}");
| ^^^^^ `total` used here but it isn't initialized
|
help: consider assigning a value
|
2 | let total: i32 = 42;
| ++++
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0381`.
The C++ lesson for this ground is Variables and fundamental types, and the Rust lesson is Variables, mutability and types. The next page in this set is Functions, overloading and default arguments.
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