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.

#![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);
}
}