Skip to content

Instantly share code, notes, and snippets.

@brendanzab
Last active November 15, 2024 17:22
Show Gist options
  • Save brendanzab/9220415 to your computer and use it in GitHub Desktop.
Save brendanzab/9220415 to your computer and use it in GitHub Desktop.

NOTE: This was first authored on 26 Feb 2014. Things may have changed since then.

C++'s Templates

C++'s templates could be seen as forming a duck typed, purely functional code generation program that is run at compile time. Types are not checked at the initial invocation stage, rather the template continues to expand until it is either successful, or runs into an operation that is not supported by that specific type – in that case the compiler spits out a 'stack trace' of the state of the template expansion.

To see this in action, lets look at a very simple example:

template <typename T>
T fact(T n) {
    return n == T(0) ? T(1) : fact(n - T(1)) * n;
}

int main() {
    auto x = fact("hi");
}

This gives us the error:

Untitled 3.cpp:3:46: error: invalid operands to binary expression ('long' and 'const char *')
    return n == T(0) ? T(1) : fact(n - T(1)) * n;
                              ~~~~~~~~~~~~~~ ^ ~
Untitled 3.cpp:7:14: note: in instantiation of function template specialization 'fact<const char *>' requested here
    auto x = fact("hi");
             ^
1 error generated.

As you can see, the template has attempted to expand, and only errors once it is actually inside the templated function and can't find an appropriate operation for that type. This can be very confusing for a user of a library because the error messages expose implementation details. In template heavy code, the the error might only present itself very deep in the expansion, which causes C++'s famous mile-high error messages.

The advantage of C++'s template system, like any other duck typed language, is that it is very expressive and flexible. A templated function can be written without any interface needed up front, and will work for any type that implements the required operations. Like any duck type language however, this inevitably pushes the specifications into the documentation, and when these specifications are ignored (or never read!) incomprehensible errors will most certainly result. This has been the main driver behind the push for the 'concepts' feature in a future version of the C++ standard.

Rust's 'generics'

Rust's parametric polymorphism and type classes (of which I will now refer to under the more colloquial term, 'generics') follows in the ML and Haskell tradition in that the type checking is like constraint program that is run at compile time. When an generic item (or also in Rust's case, region labels, like 'a) is invoked, the specification of that type is immediately checked for consistency at the call site, otherwise the typechecking fails.

Let's have a look at the previous factorial example in Rust:

use std::num::{One, one, Zero, zero};

fn fact<T: Eq + Zero + One + Mul<T, T> + Sub<T, T>>(n: T) -> T {
    if n == zero() { one() } else { fact(n - one()) * n }
}

fn main() {
    println!("{}", fact("hi"));
}

This gives us the error:

Untitled 6.rs:8:20: 8:24 error: failed to find an implementation of trait std::num::Zero for &'static str
Untitled 6.rs:8     println!("{}", fact("hi"));
                                   ^~~~
note: in expansion of format_args!
<std macros>:2:23: 2:77 note: expansion site
<std macros>:1:1: 1:1 note: in expansion of println!
Untitled 6.rs:8:5: 8:32 note: expansion site

As you can see, the specification must be given up front, which means that any error is caught at the call site, as opposed to deep in a template expansion.

Rust's generics are much more principled than templates, but they are dependent on types conforming to specific APIs. If a type does not implement the required interface, then it is impossible to use the associated functions, even if they may be perfectly valid.

Macros and syntax extensions are not a replacement for templates

Those who are more experienced at Rust might be wanting to call me out, crying, "what about macros and syntax extensions?". Indeed Rust's macros are powerful. They share many qualities with templates. But they certainly feel like second class citizens in the language:

  • They exist in a separate, global namespace.
  • Exporting macros feels like a hack.
  • Importing macros feels like a hack.
  • They also do not follow the same conventions as other language constructs – for example they cannot have type parameter lists, and there is no way to invoke them like methods.

This is certainly not an extensive exposition on how Rust's macros do not fill the same gap as templates, but it at least gives you a taste.

Compile time computation

C++'s templates also provide a powerful, if unwieldy form of compile time computation, that can allow for advanced static code generation that enables libraries like Eigen. Rust on the other hand provides no answer to compile time computation apart from syntax extensions, of which I have written about previously.

Conclusion

Rust's current generics are powerful, safe, and provide excellent errors at compile time. They will most likely serve it well heading into the 1.0 release cycle. However templates are still more flexible and expressive. Whether Rust would benefit from having a templated extension to the language in the future is up for debate (I am not even sure), but we should be up front about the both the positives and negatives when comparing Rust to C++.


This was posted as a comment on /r/rust.

Disclaimer: I am still relatively inexperienced, so I could have made some mistakes and omissions, especially when talking about C++'s templates. Feel free to correct me.

@Aethelflaed
Copy link

Thanks for your article, however C++ template also allows you to have a (scalar) typed templated parameter, like template <int N> in this case.
What you said remains true for more complex examples.

See http://www.stroustrup.com/bs_faq2.html#constraints

@Venemo
Copy link

Venemo commented Sep 21, 2017

This can be very confusing for a user of a library because the error messages expose implementation details. In template heavy code, the the error might only present itself very deep in the expansion, which causes C++'s famous mile-high error messages.

As of C++11, you can (and should!) add a static_assert to your templates which can be used to create more meaningful error messages.

@Swoorup
Copy link

Swoorup commented Apr 2, 2018

fn fact<T: Eq + Zero + One + Mul<T, T> + Sub<T, T>>(n: T) -> T {
    if n == zero() { one() } else { fact(n - one()) * n }
}

What does Zero, One mean here?

@onsah
Copy link

onsah commented Jun 25, 2018

I am not experienced in rust but Zero and One are traits. I think that means that a T such that have implementation for traits Zero and One.

@npetrenko
Copy link

Concepts are coming to C++20 -- they will make the compilation errors much more readable

@Logarithmus
Copy link

Logarithmus commented Jul 31, 2022

@brendanzab

Rust on the other hand provides no answer to compile time computation

Rust traits are Turing-complete. Someone implemented bubble sort for type-level numbers in Rust. I've just implemented merge sort for type-level numbers, but the code isn't ready for sharing yet. It does something similar to https://github.com/mpusz/units/blame/master/src/core/include/units/bits/external/type_list.h. Yes, I'm working on library for compile-time physical units inspired by mp-units.

There's also typenum which implements arithmetics (including sqrt & log) for type-level numbers. It's similar to Peano arithmetics, but it uses binary representation (e.g. UInt<UInt<UInt<UTerm, B1>, B1>, B1> == 7) instead of Succ<...Succ<Succ<Zero>>> to make compilation time O(log n) instead of O(n) and compilation errors readable.

typenum and sorting algorithms mentioned above use features which were present in Rust back in 2016.

@brendanzab
Copy link
Author

Yeah! This was authored back on the 26 Feb 2014 – I'm unsure if we were aware of that stuff at that stage.

@badolsmito
Copy link

C++ templates are also literally turing complete.

@dreamIIx
Copy link

Hello! It was interesting to read this. But why, while writing this article, did you take advantage of type traits in Rust, but ignored them in C++?
In the year your article was written, for example, <type_traits> already existed. By describing the shortcomings as “we need to document things that we wrote using templates”, you say nothing at all about “documentation that can be expressed in the language itself”. You can write type traits yourself in C++ and you, obviously, should have known this when writing such articles.
At that time, both decltype expressions and decltype(auto) already existed, convenient for similar purposes - for the purpose of restricting and checking the correctness of input types at compile time.
All this is also necessary to reduce the number of template errors. Oddly enough, SFINAE also solves some of the problems with large error messages.
It seems to me that this article is nothing more than a subjective point of view (obviously) on the specific “easy for every programmer to write” tools provided by the language. Nevertheless, the C++ language still has more powerful tools for achieving the mentioned problems than was demonstrated by the author (at the time of publication of the article). Yes, to solve some problems you have to dig deeper into the language, which is why it instills primitive horror in the programmers around it. This is why we can talk about the disadvantages of templates in C++

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment