Goals of this presentation

Take home something you can use

  • Template enables type safety
  • Template is performant
  • Template enable generic programming

Goals of this presentation

Template doesn't need to be complicated

Presentation philosophy

  • Teach by examples
  • Explain the feature used in the examples
  • Warn about common pitfalls

Nicolai Josuttis, Back To Basics: Templates, CppCon 2022

Nicolai Josuttis - Back To Basics: Templates, CppCon 2022

https://youtu.be/HqsEHG0QJXU

⏰ Agenda of today ⏰

  • Syntax & function templates
  • Implicit requirements
  • Template argument deduction
  • Variadic templates & perfect forwarding
  • Class templates
  • 3 production code examples

Template syntax

A template defines a family of functions, classes, type aliases, variables or concepts.

template< [parameter-list] >
recipe

parameter-list can be:

  • type parameter e.g. std::string
  • non type parameter (aka NTTP) e.g. enum value, constexpr value
  • parameter pack using ellipsis ...
  • template parameter e.g. template<typename> class

recipe can be:

  • function definition
  • a class/struct definition
  • a type alias definition
  • variable definition
  • concept definition

Function template

Defines a family of functions

template<typename Swappable>
void mySwap(Swappable& a, Swappable& b) {
	Swappable tmp = std::move(a);
	a = std::move(b);
	b = std::move(tmp);
}

💡 Give good name to template parameters

⚠️ The compiler does not produce any code if the template is not instantiated

Function template

Defines a family of functions

template<typename Swappable>
void mySwap(Swappable& a, Swappable& b) {
	Swappable tmp = std::move(a);
	a = std::move(b);
	b = std::move(tmp);
}
int a = 5, b = 10;
mySwap(a, b);
assert(a == 10 && b == 5);
float c = 3.14f, d = 2.71f;
mySwap(c, d);
assert(c == 2.71f && d == 3.14f);

⚠️ Calling the function with 2 different types fails

float f = 3.14f;
int i = 42;
mySwap(i, f); // ❌ Does not compile

Function template

Defines a family of functions 🔎

cppinsights.io/s/b05df164

Source:

template<typename Swappable>
void mySwap(Swappable& a, Swappable& b) {
	Swappable tmp = std::move(a);
	a = std::move(b);
	b = std::move(tmp);
}

int a = 5, b = 10;
mySwap(a, b);

float c = 3.14f, d = 2.71f;
mySwap(c, d);

Insight:

template<>
void mySwap<int>(int& a, int& b)
{
	int tmp = std::move(a);
	a = std::move(b);
	b = std::move(tmp);
}

template<>
void mySwap<float>(float& a, float& b)
{
	float tmp = std::move(a);
	a = std::move(b);
	b = std::move(tmp);
}

⚠️ The compiler generates code for each instantiation.

https://cppinsights.io/s/b05df164

Function template

How are function templates compiled?

Two-phase name lookup

template<typename Swappable>
void mySwap(Swappable& a, Swappable& b) {
	Swappable tmp = std::move(a);
	a = std::move(b);
	b = std::move(tmp);
}

int a = 5, b = 10;
mySwap(a, b);

First, the compiler checks for syntax and resolve non-dependent names identifier.

Second the template argument is substituted in for the template parameter

⚠️ The code can compile until a function template is called

Function template

Implicit requirements

Implicit requirements are the sets of operations and properties that a type must support for the template to compile

template<typename T>
void mySwap(T& a, T& b) {
	T tmp = std::move(a);
	a = std::move(b);
	b = std::move(tmp);
}

What are the implicit requirements for the type T?

🙋‍♂️🙋‍♀️

  • Must be move constructible i.e have a valid T(T&&) constructor
  • Must be move assignable i.e have a valid T& operator=(T&&) assignment operator

💡 C++ 20 formally defines the requirement via the swappable concept.

💡 Before C++20, document the requirement explicitly using comments.

ℹ️ There are numerous possible implicit requirements

For instance:

  • T must have a public class member variable T.data
  • T must have a function member T::fun(int)
  • T must have a nested type T::TYPE
  • T must have a static function member

👉 Implicit requirement is a very important notion 👈

Template argument deduction

General

Templates argument are deduced from the function arguments

For instance

template<typename T>
T myMax(T a, T b) {
	return a > b ? a : b;
}

void fun() {
	int a = 5, b = 4;
	auto c = myMax(a, b);
	static_assert(std::is_same_v<decltype(c), int>);
}

Calling myMax(a, b) deduces T = int

Template argument deduction

Type deduction

Another example

template<typename T>
T myMax(T a, T b) {
	return a > b ? a : b;
}

void fun() {
	auto c = myMax(5.1, 5.2);
}

What does T deduced to ?

🙋‍♂️🙋‍♀️

double

Template argument deduction

Explicit template arguments

Provide the template argument explicitly to override deduction

template<typename T>
T myMax(T a, T b) {
	return a > b ? a : b;
}

void fun() {
	auto c = myMax<float>(5.1, 5.2);
}

⚠️ Template arguments are never deduced from the return type ⚠️

template<typename T>
T parse(const std::string& text);

int i  = parse("42");      // ❌ Does not compile
auto i = parse<int>("42"); // 👍

Template argument deduction

How cv qualifiers are deduced

One question decides everything: is the parameter a reference or not?

template<typename T>
void funcValue(T a);

template<typename T>
void funcRefParam(T& a);
int x = 5;
const int cx = 5;

funcValue(x);  // T is deduced as int
funcValue(cx); // T is deduced as int (const is dropped)

funcRefParam(x);  // T is deduced as int
funcRefParam(cx); // T is deduced as const int (const is kept)

⚠️ A const T& parameter drops it again

template<typename T>
void funcConstRefParam(const T& a);

funcConstRefParam(cx); // T is int, but a is still const int&

💡 Rule of thumbs 💡

  • By value → cv-qualifiers are dropped
  • By reference → cv-qualifiers are preserved

Template argument deduction

Arrays and pointers

template<typename T>
void funcValue(T arg);

template<typename T>
void funcRefParam(T& arg);
int arr[5];
int* ptrArr = arr;

// the pointer remains a pointer
funcValue(ptrArr); // T is deduced as int*

// the reference keeps the int* type
funcRefParam(ptrArr); // T is deduced as int*. But arg is now int*& (dangerous)

// the array decays to a pointer, the size is lost
funcValue(arr);    // T is deduced as int*

// the reference keeps the array type
funcRefParam(arr); // T is deduced as int[5]

💡 A reference parameter can even deduce the size as an NTTP 💡

template<typename T, std::size_t N>
constexpr std::size_t arraySize(T(&)[N]) { return N; }

int arr[5];
static_assert(arraySize(arr) == 5);

Template argument deduction

⚠️ Why T& is dangerous with pointers ⚠️

A reference parameter will happily deduce T as a pointer type.

template<typename T>
void dangerous(T& retry) {
	// ... some logic
	++retry;
}

int retries = 0;
int* retriesPtr = &retries;

// 😱 Forgot the '*' 😱
dangerous(retriesPtr); // T is int*, so retry is int*&
// retriesPtr is now pointing to an invalid memory address
printf("%d\n", *retriesPtr);   // 💥 disaster 💥

The pointer is incremented, not retries

  • ⚠️ Think twice before using a non-const reference parameter
  • 💡 Validate the deduced type with static_assert and <type_traits>, e.g. std::is_pointer_v, std::is_const_v
  • 💡 From C++20, use concepts

When the deduced type surprises you

A generic myMax

template<typename T>
T myMax(T a, T b) {
	return a > b ? a : b;
}

What does the following call return? 🤔

myMax("hello", "world");

🙋‍♂️🙋‍♀️

We don't know the result 🤷‍♂️. Pointer comparison

The instantiated function is

const char* myMax(const char* a, const char* b) {
	return a > b ? a : b;
}

When the deduced type surprises you

Fix: Provide a function overload

A non-templated overload function gives a custom implementation for a specific type.

const char* myMax(const char* a, const char* b) {
	return std::strcmp(a, b) > 0 ? a : b;
}

Now myMax("hello", "world") returns the pointer to "world" 👍

💡 Out of reach for a macro: the pre-processor cannot overload 💡

Putting templates to work

Generic programming

Iterate over any kind of iterables

template<typename Iterable>
void print(Iterable& c) {
	for (const auto& element : c) {
		std::cout << element << "\n";
	}
}

Works with

Any standard containers (vector, list, set, map, ...)

std::vector<int> v = {1, 2, 3};
print(v);

C++ 20's span and ranges

std::span<int> s = {v};
print(s);

std::vector<int> v = {1, 2, 3, 4, 5};
auto even = v | std::views::filter([](int n) { return n % 2 == 0; });

print(even);

C-style arrays

int arr[] = {1, 2, 3};
print(arr);

Custom types

struct Graph { ... };
// Make Graph printable by adding an overload for print
void print(Graph& g) { ... }

Graph g;
print(g); // 👍

Extensible without ever touching print 👍

Putting templates to work

"Polymorphism" without inheritance

Polymorphic behaviour between unrelated types

template<typename T>
constexpr int legs(T& ll) { return ll.legs(); }

struct Cow {  // not in any class hierarchy
	constexpr int legs() { return 4; }
};

struct Duck { // not in any class hierarchy
	constexpr int legs() { return 2; }
};

int main()
{
	Cow c;
	Duck d;

	// resolved at compile time, no vtable, no common base
	static_assert(legs(c) == 4);
	static_assert(legs(d) == 2);
}

Example from Bjarne Stroustrup (apologies for the artificial example)

Variadic templates

A variable number of template parameters

The ellipsis ... says which parameter is a pack, and where it expands.

template<typename T, typename ...Ts>
T sum_all(Ts... args) {
    std::array<T, sizeof...(Ts)> arr = { args... }; // pack expansion fills the array
    return std::accumulate(arr.begin(), arr.end(), 0);
}
// Any number of arguments can be given to sum_all
auto total = sum_all<int>(1, 2); // total == 3

💡 T doesn't appears in any function parameter → it can only be given explicitly 💡

Variadic templates

sum_all under the hood 🔎

What the pack expansion really generates

cppinsights.io/s/b7e622a8

Source:

template<typename T, typename ...Ts>
T sum_all(Ts... args) {
	std::array<T, sizeof...(Ts)> arr = {args...};
	return std::accumulate(arr.begin(), arr.end(), 0);
}

auto total = sum_all<int>(1, 2);

Insight:

template<>
int sum_all<int, int, int>(int __args0, int __args1) {
	std::array<int, 2> arr = {{__args0, __args1}};
	return std::accumulate(arr.begin(), arr.end(), 0);
}

auto total = sum_all<int>(1, 2);

https://cppinsights.io/s/b7e622a8

⚠️ If sum_all is called with 3 arguments a new version is generated

Variadic templates

C++17's fold expressions

Syntax

(pattern op ...);

A fold repeats one expression for each element of the pack.

template<typename ...Args>
void print(Args... args) {
	((std::cout << args << " ") , ...);
}

Calling print(1, 2.5, "hello") prints 1 2.5 hello

ℹ️ Before C++17 you peeled the pack off one argument at a time, with a recursive overload and a non-template base case

Variadic templates

Under the hood: one call per pack element 🔎

cppinsights.io/s/16760da2

Source:

template<typename ...Args>
void print(Args... args)
{
	((std::cout << args << " ") , ...);
}

print(1, 2.5, "hello");

Insight:

template<>
void print<int, double, const char *>(int __args0,
                                       double __args1,
                                       const char * __args2)
{
	(std::cout << __args0 << " "),
	(std::cout << __args1 << " "),
	(std::cout << __args2 << " ");
}

print(1, 2.5, "hello");

https://cppinsights.io/s/16760da2

https://en.cppreference.com/cpp/language/fold

Perfect forwarding

Passing arguments on to another function while preserving their value category (lvalue or rvalue).

template<typename Args>
void relay(Args&& args) {
	consume(std::forward<Args>(args));
}

Textbook example: std::vector::emplace_back

template<typename ...Args>
reference emplace_back(Args&&... args);

push_back builds a temporary, then moves it

users.push_back(User{2, "Bob"});

emplace_back calls the constructor in place

users.emplace_back(2, "Bob");

https://godbolt.org/z/334GsGa4n

Perfect forwarding

The std::forward Catch

std::forward only works on a forwarding reference: a T&& where T is deduced by this very function.

template <typename T>
void relay(T&& arg) {
	consume(std::forward<T>(arg));
}

⚠️ These are not forwarding references ⚠️

void relay(std::vector<int>&& arg); // fixed type

template<typename T>
void relay(const T&& arg);          // const rvalue

Two more traps:

  • ⚠️ Using arg again after forwarding it → moved-from bug
  • ⚠️ std::move instead of std::forward → moves lvalues unconditionally

Forwarding references are also called universal references: Scott Meyers

Class/struct template

Defines a family of classes

A class template describes one class blueprint that the compiler instantiates for each type.

template<typename T>
class Stack {
private:
	std::vector<T> data;
public:
	void pop() { data.pop_back(); }
	const T& top() const { return data.back(); }
	bool empty() const { return data.empty(); }
	std::size_t size() const { return data.size(); }

	template <typename ...U>
	void emplace(U&&... args) { data.emplace_back(std::forward<U>(args)...); }
};

Stack<int> intStack;
Stack<float> floatStack;

Stack<int> and Stack<float> are different types generated from the same template.

Because Stack<int> and Stack<float> are types, they can be given as template parameter values

Perfect forwarding

The std::forward Catch reloaded

⚠️ Not all T&& are forwarding references

template <typename T> // T deduced HERE, at instantiation
class Stack {
	// ... other member functions ...

	// args looks like a forwarding reference but T is already fixed
	void emplace(T&&... args) { data.emplace_back(std::forward<T>(args)...); }
};

Example of instantiation: Stack<int> s;

struct Stack {
	// ... other member functions ...

	// ⚠️ Only accept rvalues references of type int
	void emplace(int&&... args) { data.emplace_back(std::forward<int>(args)...); }
};

Fix: make emplace templated

template <typename T>
struct Stack {
	// ... other member functions ...

	template <typename ...U>
	void emplace(U&&... args) {
		consume(std::forward<U>(args)...);
	}
};

Class/struct template

Function member instantiation

void useStack() {
	Stack<int> s{};
	s.emplace(5);
	std::printf("%d\n", s.top());
}

https://godbolt.org/z/n7K1PKdrs

Only the called member functions are instantiated

⚠️ A member function that would not compile for T stays silent until someone calls it

Class/struct template

Curiously Recurring Template Pattern (CRTP)

A class can pass itself as the template argument when deriving from it.

ℹ️ The base class knows the type CHILD of the class deriving from it

template <typename CHILD>
struct Base {
	void greet() {
		static_cast<CHILD*>(this)->greet_impl(); // Base knows the derived type
	}
};

struct Derived : public Base<Derived> { // pass yourself as the argument
	void greet_impl() { std::puts("hello"); }
};

Derived d;
d.greet(); // prints "hello", no virtual call

💡 Static polymorphism, resolved at compile time 💡

Let's see templates in action

🚀🚀🚀🚀🚀🚀🚀

Generic serializer using a policy-based design

  • Design a dump_object function to serialize an object into different formats (e.g., JSON, YAML)
  • It should use policy classes (sinks) to determine the output format
  • No inheritance required
  • Easy to extend with new sinks without modifying the function itself

Generic serializer using a policy-based design

template <typename Sink>
void dump_object(const Object& d)
{
	Sink::begin_object("object");
	Sink::field("id", d.id());
	Sink::field("name", d.name());
	Sink::end_object();
}
struct JsonSink {
	static void begin_object(std::string_view n) {
		std::printf("\"%s\": {\n", n.data());
	}
	static void field(std::string_view k, int v) {
		std::printf("  \"%s\": %d,\n", k.data(), v);
	}
	static void field(std::string_view k, std::string_view v) {
		std::printf("  \"%s\": \"%s\",\n", k.data(), v.data());
	}
	static void end_object() { std::printf("}\n"); }
};
struct YamlSink {
	static void begin_object(std::string_view n) {
		std::printf("%s:\n", n.data());
	}
	static void field(std::string_view k, int v) {
		std::printf("  %s: %d\n", k.data(), v);
	}
	static void field(std::string_view k, std::string_view v) {
		std::printf("  %s: %s\n", k.data(), v.data());
	}
	static void end_object() {}
};

Main function

int main() {
	Object apple{1, "apple"};
	std::printf("---------JSON-----------\n");
	dump_object<JsonSink>(apple);
	std::printf("---------YAML-----------\n");
	dump_object<YamlSink>(apple);
}

https://godbolt.org/z/crqP67bTW

Output:

---------JSON-----------
"object": {
  "id": 1,
  "name": "apple",
}
---------YAML-----------
object:
  id: 1
  name: "apple"

Generic abstraction layer

Register bank manager

  • Register bank is used to program the hardware
  • 2 set of registers bank exists: Active and Standby
  • Driver writes the standby bank using write(...)
    • write takes an unspecified number of parameters
    • In our case it is write(name, value)
  • Driver calls commit() to swap the active and standby banks
  • Bank-swap policy is decoupled from register access logic
  • The driver code is unaware of the active bank

Generic abstraction layer

Register bank manager

Driver code

int main()
{
	ActiveStandbyBank<MotorConfig> mgr(MotorConfig{});

	// Write to the current standby bank i.e bank 0
	if(!mgr.write("speed_rpm", 1500)) {
		std::cerr << "Cannot write speed_rpm\n";
	}
	if(!mgr.write("torque_limit", 80)) {
		std::cerr << "Cannot write torque_limit\n";
	}

	mgr.commit();

	// Now writing to bank 1 (the new standby bank)
	if(!mgr.write("speed_rpm", 1000)) {
		std::cerr << "Cannot write speed_rpm\n";
	}
}

Register access logic implementation

class MotorConfig
{
private:
	struct Config { uint16_t speed_rpm = 0; 
									uint16_t torque_limit = 0; };
	Config m_banks[2]{};
public:
	bool write(uint8_t bank_id, std::string_view field, uint16_t value)
	{
		if (field == "speed_rpm") {
			m_banks[bank_id].speed_rpm = value;
			return true;
		} else if (field == "torque_limit") {
			m_banks[bank_id].torque_limit = value;
			return true;
		}
		return false;
	}

	void commit(uint8_t bank_id)
	{
		// action to commit the bank
	}
};

Generic abstraction layer

Register bank manager

ActiveStandbyBank implementation

template <typename REGISTER_ACCESS_LOGIC>
class ActiveStandbyBank
{
private:
	REGISTER_ACCESS_LOGIC m_register_access_logic{};
	uint8_t	m_active_id = 0;
public:
	explicit ActiveStandbyBank(REGISTER_ACCESS_LOGIC register_access_logic)
		: m_register_access_logic(std::move(register_access_logic)){}

	uint8_t active_id() const  { return m_active_id; }
	uint8_t standby_id() const { return (m_active_id + 1) % 2; }

	template <typename ...Args>
	bool write(Args&&... args)
	{
		return m_register_access_logic.write(standby_id(), std::forward<Args>(args)...);
	}

	template <typename ...Args>
	void commit(Args&&... args)
	{
		const auto current_standby_id = standby_id();
		m_active_id = current_standby_id;
		m_register_access_logic.commit(current_standby_id, std::forward<Args>(args)...);
	}
};

https://godbolt.org/z/zd5EG59P4

Flexible and safe register accesses

Concept

  • Hardware is programmed through registers
  • Each register has fields of different sizes
  • Modifying a field is a generic read-modify-write (RMW) algorithm
    Read the register, clear the field's bits, set the new value, write it back

For instance: ARM PL011 UART has a register called UARTLCR_H

Bits Name
15:8 Reserved
7 SPS - Stick parity select
6:5 WLEN - Word length
4 FEN - Enable FIFOs
Bits Name
3 STP2 - Two stop bits select
2 EPS - Even parity select
1 PEN - Parity enable
0 BRK - Send break

Flexible and safe register accesses

Goal

  • Provide a Register::write(...) function to write the fields
    • Multiple fields can be written at once i.e multiple parameters
    • We can choose how many field to write at once
    • The order of parameters doesn't matter
  • Type safe

Client code

UARTLCR_H lcrh{registerAddress};

lcrh.write(
	// 8 data bits
	UARTLCR_H::WLEN{0b11},
	// enable FIFOs
	UARTLCR_H::FEN{0b1},
	// no parity
	UARTLCR_H::PEN{0b0}
);
UARTLCR_H lcrh{registerAddress};

lcrh.write(
	// enable FIFOs
	UARTLCR_H::FEN{0b1},
	// no parity
	UARTLCR_H::PEN{0b0},
	// 8 data bits
	UARTLCR_H::WLEN{0b11}
);

Flexible and safe register accesses

UARTLCR_H register definition

// Use CRTP to give the parent class the type of the register it abstracts
struct UARTLCR_H : public Register<UARTLCR_H> {
	UARTLCR_H(volatile uint32_t* regAddr):Register{regAddr} {}

	struct WLEN {
		using REG = UARTLCR_H;
		static constexpr uint32_t FIELD_OFFSET = 5;
		static constexpr uint32_t FIELD_SIZE = 2;
		static constexpr uint32_t FIELD_MASK = ((1 << FIELD_SIZE) - 1) << FIELD_OFFSET;
		uint32_t value;
		WLEN(uint32_t value):value{value} {}
	};

	struct PEN {
		using REG = UARTLCR_H;
		static constexpr uint32_t FIELD_OFFSET = 1;
		static constexpr uint32_t FIELD_SIZE = 1;
		static constexpr uint32_t FIELD_MASK = ((1u << FIELD_SIZE) - 1) << FIELD_OFFSET;
		uint32_t value;
		PEN(uint32_t value):value{value} {}
	};

    // Other fields implementation...
};

ℹ️ Each sub-structure implements the implicit requirements of the register abstraction

Flexible and safe register accesses

Register abstraction implementation

template<typename CHILD>
class Register{
private:
	volatile uint32_t* value; // Points to the actual memory-mapped register
public:
	Register(volatile uint32_t* regAddr):value{regAddr}
	{}

	uint32_t& operator*() {
		return *value;
	}

	template <typename ...FIELD>
	void write(FIELD... field)
	{
		static_assert((std::is_same_v<typename FIELD::REG, CHILD> && ...), 
									"Using field from the wrong register");
		uint32_t curVal = *value; // Read value

		auto applyField = [&](auto f)
		{
			using FIELD_TYPE = decltype(f);
			curVal &= ~FIELD_TYPE::FIELD_MASK;                                        // Clear the field 
			curVal |= (f.value << FIELD_TYPE::FIELD_OFFSET) & FIELD_TYPE::FIELD_MASK; // Set the field's value
		};
		(applyField(field), ...);

		*value = curVal; // Write back value
	}
};

👍 Any number of fields

👍 Fields order doesn't matter

💡 static_assert + <type_traits> ensure fields from the right register are used 💡

ℹ️ The fold expression executes the lambda for each field

Flexible and safe register accesses

What does all of this cost?

The abstraction executes a lambda for each field...

uint32_t testValueAbstraction() {
	uint32_t v{0};
	UARTLCR_H lcrh{&v};
	lcrh.write(
		UARTLCR_H::WLEN{0b11},
		UARTLCR_H::FEN{0b1},
		UARTLCR_H::PEN{0b0}
	);
	return *lcrh;
}
uint32_t testValueDirect() {
	volatile uint32_t v{0};

	v = (v & ~0x72) | 0x70;

	return v;
}

Is the abstraction slower, faster or the same?

🙋‍♂️🙋‍♀️

Flexible and safe register accesses

True zero cost abstraction

uint32_t testValueAbstraction() {
	uint32_t v{0};
	UARTLCR_H lcrh{&v};
	lcrh.write(
		UARTLCR_H::WLEN{0b11},
		UARTLCR_H::FEN{0b1},
		UARTLCR_H::PEN{0b0}
	);
	return *lcrh;
}
testValueAbstraction():
		sub     sp, sp, #16
		str     wzr, [sp, 12]
		ldr     w0, [sp, 12]
		and     w0, w0, -3
		orr     w0, w0, 112
		str     w0, [sp, 12]
		ldr     w0, [sp, 12]
		add     sp, sp, 16
		ret
uint32_t testValueDirect() {
	volatile uint32_t v{0};

	v = (v & ~0x72) | 0x70;

	return v;
}



testValueDirect():
		sub     sp, sp, #16
		str     wzr, [sp, 12]
		ldr     w0, [sp, 12]
		and     w0, w0, -3
		orr     w0, w0, 112
		str     w0, [sp, 12]
		ldr     w0, [sp, 12]
		add     sp, sp, 16
		ret
https://godbolt.org/z/dfKK4nKoh

Conclusion: Key Takeaways

  • 🔑 Template is a very powerful feature of C++
  • 🔑 😈 Beware of the implicit requirements 😈
  • 🔑 Replace your macros with templates
  • 🔑 Generic programming
  • 🔑 Allows extensibility
  • 🔑 Good template abstraction can simplify client code without compromise on performance

Don't be scared of templates 👻

The end!

Conquering templates

Questions?

🙋‍♂️🙋‍♀️

Slides available at: laurentcarlier.com/b2b-template-cppcon-2026/