Templates

Just as a function parameterizes values, templates parameterize types. Types include primitives such as int as well as user-defined classes.

Class Templates

Class templates define a class where some types, return types of methods or parameters to those methods are specified as parameters for client code to instantiate. Class templates are suited for container objects. The following discussion uses the Grid class shown in Professional C++. All the following examples are from Professional C++ as well.

Defining a class template

A generic game board can be envisioned which stores chess pieces, checkers pieces, tic-tac-toe pieces or any other 2D game board pieces. Without templates, the approach to take would be polymorphism. GamePiece is the base class and ChessPiece would derive from it. To copy a GameBoard, one has to be able to copy GamePieces. A pure virtual clone method could be used:
class GamePiece
{
    public: 
        virtual std::unique_ptr<GamePiece> clone() const = 0;
}
ChessPiece implements the clone method:
class ChessPiece : public GamePiece
{
    public: 
        virtual std::unique_ptr<GamePiece> clone() const override;
}

std::unique_ptr<GamePiece> ChessPiece::clone() const
{
    return std::make_unique<ChessPiece>(*this); //use the copy constructor to copy the instance.
}
The implementation of Gameboard uses a vector of vector:
class GameBoard
{
    public:
        explicit GameBoard(size_t width = kDefaultWidth,
            size_t height = kDefaultHeight);
        GameBoard(const GameBoard& src);   // copy constructor
        virtual ~GameBoard() = default;    // virtual defaulted destructor
        GameBoard& operator=(const GameBoard& rhs); // assignment operator

        GameBoard(GameBoard&& src) = default;
        GameBoard& operator=(GameBoard&& src) = default;

        std::unique_ptr<GamePiece>& at(size_t x, size_t y);
        const std::unique_ptr<GamePiece>& at(size_t x, size_t y) const;

        size_t getHeight() const { return mHeight; }
        size_t getWidth() const { return mWidth; }

        static const size_t kDefaultWidth = 10;
        static const size_t kDefaultHeight = 10;

        friend void swap(GameBoard& first, GameBoard& second) noexcept;

    private:
        void verifyCoordinate(size_t x, size_t y) const;

        std::vector<std::vector<std::unique_ptr<GamePiece>>> mCells;
        size_t mWidth, mHeight;
};
The implementation is as follows:
GameBoard::GameBoard(size_t width, size_t height)
    : mWidth(width), mHeight(height)
{
    mCells.resize(mWidth);
    for (auto& column : mCells) {
        column.resize(mHeight);
    }
}

GameBoard::GameBoard(const GameBoard& src)
    : GameBoard(src.mWidth, src.mHeight)
{
    // The ctor-initializer of this constructor delegates first to the
    // non-copy constructor to allocate the proper amount of memory.

    // The next step is to copy the data.
    for (size_t i = 0; i < mWidth; i++) {
        for (size_t j = 0; j < mHeight; j++) {
            if (src.mCells[i][j])
                mCells[i][j] = src.mCells[i][j]->clone();
        }
    }
}

void GameBoard::verifyCoordinate(size_t x, size_t y) const
{
    if (x >= mWidth || y >= mHeight) {
        throw std::out_of_range("");
    }
}

void swap(GameBoard& first, GameBoard& second) noexcept
{
    using std::swap;

    swap(first.mWidth, second.mWidth);
    swap(first.mHeight, second.mHeight);
    swap(first.mCells, second.mCells);
}

GameBoard& GameBoard::operator=(const GameBoard& rhs)
{
    // Check for self-assignment
    if (this == &rhs) {
        return *this;
    }

    // Copy-and-swap idiom
    GameBoard temp(rhs); // Do all the work in a temporary instance
    swap(*this, temp); // Commit the work with only non-throwing operations
    return *this;
}

const unique_ptr<GamePiece>& GameBoard::at(size_t x, size_t y) const
{
    verifyCoordinate(x, y);
    return mCells[x][y];
}

unique_ptr<GamePiece>& GameBoard::at(size_t x, size_t y)
{
    return const_cast<unique_ptr<GamePiece>&>(as_const(*this).at(x, y));
}
The problems with this implementation are as follows:
  • There is no way to store elements by value--everything is stored using pointers.
  • No way to store primitives such as ints
  • Even when storing objects of derived classes like ChessPiece, one gets back GamePiece and has to downcast it.
  • Programmer has to remember which derived class's object each cell in the Gameboard is storing in order to properly downcast it.

Grid class

With templates, the Gameboard transforms into the generic Grid class:
template <typename T>
class Grid
{
    public:
        explicit Grid(size_t width = kDefaultWidth,
            size_t height = kDefaultHeight);
        virtual ~Grid() = default;

        // Explicitly default a copy constructor and assignment operator.
        Grid(const Grid& src) = default;
        Grid<T>& operator=(const Grid& rhs) = default;

        // Explicitly default a move constructor and assignment operator.
        Grid(Grid&& src) = default;
        Grid<T>& operator=(Grid&& rhs) = default;

        std::optional<T>& at(size_t x, size_t y);
        const std::optional<T>& at(size_t x, size_t y) const;

        size_t getHeight() const { return mHeight; }
        size_t getWidth() const { return mWidth; }

        static const size_t kDefaultWidth = 10;
        static const size_t kDefaultHeight = 10;

    private:
        void verifyCoordinate(size_t x, size_t y) const;

        std::vector<std::vector<std::optional<T>>> mCells;
        size_t mWidth, mHeight;
};
From the above code, note that the compiler interprets Grid as Grid<T>. However, outside the class definition, the <T> cannot be omitted. Templates require one to put the implementation of the methods in the header file itself because the compiler needs to know the complete definition before it can create an instance of the template. template<typename T> specifier must precede each method definition. Before the scope resolution operator, the name should always be Grid<T>, and not Grid. The implementations are as follows:
template <typename T>
Grid<T>::Grid(size_t width, size_t height)
    : mWidth(width), mHeight(height)
{
    mCells.resize(mWidth);
    for (auto& column : mCells) {
    // Equivalent to:
    //for (std::vector<std::optional<T>>& column : mCells) {
        column.resize(mHeight);
    }
}
template <typename T>
void Grid<T>::verifyCoordinate(size_t x, size_t y) const
{
    if (x >= mWidth || y >= mHeight) {
        throw std::out_of_range("");
    }
}

template <typename T>
const std::optional<T>& Grid<T>::at(size_t x, size_t y) const
{
    verifyCoordinate(x, y);
    return mCells[x][y];
}

template <typename T>
std::optional<T>& Grid<T>::at(size_t x, size_t y)
{
    return const_cast<std::optional<T>&>(std::as_const(*this).at(x, y));
}
Client code can use Grid<int> or Grid<ChessPiece>, etc. Pointer types can be stored as well:
int* intPtr = new int;
Grid<int*> inPtrGrid;
intPtrGrid.at(1,1) = intPtr;
The type can be another template type like vector:
Grid<vector<double>> gridOfDoubleVectors;
vector<double> myVec = {1.0,2.0,3.0,4.0};
gridOfDoubleVectors.at(1,2) = myVec;
One can store Grid template instantiations on the heap:
auto heapGrid = std::make_unique<Grid<double>>(3,3);
heapGrid->at(1,2) = 3.14;
When the compiler encounters a Grid<int> it generates all the code needed for the Grid<int>. Similarly, if it encounters Grid<ChessPiece>, it encounters all the necessary code for that. If there are no template instantiations, the class method definitions are not compiled. The compiler always generates code for all virtual methods. For non-virtual methods, it only generates code for the methods that are called.
If one attempts to instantiate a template with a type that does not support all the operations used by the template (like copy constructor), the code will not compile.

References

Gregoire, M. (2018). Professional C++. Indiana, IN: John Wiley & Sons.

Comments

Popular posts from this blog

QTreeView and QTableView dynamic changes

C++ strings and string_view