Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

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> and Vector<std::string> are separate types.
  • Change only the ints that meant element; indices and counts stay std::size_t.
  • Take const T& rather than T, since you do not know T's copy cost.
  • Move rather than copy when reallocating, or the template is slow for expensive types.
  • Inside the class, Vector means Vector<T>; outside, write Vector<T> in full.
  • Out-of-class definitions repeat template <typename T> and use Vector<T>::.
  • A default argument like typename Tag = std::string must 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.