Template parameters aren't limited to types. C++ templates can also accept values as parameters, known as non-type parameters. While type parameters let you parameterize which types a template uses, non-type parameters let you parameterize constant values that configure template behavior.

What are non-type parameters?

A non-type parameter is a template parameter with a predefined type that accepts a compile-time constant value. The value becomes part of the template's identity, allowing the compiler to optimize and specialize code based on that value.

Non-type parameters can be:

  • Integral types (int, long, char, bool, etc.)
  • Enumeration types
  • Pointers or references to objects
  • Pointers or references to functions
  • Pointers to member functions
  • std::nullptr_t
  • Floating-point types (since C++20)

Fixed-size buffer with non-type parameters

Consider a scenario where you want a buffer with compile-time fixed capacity. Using a non-type parameter, you can specify the size at compile time:

#include <iostream>

template <typename T, int Capacity>
class FixedBuffer
{
private:
    T m_data[Capacity]{};
    int m_count{0};

public:
    void add(const T& value)
    {
        if (m_count < Capacity)
            m_data[m_count++] = value;
    }

    const T& get(int index) const
    {
        return m_data[index];
    }

    T& operator[](int index)
    {
        return m_data[index];
    }

    int size() const { return m_count; }
    int capacity() const { return Capacity; }
};

int main()
{
    FixedBuffer<int, 8> numbers;

    for (int i{0}; i < 8; ++i)
        numbers.add(i * 5);

    std::cout << "Buffer contents: ";
    for (int i{0}; i < numbers.size(); ++i)
        std::cout << numbers.get(i) << ' ';
    std::cout << '\n';

    std::cout << "Capacity: " << numbers.capacity() << '\n';

    return 0;
}

Output:

Buffer contents: 0 5 10 15 20 25 30 35
Capacity: 8

In this example, Capacity is a non-type parameter of type int. When you create FixedBuffer<int, 8>, the compiler replaces Capacity with 8, making m_data an array of size 8. Since the size is known at compile time, the array can be allocated on the stack rather than requiring dynamic allocation.

Why use non-type parameters?

Non-type parameters provide several advantages:

1. No dynamic allocation needed

Since the size is known at compile time, you can use stack-allocated arrays:

template <typename T, int Size>
class CircularQueue
{
private:
    T m_buffer[Size]{};  // Stack allocation, no new/delete needed
    int m_head{0};
    int m_tail{0};
    int m_count{0};

public:
    void enqueue(const T& item)
    {
        if (m_count < Size)
        {
            m_buffer[m_tail] = item;
            m_tail = (m_tail + 1) % Size;
            ++m_count;
        }
    }

    T dequeue()
    {
        T value{m_buffer[m_head]};
        m_head = (m_head + 1) % Size;
        --m_count;
        return value;
    }

    int size() const { return m_count; }
    bool isEmpty() const { return m_count == 0; }
    bool isFull() const { return m_count == Size; }
};

2. Better performance

Compile-time constants enable optimizations that runtime values don't:

int main()
{
    CircularQueue<double, 4> temps;

    temps.enqueue(98.6);
    temps.enqueue(99.1);
    temps.enqueue(98.8);

    while (!temps.isEmpty())
        std::cout << temps.dequeue() << '\n';

    return 0;
}

The compiler knows the exact size and can optimize array access without bounds checking overhead.

3. Type safety

Different sizes create different types, preventing accidental mixing:

FixedBuffer<int, 10> buf10;
FixedBuffer<int, 20> buf20;

// These are different types and cannot be assigned to each other
// buf10 = buf20;  // Compilation error

Defining member functions outside the class

When defining member functions outside a template class with non-type parameters, you must include all template parameters:

template <typename T, int Rows, int Cols>
class Matrix
{
private:
    T m_data[Rows][Cols]{};

public:
    T& at(int row, int col);
    const T& at(int row, int col) const;

    void fill(const T& value);
    void print() const;
};

template <typename T, int Rows, int Cols>
T& Matrix<T, Rows, Cols>::at(int row, int col)
{
    return m_data[row][col];
}

template <typename T, int Rows, int Cols>
const T& Matrix<T, Rows, Cols>::at(int row, int col) const
{
    return m_data[row][col];
}

template <typename T, int Rows, int Cols>
void Matrix<T, Rows, Cols>::fill(const T& value)
{
    for (int r{0}; r < Rows; ++r)
        for (int c{0}; c < Cols; ++c)
            m_data[r][c] = value;
}

template <typename T, int Rows, int Cols>
void Matrix<T, Rows, Cols>::print() const
{
    for (int r{0}; r < Rows; ++r)
    {
        for (int c{0}; c < Cols; ++c)
            std::cout << m_data[r][c] << ' ';
        std::cout << '\n';
    }
}

Usage:

#include <iostream>

int main()
{
    Matrix<int, 3, 3> mat;

    mat.fill(0);
    mat.at(0, 0) = 1;
    mat.at(1, 1) = 2;
    mat.at(2, 2) = 3;

    std::cout << "3x3 Matrix:\n";
    mat.print();

    return 0;
}

Output:

3x3 Matrix:
1 0 0
0 2 0
0 0 3

Non-type parameters must be constexpr

The value passed to a non-type parameter must be known at compile time. This means it must be a literal or constexpr value:

template <int Size>
class Ring
{
private:
    int m_buffer[Size]{};

public:
    // ...
};

int main()
{
    constexpr int bufSize{16};
    Ring<16> ring1;      // OK: literal value
    Ring<bufSize> ring2; // OK: constexpr variable

    int dynamicSize{32};
    Ring<dynamicSize> ring3; // Error: dynamicSize is not constexpr

    return 0;
}

This restriction exists because the compiler needs to know the value during compilation to generate code.

Real-world example: std::array

The C++ Standard Library's std::array uses non-type parameters. When you write:

#include <array>

std::array<int, 5> numbers{};

You're using a template with a type parameter (int) and a non-type parameter (5). The 5 becomes part of the type, making std::array<int, 5> fundamentally different from std::array<int, 10>.

Here's a simplified view of how std::array might be implemented:

template <typename T, std::size_t N>
class array
{
private:
    T m_elements[N];

public:
    constexpr std::size_t size() const { return N; }

    T& operator[](std::size_t index) { return m_elements[index]; }
    const T& operator[](std::size_t index) const { return m_elements[index]; }

    // Additional member functions...
};

Combining type and non-type parameters

You can mix multiple type and non-type parameters for sophisticated compile-time configuration:

#include <iostream>
#include <cstring>

template <typename CharT, int MaxLength>
class FixedString
{
private:
    CharT m_buffer[MaxLength + 1]{}; // +1 for null terminator
    int m_length{0};

public:
    FixedString() = default;

    FixedString(const CharT* str)
    {
        int len{0};
        while (str[len] && len < MaxLength)
        {
            m_buffer[len] = str[len];
            ++len;
        }
        m_length = len;
        m_buffer[m_length] = '\0';
    }

    void append(CharT ch)
    {
        if (m_length < MaxLength)
        {
            m_buffer[m_length++] = ch;
            m_buffer[m_length] = '\0';
        }
    }

    const CharT* c_str() const { return m_buffer; }
    int length() const { return m_length; }
    int maxLength() const { return MaxLength; }
};

int main()
{
    FixedString<char, 20> name{"Alice"};
    name.append('!');

    std::cout << "Name: " << name.c_str() << '\n';
    std::cout << "Length: " << name.length() << '/' << name.maxLength() << '\n';

    FixedString<wchar_t, 10> wideName{L"Bob"};
    std::wcout << L"Wide name: " << wideName.c_str() << '\n';

    return 0;
}

This template has both a type parameter (CharT for the character type) and a non-type parameter (MaxLength for the maximum string size).

Default values for non-type parameters

Non-type parameters can have default values:

template <typename T, int Capacity = 100>
class Cache
{
private:
    T m_items[Capacity]{};
    int m_size{0};

public:
    void store(const T& item)
    {
        if (m_size < Capacity)
            m_items[m_size++] = item;
    }

    int size() const { return m_size; }
    int capacity() const { return Capacity; }
};

int main()
{
    Cache<int> defaultCache;      // Uses default capacity of 100
    Cache<int, 50> smallCache;    // Uses capacity of 50

    std::cout << "Default capacity: " << defaultCache.capacity() << '\n';
    std::cout << "Small capacity: " << smallCache.capacity() << '\n';

    return 0;
}

Common use cases

Non-type parameters excel in these scenarios:

  1. Fixed-size containers: Arrays, buffers, matrices with compile-time sizes
  2. Compile-time algorithms: Unrolled loops, compile-time calculations
  3. Policy-based design: Configuring behavior with integral constants
  4. Memory optimization: Avoiding dynamic allocation for fixed-size data

Non-type parameters are a powerful feature that enables compile-time configuration and optimization. They work hand-in-hand with type parameters to create flexible, efficient template classes.