C++ and Rust are often compared to each other. They occupy a similar space in terms of power and flexibility - neither has a garbage collector and thus can fit in resource-constrained domains, yet both provide richer high-level tools than a language like C which increase safety and correctness.
However, the experience of writing a program in each can be pretty different. Once such difference beginners in Rust will run into quickly is what happens when you pass a parameter by value. Rust handles this situation differently than C++, and it's worth exploring why.
C++
In C++, passing by value passes a copy of the object into the function. This is fine for primitives like integers - my 5 is the same as your 5. The fact that they're distinct values in memory won't ever matter for their use, because the meaning of 5 isn't context or state dependent. Lots of other things are, though. When an object is copied in C++, its copy constructor gets called. These have a prototype that looks like this:
classname (const classname &obj);
When an object is passed as a parameter to a method, this constructor is used to copy the object into the function body. Check out that keyword at the beginning of the parameter list, "const". This means we can't use this constructor to make any changes to the initial object. Instead, it's just going to create a new copy, which is what's getting used inside any function. To illustrate, here's a simple class with just a single data member, a default constructor, and a getter and setter:
class CoolObject
{
int coolValue;
public:
CoolObject()
{
coolValue = 5;
}
int getCoolValue() const
{
return coolValue;
}
void setCoolValue(int val)
{
coolValue = val;
}
};
We'll write a function that takes one of these objects by value and sets it to 10:
#include <iostream>
void setCoolValueToTen(CoolObject co)
{
using std::cout;
cout << "Current: " << co.getCoolValue() << " | Setting...\n";
co.setCoolValue(10);
cout << "New: " << co.getCoolValue() << "\n";
};
If we make two of these, and use this function on one, you'd expect it to stick, right?
int main()
{
using std::cout;
CoolObject co1;
CoolObject co2;
cout << "co1: " << co1.getCoolValue() << " | co2: " << co2.getCoolValue() << "\n";
setCoolValueToTen(co2);
cout << "co1: " << co1.getCoolValue() << " | co2: " << co2.getCoolValue();
return 0;
}
Instead, we get the following:
co1: 5 | co2: 5
Current: 5 | Setting...
New: 10
co1: 5 | co2: 5
The code inside the setCoolValueToTen() function is operating on its very own copy, made from and identical to co2 when it was passed in but entirely distinct from it. Calling the setter on this local instance has no effect on co2, because it's no longer involved.
If you pass by value, all your changes are stuck in this new local copy and never make it back to your intended target. A reference to the original solves this problem:
void reallySetCoolValueToTen(CoolObject &co) // Just take a reference - rest is identical!
{
using std::cout;
cout << "Current: " << co.getCoolValue() << " | Setting...\n";
co.setCoolValue(10);
cout << "New: " << co.getCoolValue() << "\n";
}
int main()
{
using std::cout;
CoolObject co1;
CoolObject co2;
cout << "co1: " << co1.getCoolValue() << " | co2: " << co2.getCoolValue() << "\n";
setCoolValueToTen(co2);
cout << "co1: " << co1.getCoolValue() << " | co2: " << co2.getCoolValue() << "\n";
reallySetCoolValueToTen(co2);
cout << "co1: " << co1.getCoolValue() << " | co2: " << co2.getCoolValue() << "\n";
return 0;
}
The second call works as expected:
co1: 5 | co2: 5
Current: 5 | Setting...
New: 10
co1: 5 | co2: 5
Current: 5 | Setting...
New: 10
co1: 5 | co2: 10
Rust
Let's attempt to re-implement this small program in Rust. Here's our CoolObject
:
struct CoolObject {
cool_value: i32,
}
impl CoolObject {
fn get_cool_value(&self) -> i32 {
self.cool_value
}
fn set_cool_value(&mut self, val: i32) {
self.cool_value = val;
}
}
impl Default for CoolObject {
fn default() -> Self {
Self { cool_value: 5 }
}
}
We need a function to set the value to ten, taking the parameter by value:
fn set_cool_value_to_ten(mut co: CoolObject) {
println!("Current: {} | Setting...", co.get_cool_value());
co.set_cool_value(10);
println!("New: {}", co.get_cool_value());
}
We're already starting to see a problem - we can't just mutate values without asking first, like we can in C++. If I hadn't included that mut
in the parameter list, the set_cool_value()
call would complain: "cannot borrow co
as mutable, as it is not declared as mutable". We need to specifically tell the compiler that we intend to mutate the object.
Let's try to emulate the first go of the C++ version:
fn main() {
let co1 = CoolObject::default();
let co2 = CoolObject::default();
println!("co1: {} | co2: {}", co1.get_cool_value(), co2.get_cool_value());
set_cool_value_to_ten(co2);
println!("co1: {} | co2: {}", co1.get_cool_value(), co2.get_cool_value());
}
Attempting to compile this code will net you an error like the following:
error[E0382]: borrow of moved value: `co2`
--> src/main.rs:34:57
|
31 | let co2 = CoolObject::new();
| --- move occurs because `co2` has type `CoolObject`, which does not implement the `Copy` trait
32 | println!("co1: {} | co2: {}", co1.get_cool_value(), co2.get_cool_value());
33 | set_cool_value_to_ten(co2);
| --- value moved here
34 | println!("co1: {} | co2: {}", co1.get_cool_value(), co2.get_cool_value());
| ^^^ value borrowed here after move
error: aborting due to previous error
And there's the problem. When pass by value in C++, the compiler will just assume you know what you're doing and call a copy constructor for you, even if it doesn't really make sense. If you haven't manually defined a copy constructor, no sweat - the compiler will do it's damndest to generate one for you and call that. After all, you've passed by value, s this must be what you want!
Rust pumps the brakes. When you pass by value, it actually moves ownership of the original value. It's not copying the original object in, it's actually bringing the object from outside - but the caveat is that the calling scope no longer owns this value at all, the new function does. When set_cool_value_to_ten()
reaches the end of its body, this value goes out of scope! It's dropped. When we attempt to refer to co2
again in the next line, we can't - it's not ours to use anymore.
In Rust, any value only has one owner. You can borrow as many immutable references as you like, which we do when we call get_cool_value(&self)
, or we can have one single mutable reference, like with really_set_cool_value_to_ten(co: &mut CoolObject)
, but if there's no borrow, like with set_cool_value_to_ten(mut co: CoolObject)
, you know ownership of this value will be moving.
This skirts the common pass-by-value bug in C++ where you think you're working with an object but you're actually just working with a copy. C++ will just silently try to make things work, and may not be on the same page as you are. Rust is very explicit. It even specifically tells you that if your object did implement the Copy
trait, it would have attempted to copy the value - but of course, this still wouldn't solve this problem. As with C++, the solution is to refer to the original instead of move the value. In C++, you say "take a reference", but in Rust, you'd call it a "mutable borrow":
fn really_set_cool_value_to_ten(co: &mut CoolObject) {
println!("Current: {} | Setting...", co.get_cool_value());
co.set_cool_value(10);
println!("New: {}", co.get_cool_value());
}
We also need to declare co2
itself as mutable:
fn main() {
let co1 = CoolObject::default();
let mut co2 = CoolObject::default(); // right here
println!("co1: {} | co2: {}", co1.get_cool_value(), co2.get_cool_value());
really_set_cool_value_to_ten(&mut co2); // and pass a mutable reference
println!("co1: {} | co2: {}", co1.get_cool_value(), co2.get_cool_value());
}
This illustrates one of the reasons I prefer working with Rust over C++. In C++, the programmer just has to know all of these details about how the language operates, and the compiler has no qualms about implicit actions that it takes. You've got no help reading through your code to figure out where you've made this mistake, and even full awareness of this issue is insufficient to avoid it in 100% of cases. Rust, on the other hand, doesn't let you ask for stupid things. In this situation, the compiler was able to tell me in plain English why my code was incorrect and how to fix it.
Photo by Natalia Y on Unsplash
Top comments (10)
There is no common pass-by-value bug in C++. This behavior is by design since you have Value and Reference semantics in the language.
C++ is a language where you should express your intent.
If you want to transfer ownership you should use std::move.
If you want to keep ownership and work with the same object you pass by reference to non const.
If you want to work with a copy you pass by value.
Sure, it's not a bug with C++, its a bug with the C++ programmer. It's still something that only comes with experience, whereas Rust by default will catch this programmer-introduced mistake for you.
I'm not trying to state this in the general case, only from a beginner's perspective. Both languages have their uses, it's not a cut-and-dry superiority situation.
The only difference is that Rust compiler will warn/error you about that and C++ will not for most of the cases (unless you use a static analyzer which will reduce the odds).
In every language you need to learn the rules, otherwise you will fight with the compiler everytime.
Great article! I really liked that you walked through the examples in both languages!
C++ gets a lot of bad rap. Some is well deserved. But "novice programmers mistaking values for references", is simply not a thing.
Value semantics is fundamental to all things math or computing.
An algebraic expression? 1 + 2 = 3, all these things are values.
No amount of Rust evangelism will ever change that.
C, and lots of early languages, were made with this in mind.
May I suggest a good article by (the) Andrzej Krzemienski detailing on this topic further
ttps://akrzemi1.wordpress.com/2012/02/03/value-semantics/
(it's from 2012, but absolutely relevant)
I understand the ins-and-outs of C++ semantics and how it translates to machine code, but I still have some doubts about how it works in Rust. If I pass a reference in Rust, obviously it translates to an address being passed to the function.
However, if I pass an object that will move ownership to the function called, is this also an address? This is how the
std::move
would do it in C++ and it would make sense. Are any objects (or structs in Rust) ever passed in their entirety on the stack in Rust? There should be no need to, I think.I'm actually not positive and thus am hesitant to reply, but I think the semantics in Rust match
std::move
. I'll see if I can verify that and update you, though, don't make any life-critical decisions or anything based on that...C++ has a move constructor for transferring ownership. Which, of course, forces you to learn that move semantics are a thing. But the tradeoff is more complexity for more control. Like any two languages, C++ and Rust are good for different things and different "feels".
Yep, both languages let you use either semantics, the difference is the default. You're right, use the tool for the job.
Thanks! Great read