Rust-like traits in C++
As mentioned in my post on
uniform call syntax in C++,
I have become envious of
traits in Rust
and would like us to have something similar in C++.
The great thing about traits in Rust is that you can keep the data separate from the
implementation.
For instance, the data needed to represent a 3D vector Vector3
can be kept as a struct,
while traits of the vector, such as its length, are implemented separetely.
Note: This post is still a draft and might change in the future.
Furthermore, the traits can be defined in general, so that multiple types of data,
such as a Circle
and a Rectangle
can share the same trait,
HasArea
, without being of the same type.
This makes it easy to extend with both new types and new traits.
Here is an example implementation of Vector3
and Length
in Rust:
struct Vector3 {
x: f64,
y: f64,
z: f64
}
trait Length {
fn length(&self) -> f64;
}
impl Length for Vector3 {
fn length(&self) -> f64 {
return (self.x*self.x + self.y+self.y + self.z+self.z).sqrt();
}
}
fn main() {
let a = Vector3{x: 12.0, y: 4.0, z: 2.0};
println!("Length is {}", a.length());
}
With inheritance in C++, it is easy to introduce new types,
but hard to extend with new functionality
(all existing types will need to implement any new functions).
Further, it is hard to add functionality after-the-fact.
If a library has defined length()
as a function of Vector3
,
but you need lengthSquared()
for performance reasons,
there is no easy way to add this to the Vector3
class.
You will typically have to make lengthSquared(Vector3 v)
a free
function, which makes it awkward, because you are now calling
v.length()
for the length, but lengthSquared(v)
for the length squared.
After playing around with the ideas presented in Sean Parent’s NDC talk, I realized that we can make Rust-like traits happen in C++.
In the end, it allows us to do things like this:
#include "trait.h"
#include "circle.h"
#include "rectangle.h"
#include "circle_shape.h"
#include "rectangle_shape.h"
#include "shape.h"
using namespace std;
int main()
{
vector<Shape> geometries;
geometries.emplace_back(Circle{8});
geometries.emplace_back(Rectangle{6});
for (const auto &shape : geometries) {
cout << shape.area() << endl;
}
return 0;
}
Here, the shapes themselves are just simple structs:
// circle.h
struct Circle
{
const double radius = 4.0;
};
// rectangle.h
struct Rectangle
{
const double width = 2.0;
const double height = 3.0;
};
While the definition of the shape inherits from a Trait
class that we’ll define later:
// shape.h
#include "trait.h"
struct Shape : public Trait
{
template<typename T>
Shape(T t)
: Trait(std::move(t))
, area([this](){ return ShapeImpl::area(cast<T>()); })
{
}
std::function<double()> area;
};
The implementation of the area
function for each shape is the straightforward implementation
you would do for a free function.
// circle_shape.h
namespace ShapeImpl {
double area(const Circle &circle);
}
// rectangle_shape.h
namespace ShapeImpl {
double area(const Rectangle &rectangle);
}
// circle_shape.cpp
namespace ShapeImpl {
double area(const Circle &circle)
{
double r = circle.radius;
return 3.14 * r * r;
}
}
// rectangle_shape.cpp
namespace ShapeImpl {
double area(const Rectangle &rectangle)
{
return rectangle.width * rectangle.height;
}
}
Notice how we put it inside the ShapeImpl
namespace to match the implementation of Shape
above.
We could have used the global namespace, but we’re better off by not polluting that.
All that remains is to include trait.h
, which makes this all possible.
This only needs to be defined once, and can be used to create many different Trait
objects
like the Shape
we defined above:
#include <functional>
#include <iostream>
#include <memory>
using namespace std;
struct Trait
{
public:
Trait(const Trait &trait) = default;
Trait(Trait &&trait) = default;
Trait &operator=(const Trait &trait) = default;
Trait &operator=(Trait &&trait) = default;
virtual ~Trait() = default;
template<typename T>
explicit Trait(T t)
: container(std::make_shared<Model<T>>(std::move(t)))
{
}
template<typename T>
T cast()
{
auto typed_container = std::static_pointer_cast<const Model<T>>(container);
return typed_container->m_data;
}
private:
struct Concept
{
// All need to be explicitly defined just to make the destructor virtual
Concept() = default;
Concept(const Concept &concept) = default;
Concept(Concept &&concept) = default;
Concept &operator=(const Concept &concept) = default;
Concept &operator=(Concept &&concept) = default;
virtual ~Concept() = default;
};
template<typename T>
struct Model : public Concept
{
Model(T x)
: m_data(move(x))
{ }
T m_data;
};
std::shared_ptr<const Concept> container;
};
In an update of this post, I will try to extend this Trait
class so that we can easily convert
from one Trait
to another.
I.e. to allow us to go from a class Shape : public Trait
to a class Length : public Trait
and still keep the data contained in the first.
And after that, I’ll consider extending it to become a copy-on-write container so that the contained data can be modified, but in a way that allows us to keep value semantics.