Lesson 5 of 8
A template parameter does not have to be a type. It can be a value, known at compile time.
That is what makes std::array<int, 5> different from std::vector<int>: the 5 is part of the
type.
The syntax
template <typename T, std::size_t N>
class FixedArray { /* ... */ };
typename T introduces a type; std::size_t N introduces a value. The value must be a compile-
time constant — a literal, a constexpr, or another template parameter. A run-time variable
will not do.
A fixed-size array
#include <iostream>
#include <stdexcept>
// N is a value, not a type. It must be known at compile time.
template <typename T, std::size_t N>
class FixedArray {
T items[N] = {};
public:
T& operator[](std::size_t i) { return items[i]; }
const T& operator[](std::size_t i) const { return items[i]; }
T& at(std::size_t i) {
if (i >= N) {
throw std::out_of_range("FixedArray::at index out of range");
}
return items[i];
}
// N is a compile-time constant, so size() is too.
static constexpr std::size_t size() { return N; }
};
int main() {
FixedArray<int, 5> a;
for (std::size_t i = 0; i < a.size(); ++i) {
a[i] = static_cast<int>(i * i);
}
for (std::size_t i = 0; i < a.size(); ++i) {
std::cout << a[i] << " ";
}
std::cout << "\n";
std::cout << "sizeof(FixedArray<int, 5>) = " << sizeof(FixedArray<int, 5>) << "\n";
std::cout << "sizeof(FixedArray<int, 100>) = " << sizeof(FixedArray<int, 100>) << "\n";
// The size is part of the type, so this is checked at compile time:
static_assert(FixedArray<int, 5>::size() == 5);
try {
a.at(99);
} catch (const std::out_of_range& e) {
std::cout << "caught: " << e.what() << "\n";
}
return 0;
}
0 1 4 9 16
sizeof(FixedArray<int, 5>) = 20
sizeof(FixedArray<int, 100>) = 400
caught: FixedArray::at index out of range
Read the sizeof line. There is no pointer and no heap allocation — the elements live inside
the object, so a FixedArray<int, 5> is exactly five ints. Compare Vector<T> from lesson 12.3,
which is 24 bytes regardless of how much it holds, with the elements on the heap.
That is the real trade. std::array is stack-allocated and its size is fixed at compile time;
std::vector is heap-allocated and can grow. Neither is better — they answer different questions.
static_assert(FixedArray<int, 5>::size() == 5) is checked while compiling. If it were false the
build would fail, with no program to run.
What can be a non-type parameter
Since C++20, more than you might expect:
#include <iostream>
// C++20 allows a floating-point non-type parameter, and a class type too.
template <double Scale>
double apply(double x) {
return x * Scale;
}
struct Point {
int x;
int y;
};
template <Point P>
void show() {
std::cout << "compile-time point (" << P.x << ", " << P.y << ")\n";
}
int main() {
std::cout << apply<2.5>(4.0) << "\n";
show<Point{3, 4}>();
return 0;
}
10
compile-time point (3, 4)
Both of those are C++20 features. Compiled as C++17 the same file gives:
error: 'double' is not a valid type for a template non-type parameter
error: non-type template parameters of class type only available with '-std=c++20'
The permitted kinds, in order of how often you will see them:
- Integers and
bool— by far the most common, and the only kind before C++20. charand enumerations — useful for tagging.- Pointers and references to objects with static storage — rare.
- Floating point (C++20).
- Literal class types (C++20) — a
structof constant members, as above.
What is not allowed: std::string, or anything requiring a heap allocation. A compile-time
string has to be a const char* or a fixed character array.
Compile-time computation
Because the parameter is a constant, the compiler can compute with it:
#include <array>
#include <iostream>
#include <string>
// A compile-time computation: the value is produced while compiling.
template <int N>
struct Factorial {
static constexpr long long value = N * Factorial<N - 1>::value;
};
template <>
struct Factorial<0> {
static constexpr long long value = 1;
};
// Units checked by the type system: metres and feet cannot be added.
template <int MetreExponent>
class Length {
double amount;
public:
explicit constexpr Length(double a) : amount(a) {}
constexpr double value() const { return amount; }
};
using Metres = Length<1>;
using SquareMetres = Length<2>;
constexpr Metres operator+(Metres a, Metres b) { return Metres(a.value() + b.value()); }
constexpr SquareMetres operator*(Metres a, Metres b) { return SquareMetres(a.value() * b.value()); }
int main() {
// Computed at compile time — the binary contains the number, not the loop.
std::cout << "10! = " << Factorial<10>::value << "\n";
static_assert(Factorial<5>::value == 120);
Metres width(3.0);
Metres height(4.0);
std::cout << "perimeter part " << (width + height).value() << " m\n";
std::cout << "area " << (width * height).value() << " m^2\n";
// Adding a Metres to a SquareMetres does not compile — the exponents differ,
// so they are different types.
// std::array is exactly this: template <typename T, std::size_t N>
std::array<int, 4> fixed = {1, 2, 3, 4};
std::cout << "std::array size " << fixed.size()
<< ", sizeof " << sizeof(fixed) << "\n";
return 0;
}
10! = 3628800
perimeter part 7 m
area 12 m^2
std::array size 4, sizeof 16
Two patterns there, both worth knowing.
Factorial<N> is recursion in the type system: the general template refers to
Factorial<N-1>, and the specialization at 0 stops it. The result is a constant in the binary —
there is no loop at run time. This is template metaprogramming, and it was the only way to
compute at compile time before C++11. Today, write a constexpr function instead; it reads like
ordinary code and does the same job. The pattern is here because you will meet it in older
libraries.
Length<Exponent> encodes units in the type. Metres and SquareMetres are different types,
so width + area does not compile. width * height returns SquareMetres because the operator
says so. A whole class of unit-mixing bugs — the kind that has lost spacecraft — becomes
impossible, at zero run-time cost, because the exponent exists only while compiling.
The cost: one instantiation per value
Each distinct value produces a separate instantiation, exactly as each distinct type does:
#include <array>
#include <cstddef>
#include <iostream>
template <std::size_t N>
int sum_of(const std::array<int, N>& values) {
int total = 0;
for (std::size_t i = 0; i < N; ++i) {
total += values[i];
}
return total;
}
int main() {
std::array<int, 3> a = {1, 2, 3};
std::array<int, 4> b = {1, 2, 3, 4};
std::array<int, 5> c = {1, 2, 3, 4, 5};
std::cout << sum_of(a) << " " << sum_of(b) << " " << sum_of(c) << "\n";
return 0;
}
6 10 15
Three sizes, three functions in the binary:
$ nm -C ./sums | grep sum_of
00000000000012a6 W int sum_of<3ul>(std::array<int, 3ul> const&)
00000000000012f0 W int sum_of<4ul>(std::array<int, 4ul> const&)
000000000000133a W int sum_of<5ul>(std::array<int, 5ul> const&)
That is fine for three sizes. Instantiate a large class template over fifty different sizes and you get fifty copies of every member — the binary grows, and compilation slows. This is template code bloat, and non-type parameters cause more of it than type parameters do, because values vary more than types do.
The usual fix is to put the size-independent work in a non-template function taking a pointer and
a length (or, better, a std::span — lesson 13.11), and keep only a thin size-aware wrapper as
the template.
std::array versus std::vector
| | std::array<T, N> | std::vector<T> |
|---|---|---|
| Size known | at compile time | at run time |
| Storage | in the object (stack, if the object is) | heap |
| Can grow | no | yes |
| sizeof | N * sizeof(T) | fixed, usually 24 |
| Size is part of the type | yes | no |
| Passing to a function | size is in the type | size is a member |
Use std::array when the size is genuinely fixed and known — a 3D coordinate, a lookup table, a
fixed-size buffer. Use std::vector otherwise. The compile-time size buys you no allocation and
better optimisation, at the cost of a separate instantiation per size.
Try it
Write Matrix<T, Rows, Cols> with operator()(row, col) access, a transpose() returning
Matrix<T, Cols, Rows>, and multiplication that only compiles when the dimensions agree. Prove
the dimension check works by writing a multiplication that should be rejected.
Checkpoint
- A template parameter can be a value, not just a type:
std::array<int, 5>. - The value must be a compile-time constant.
- Integers,
bool,char, and enums have always worked; C++20 adds floats and literal classes. std::stringcannot be a non-type parameter.- The size becomes part of the type, so it can be checked with
static_assert. - Encoding units or dimensions in the type makes whole bug classes impossible for free.
Factorial<N>shows compile-time recursion — real, but write aconstexprfunction instead.- Each distinct value creates a separate instantiation, so watch for code bloat.
std::arrayis compile-time sized and stack-allocated;std::vectoris neither.
Questions about this lesson
Log in to ask a question.