Lesson 3 of 8
Lesson 11.6 built IntVector. It works for int and nothing else. This lesson makes it work for
every type — and the result is, structurally, std::vector.
The change
A class template is declared the same way as a function template:
template <typename T>
class Vector {
T* items;
// ...
};
Then every int that meant "the element type" becomes T. Every int that meant something
else — an index, a count — stays as it was. Getting that distinction right is the whole exercise.
The full class
#include <iostream>
#include <stdexcept>
#include <string>
#include <utility>
template <typename T>
class Vector {
T* items = nullptr;
std::size_t count = 0;
std::size_t space = 0;
void grow_to(std::size_t new_space) {
T* fresh = new T[new_space];
for (std::size_t i = 0; i < count; ++i) {
fresh[i] = std::move(items[i]);
}
delete[] items;
items = fresh;
space = new_space;
}
public:
Vector() = default;
~Vector() { delete[] items; }
Vector(const Vector& other)
: items(new T[other.space]), count(other.count), space(other.space) {
for (std::size_t i = 0; i < count; ++i) {
items[i] = other.items[i];
}
}
Vector(Vector&& other) noexcept
: items(other.items), count(other.count), space(other.space) {
other.items = nullptr;
other.count = 0;
other.space = 0;
}
Vector& operator=(Vector other) noexcept {
std::swap(items, other.items);
std::swap(count, other.count);
std::swap(space, other.space);
return *this;
}
void push_back(const T& value) {
if (count == space) {
grow_to(space == 0 ? 1 : space * 2);
}
items[count] = value;
++count;
}
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 >= count) {
throw std::out_of_range("Vector::at index out of range");
}
return items[i];
}
std::size_t size() const { return count; }
std::size_t capacity() const { return space; }
bool empty() const { return count == 0; }
};
int main() {
Vector<int> numbers;
numbers.push_back(10);
numbers.push_back(20);
numbers.push_back(30);
std::cout << "ints: ";
for (std::size_t i = 0; i < numbers.size(); ++i) {
std::cout << numbers[i] << " ";
}
std::cout << "(size " << numbers.size() << " capacity " << numbers.capacity() << ")\n";
Vector<std::string> words;
words.push_back("alpha");
words.push_back("beta");
std::cout << "strings: " << words[0] << " " << words[1] << "\n";
Vector<std::string> copy = words;
copy[0] = "changed";
std::cout << "original " << words[0] << " copy " << copy[0] << "\n";
try {
words.at(99);
} catch (const std::out_of_range& e) {
std::cout << "caught: " << e.what() << "\n";
}
return 0;
}
ints: 10 20 30 (size 3 capacity 4)
strings: alpha beta
original alpha copy changed
caught: Vector::at index out of range
Builds clean under -Wall -Wextra -Wpedantic -Werror and runs clean under
-fsanitize=address,undefined. The same class now stores int and std::string with no separate
implementation.
What changed, and what deliberately did not
int* items became T* items. The element type.
std::size_t count stayed. It is a count of elements, not an element.
push_back(int) became push_back(const T&). With an unknown T the copy may be expensive,
so take a reference (lesson 12.2). std::vector also has a T&& overload, which is lesson 12.6's
territory.
grow_to now moves rather than copies. With int there is no difference. With
std::string it is the difference between reallocating every buffer and swapping pointers
(lesson 9.9). The std::move is what makes the template scale to expensive types.
get_capacity() became capacity(). In lesson 11.6 the member capacity occupied the name.
Renaming the member to space frees it, and now the class matches std::vector's interface.
Where the <T> goes, and where it does not
Inside the class body, Vector alone means Vector<T> — the injected class name. That is why
the copy constructor reads Vector(const Vector& other) rather than
Vector(const Vector<T>& other). Both compile; the short form is conventional.
Outside the class you must be explicit:
Vector<int> a; // fine
// Vector b; // error: which T?
void takes(const Vector<int>& v); // fine
template <typename T>
void takes_any(const Vector<T>& v); // fine — deduces T
Defining members outside the class
For a longer class you may want the definitions separate. The syntax repeats the template header:
template <typename T>
class Vector {
public:
void push_back(const T& value); // declaration
std::size_t size() const;
};
// Definition: the template header is repeated, and the class name carries <T>.
template <typename T>
void Vector<T>::push_back(const T& value) {
// ...
}
template <typename T>
std::size_t Vector<T>::size() const {
return count;
}
Note Vector<T>:: and not Vector:: — outside the class the injected name is gone.
Both the declaration and the definition must still be in the header. Splitting a template
across a .h and a .cpp produces a linker error rather than a compiler error, which is why it
is so confusing the first time (lesson 12.8).
Default arguments, and members with their own parameters
#include <iostream>
#include <string>
#include <vector>
// A class template with a default argument and a member function template.
template <typename T, typename Tag = std::string>
class Labelled {
Tag label;
T value;
public:
Labelled(Tag l, T v) : label(std::move(l)), value(std::move(v)) {}
// A member function template: its own parameter, independent of T.
template <typename U>
bool same_value_as(const Labelled<U, Tag>& other) const {
return value == other.get();
}
const T& get() const { return value; }
const Tag& name() const { return label; }
};
int main() {
Labelled<int> count("hits", 42);
std::cout << count.name() << " = " << count.get() << "\n";
Labelled<double> ratio("ratio", 42.0);
std::cout << ratio.name() << " = " << ratio.get() << "\n";
std::cout << std::boolalpha << count.same_value_as(ratio) << "\n";
// C++17 class template argument deduction: no <> needed.
Labelled deduced(std::string("auto"), 7);
std::cout << deduced.name() << " = " << deduced.get() << "\n";
return 0;
}
hits = 42
ratio = 42
true
auto = 7
Three features in one program.
A default argument. Labelled<int> means Labelled<int, std::string>. Same rule as default
function arguments: defaults must come last.
A member function template. same_value_as has its own parameter U, so a Labelled<int> can
be compared with a Labelled<double>. The class is instantiated with one type; the member is
instantiated separately with another.
Class template argument deduction (CTAD, C++17). Labelled deduced(std::string("auto"), 7)
works out Tag = std::string and T = int from the constructor arguments. Before C++17 you had to
write Labelled<int, std::string>, which is why older code has helper functions like
std::make_pair — CTAD made most of them unnecessary.
Only what you use gets compiled
A member function of a class template is instantiated only if it is called. So this compiles:
// Vector<Widget> holds a type with no operator<.
// A member using < would be an error — but only if you call it.
// template <typename T>
// class Vector {
// public:
// void sort(); // uses < on T
// std::size_t size() const;
// };
//
// Vector<Widget> v;
// v.size(); // fine: sort() was never instantiated
// v.sort(); // error, and only now
This is why std::vector<T> works for types that are not comparable, not printable, and not
default-constructible — you simply must not call the members that need those things.
It is also a trap: a member with a mistake in it can sit in a template for months, compiling cleanly, until someone calls it.
Try it
Turn a fixed-size stack into a class template Stack<T> with push, pop, top, empty, and
size. Use it with int and std::string. Then add a member function template push_all that
accepts a Stack<U> of a different type and pushes its elements, converting each.
Checkpoint
- A class template is a recipe for a class;
Vector<int>andVector<std::string>are separate types. - Change only the
ints that meant element; indices and counts staystd::size_t. - Take
const T&rather thanT, since you do not knowT's copy cost. - Move rather than copy when reallocating, or the template is slow for expensive types.
- Inside the class,
VectormeansVector<T>; outside, writeVector<T>in full. - Out-of-class definitions repeat
template <typename T>and useVector<T>::. - A default argument like
typename Tag = std::stringmust come last. - Member function templates get their own parameter, independent of the class's.
- CTAD (C++17) deduces class template arguments from constructor arguments.
- Members are instantiated only when called — which is both a feature and a trap.
Questions about this lesson
Log in to ask a question.