C++ & Rust: Generics and Specialization

Generics are an incredibly important part of programming when using a statically typed language like C++ or Rust. Let's learn why!

May 4, 2022

Jeremy Steward
Senior Perception Engineer

At Tangram Vision, we are really excited about Rust and use it daily. The language matches our needs and values to such a degree that we joined the Rust Foundation. In particular, we believe the features of the language and the larger community are necessary to meet the challenges of writing complex systems for robotics, autonomous vehicles (AV), and multi-sensor systems.

C and C++ are the lingua francas of the perception space. Because we’re using Rust instead, we’d like to take a closer look at some of the features we use day-to-day and demonstrate how C++ and Rust differ. Hopefully we can shed some light on how we use these features and understand the trade-offs between these languages.

There are an array of various topics to compare between C++ and Rust. Today, we’ll start with generics. Often colloquially referred to as templates, generics allow us to write code in such a way that we can abstract across a variety of input or output types. This is one common way to produce some level of abstraction across a code-base, but C++ and Rust differ in terms of how they’re implemented. In particular, we’ll look at how each language handles substitution failures, and ways in which we can specialize our code.

Generics 101: Types as Inputs

Generics in both C++ and Rust are a kind of type that depend on other types as part of their definition. Types can represent one of the following concepts in a programming language:

  1. Structures (or Classes)

  2. Unions (enum in Rust)

  3. Functions (or class methods in C++, static functions, procedures, etc.)

  4. Primitives (e.g. char , int , etc.)

Put another way: all structures, unions, functions, and primitives have a type.

A generic is a way of specifying a placeholder within our type definitions, that can later be substituted by a more concrete type definition. For example, in C++ we might write:

template<typename T>
struct MyArray {
    T* raw_array;

    size_t size;
]

With this, we can say that MyArray<int> is a distinct type from MyArray<double> . We re-use the generic struct MyArray , by giving it a type T . This isn’t limited to structs either, as we can write similar code for functions:

template<typename T>
T timestwo(T number) {
    return number + number;
}

Here, we’ve defined a fairly simplistic function that takes a number in by value and doubles it. Again, timestwo<int> is distinct from timestwo<double> .

The equivalent in Rust is a bit more complicated than what’s written above and looks like:

use std::ops::Add;

fn timestwo<T>(number: T) -> <T as Add>::Output
where
    T: Add + Copy,
{
    number + number
}

That seems like a lot of extra syntax compared to C++! The main difference is that we have what are called trait bounds, which are the part that says T: Add + Copy , or in more plain English: the type T must implement the traits Add and Copy .

Traits

To describe the ways in which we can interface with types in our programs, Rust uses traits. A trait is a set of properties, functions, or types that are associated with the type that implements the trait. For example, the Add trait is an interface that allows for addition. It is a trait that says that a type has the property of being able to be “added” to some other type. The definition is roughly:

pub trait Add<Rhs = Self> {
    type Output;

    fn add(self, rhs: Rhs) -> Self::Output;
}

This trait has two associated properties:

  1. An associated type Output , which defines the type of the output of the add function.

  2. The add function, which adds self to the right hand side (rhs ).

Rust uses traits like this to define sub-groups of types when writing generics. In our initial Rust example:

use std::ops::Add;

fn timestwo<T>(number: T) -> <T as Add>::Output
where
    T: Add + Copy,
{
    number + number
}

The where clause is effectively stating, “this generic only exists where T implements the Add and Copy traits.” Instead of providing a generic for timestwo for any type T , we limit the definition such that timestwo is only valid for T that implement Add and Copy .

‍💡 To perhaps put this another way, traits are properties of our types, and we can restrict or specialize our generics in Rust to only be valid if types have certain properties. We’ll see why this is important below.

Type Substitution

We still haven’t really explained why the Rust example is so much more verbose than the C++ example. With a bit of knowledge about traits under our belts, we can now start to understand substitution, mainly

  1. What substitution is

  2. When substitution happens

  3. What kinds of substitution failures are errors

Substitution is the act of filling in the type T placeholders in our generics. So when we express timestwo<int> in C++, we are substituting the parameter T with the concrete int type. The primary difference that matters in C++ and Rust generics are the second and third parts above: when substitution happens and what kinds of substitutions fail, or are errors.

Substitution Ordering & Failures

In C++, substitution happens before the the final type of the function / struct / etc. is type-checked. So if we use our previous example of the timestwo function, if we never introduce any substitutions, C++ largely will not care about the template or how it might be used. For example if we wrote:

#include <iostream>

template<typename T>
T timestwo(T number) {
    return number + number;
}

void main() {
    std::cout << "Hello, world!\n";
}

Barring any syntax errors in the template definition itself, C++ doesn’t care that timestwo makes no sense for many types. Type checking doesn’t happen until after substitution, so nonsensical types being slotted into our timestwo function don’t present a problem.

Interestingly, C++ will sometimes be just fine with substituting some unexpected types. std::string and std::filesystem::path for example both implement operator+ , which allow them to be added (technically, + is overloaded for “appending” rather than adding). Unfortunately, this means that timestwo is valid for these types, even if we want this to only be valid for numeric types.

At times, this can lead to confusion because templates will work for types that weren’t quite intended. As we’ve seen, Rust can avoid this by adding a trait bound such that we only include numeric types that can be added. Without some advanced features, we won’t be able to do this in C++! Needless to say, correctly specifying the properties of our code can be hard.

The C++ example only fails when attempting to instantiate timestwo with a type that doesn’t support operator+ . But even if the template is wrong for all types, it only needs to be correct for the types that you use it with. In the above example, we might call:

// Okay, because `int`s support operator+
int a = 2;
int b = timestwo(a);

// Doesn't work, because `struct MyStruct` does not support operator+
struct MyStruct {
    int some_integer;
    float some_float;
};

MyStruct c = MyStruct { 0, 3.0 };
// The error doesn't show up at this line, though.
//
// Instead, it will show up inside the definition of `timestwo`,
// because type-checking happens after the generic has had its types 
// substituted.
MyStruct d = timestwo(c)

So as long as we don’t try to use the template with a type that doesn’t support the properties we need, your C++ compiler won’t complain.

In contrast, Rust handles this the opposite way. Type checking happens before substitution in Rust, which means that our generic has to be valid for any type that can be substituted before we’re allowed to even substitute a type at all.

This is why the Rust example did not look like the following:

// Fails to compile, as this does not work for every `T`
fn timestwo<T>(number: T) -> T {
    number + number
}

fn main() {
    println!("Hello, world!");
}

If we had written the Rust code this way, we cannot guarantee that every possible T in the above function can be added to itself, because we don’t know that number + number is valid for every type. For example, timestwo<bool> would not be valid, because bool cannot be added to itself in Rust (what would the answer be anyways?). So timestwo<T> is invalid, because we cannot verify that the type of the function timestwo exists for every possible type T .

This is why Rust has traits — by specifying trait boundaries on our type T , we limit the scope of what properties a generic needs to have. By specifying the Add trait within a where clause, we are laying out what properties about type T are used by timestwo . So even if we never try to use timestwo<String> in Rust, our definition of timestwo without a trait bound can never be valid, because we rely on the property of “a type can be added” to define timestwo .

What are the Trade-Offs?

This underscores the major difference between Rust and C++ generics, namely that Rust has stricter guarantees around correctness. Correctness in the template has to be guaranteed in the template definition, and all traits that we rely on for a given type parameter must be specified in order to do so. C++ on the other hand does not require that a template is correct for every type, only for every type that it is used to instantiate.

It’s a subtle distinction, but it has a lot of implications! For starters, it means that generics in C++ aren’t guaranteed to work for all types. There’s not really a clear way to write a C++ template that, once it compiles successfully, will always be correct for any type. There’s always a possibility of breaking the template with a new type in the future, which is one reason why generics are often seen as a maintenance hazard! The more complex generics are, the more carefully one has to use them (in C++).

💡 Utilizing some advanced or modern features such as SFINAE or C++20’s Concepts, one can sort of approximate this how Rust implements traits, and get closer to guaranteeing that a generic is valid for all types that implement a “concept.”

But this still isn’t quite the same. Trait bounds provide us a way to list what properties of a type need to be guaranteed for a generic to work. With concepts this is also somewhat the case, but the resultant error messages and ergonomics are not quite there.

Rust, in contrast, does guarantee that the generic will continue to build and be well-formed for all acceptable types if it builds. However, we’ve seen a bit of the trade-off here as well — there’s an extra burden on us when writing Rust code to be more explicit in the code, and we need to be able to guarantee that all properties we use are listed in the trait bounds, or it won’t build in the first place. These trait bounds can get long and unwieldy if we need a lot of them, and sometimes what seems obvious in terms of encoding properties of our code into traits ends up very non-obvious in practice.

An additional (if somewhat tangential) point is how substitution errors are presented to the user when compiling code. In C++, errors occur at template instantiation time. In Rust, the module system forces one to import all relevant traits where they are used, so the compiler has all the information needed to type-check a definition before a specific implementation is generated.

In Rust, if you try to use some functionality in generic function that’s not specified in your trait bounds, you’ll get a corresponding error in the body of the generic function. If you try to use a generic with a type that doesn’t meet the generic’s trait bounds, you’ll be told exactly which trait the type in question is missing.

In contrast, C++ will generate errors at template instantiation time. Meaning that missing properties on input types will manifest themselves as errors in the body of the function template. You get errors for each input type in-use that doesn’t work. Moreover since templates are repeated in each translation unit, it’s not uncommon to see the same template substitution error repeated multiple times in your compiler output, ugh!

The difference here is maybe a bit minor if you’re familiar with C++, but the end result is that in Rust you get an error that will definitively tell you if your generic needs a trait bound or if your type doesn’t implement that trait during substitution.

Specialization

Another key difference between C++ and Rust is the concept of generic specialization. Specialization is the process in which we start with a definition in which most types can be slotted into a generic, but allow for more specific definitions for certain types to co-exist alongside the generic. One can think of it as carving out exceptions in our code. The canonical example of this in C++ is std::vector — the internal representation and behaviour of std::vector<T> is different from the behaviour of std::vector<bool> . There is even explicit documentation for this specialization!

The differences between C++ and Rust require us to re-think how we might translate code where specialization is used. As we’ll see, specialization is possible in C++, but makes type checking certain properties of our code difficult. Conversely, Rust does not support specialization. Let’s look at a simple example using a generic Image type to see how specialization plays out.

Specialization in C++

For our C++ implementation, we might construct our generic Image type like:

template<typename Pixel>
struct Image {
    std::vector<Pixel> pixels;
    
    size_t width;
    size_t height;
}

This works for many kinds of pixel types, particularly for mono pixels, RGB pixels, BGRA pixels, etc. But if we wanted to use an interleaved pixel representation (such as a YUV422-like representation) where values from multiple pixels are grouped together such that one element of the vector pixels doesn’t necessarily represent one pixel, we wouldn’t be able to use the above struct definition. At least, it would make many parts of the implementation much more difficult. Instead, assuming we have some YUV422-like representation UYVY , we might specialize it by appending the above code with:

struct UYVY {};

template<>
struct Image<UYVY> {
    // U, V, and Y sub-pixels are just single bytes.
    // 
    // So we store the whole interleaved buffer without transforming it
    // or changing from YUV422 to YUV444, or RGB8, or something else.
    //
    // Then, when we index into this vector (with a member function or
    // otherwise), we just need to remember the interleaved pattern but
    // _ONLY_ for this specialization.
    std::vector<unsigned char> pixels;

    size_t width;

    size_t height;
}

As we can see, adding a specialization in C++ is straightforward. We don’t even have to prove anything to the compiler about our code unless we call it, because type checking comes after substitution in C++! C++ provides a set of rules for how “specialized” or “specific” a generic definition is, and then always uses the most specific definition when possible. In the above case, template<> is more specific than template<typename T> , because it has fewer generic types!

The downside is that for every specialized definition, we need to provide specialized definitions for every class method, member function, etc. on the specialized version. That may seem obvious since the specialized version is an exceptional circumstance to the generic, but it is important to recognize that if there are a lot of specializations that this can be a lot of work!

Specialization in Rust

Rust does not at time of writing have any means to do specialization in the way C++ does. There is no ruleset or syntax for declaring a more “specific” version of a generic.

Returning back to our Image example from the previous section, we very clearly cannot specialize for a single type, such as with C++’s std::vector<bool> or the Image<UYVY> that we wrote. Traits do allow us to group types according to some interface, and we are allowed to have multiple of the same generic with different trait bounds. So if we grouped our pixel types into two groups:

  • NotInterleaved - RGB, BGRA, mono, etc.

  • Interleaved - UYVY, YUYV, etc.

We could then write our code as follows:

pub struct Rgb {
    r: u8,
    g: u8,
    b: u8,
}

pub struct Bgra {
    b: u8,
    g: u8,
    r: u8,
    a: u8,
}

pub struct Uyvy {}

pub trait NotInterleaved {}
impl NotInterleaved for Rgb;
impl NotInterleaved for Bgra;

pub trait Interleaved {}
impl Interleaved for Uyvy;

pub struct Image<Pixel> 
where
    Pixel: NotInterleaved,
{
    pixels: Vec<Pixel>,

    width: usize,

    height: usize,
}

pub struct Image<Pixel>
where
    Pixel: Interleaved,
{
    pixels: Vec<u8>,

    width: usize,
    
    height: usize,
}

It’s not always ideal to write code this way though! These traits don’t really do much for us, and now every time we want to add some kind of functionality to Image<P> , we have to specify whether we’re implementing against Image<P> where P: Interleaved or Image<P> where P: NotInterleaved . This can very quickly become untenable, and kind of defeats the point of having a generic in the first place (because we have to define everything at least twice, for the not-interleaved and interleaved cases).

Instead, if we know that we’re only trying to support RGB , BGRA , and UYVY pixel types, we might instead abstract our Rust code using traits and generics like follows:

pub struct ContiguousPixelImage<Pixel> {
    pixels: Vec<Pixel>,

    width: usize,

    height: usize,
}

pub struct UyvyImage {
    pixels: Vec<u8>,

    width: usize,

    height: usize,
}

// Instead of trying to make a template that does everything, we make several
// separate types from a template and group them via a trait instead.
pub trait Image {
    // All image operations / types / functions in here
}

impl<P> Image for ContiguousPixelImage<P> { /* ... */ }

impl Image for UyvyImage { /* ... */ }

Instead of specializing our generic to a single type, we just created a new type with a different name. We still have to provide different definitions for RGB / BGRA images from UyvyImage , but at least we can treat the two of these types similarly according to a trait called Image .

There’s a lot of different ways to express this same problem; regardless, the end result is that Rust’s generics are quite different from C++’s, and the lack of specialization forces us to use our abstractions differently.

Conclusion

Generics are an incredibly important part of programming when using a statically typed language like C++ or Rust. They enable us to express and abstract code across our types, and are used heavily in anything from storing data in a vector (std::vector , Rust’s Vec ) to implementing our own image, point cloud, or other sensor observation types.

Unlike with C++, where code can grow organically and cause weird interactions with templates over time, the structure of our programs in Rust is either valid or not based on the properties we specify upfront with trait bounds. This is very useful, and is one of the things that we at Tangram Vision love about Rust as a language. There’s no worrying about code in the future, because the compiler has already guaranteed that the type will work if its properties do not change. Likewise, while Rust doesn’t provide rules for specialization, there are ways to utilize traits to abstract and group code. This can often be useful in modeling problems differently, without specialization.

At Tangram Vision we’re invested in Rust. We believe that while sometimes it requires a bit of upfront thinking about our types, the benefits Rust confers are worth the extra effort. If you decide to transition to Rust you’ll be better able to understand the trade-offs between these two languages.

‍—

Edits:

June 2, 2025: Removed hiring links.‍

Subscribe to our newsletter and keep up with the latest

Note: Tangram Robotics needs the contact information you provide to us to contact you about our products and services. You may unsubscribe from these communications at any time.

Subscribe to our newsletter and keep up with the latest

Note: Tangram Robotics needs the contact information you provide to us to contact you about our products and services. You may unsubscribe from these communications at any time.

Subscribe to our newsletter and keep up with the latest

Note: Tangram Robotics needs the contact information you provide to us to contact you about our products and services. You may unsubscribe from these communications at any time.