Concepts, interfaces, and static dispatch
In C++, static dispatch over an interface is achieved by implementing a template function or template method that interacts with the type using some expected interface.
The template function twiceArea
in the example below makes use of an area()
method on the template type parameter.
To achieve the same goal in Rust involves defining a trait (Shape
) with the
desired method (twice_area
) and using the trait as a bound on the type
parameter for the generic function.
#include <iostream>
struct Triangle {
double base;
double height;
Triangle(double base, double height)
: base(base), height(height) {}
// NOT virtual: it will be used with static dispatch
double area() {
return 0.5 * base * height;
}
};
// Generic function using interface
template <class T>
double twiceArea(T &shape) {
return shape.area() * 2;
}
int main() {
Triangle triangle{1.0, 1.0};
std::cout << twiceArea(triangle) << std::endl;
return 0;
}
// Interface that generic function will use trait Shape { fn area(&self) -> f64; } struct Triangle { base: f64, height: f64, } // Implementation of interface for type impl Shape for Triangle { fn area(&self) -> f64 { 0.5 * self.base * self.height } } // Generic function using interface fn twice_area<T: Shape>(shape: &T) -> f64 { 2.0 * shape.area() } fn main() { let triangle = Triangle { base: 1.0, height: 1.0, }; println!("{}", twice_area(&triangle)); }
Note that in the Rust example, the definition of the trait and the struct have not changed from the example in the chapter on virtual methods and dynamic dispatch. Even so, this example does use static dispatch. This is the result of a design trade-off in Rust around the representation of vtables and vptrs which is described later in that chapter.
The difference between Rust and C++ in the above examples arises from Rust being nominally typed (types must opt in to supporting a specific interface, merely having the right methods isn't enough) and C++'s template meta-programming enabling a kind of structural or duck typing (types only need to have the methods actually used, and there is no need to explicitly opt in to supporting an interface).
Templates vs generic functions
The reason why Rust is nominally typed instead of structurally typed has to do with the difference between C++ templates and Rust generic functions. In particular, C++ templates are only type checked after all of the template arguments are provided and they are fully expanded, while Rust generic functions are type checked independently of the type arguments.
Since the functions are checked before the type arguments are known, the methods and functions that can be applied to values of those types also need to be known before the type arguments are known.
This point in the programming language design space favors simplicity of reasoning about these functions over the flexibility that comes from the template programming approach. This becomes especially valuable when writing libraries that both provide generic functions defined in terms of other generic functions, for which a C++ compiler can give many fewer static guarantees, since it would not be possible to test all possible instantiations.
In both C++ and Rust, however, multiple implementations are generated by the compiler in order to achieve static dispatch.
C++ constraints and concepts
Rust's approach to static dispatch over an interface can be partially (but only partially) modeled with a strict application of C++ concepts.
The usual way to apply concepts is still structural and does not model Rust's approach: it only requires that a method with specific properties be present on the type.
#include <concepts>
template <typename T>
concept shape = requires(T t) {
{ t.area() } -> std::same_as<double>;
};
template <shape T>
double twiceArea(T shape) {
return shape.area() * 2;
}
A closer equivalent to the above Rust program in C++ is to use a combination of abstract classes and concepts.
#include <concepts>
struct Shape {
Shape() {}
virtual ~Shape() {}
virtual double area() = 0;
};
template <typename T>
concept shape = std::derived_from<T, Shape>;
struct Triangle : Shape {
double base;
double height;
Triangle(double base, double height) : base(base), height(height) {}
// still NOT virtual: will be used static dispatch
double area() override {
return 0.5 * base * height;
}
};
template <shape T>
double twiceArea(T shape) {
return shape.area() * 2;
}
int main() {
Triangle triangle{1.0, 1.0};
std::cout << twiceArea(triangle) << std::endl;
return 0;
}
This is still not the same, however, because the concept only creates a
requirement on the use of the template, not on the use of values of type T
within the template. In Rust, the trait bound constrains both. So the following
still compiles in C++.
#include <concepts>
struct Shape {
Shape() {}
virtual ~Shape() {}
virtual double area() = 0;
};
template <typename T>
concept shape = std::derived_from<T, Shape>;
template <shape T>
double twiceArea(T shape) {
// note the call to a method not defined in Shape
return shape.volume() * 2;
}
However, the equivalent does not compile in Rust and instead produces an error.
trait Shape {
fn area(&self) -> f64;
}
fn twice_area<T: Shape>(shape: &T) -> f64 {
// note the call to a method not defined in Shape
2.0 * shape.volume()
}
error[E0599]: no method named `volume` found for reference `&T` in the current scope
--> example.rs:7:17
|
7 | 2.0 * shape.volume()
| ^^^^^^ method not found in `&T`
These additional static checks mean that in many situations where C++ templates would be useful but hard to implement correctly, Rust generics are freely used.
Required traits and ergonomics
In the above examples, the function requiring a trait was defined like the following.
fn twice_area<T: Shape>(shape: &T) -> f64 {
2.0 * shape.area()
}
This is a commonly used shorthand for the following:
fn twice_area<T>(shape: &T) -> f64
where
T: Shape,
{
2.0 * shape.area()
}
The more verbose form is preferred when there are many type parameters or those
type parameters must implement many traits. An even shorter-hand available in some
cases is the impl
keyword:
fn twice_area(shape: &impl Shape) -> f64 {
2.0 * shape.area()
}
Generics and lifetimes
When defining a template in C++ that makes use of a type template parameter, the lifetimes of references stored within objects of that type must be tracked manually by the programmer.
The following (contrived) C++ example compiles without error, but could be used in a way that results in undefined behavior.
#include <memory>
struct Shape {
Shape() {}
virtual ~Shape() {}
virtual double area() = 0;
};
template<typename S>
void store(S s, std::unique_ptr<Shape> data) {
// Will pointers or references in `s` become dangling while `data`
// is still in use?
*data = s;
}
Rust checks the bounds on lifetimes of references contained within type parameters. Just as with trait object types, these bounds are usually inferred according to the lifetime elision rules. When they cannot be inferred, or they are inferred incorrectly, the bounds can be declared manually.
In the Rust transliteration of the above example, the lifetime bounds have to be given manually because the inferred bounds are incorrect. Without explicit bounds, the compiler produces an error.
trait Shape {}
fn store<S: Shape>(x: S, data: &mut Box<dyn Shape>) {
*data = Box::new(x);
}
error[E0310]: the parameter type `S` may not live long enough
--> example.rs:7:5
|
7 | *data = Box::new(x);
| ^^^^^
| |
| the parameter type `S` must be valid for the static lifetime...
| ...so that the type `S` will meet its required lifetime bounds
|
The error message becomes clearer when the inferred lifetime bounds are made
explicit. With the given type for store
, the argument for x
could be
something that has a lifetime that does not last as long as the lifetimes in the
contents in the box.
trait Shape {}
struct Triangle {
base: f64,
height: f64,
}
impl Shape for Triangle {}
// The type parameter S is assigned no lifetime bound.
fn store<'a, S: Shape>(
x: S,
// The reference is assigned a fresh lifetime by rule
// [lifetime-elision.function.implicit-lifetime-parameters].
//
// The trait object is assigned 'static by rule
// [lifetime-elision.trait-object.default] and
// [lifetime-elision.trait-object.innermost-type].
data: &'a mut Box<dyn Shape + 'static>,
) {
*data = Box::new(x);
}
// An example of how the implementation of store could be misused with
// the given type.
fn main() {
let triangle = Triangle {
base: 1.0,
height: 2.0,
};
let mut b: Box<dyn Shape> = Box::new(triangle);
{
let short_lived_triangle = Triangle {
base: 5.0,
height: 10.0,
};
store(short_lived_triangle, &mut b);
}
// Here b contains a dangling reference.
}
For this specific case, the most general solution is to define a new lifetime
parameter to bound both S
and dyn Shape
. The type parameter for the
reference can be elided, because it will be assigned a fresh lifetime parameter.
Click here to leave us feedback about this page.#![allow(unused)] fn main() { trait Shape {} // Note the common bound // -----------------here-\ // ----------------------|---------------------------and here-\ // v v fn store<'s, S: Shape + 's>(x: S, data: &mut Box<dyn Shape + 's>) { *data = Box::new(x); } }