Inheritance and traits: C++ and Rust
C++ shares behaviour through inheritance: a base class declares a virtual function, a derived class overrides it, and a call through a base reference dispatches to the derived one. Rust has no inheritance, so the shared behaviour is a trait and the call goes through a reference to the trait object. The difference shows when a derived value is passed by value.
The task in C++
The base class has a virtual destructor and a virtual area that returns zero. The square overrides area and returns its own value. The call on the object gives nine, and the call inside report gives the same, because the parameter is a reference.
The program prints the area twice. The destructor is virtual so that deleting through a base pointer would be safe, which is the other half of the inheritance contract.
#include <iostream>
class Shape
{
public:
virtual ~Shape() = default;
virtual double area() const { return 0.0; }
};
class Square : public Shape
{
public:
explicit Square(double side) : side_(side) {}
double area() const override { return side_ * side_; }
private:
double side_;
};
void report(Shape shape)
{
std::cout << "area " << shape.area() << '\n';
}
int main()
{
Square square(3.0);
std::cout << "area " << square.area() << '\n';
report(square);
}
g++ -std=c++23 -Wall -Wextra inheritance.cpp -o inheritance
./inheritance
area 9
area 0
The two lines agree because the argument is passed by reference. The report function is declared to take a Shape by value in the source, which the next section explains: the copy is the bug.
The same task in Rust
The trait declares area, the square implements it, and the report function takes &dyn Area. The call inside the function goes through the trait object, and the concrete type is still the square.
The program prints nine twice. There is no base class and no virtual destructor: the value is owned by main, the reference borrows it, and nothing is copied.
trait Area {
fn area(&self) -> f64;
}
struct Square {
side: f64,
}
impl Area for Square {
fn area(&self) -> f64 {
self.side * self.side
}
}
fn report(shape: &dyn Area) {
println!("area {}", shape.area());
}
fn main() {
let square = Square { side: 3.0 };
println!("area {}", square.area());
report(&square);
}
rustc --edition 2024 inheritance.rs
./inheritance
area 9
area 9
The trait object is the dynamic form; a generic bound would use the same trait with static dispatch. The lesson on traits covers the declaration, and the one on trait objects the pointer.
What differs and why
Who owns the value: in C++ a derived object contains a base subobject, so converting to the base by value copies only that part. In Rust a trait is not a type that a struct contains; the struct is the whole value, and the trait is a set of methods it implements.
What the compiler rejects: C++ accepts the by-value parameter and slices the object, silently losing the override. Rust rejects a parameter of type dyn Area by value, because the size is unknown, so the caller is forced to pass a reference or a box.
What happens at run time: the C++ program prints zero for the sliced copy, which is the base implementation. The Rust program prints nine, because there is no way to pass the value without its implementation.
The habit that breaks
The habit is the by-value base parameter. In C++ it compiles, copies the base subobject and calls the base function, which is object slicing; the output below is the base area, not the square.
The Rust function with the same signature is rejected, and the help offers impl Trait, a reference or a box. All three keep the implementation with the value.
trait Area {
fn area(&self) -> f64;
}
struct Square {
side: f64,
}
impl Area for Square {
fn area(&self) -> f64 {
self.side * self.side
}
}
fn report(shape: dyn Area) {
println!("area {}", shape.area());
}
fn main() {
let square = Square { side: 3.0 };
report(square);
}
rustc --edition 2024 slice_err.rs
error[E0277]: the size for values of type `(dyn Area + 'static)` cannot be known at compilation time
--> slice_err.rs:15:18
|
15 | fn report(shape: dyn Area) {
| ^^^^^^^^ doesn't have a size known at compile-time
|
= help: the trait `Sized` is not implemented for `(dyn Area + 'static)`
help: you can use `impl Trait` as the argument type
|
15 - fn report(shape: dyn Area) {
15 + fn report(shape: impl Area) {
|
help: function arguments must have a statically known size, borrowed types always have a known size
|
15 | fn report(shape: &dyn Area) {
| +
error[E0308]: mismatched types
--> slice_err.rs:21:12
|
21 | report(square);
| ------ ^^^^^^ expected `dyn Area`, found `Square`
| |
| arguments to this function are incorrect
|
= note: expected trait object `(dyn Area + 'static)`
found struct `Square`
= help: `Square` implements `Area` so you could box the found value and coerce it to the trait object `Box<dyn Area>`, you will have to change the expected type as well
note: function defined here
--> slice_err.rs:15:4
|
15 | fn report(shape: dyn Area) {
| ^^^^^^ ---------------
error[E0277]: the size for values of type `(dyn Area + 'static)` cannot be known at compilation time
--> slice_err.rs:21:12
|
21 | report(square);
| ^^^^^^ doesn't have a size known at compile-time
|
= help: the trait `Sized` is not implemented for `(dyn Area + 'static)`
= note: all function arguments must have a statically known size
error: aborting due to 3 previous errors
Some errors have detailed explanations: E0277, E0308.
For more information about an error, try `rustc --explain E0277`.
The C++ lesson for this ground is Inheritance, and the Rust lesson is Traits and default methods. The next page in this set is Templates and generics.
The C++ program was built with g++ 15.2.0 and the Rust program 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