References
- Professional C++
- https://en.cppreference.com/w/
Contents
- 클래스 템플릿 (Class Template)
- 템플릿 특수화 (Template Specialization)
- 상속 vs 특수화 비교
- 함수 템플릿
- 변수 템플릿
C++은 언어 차원에서 객체지향 프로그래밍뿐만 아니라 제너릭 프로그래밍(generic programming)도 지원합니다. 제너릭 프로그래밍의 목적은 코드를 재사용할 수 있게 작성하는 것입니다. C++에서 제너릭 프로그래밍을 위해 제공하는 핵심 도구는 템플릿입니다. 엄밀히 말하면 템플릿은 객체지향 기법에 속하지는 않지만 객체지향 프로그래밍에서 함께 적용하면 강력한 효과를 발휘합니다.
이번 포스팅부터 템플릿에 대한 기본적인 내용들에 대해 알아보도록 하겠습니다.
1. Overview
절차형 프로그래밍 패러다임에서는 프로시저(procedure)나 함수(function) 단위로 프로그램을 작성합니다. 그중에서도 특히 함수를 많이 사용하는데, 알고리즘을 작성할 때 특정한 값에 의존하지 않게 구현해두면 나중에 임의의 값에 대해 얼마든지 재사용할 수 있기 때문입니다. 예를 들어 C++의 sqrt() 함수는 호출한 측에서 요청한 값에 대한 제곱근을 구합니다. 제곱근을 구하는 함수가 특정한 값에 대해서만 호출될 수 있다면 활용도가 떨어질 것입니다. 그래서 sqrt() 함수는 사용자가 매개변수에 값을 지정하기만 하면 실행할 수 있도록 구현되었고, 이렇게 함수를 작성하는 것을 매개변수화(parameterize)한다고 표현합니다.
객체지향 프로그래밍 패러다임은 객체(object)라는 개념을 도입했습니다. 객체란 데이터와 동작을 하나로 묶은 것으로, 함수나 메소드에서 값을 매개변수화하는 방식과는 별개입니다.
템플릿은 매개변수화 개념을 더욱 발전시켜 값뿐만 아니라 타입에 대해서도 매개변수화합니다. C++에서 기본으로 제공하는 int, double 같은 기본 타입뿐만 아니라 사용자가 정의한 클래스에 대해서도 매개변수화할 수 있습니다. 템플릿을 이용하면 주어진 값뿐만 아니라 그 값의 타입에 대해서도 독립적인 코드를 작성할 수 있습니다. 예를 들어 클래스를 정의할 때 int, Car, SpreadsheetCell과 같은 각각의 타입마다 따로 정의하지 않고, 스택 클래스 하나로 모든 타입에 적용할 수 있게 만들 수 있습니다.
템플릿이 정말 뛰어난 기능이지만, C++의 템플릿 문법이 상당히 복잡해서 템플릿을 직접 정의하지 않는 경우가 상당히 많습니다. 그래도 C++ 프로그래머라면 최소한 템플릿 사용법을 익혀둘 필요가 있고, C++ 표준 라이브러리와 같은 라이브러리류의 코드는 템플릿을 상당히 많이 활용합니다.
포스팅에서는 C++에서 제공하는 템플릿 기능을 주로 표준 라이브러리를 사용하는 관점에서 소개하고, 또한 프로그램을 새로 구현할 때 좀 더 쓰기 좋게 만드는 데 활용할 수 있는 템플릿 기능들도 알아보겠습니다.
2. 클래스 템플릿
클래스 템플릿은 멤버 변수 타입, 메소드의 매개변수 또는 리턴 타입을 매개변수로 받아서 클래스를 만듭니다. 클래스 템플릿은 주로 객체를 저장하는 컨테이너나 데이터 구조에서 많이 사용합니다. 이를 알아보기 위해 Grid 컨테이너를 구현해보면서 클래스 템플릿에 대해 자세히 알아보겠습니다.
2.1 클래스 템플릿 작성
체스나 틱택토와 같은 이차원 게임에서 공통적으로 사용할 수 있는 제너릭 게임보드를 만들어보겠습니다. 최대한 범용으로 구성하려면 게임의 종류에 상관없이 게임의 말을 저장할 수 있어야 합니다.
2.1.1 템플릿없이 구현한 Grid 클래스
템플릿을 사용하지 않고 제너릭 게임보드를 구현하는 가장 좋은 방법은 다형성을 이용하여 제너릭 GamePiece 객체를 저장하게 만드는 것입니다. 그러면 게임의 종류에 따라 GamePiece 클래스를 상속해서 구현할 수 있습니다. 예를 들어 체스 게임이라면 GamePiece의 파생 클래스로 ChessPiece를 구현합니다. 다형성 덕분에 GamePiece를 저장하도록 정의한 GameBoard를 ChessPiece 저장에도 활용할 수 있습니다. 이때 GameBoard를 복사할 수 있어야 하기 때문에 GameBoard에서 GamePiece를 복사하는 기능도 구현해야 합니다. 이렇게 다형성을 적용하기 위해 다음과 같이 GamePiece 베이스 클래스에 clone() 이라는 순수 가상 메소드를 추가합니다. GamePiece의 인터페이스는 다음과 같습니다.
class GamePiece
{
public:
virtual ~GamePiece() = default;
virtual std::unique_ptr<GamePiece> clone() const = 0;
};
GamePiece는 추상 베이스 클래스입니다. 그래서 ChessPiece와 같은 구체적인 클래스는 GamePiece를 상속할 때 clone() 메소드를 구현해야 합니다.
class ChessPiece : public GamePiece
{
public:
virtual std::unique_ptr<GamePiece> clone() const override
{
return std::make_unique<ChessPiece>(*this);
}
};
GameBoard 클래스를 구현할 때 GamePiece를 저장하는 부분은 unique_ptr의 vector에 대한 vector로 작성합니다.
class GameBoard
{
public:
explicit GameBoard(size_t width = DefaultWidth,
size_t height = DefaultHeight);
GameBoard(const GameBoard& src); // copy constructor
virtual ~GameBoard() = default; // virtual defaulted destructor
GameBoard& operator=(const GameBoard& rhs); // assignment operator
GameBoard(GameBoard&& src) = default; // default move constructor
GameBoard& operator=(GameBoard&& src) = default; // default move assignment operator
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 m_height; }
size_t getWidth() const { return m_width; }
static const size_t DefaultWidth{ 10 };
static const size_t DefaultHeight{ 10 };
void swap(GameBoard& other) noexcept;
private:
void verifyCoordinate(size_t x, size_t y) const;
std::vector<std::vector<std::unique_ptr<GamePiece>>> m_cells;
size_t m_width{ 0 }, m_height{ 0 };
};
void swap(GameBoard& first, GameBoard& second) noexcept;
이 코드에서 at() 메소드는 인수로 지정한 지점에 있는 말을 복사하지 않고 레퍼런스로 리턴합니다. GameBoard를 이차원 배열로 추상화하므로 인덱스로 지정한 객체의 복사본이 아닌 실제 객체를 제공하는 방식으로 배열에 접근하도록 만들어야 합니다. 이렇게 구한 객체 레퍼런스는 나중에 유효하지 않게 될 수 있기 때문에 리턴한 레퍼런스를 저장했다가 다시 쓸 수 없고, 이 레퍼런스가 필요할 때마다 at()을 호출해서 리턴한 레퍼런스를 곧바로 사용해야 합니다.
(표준 라이브러리의 std::vector 클래스가 이러한 방식으로 구현되어 있습니다.)
GameBoard 클래스를 정의하는 구현 코드는 다음과 같습니다. 여기서 대입 연산자는 copy-and-swap 패턴으로 구현되었습니다. 또한 코드 중복을 피하도록 const_cast()를 사용했습니다.
GameBoard::GameBoard(size_t width, size_t height)
: m_width{ width }, m_height{ height }
{
m_cells.resize(m_width);
for (auto& column : m_cells)
column.resize(m_height);
}
GameBoard::GameBoard(const GameBoard& src)
: GameBoard{ src.m_width, src.m_height }
{
for (size_t i = 0; i < m_width; i++) {
for (size_t j = 0; j < m_height; j++) {
if (src.m_cells[i][j])
m_cells[i][j] = src.m_cells[i][j]->clone();
}
}
}
GameBoard& GameBoard::operator=(const GameBoard& rhs)
{
GameBoard temp{ rhs };
swap(temp);
return *this;
}
const std::unique_ptr<GamePiece>& GameBoard::at(size_t x, size_t y) const
{
verifyCoordinate(x, y);
return m_cells[x][y];
}
std::unique_ptr<GamePiece>& GameBoard::at(size_t x, size_t y)
{
return const_cast<std::unique_ptr<GamePiece>&>(std::as_const(*this).at(x, y));
}
void GameBoard::swap(GameBoard& other) noexcept
{
std::swap(m_width, other.m_width);
std::swap(m_height, other.m_height);
std::swap(m_cells, other.m_cells);
}
void GameBoard::verifyCoordinate(size_t x, size_t y) const
{
if (x >= m_width) {
throw std::out_of_range{ std::to_string(x) + " must be less than " + std::to_string(m_width) };
}
if (y >= m_height) {
throw std::out_of_range{ std::to_string(y) + " must be less than " + std::to_string(m_height) };
}
}
void swap(GameBoard& first, GameBoard& second) noexcept
{
first.swap(second);
}
이렇게 정의한 GameBoard는 다음과 같이 사용할 수 있습니다.
GameBoard chessBoard{ 8, 8 };
auto pawn{ std::make_unique<ChessPiece>() };
chessBoard.at(0, 0) = std::move(pawn);
chessBoard.at(0, 1) = std::make_unique<ChessPiece>();
chessBoard.at(0, 1) = nullptr;
2.1.2 템플릿으로 구현한 Grid 클래스
위처럼 GameBoard 클래스를 구현해도 되지만 몇 가지 아쉬운 점이 있습니다. 첫째, GameBoard는 원소를 항상 포인터로 저장합니다. 그래서 원소를 값으로 저장할 수 없습니다. 둘째, 타입 안전성(type safety)이 떨어집니다. 이는 원소를 포인터로 저장하는 문제보다 심각합니다. GameBoard는 각 셀을 unique_ptr<GamePiece>로 저장합니다. ChessPiece로 저장했던 셀을 요청하기 위해 at()을 호출해도 unique_ptr<GamePiece>로만 리턴합니다. 따라서 GamePiece를 ChessPiece로 다운캐스팅해야 ChessPiece의 고유 기능을 활용할 수 있습니다. 셋째, int나 double 같은 기본 타입으로 저장할 수 없는데, 이는 셀이 GamePiece를 상속한 타입만 저장할 수 있기 때문입니다.
따라서 ChessPiece나 SpreadsheetCell뿐만 아니라 int, double 같은 타입도 모두 수용하려면 Grid를 제너릭 클래스로 만드는 것이 훨씬 좋습니다. 이럴 때, C++에서 제공하는 클래스 템플릿을 이용하여 특정한 타입에 종속되지 않게 클래스를 구현하면 됩니다. 그러면 클라이언트는 이 템플릿에 저마다 원하는 타입에 맞는 클래스를 인스턴스화해서 사용할 수 있습니다. 이런 방식을 제너릭 프로그래밍이라고 부릅니다.
제너릭 프로그래밍의 가장 큰 장점은 타입 안전성입니다. 앞에서처럼 다형성을 이용하면 추상 베이스 클래스로 정의해야 하지만, 이렇게 클래스 템플릿을 활용하면 클래스 안에 있는 메소드를 비롯한 멤버의 타입을 모두 구체적으로 정의할 수 있습니다.
예를 들어 ChessPiece뿐만 아니라 TicTacToePiece도 지원한다고 가정해봅시다.
class TicTacToePiece : public GamePiece
{
public:
std::unique_ptr<GamePiece> clone() const override
{
return std::make_unique<TicTacToePiece>(*this);
}
};
제너릭 게임보드는 다형성으로 구현하면, 체스보드 객체에 ChessPiece뿐만 아니라 TicTacToePiece마저 저장해버릴 위험이 있습니다.
GameBoard chessBoard{ 8, 8 };
chessBoard.at(0, 0) = std::make_unique<ChessPiece>();
chessBoard.at(0, 1) = std::make_unique<TicTacToePiece>():
이렇게 구현하면 저장할 시점에 말의 타입을 기억해두지 않으면 나중에 at() 메소드로 저장된 셀을 가져올 때 정확한 타입으로 다운캐스팅할 수 없다는 심각한 문제가 발생합니다.
Grid 클래스 정의
클래스 템플릿을 이해하기 위해서 문법을 간단하게 살펴보겠습니다. 이를 위해서 방금 정의한 GameBoard 클래스에서 템플릿 기반으로 만든 Grid 클래스를 사용하도록 수정하기 위해 Grid 클래스를 템플릿으로 정의합니다.
또한 Grid 클래스는 int나 double과 같은 기본 타입도 지원해야 합니다. 그러기 위해서는 앞서 GameBoard를 구현할 때처럼 다형성 기반의 포인터 전달 방식(pointer semantics)으로 구현하는 것보다 다형성을 사용하지 않고 값 전달 방식(value semantics)으로 구현하는 것이 유리합니다. 하지만 한 가지 단점이 있는데 포인터 방식과 달리 값 전달 방식을 적용할 때는 셀에 항상 어떤 값이 들어 있어야 하기 때문에 완전히 빈 셀을 만들 수가 없습니다. 이에 반해 포인터 기반으로 구현하면 nullptr로 초기화하는 방식으로 빈 셀을 만들 수 있습니다. 다행히 C++17부터 지원하는 std::optional을 이용하면 값 전달 방식을 지원하는 동시에 빈 셀도 표현할 수 있습니다. optional은 <optional> 헤더에 정의되어 있습니다.
template<typename T>
class Grid
{
public:
explicit Grid(size_t width = DefaultWidth,
size_t height = DefaultHeight);
virtual ~Grid() = default;
// Explicitly default a copy constructor and assignment operator
Grid(const Grid& src) = default;
Grid& operator=(const Grid& rhs) = default;
// Explicitly default a move constructor and assignmnet operator
Grid(Grid&& src) = default;
Grid& 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 m_height; }
size_t getWidth() const { return m_width; }
static const size_t DefaultWidth{ 10 };
static const size_t DefaultHeight{ 10 };
private:
void verifyCoordinate(size_t x, size_t y) const;
std::vector<std::vector<std::optional<T>>> m_cells;
size_t m_width{ 0 }, m_height{ 0 };
};
위 코드에서 첫 줄은 뒤에 나올 클래스 정의가 특정한 타입에 적용할 수 있는 템플릿이라고 선언합니다. template와 typename은 모두 C++에 정의된 키워드입니다. 앞서 이야기했듯이 템플릿은 타입을 매개변수로 받습니다(이를 매개변수화한다고 표현합니다). 이는 함수가 값을 매개변수화하는 방식과 같습니다. 함수를 호출할 때 지정하는 인수를 매개변수 이름으로 표현하듯이 템플릿에 적용할 타입도 템플릿의 매개변수 이름(such as T)으로 표현합니다. T라는 이름 자체에는 특별한 의미가 없으며, 이름은 마음대로 정해도 됩니다. 관례상 타입을 T로 표기할 뿐입니다.
그리고 템플릿 지정자(template specifier)는 문장 전체에 적용되며, 위의 코드에서는 클래스를 정의하는 코드 전체에 적용됩니다.
템플릿 타입 매개변수를 typename 대신 class 키워드로 표기해도 됩니다. 따라서, template<class T>라고 표기하는 경우도 있습니다. 하지만 템플릿 매개변수를 표현할 때 class란 키워드를 사용하면 타입을 반드시 클래스로 지정해야 한다고 오해할 수 있습니다. 사실 클래스뿐만 아니라 struct, union, in, double 같은 언어의 기본 타입도 얼마든지 지정할 수 있습니다.
처음에 정의한 GameBoard 클래스는 m_cells라는 데이터 멤버를 포인터에 대한 vector의 vector로 구현했습니다. 그래서 복사 작업을 복사 생성자와 복사 대입 연산자로 처리해야 했습니다. 하지만 템플릿을 이용한 Grid 클래스는 m_cells의 타입을 optional 값에 대한 vector의 vector로 정의합니다. 그러면 복사 생성자와 대입 연산자를 컴파일러가 만들어주는데, 이렇게 기본으로 생성된 것으로도 충분합니다. 하지만, 사용자가 직접 소멸자를 정의하면 복사 생성자와 복사 대입 연산자가 자동으로 생성되지 않기 때문에 Grid 템플릿에서 자동 생성되도록 명시적으로 디폴트로 지정했습니다. 이동 생성자와 이동 대입 연산자도 마찬가지로 명시적으로 디폴트로 선언했습니다.
Grid& operator=(const Grid& rhs) = default;
이 문장을 보면 앞에서 const GameBoard& 타입으로 선언했던 rhs 매개변수가 const Grid&타입으로 변경된 것을 알 수 있습니다. 이 타입을 const Grid<T>&로 표기해도 됩니다. 참고로 클래스 정의 코드 안에서 Grid라고만 적어도 컴파일러는 Grid<T>로 해석합니다.
Grid<T>& operator=(const Grid<T>& rhs) = default;
하지만 클래스 정의 밖에서는 반드시 Grid<T>라고 적어야 합니다. 클래스 템플릿을 작성할 때는 Grid가 클래스 이름처럼 보이지만 엄밀히 말하자면 Grid는 템플릿 이름입니다. 실제 클래스를 가리킬 때는 int나 ChessPiece 등과 같은 구체적인 타입으로 템플릿을 인스턴스화한 이름으로 표현해야 합니다. 따라서 Grid 클래스 템플릿으로 인스턴스화한 실제 클래스를 가리킬 때는 Grid<T>로 표현해야 합니다.
이제 m_cells는 더 이상 포인터가 아닌 optional 타입의 값으로 저장합니다. 따라서 at() 메소드의 리턴 타입을 unique_ptr이 아닌 optional<T>& 또는 const optional<T>&로 변경합니다.
std::optional<T>& at(size_t x, size_t y);
const std::optional<T>& at(size_t x, size_t y) const;
Grid 클래스 메소드 정의
Grid 템플릿에서 메소드를 정의할 때는 반드시 템플릿 지정자(template<typename T>)를 앞에 적어주어야 합니다. 예를 들어 생성자를 정의하는 코드는 다음과 같습니다.
template<typename T>
Grid<T>::Grid(size_t width, size_t height)
: m_width{ width }, m_height{ height }
{
m_cells.resize(m_width);
for (auto& column : m_cells) {
column.resize(m_height);
}
}
여기서 :: 기호 앞에 클래스 이름이 Gird가 아닌 Grid<T>인 점에 주목합니다. 메소드나 static 데이터 멤버를 정의하는 코드는 반드시 클래스 이름을 Grid<T>와 같이 표기해야 합니다.
템플릿을 정의할 때는 반드시 메소드 구현 코드를 헤더 파일에 적어주어야 합니다. 그래야 컴파일러가 템플릿 인스턴스를 생성하기 전에 메소드 정의를 포함한 클래스 정의 전체를 알 수 있기 때문입니다. 하지만 이러한 제약을 우회하여 여러 파일로 나눌 수 있는데, 이는 밑에서 알아보도록 하겠습니다.
나머지 메소드 구현은 템플릿 지정자와 Grid<T>를 제외하면, GameBoard와 동일합니다.
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));
}
template<typename T>
const std::optional<T>& Grid<T>::at(size_t x, size_t y) const
{
verifyCoordinate(x, y);
return m_cells[x][y];
}
template<typename T>
void Grid<T>::verifyCoordinate(size_t x, size_t y) const
{
if (x >= m_width) {
throw std::out_of_range{ std::to_string(x) + " must be less than " + std::to_string(m_width) };
}
if (y >= m_height) {
throw std::out_of_range{ std::to_string(y) + " must be less than " + std::to_string(m_height) };
}
}
Grid 템플릿 사용법
Grid 템플릿으로 Grid 객체를 생성할 때는 타입에 Grid뿐만 아니라 Grid에 저장할 대상의 타입도 함께 지정해주어야 합니다. 이렇게 클래스 템플릿에 특정한 타입을 지정해서 구체적인 클래스를 만드는 것을 템플릿 인스턴스화(template instantiation)라고 합니다. 템플릿 인스턴스화는 다음과 같이 객체를 선언하는 과정에서 적용할 수 있습니다.
Grid<int> myIntGrid; // Declare a grid that stores ints,
// using default arguments for the constructor
Grid<double> myDoubleGrid{ 11, 11 }; // Declares an 11x11 Grid of doubles
myIntGrid.at(0, 0) = 10;
int x{ myIntGrid.at(0, 0).value_or(0) };
Grid<int> grid2{ myIntGrid }; // Copy constructor
Grid<int> anotherIntGrid;
anotherIntGrid = grid2; // Assignment operator
여기서 나온 myIntGrid, grid2, anotherIntGrid의 타입은 모두 Grid<int>입니다. 이렇게 만든 Grid 객체에 ChessPiece 객체를 저장하는 코드를 작성하려고 시도하면 컴파일 에러가 발생합니다.
또한 value_or()을 사용하고 있는데, at() 메소드는 std::optional 레퍼런스를 리턴합니다. optional에 값이 없을 수도 있는데, value_or() 메소드는 optional에 값이 있을 때만 그 값을 리턴하고, 값이 없다면 value_or()에 전달한 인수를 리턴합니다.
타입을 지정하는 문법도 중요합니다. 다음과 같이 작성하면 컴파일 에러가 발생합니다.
Grid test; // compile error
Grid<> test; // compile error
Grid 객체를 받는 함수나 메소드를 선언할 때도 Grid에 저장할 타입을 구체적으로 지정해야 합니다.
void processIntGrid(Grid<int>& grid) { /* 본문 코드 생략 */ }
아니면 함수 템플릿(function template)를 사용하여 Grid의 원소 타입을 매개변수로 표현한 함수로 작성할 수 있습니다.
Grid<int> 처럼 매번 Grid 타입에 대한 정식 표기법으로 작성하기가 번거롭다면 다음과 같이 타입 앨리어스로 이름을 간단하게 표현할 수 있습니다.
using IntGrid = Grid<int>;
이렇게 선언한 뒤에는 다음과 같이 사용할 수 있습니다.
void processIntGrid(IntGrid& grid) { }
Grid 템플릿은 int 외의 타입 객체도 지정할 수 있습니다. 예를 들어, SpreadsheetCell 객체를 저장하는 Grid를 인스턴스화할 수 있습니다.
Grid<SpreadsheetCell> mySpreadsheet;
SpreadsheetCell myCell{ 1.234 };
mySpreadsheet.at(3, 4) = myCell;
다음처럼 포인터 타입 객체도 저장할 수 있습니다.
Grid<const char*> myStringGrid;
myStringGrid.at(2, 2) = "hello";
심지어 다른 템플릿 타입을 지정할 수도 있습니다.
Grid<std::vector<int>> gridOfVectors;
std::vector<int> myVector{ 1, 2, 3, 4 };
gridOfVectors.at(5, 6) = myVector;
또한, Grid 템플릿 인스턴스화를 통해 객체를 동적으로 할당할 수도 있습니다.
auto myGridOnFreeSpace{ std::make_unique<Grid<int>>(2, 2) }; // 2x2 Grid on free space
myGridOnFreeStore->at(0, 0) = 10;
int x{ myGridOnFreeStore->at(0, 0).value_or(0) };
2.2 컴파일러가 템플릿을 처리하는 방법
템플릿의 문법과 의미를 정확히 이해하려면, 컴파일러가 템플릿 코드를 처리하는 방식을 이해할 필요가 있습니다. 컴파일러는 템플릿 메소드를 정의하는 코드를 발견하면 문법 검사만 하고 템플릿 코드를 실제로 컴파일하지 않습니다. 템플릿 정의만 보고서 그 안에서 어떤 타입을 사용할지 알 수 없기 때문입니다. 다시 말해 x = y란 문장에서 x와 y의 타입을 모르면서 컴파일러가 코드를 생성할 수 없습니다.
컴파일러가 Grid<int> myIntGrid처럼 템플릿을 인스턴화하는 코드를 발견하면 Grid 템플릿의 매개변수 T에 int를 대입해서 int 버전의 Grid 클래스를 생성합니다. 또한 Grid<SpreadsheetCell> mySpreadSheet처럼 다른 타입에 대한 템플릿 인스턴스화 코드를 발견하면 SpreadsheetCell 타입에 대한 Grid 클래스를 추가로 생성합니다. 이처럼 템플릿 기능이 없을 때는 원소의 타입마다 일일이 클래스를 따로 정의했어야 할 작업을 컴파일러가 대신 해주는 것입니다.
템플릿 처리 과정은 사실 복잡하지 않습니다. 단순히 귀찮은 반복작업을 자동화한 것에 불과합니다. 만약 클래스 템플릿을 정의하는 코드만 작성하고 특정한 타입에 대해 인스턴스를 만드는 코드를 작성하지 않으면 클래스 메소드를 정의하는 코드는 컴파일되지 않습니다.
이처럼 컴파일러가 내부적으로 인스턴스화하는 과정을 알고 있다면 왜 구현 코드마다 Grid<T>와 같은 문법을 사용해야하는지 이해할 수 있습니다. 컴파일러는 특정한 타입(ex, int)에 대해 템플릿을 인스턴스화하는 코드를 볼 때마다 T 자리에 그 타입을 대입해서 구체적인 타입(Grid<int>)으로 만듭니다.
2.2.1 선택적 인스턴스화 (Selective Instantiation)
다음과 같이 선언하는 것을 암시적 템플릿 인스턴스화(implicit template instantiation)라고 합니다.
Grid<int> myIntGrid;
이때, 컴파일러는 항상 제너릭 클래스에 있는 모든 가상 메소드에 대한 코드를 생성합니다. 하지만 non-virtual 메소드 중에서는 실제로 호출하는 non-virtual 메소드만 컴파일러가 코드를 생성합니다.
예를 들어, Grid 클래스 템플릿을 사용하여 main() 함수에 아래 코드만 작성한 경우를 살펴봅시다.
Grid<int> myIntGrid;
myIntGrid.at(0, 0) = 10;
그러면 컴파일러는 int 버전의 Grid에서 zero-argument 생성자, 소멸자, non-const at() 메소드만 컴파일합니다. 복사 생성자나 대입 연산자, getHeight()와 같은 사용하지 않는 다른 메소드는 생성하지 않습니다. 이를 선택적 인스턴스화(selective instantiation)이라고 합니다.
이러한 이유로 일부 클래스 템플릿 메소드에 알 수 없는 컴파일 에러가 존재할 수 있는 위험이 있습니다. 클래스 템플릿에서 사용하지 않는 메소드가 문법 에러가 있을 수 있고, 그렇게 되면 컴파일이 되지 않습니다. 이는 모든 코드에 대한 문법 에러 체크를 하기 어렵게 만듭니다. 이때, 아래와 같이 명시적 템플릿 인스턴스화(explicit template instantiation)를 사용하면 모든 virtual, non-virtual 메소드에 대한 코드를 컴파일러가 생성하도록 강제할 수 있습니다.
template class Grid<int>;
explicit template instantiation을 사용할 때는 int와 같은 기본 타입으로만 인스턴스화하지 말고 조금 더 복잡한 std::string과 같은 타입으로도 인스턴스화해보는 것이 좋습니다.
2.2.2 템플릿에 사용할 타입의 요건
타입에 독립적인 코드를 작성하려면, 적용할 타입에 대해 어느 정도 고려해야 합니다. 예를 들어 Grid 템플릿을 작성할 때 T에 지정한 타입의 원소는 언제든지 소멸될 수 있다는 점을 고려해야 합니다. 앞에서 본 Grid 템플릿을 구현할 때는 고려할 사항이 많지 않지만, 템플릿의 타입 매개변수에 맞게 대입 연산자를 제공해야 한다면 고려할 사항이 많습니다.
어떤 템플릿을 인스턴스화할 때 그 템플릿에 있는 연산을 모두 지원하지 않으면 컴파일 에러가 발생합니다. 그런데 이런 에러 메세지를 확인해보면 내용이 상당히 난해합니다. 그러나, 템플릿을 인스턴스화할 타입이 그 템플릿에 정의된 모든 연산에 적용할 수 없다면, 위에서 설명한 선택적 인스턴스화를 이용하여 그중 일부 메소드만 사용하게 만들면 됩니다.
C++20부터는 concept가 도입되었는데, 이는 컴파일러가 해석하고 검증할 수 있는 템플릿 파라미터에 대한 요구사항을 작성할 수 있도록 해줍니다. 컴파일러는 만약 템플릿을 인스턴스화하기 위해 전달된 템플릿 파라미터가 이러한 요구사항을 만족하지 못하면 조금 더 이해하기 쉽고 읽기 쉬운 에러를 발생시킵니다.
2.3 템플릿 코드르 여러 파일로 나누는 방법
일반적으로 클래스 정의는 헤더 파일에 작성하고, 메소드 정의는 소스 파일에 작성하는 경우가 많습니다. 객체를 생성할 때는 그 객체가 속한 클래스의 정의가 담긴 헤더 파일을 #include 문에 적어주어야 합니다. 링커는 이 문장을 보고 해당 클래스의 메소드를 참조할 경로를 마련해줍니다.
하지만, 템플릿을 처리하는 과정은 조금 다릅니다. 말 그대로 '템플릿'이기 때문에 사용자가 지정한 타입에 대한 메소드를 사용하는 문장이 나올 때마다 컴파일러는 템플릿 정의와 메소드 정의 코드를 모두 볼 수 있어야 코드를 제대로 생성할 수 있습니다.
이렇게 두 코드를 다른 파일에 작성했지만 연결할 수 있는 방법은 여러 가지가 있습니다.
2.3.1 헤더 파일에 템플릿 정의
먼저 가장 기본적으로 메소드 정의 코드를 클래스 정의 코드가 있는 헤더 파일에 함께 적는 방법이 있습니다. 그러면 이 템플릿을 사용하는 소스 파일에 #include문으로 헤더 파일만 불러오면 컴파일러는 클래스 정의와 메소드 정의를 모두 참조할 수 있습니다. 앞서 살펴본 Grid 클래스 템플릿이 이렇게 처리됩니다.
또 다른 방법은 템플릿 메소드 정의 코드를 다른 헤더 파일에 적고, 그 헤더 파일을 클래스 정의를 담은 헤더 파일에서 #include문으로 불러오는 것입니다. 이때 메소드 정의가 담긴 헤더를 추가하는 #include 문은 반드시 클래스 정의 코드 뒤에 적어야 합니다. 그렇지 않으면 컴파일 에러가 발생합니다.
예를 들면, 다음과 같습니다.
template<typename T>
class Grid
{
// .. 클래스 정의 코드 생략
};
#include "GridDefinitions.h"
이렇게 정의한 Grid 템플릿을 사용할 때는 Grid.h 헤더 파일만 인클루드하면 됩니다. 이처럼 메소드 정의와 클래스 정의를 두 헤더 파일에 나눠서 작성하면 클래스 정의와 메소드 정의를 명확히 구분할 수 있는 장점이 있습니다.
2.3.2 소스 파일에 템플릿 정의
헤더 파일에 구현 코드를 정의하는 것이 어색하면 기존처럼 메소드 정의 코드를 소스 파일에 작성해도 됩니다. 그렇다 하더라도 템플릿을 사용하는 코드에서 메소드 정의 코드를 볼 수 있어야 한다는 사실은 변하지 않습니다. 이럴 때는 클래스 템플릿 정의가 있는 헤더 파일에 메소드 구현 코드가 있는 소스 파일을 추가하는 #include문을 작성하면 됩니다. 이 방식을 처음 보면 어색할 수 있지만 엄연히 C++에서 정식으로 지원하는 문법입니다.
예를 들어 헤더 파일을 다음과 같이 작성합니다.
/* Grid.h */
template<typename T>
class Grid
{
// .. 클래스 정의 코드 생략
};
#include "Grid.cpp"
이때 Grid.cpp 파일이 프로젝트 빌드 목록에 추가되지 않도록 주의해야 합니다. 이 파일은 추가하면 안될 뿐만 아니라 추가할 방법도 없으며 따로 컴파일할 수도 없습니다.
메소드 구현 코드가 담긴 파일의 이름은 마음대로 정해도 됩니다. 그래서 이 용도로 작성한 소스 파일의 확장자를 Grid.ini처럼 표기하는 경우도 있습니다.
클래스 템플릿의 인스턴스화 제한
클래스 템플릿을 특정한 타입에만 적용하게 만들고 싶다면 다음과 같은 테크닉을 사용하면 됩니다.
예를 들어, Grid 클래스를 int, double, vector<int>에 대해서만 인스턴스화할 수 있게 만들고 싶다면 헤더 파일을 다음과 같이 작성합니다.
/* Grid.h */
template<typename T>
class Grid
{
// .. 코드 생략
};
이 헤더 파일에는 메소드 정의도 없으며, 마지막에 #include도 없습니다.
이렇게 작성하면 실제 메소드 정의 코드가 담긴 .cpp 파일을 프로젝트 빌드 목록에 추가해야 합니다.
.cpp 파일은 다음과 같이 작성합니다.
#include "Grid.h"
#include <utility>
template<typename T>
Grid<T>::Grid(size_t width, size_t height)
: m_width{ width }, m_height{ height }
{
m_cells.resize(m_width);
for (auto& column : m_cells) {
column.resize(m_height);
}
}
// .. 나머지 메소드 정의 코드 생략
그리고 나서 이 메소드를 사용할 수 있게 하려면 템플릿에서 허용하는 타입으로 명시적으로 인스턴스화해두어야 합니다.
예를 들어 위에서 작성한 .cpp 파일의 마지막에 다음과 같이 작성합니다.
template class Grid<int>;
template class Grid<double>;
template class Grid<std::vector<int>>;
이렇게 명시적으로 인스턴스화해두면 여기에 나온 타입에 대해서만 빌드하기 때문에 앞에 나온 Grid 클래스 템플릿에 다른 타입으로 인스턴스화할 수 없게 됩니다.
또한, 이렇게 명시적으로 클래스 템플릿을 인스턴스화하면 위에서 언급했듯이 클래스 템플릿에 있는 메소드를 실제로 사용하지 않더라도 그 템플릿에 있는 모든 메소드를 컴파일합니다.
2.4 템플릿 매개변수
앞에서 본 Grid 템플릿은 하나의 템플릿 매개변수만을 가지고 있습니다. 이 타입은 그리드에 저장할 대상의 타입입니다. 일반적으로 템플릿을 작성할 때는 다음과 같이 꺽쇠괄호(<>) 안에 매개변수를 나열합니다.
template <typename T>
여기서 매개변수 목록을 지정하는 방법은 함수나 메소드에서 매개변수 목록을 작성할 때와 비슷합니다. 함수나 메소드처럼 클래스 템플릿의 매개변수도 원하는 수만큼 지정할 수 있습니다. 이때 매개변수를 타입 대신 디폴트값으로 지정해도 됩니다.
2.4.1 Non-type 템플릿 매개변수
Non-type parameter(비타입 매개변수)란 int나 포인터처럼 함수나 메소드에서 흔히 사용하는 종류의 매개변수를 말합니다. 하지만 정수 계열의 타입(char, int, long 등), 열거 타입, 포인터, 레퍼런스, std::nullptr_t 등만 비타입 매개변수로 사용할 수 있습니다. C++17부터는 auto, auto& auto* 등도 비타입 매개변수로 사용할 수 있고, C++20부터는 부동소수점, 클래스도 비타입 매개변수로 사용할 수 있습니다.
앞에서 본 Grid 클래스 템플릿에서 그리드의 높이와 너비를 생성자에서 지정하지 않고 비타입 템플릿 매개변수로 표현할 수 있습니다. 이렇게 생성자 대신 템플릿 목록에서 비타입 매개변수를 사용하면 코드를 컴파일하기 전에 값을 알 수 있다는 장점이 있습니다. 앞에서 설명했듯이 컴파일러는 템플릿 메소드의 코드를 생성하기 전에 먼저 템플릿 매개변수를 대입합니다. 따라서 코드에서 2차원 배열을 vector에 대한 vector가 아닌 기존 정적 배열로 작성하더라도 크기를 동적으로 조절할 수 있습니다. 물론 컴파일 시간에 결정되어야 하기 때문에 실행 시간에 크기를 마음대로 바꿀 수 있다는 진정한 의미한 동적은 아니고, 인스턴스화 코드를 볼 때마다 배열의 크기를 마음대로 지정할 수 있다는 의미입니다.
Grid 클래스 템플릿 코드를 이렇게 수정하면 다음과 같습니다.
template<typename T, size_t WIDTH, size_t HEIGHT>
class Grid
{
public:
Grid() = default;
virtual ~Grid() = default;
// Explicitly default a copy constructor and assignment operator
Grid(const Grid& src) = default;
Grid<T, WIDTH, HEIGHT>& operator=(const 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 HEIGHT; }
size_t getWidth() const { return WIDTH; }
private:
void verifyCoordinate(size_t x, size_t y) const;
std::optional<T> m_cells[WIDTH][HEIGHT];
};
이번에는 이동 생성자와 이동 대입 연산자를 명시적으로 디폴트로 지정하지 않았는데, C 스타일 배열은 어짜피 이동 의미론을 지원하지 않기 때문입니다.
여기서 템플릿 매개변수 목록으로 Grid에 저장할 타입, 그리드의 너비와 높이를 지정해야 합니다. 너비와 높이는 객체를 저장할 이차원 배열을 생성하는데 필요합니다. 메소드를 정의하는 코드는 다음과 같습니다.
template<typename T, size_t WIDTH, size_t HEIGHT>
std::optional<T>& Grid<T, WIDTH, HEIGHT>::at(size_t x, size_t y)
{
return const_cast<std::optional<T>&>(std::as_const(*this).at(x, y));
}
template<typename T, size_t WIDTH, size_t HEIGHT>
const std::optional<T>& Grid<T, WIDTH, HEIGHT>::at(size_t x, size_t y) const
{
verifyCoordinate(x, y);
return m_cells[x][y];
}
template<typename T, size_t WIDTH, size_t HEIGHT>
void Grid<T, WIDTH, HEIGHT>::verifyCoordinate(size_t x, size_t y) const
{
if (x >= WIDTH) {
throw std::out_of_range{ std::to_string(x) + " must be less than " + std::to_string(WIDTH) };
}
if (y >= HEIGHT) {
throw std::out_of_range{ std::to_string(y) + " must be less than " + std::to_string(HEIGHT) };
}
}
수정한 코드를 살펴보면 이전에 Grid<T>로 표기했던 부분을 Grid<T, WIDTH, HEIGHT>와 같이 템플릿 매개변수로 3개를 받도록 변경했습니다.
이렇게 변경한 템플릿은 다음과 같이 사용할 수 있습니다.
Grid<int, 10, 10> myGrid;
Grid<int, 10, 10> anotherGrid;
myGrid.at(2, 2) = 42;
anotherGrid = myGrid;
std::cout << anotherGrid.at(2, 3).value_or(0) << std::endl;
얼핏보면 코드가 간결해서 이전보다 개선된 것 같지만 아쉽게도 의도와 달리 제약사항이 더 많아졌습니다.
첫 번째 제약은 높이와 너비 값에 (변수와 같은)non-const 정수를 지정할 수 없다는 것입니다. 따라서 다음과 같이 작성하면 컴파일 에러가 발생합니다.
size_t height{ 10 };
Grid<int, 10, height> testGrid; // compile error!
여기서 height를 const로 정의하면 문제없이 컴파일이 됩니다.
const size_t height{ 10 };
Grid<int, 10, height> testGrid; // compile success
리턴 타입을 정확히 지정한 constexpr 함수로 표현해도 됩니다. 예를 들어 size_t 타입의 값을 리턴하는 constexpr 함수로 높이에 대한 템플릿 매개변수를 초기화할 수 있습니다.
constexpr size_t getHeight() { return 10; }
...
Grid<double, 2, getHeight()> myDoubleGrid;
두 번째 제약은 첫 번째보다 심각한데, 높이와 너비가 템플릿 매개변수이기 때문에 두 값이 그리드 타입의 일부가 됩니다. 다시 말해서 Grid<int, 10, 10>과 Grid<int, 10, 11>은 서로 다른 타입입니다. 그래서 두 타입의 객체는 서로 대입할 수 없고 함수나 메소드에 전달될 때도 호환되지 않습니다.
2.4.2 타입 매개변수의 기본값
너비와 높이를 비타입 매개변수로 지정할 때도 앞에서 Grid<T> 클래스의 생성자에서 했던 것처럼 디폴트값을 지정할 수 있습니다. 템플릿 매개변수에 디폴트값을 지정하는 문법은 생성자와 비슷합니다. 또한 타입 매개변수 T에도 디폴트값을 지정할 수 있습니다.
예를 들면 다음과 같습니다.
template<typename T = int, size_t WIDTH = 10, size_t HEIGHT = 10>
class Grid
{
// .. 나머지 코드 생략
};
이때 메소드를 정의하는 코드에서는 템플릿 선언문에 T, WIDTH, HEIGHT의 디폴트값을 생략해도 됩니다. 예를 들어, at() 메소드를 다음과 같이 구현할 수 있습니다.
template<typename T, size_t WIDTH, size_t HEIGHT>
const std::optional<T>& Grid<T, WIDTH, HEIGHT>::at(size_t x, size_t y) const
{
verifyCoordinate(x, y);
return m_cells[x][y];
}
이렇게 작성하면 Grid를 인스턴스화할 때 다음과 같이 다양하게 표현할 수 있습니다. 템플릿 매개변수를 모두 생략해도 되고, 원소의 타입만 지정해도 되고, 원소의 타입과 너비만 지정해도되고, 원소의 타입과 너비와 높이를 모두 지정해도 됩니다.
Grid<> myIntGrid;
Grid<int> myGrid;
Grid<int, 5> anotherGrid;
Grid<int, 5, 5> aFourthGrid;
참고로 클래스 템플릿 매개변수를 모두 생략하더라도 꺽쇠괄호는 반드시 적어주어야 합니다. 따라서 다음의 코드는 컴파일 에러가 발생합니다.
Grid myIntGrid;
클래스 템플릿 매개변수의 디폴트 인수를 지정할 때는 함수나 메소드에 디폴트 인수를 지정할 때와 똑같은 제약사항이 적용됩니다. 따라서 매개변수 목록에서 오른쪽 끝에서 왼쪽 방향으로 중간에 건너뛰지 않고 디폴트값을 지정해야 합니다.
2.4.3 템플릿 매개변수 추론
C++17부터 클래스 템플릿 생성자에 전달된 인수를 보고 템플릿 매개변수를 자동으로 추론하는 기능이 추가되었습니다. C++17이전에는 항상 클래스 템플릿에 템플릿 매개변수를 명시적으로 지정해야 했습니다.
예를 들어, 표준 라이브러리에는 <utility> 헤더에 정의된 std::pair이란 클래스 템플릿이 있습니다. std::pair는 두 값을 하나로 묶어서 저장해주는 타입이라고 볼 수 있습니다. 참고로 두 값의 타입은 서로 다를 수 있고, 다음과 같이 템플릿 매개변수에 각 타입을 지정해줍니다.
std::pair<int, double> pair1{ 1, 2.3 };
C++은 템플릿 매개변수를 일일이 적는 번거로움을 덜어주기 위해 std::make_pair()라는 헬퍼 함수 템플릿을 제공합니다. 함수 템플릿은 밑에서 자세히 설명하도록 하고, 여기서는 전달된 인수를 보고 템플릿 매개변수를 알아서 결정한다는 것만 알아두도록 하겠습니다. 따라서 make_pair()는 전달된 값을 보고 템플릿 타입 매개변수를 자동으로 알아냅니다.
예를 들어 다음과 같이 호출하면 컴파일러는 템플릿 매개변수가 pair<int, double>이라고 추론합니다.
auto pair2{ std::make_pair(1, 2.3) };
CTAD(class template argument deduction) 덕분에, C++17부터는 이런 헬퍼 함수 템플릿을 더 이상 사용할 필요가 없습니다. 생성자에 전달된 인수를 보고 템플릿의 타입 매개변수를 자동으로 알아내기 때문입니다. 따라서 pair 클래스 템플릿을 다음과 같이 간단히 작성해도 됩니다.
std::pair pair3{ 1, 2.3 };
물론 이것은 모든 클래스 템플릿의 모든 템플릿 매개변수에 디폴트값을 지정했거나 생성자에서 이 매개변수를 사용할 때만 자동으로 추론할 수 있습니다.
따라서 다음의 코드는 유효하지 않습니다.
std::pair pair4;
대부분의 표준 라이브러리는 CTAD를 지원하며, vector, array와 같은 라이브러리도 지원합니다.
std::unique_ptr과 std::shared_ptr에는 방금 설명한 타입 추론 기능이 적용되지 않도록 할 수 있습니다. std::unique_ptr 이나 shared_ptr의 생성자에 T*를 전달하면 컴파일러는 <T>나 <T[]> 중 하나를 선택해야 하는데, 잘못 결정하면 치명적인 결과를 발생할 수 있습니다. 따라서 unique_ptr과 shared_ptr은 각각 make_unique()와 make_shared()를 사용하도록 작성하는 것이 좋습니다.
2.4.4 사용자 정의 추론 방식 (User-Defined Deduction)
템플릿 매개변수를 추론하는 규칙을 사용자가 직접 정할 수 있습니다. 고급 기능으로 저도 잘 모르기 때문에 간단하게 이렇게 사용할 수도 있다는 것만 알아두고 넘어가겠습니다.
다음과 같이 작성된 SpreadsheetCell 클래스 템플릿이 있다고 가정해보겠습니다.
template<typename T>
class SpreadsheetCell
{
public:
SpreadsheetCell(const T t) : m_content{ std::move(t) } {}
const T& getContent() const { return m_content; }
private:
T m_content;
};
자동 템플릿 매개변수 추론 기능을 이용하면 std::string 타입에 대한 SpreadsheetCell을 생성하는 코드를 다음과 같이 작성할 수 있습니다.
std::string myString{ "Hello World!" };
SpreadsheetCell cell{ myString };
이때 SpreadsheetCell 생성자에 const char* 타입으로 전달하면 원래 의도와 달리 T의 타입을 const char*로 결정합니다. 이럴 때는 다음과 같이 규칙을 직접 지정해서 생성자의 인수를 const char* 타입으로 전달할 때 T를 std::string으로 추론하게 만듭니다.
SpreadsheetCell(const char*) -> SpreadsheetCell<std::string>;
이 문장은 반드시 클래스 정의 바깥에 작성해야 합니다. 단, 네임스페이스는 SpreadsheetCell 클래스와 같아야 합니다.
기본 문법은 다음과 같습니다. 여기서 explicit 키워드는 생략해도 되며, 효과는 단일 매개변수 생성자에 대해 explicit을 지정할 때와 같습니다. 따라서 매개변수가 하나일 때만 적용할 수 있습니다.
explicit TemplateName(parameters) -> DeducedTemplate;
2.5 메소드 템플릿
클래스뿐만 아니라 메소드도 템플릿화할 수 있습니다. 이러한 메소드 템플릿은 클래스 템플릿 안에 정의해도 되고, 일반 클래스 안에 정의해도 됩니다. 메소드를 템플릿으로 제공하면 한 메소드를 다양한 타입에 대한 버전으로 만들 수 있습니다. 메소드 템플릿은 클래스 템플리셍 복사 생성자와 대입 연산자를 정의할 때 특히 유용합니다.
가상 메소드와 소멸자는 메소드 템플릿으로 만들 수 없습니다.
앞서 정의한 원소 타입 하나만 템플릿 매개변수로 받는 버전의 Grid 템플릿을 다시 살펴보겠습니다. 이 템플릿은 int나 double을 비롯한 여러 가지 타입에 대해 Grid를 인스턴스화할 수 있습니다.
Grid<int> myIntGrid;
Grid<double> myDoubleGrid;
그런데 이렇게 만든 Grid<int>와 Grid<double>은 타입이 서로 다릅니다. Grid<double> 객체를 받는 함수는 Grid<int> 객체를 인수로 받을 수 없습니다. int를 double로 강제 형변환해서 int 원소를 double 원소로 복사할 수는 있지만, Grid<int> 타입 객체를 Grid<double> 객체에 대입하거나 Grid<int>로 Grid<double> 객체를 만들 수는 없습니다. 따라서 다음과 같이 작성하면 컴파일 에러가 발생합니다.
myDoubleGrid = myIntGrid; // compile error
Grid<double> newDoubleGrid(myIntGrid); // compile error
그 이유는 Grid 템플릿에 대한 복사 생성자와 대입 연산자가 다음과 같이 정의되었기 때문입니다.
Grid(const Grid& src);
Grid<T>& operator=(const Grid& rhs);
이를 정확히 표현하면 다음과 같습니다.
Grid(const Grid<T>& src);
Grid<T>& operator=(const Grid<T>& rhs);
복사 생성자인 Grid와 대입 연산자인 operator=는 모두 const Grid<T> 레퍼런스를 인수로 받습니다. 그러므로 Grid<double>을 인스턴스화해서 Grid 복사 생성자와 operator=을 호출하면 컴파일러는 각각에 대한 프로토타입을 다음과 같이 생성합니다.
Grid(const Grid<double>& src);
Grid<double>& operator=(const Grid<double>& rhs);
생성된 Grid<double> 클래스 코드를 보면 Grid<int>를 받는 생성자나 operator=가 없습니다.
다행히 이 문제를 해결한 방법이 있습니다. Grid 클래스의 복제 생성자와 대입 연산자를 메소드 템플릿으로 만들면 서로 다른 타입을 처리할 수 있습니다. 이렇게 수정한 Grid 클래스 정의 코드는 다음과 같습니다.
template<typename T>
class Grid
{
public:
// .. 코드 생략
template<typename E>
Grid(const Grid<E>& src) = default;
template<typename E>
Grid& operator=(const Grid<E>& rhs) = default;
void swap(Grid& other) noexcept;
// .. 코드 생략
};
먼저 템플릿 버전으로 수정한 복사 생성자부터 살펴보겠습니다.
template<typename E>
Grid(const Grid<E>& src);
템플릿 선언문에 E(element) 라는 새로운 타입 이름을 지정했습니다. 클래스는 T라는 타입에 대해 템플릿화되고, 방금 수정한 복사 생성자는 T와는 다른 E라는 타입에 대해 템플릿화됩니다. 이렇게 두 타입에 대해 템플릿화함으로써 한 타입의 Grid 객체를 다른 타입의 Grid로 복사할 수 있습니다. 수정한 복사 생성자는 다음과 같이 정의할 수 있습니다.
template<typename T>
template<typename E>
Grid<T>::Grid(const Grid<E>& src)
: Grid(src.getWidth(), src.getHeight())
{
for (size_t i = 0; i < m_width; i++) {
for (size_t j = 0; j < m_height; j++) {
m_cells[i][j] = src.at(i, j);
}
}
}
위 코드에서 볼 수 있듯이 클래스 템플릿 선언문(T)을 먼저 적고, 그 뒤에 멤버 템플릿(E)을 선언하는 문장을 따로 작성합니다. 두 문장을 합쳐서 적을 수는 없습니다. (ex, template<typename T, typename E>)
이렇게 생성자 정의 코드 앞에 템플릿 매개변수를 선언하는 문장을 하나 더 추가해야할 뿐만 아니라 src의 원소에 접근할 때 반드시 getWidth(), getHeight(), at()과 같은 public 접근자 메소드를 사용해야 합니다. 복사할 원본 객체의 타입은 Grid<E>이고, 타겟 객체의 타입은 Grid<T>이기 때문입니다. 두 타입은 서로 다르기 때문에 반드시 public 메소드를 사용해야 합니다.
swap() 메소드는 다음과 같이 구현합니다.
template<typename T>
void Grid<T>::swap(Grid& other) noexcept
{
std::swap(m_width, other.m_width);
std::swap(m_height, other.m_height);
std::swap(m_cells, other.m_cells);
}
템플릿화한 대입 연산자는 const Grid<E>& 타입의 인수를 받아서 Grid<T>& 타입을 리턴합니다.
template<typename T>
template<typename E>
Grid<T>& Grid<T>::operator=(const Grid<E>& rhs)
{
// copy-and-swap idiom
Grid<T> temp{ rhs };
swap(temp);
return *this;
}
여기서 대입 연산자는 copy-and-swap 패턴으로 구현했습니다. swap() 메소드는 오직 같은 타입의 Grid만을 swap하는데, 템플릿화된 대입 연산자가 먼저 Grid<E>를 Grid<T>로 변환해주기 때문에 상관없습니다. 그리고 나서 swap() 메소드로 Grid<T> 타입인 temp를 this와 맞바꾸며, this의 타입 또한 Grid<T>입니다.
비타입 매개변수를 사용하는 메소드 템플릿
위에서 본 HEIGHT와 WIDTH를 정수 타입 템플릿 매개변수로 지정하면 높이와 너비가 타입의 일부가 되어버리는 심각한 문제가 있었습니다. 이렇게 하면 높이와 너비가 다른 Grid에 대입할 수 없습니다. 그런데 간혹 크기가 다른 그리그끼리 대입하거나 복제해야할 경우가 있씁니다. 이럴 때는 대상 객체를 원본 객체와 완전히 똑같이 만드는 대신 원본 배열의 높이와 너비 둘 다 타겟 배열보다 작다면 서로 겹치는 부분만 복사하고 나머지 부분은 디폴트값으로 채워 넣는 방식으로 구현할 수 있습니다. 대입 연산자와 복사 생성자를 메소드 템플릿으로 만들면 이처럼 크기가 다른 그리드끼리 대입하거나 복사하게 만들 수 있습니다.
따라서 클래스 정의를 다음과 같이 수정합니다.
template<typename T, size_t WIDTH = 10, size_t HEIGHT = 10>
class Grid
{
public:
Grid() = default;
virtual ~Grid() = default;
// Explicitly default a copy constructor and assignment operator
Grid(const Grid& src) = default;
Grid<T, WIDTH, HEIGHT>& operator=(const Grid& rhs) = default;
template<typename E, size_t WIDTH2, size_t HEIGHT2>
Grid(const Grid<E, WIDTH2, HEIGHT2>& src);
template<typename E, size_t WIDTH2, size_t HEIGHT2>
Grid<T, WIDTH, HEIGHT>& operator=(const Grid<E, WIDTH2, HEIGHT2>& rhs);
void swap(Grid& other) noexcept;
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 HEIGHT; }
size_t getWidth() const { return WIDTH; }
private:
void verifyCoordinate(size_t x, size_t y) const;
std::optional<T> m_cells[WIDTH][HEIGHT];
};
이렇게 수정한 클래스 정의를 살펴보면 복사 생성자와 대입 연산자에 대한 메소드 템플릿과 swap() 이런 헬퍼 메소드를 가지고 있습니다. 참고로 템플릿 버전이 아닌 기존 복사 생성자와 대입 연산자를 명시적으로 디폴트로 지정했습니다. 두 메소드는 단순히 m_cells만 복사하거나 대입하는데, 서로 크기가 같은 그리드끼리 대입하거나 복사할 때는 이렇게 처리해야 하기 때문입니다.
템플릿화한 복사 생성자를 정의하는 코드는 다음과 같습니다.
template<typename T, size_t WIDTH, size_t HEIGHT>
template<typename E, size_t WIDTH2, size_t HEIGHT2>
Grid<T, WIDTH, HEIGHT>::Grid(const Grid<E, WIDTH2, HEIGHT2>& src)
{
for (size_t i = 0; i < WIDTH; i++) {
for (size_t = 0; j < HEIGHT; j++) {
if (i < WIDTH2 && j < HEIGHT2) {
m_cells[i][j] = src.at(i, j);
}
else {
m_cells[i][j].reset();
}
}
}
}
이 복사 생성자는 src가 더 크더라도 x와 y축에서 각각 WIDTH, HEIGHT로 지정된 크기만큼만 원소를 복사합니다. 도 축 중 어느 하나가 src보다 작다면 나머지 영역에 있는 std::optional 객체들은 reset() 메소드로 리셋됩니다.
swap()과 operator=의 구현은 다음과 같습니다.
template<typename T, size_t WIDTH, size_t HEIGHT>
void Grid<T, WIDTH, HEIGHT>::swap(Grid& other) noexcept
{
std::swap(m_cells, other.m_cells);
}
template<typename T, size_t WIDTH, size_t HEIGHT>
template<typename E, size_t WIDTH2, size_t HEIGHT2>
Grid<T, WIDTH, HEIGHT>& Grid<T, WIDTH, HEIGHT>::operator=(
const Grid<E, WIDTH2, HEIGHT2>& rhs)
{
// Copy-and swap idiom
Grid<T, WIDTH, HEIGHT> temp{ rhs };
swap(temp);
return *this;
}
2.6 Class Template Specialization
특정한 타입에 대해 클래스 템플릿을 다른 방식으로 구현하게 만들 수도 있습니다. 예를 들어 기존 C 스타일 문자열인 const char*에 대한 Grid의 동작이 맞지 않을 수 있습니다. Grid<const char*>로 인스턴스화하면 원소가 vector<vector<optional<const char*>>>에 저장됩니다. 그러면 복사 생성자와 대입 연산자에서 const char* 포인터 타입에 얕은 복사가 적용됩니다. const char* 문자열은 깊은 복사로 처리해야 합니다. 이 문제를 쉽게 해결하는 방법은 const char*에 대해서만 다르게 처리하도록 구현하는 것입니다. 다시 말해 메모리를 자동으로 관리하도록 C 스타일 문자열을 C++ string으로 변환해서 vector<vector<optional<string>>>에 저장하게 만드는 것입니다.
이렇게 특정한 경우에 대해서만 템플릿을 다르게 구현하는 것을 템플릿 특수화(template specialization)이라고 합니다. 문법이 조금 어색할 수 있지만, 클래스 템플릿 특수화 코드를 작성할 때는 이 코드가 템플릿이라는 사실뿐만 아니라 이 템플릿이 특정한 타입에 특화된 버전이라는 것도 반드시 명심해야 합니다.
Grid를 const char*에 대해 특수화하는 방법은 다음과 같습니다.
// 템플릿 특수화를 적용할 때는 원본 템플릿도 반드시 참조할 수 있어야 합니다.
#include "Grid.h"
template<>
class Grid<const char*>
{
public:
explicit Grid(size_t width = DefaultWidth,
size_t height = DefaultHeight);
virtual ~Grid() = default;
// Explicitly default a copy constructor and assignment operator
Grid(const Grid & src) = default;
Grid& operator=(const Grid & rhs) = default;
// Explicitly default a move constructor and assignmnet operator
Grid(Grid && src) = default;
Grid& operator=(Grid && rhs) = default;
std::optional<std::string>& at(size_t x, size_t y);
const std::optional<std::string>& at(size_t x, size_t y) const;
size_t getHeight() const { return m_height; }
size_t getWidth() const { return m_width; }
static const size_t DefaultWidth{ 10 };
static const size_t DefaultHeight{ 10 };
private:
void verifyCoordinate(size_t x, size_t y) const;
std::vector<std::vector<std::optional<std::string>>> m_cells;
size_t m_width{ 0 }, m_height{ 0 };
};
이렇게 특수화할 때는 T와 같은 타입 매개변수를 적지 않고 곧바로 const char*를 지정합니다.
위 코드를 보고 드는 의문은 왜 이 클래스가 템플릿으로 취급되며, 다음과 같이 작성하면 어떤 좋은 점이 있는가 입니다.
template<>
class Grid<const char*>
이렇게 작성하면 컴파일러는 이 클래스가 const char*에 대해 특수화한 Grid라도 판단합니다.
이렇게 적지 않고, 다음과 같이 적었다고 생각해봅시다.
class Grid
Grid란 이름의 클래스 템플릿이 이미 있기 때문에 위 코드에서는 컴파일 에러가 발생합니다. 오직 특수화할 때만 클래스 이름을 중복해서 사용할 수 있습니다. 특수화의 대표적인 장점은 이렇게 특수화됬다는 사실이 사용자에게 드러나지 않는다는 것입니다. Grid를 int나 SpreadsheetCell에 대해 인스턴스화하면 컴파일러는 원본 Grid 템플릿을 이용하여 코드를 생성합니다. 하지만 const char*에 대한 Grid를 인스턴스화할 때는 const char*에 대한 특수화한 버전을 사용합니다. 이 과정은 사용자에게 드러나지 않고 모두 내부적으로 처리됩니다.
Grid<int> myIntGrid; // 원본 Grid 사용
Grid<const char*> stringGrid1(2, 2); // 특수화 버전 사용
const char* dummy{ "dummy" };
stringGrid1.at(0, 0) = "hello";
stringGrid1.at(0, 1) = dummy;
stringGrid1.at(1, 0) = dummy;
stringGrid1.at(1, 1) = "there";
Grid<const char*> stringGrid2{ stringGrid1 };
템플릿을 특수화하는 것은 상속과는 다른 개녑입니다. 특수화할 때는 클래스 전체를 완전히 새로 구현해야 하며, 어떠한 코드도 상속하지 않습니다. 따라서 상속할 때처럼 메소드의 이름과 동작을 똑같이 정의할 필요가 없습니다. 예를 들어 Grid를 const char*에 대해 특수화한 코드를 보면 at() 메소드가 std::optional<const char*>가 아닌 std::optional<std::string> 타입을 리턴합니다. 사실 원본 클래스와는 전혀 다른 형태로 작성해도 됩니다. 물론 템플릿 특수화 기능을 본래 목적과 다르게 남용하는 것이기 때문에 특별한 이유가 없다면 이렇게 작성하면 안됩니다.
const char*에 대한 특수화 버전의 메소드는 다음과 같이 구현합니다. 여기서 템플릿 정의 코드와는 달리 메소드 앞에 template<> 구문을 적지 않아도 됩니다.
Grid<const char*>::Grid(size_t width, size_t height)
: m_width{ width }, m_height{ height }
{
m_cells.resize(m_width);
for (auto& column : m_cells) {
column.resize(m_height);
}
}
std::optional<std::string>& Grid<const char*>::at(size_t x, size_t y)
{
return const_cast<std::optional<std::string>&>(
std::as_const(*this).at(x, y));
}
const std::optional<std::string>& Grid<const char*>::at(size_t x, size_t y) const
{
verifyCoordinate(x, y);
return m_cells[x][y];
}
void Grid<const char*>::verifyCoordinate(size_t x, size_t y) const
{
if (x >= m_width) {
throw std::out_of_range{ std::to_string(x) + " must be less than " + std::to_string(m_width) };
}
if (y >= m_height) {
throw std::out_of_range{ std::to_string(y) + " must be less than " + std::to_string(m_height) };
}
}
이렇게 템플릿 특수화를 적용하면 특정한 타입에 대해서는 템플릿을 다르게 구현할 수 있습니다.
2.7 클래스 템플릿 상속
클래스 템플릿도 상속할 수 있습니다. 단, 템플릿을 상속한 파생 클래스도 템플릿이어야 합니다. 반면 클래스 템플릿을 특정한 타입으로 인스턴스화한 클래스를 상속할 때는 파생 클래스가 템플릿이 아니어도 됩니다. 두 경우 중에서 파생 클래스도 템플릿인 경우를 살펴보도록 하겠습니다.
이를 설명하기 위해 게임보드의 한 지점에서 다른 지점으로 말을 옮기는 move() 메소드를 추가한 GameBoard 클래스 템플릿을 다음과 같이 정의하여 사용하겠습니다.
#include "Grid.h"
template<typename T>
class GameBoard : public Grid<T>
{
public:
explicit GameBoard(size_t width = Grid<T>::DefaultWidth,
size_t height = Grid<T>::DefaultHeight);
void move(size_t xSrc, size_t ySrc, size_t xDest, size_t yDest);
};
GameBoard 클래스는 Grid 템플릿을 상속합니다. 따라서 Grid 템플릿에 있던 기능을 모두 물려받습니다. at()이나 getHeight() 같은 메소드는 다시 작성하지 않아도 됩니다. 또한 GameBoard는 동적 할당 메모리를 사용하지 않기 때문에 복사 생성자, operator=, 소멸자도 추가할 필요가 없습니다.
템플릿을 상속하는 구문은 베이스 클래스가 Grid가 아닌 Grid<T>라는 점만 빼면 기존 상속 구문과 큰 차이가 없습니다. 사실 GameBoard 템플릿은 제너릭 템플릿인 Grid를 곧바로 상속하는 것이 아니라 GameBoard를 특정한 타입에 대해 인스턴스화할 때마다 그 타입에 대한 Grid를 인스턴화한 클래스를 상속하는 것입니다. 그래서 템플릿 상속 문법이 일반 클래스 상속과 같은 것입니다.
예를 들어 GameBoard를 ChessPiece 타입에 대해 인스턴스화하면 컴파일러는 Grid<ChessPiece>에 대한 코드도 함께 생성합니다. 그리고 앞에 나온 코드에서 : public Grid<T>라고 적은 부분을 발견하면 Grid 인스턴스에서 T 타입에 적합한 것들을 모두 상속하도록 처리합니다. 참고로 템플릿 상속에 대한 name lookup rule(이름 조회 규칙)에 따르면 베이스 클래스 템플릿의 데이터 멤버나 메소드를 가리킬 때 this 포인터나 Grid<T>::를 붙여야 하는데, 이를 강제하는 컴파일러가 많지는 않습니다.
GameBoard와 move() 메소드의 구현은 다음과 같습니다.
template<typename T>
GameBoard<T>::GameBoard(size_t width, size_t height)
: Grid<T>{ width, height }
{
}
template<typename T>
void GameBoard<T>::move(size_t xSrc, size_t ySrc, size_t xDest, size_t yDest)
{
Grid<T>::at(xDest, yDest) = std::move(Grid<T>::at(xSrc, ySrc));
Grid<T>::at(xSrc, ySrc).reset();
}
이렇게 정의한 GameBoard 템플릿은 다음과 같이 사용할 수 있습니다.
GameBoard<ChessPiece> chessBoard(8, 8);
ChessPiece pawn;
chessBoard.at(0, 0) = pawn;
chessBoard.move(0, 0, 0, 1);
2.8 상속 vs 특수화
상속화 특수화의 차이점이 헷갈릴 수도 있는데, 간략히 정리하면 다음과 같습니다.
보통 구현을 확장하거나 다형성을 지원하려면 상속을 사용하고, 특정한 타입에 대한 템플릿 구현을 커스터마이즈하고 싶다면 특수화를 사용합니다.
2.9 Alias Template
타입 앨리어스(using)와 typedef의 개념을 이용하면 특정한 타입을 다른 이름으로 부를 수 있습니다. 예를 들어 다음 코드처럼 using을 사용하여 int를 MyInt로 표기할 수 있습니다.
using MyInt = int;
클래스 템플릿에 대해서도 타입 앨리어스를 적용할 수 있습니다. 예를 들어 다음과 같은 클래스 템플릿이 있다고 가정해보겠습니다.
template<typename T1, typename T2>
class MyTemplateClass { /* ... */ };
이 클래스 템플릿의 타입 매개변수를 다음과 같이 지정한 인스턴스에 타입 앨리어스를 적용할 수 있습니다.
using OtherName = MyClassTemplate<int, double>;
여기서 타입 앨리어스 대신 typedef를 사용해도 됩니다.
또한 타입 매개변수 중에서 일부만 지정하고, 나머지 타입은 그대로 템플릿 타입 매개변수 형태로 남겨둘 수 있는데, 이를 앨리어스 템플릿(alias template)라고 부릅니다.
template<typename T1>
using OtherName = MyClassTemplate<T1, double>;
위와 같은 경우에는 typedef로 표현할 수 없습니다.
3. 함수 템플릿
메소드가 아닌 일반(stand-alone) 함수도 템플릿화할 수 있습니다. 예를 들어 배열에서 값을 하나 찾아서 그 값의 인덱스를 리턴하는 제너릭 함수를 작성해보도록 하겠습니다.
static const size_t NOT_FOUND{ static_cast<size_t>(-1) };
template<typename T>
size_t Find(const T& value, const T* arr, size_t size)
{
for (size_t i = 0; i < size; i++) {
if (arr[i] == value) {
return i;
}
}
return NOT_FOUND;
}
이렇게 작성한 Find() 함수 템플릿은 모든 타입의 배열에 적용할 수 있습니다. 예를 들어 이 템플릿으로 int 배열에 담긴 정수값의 인덱스를 찾을 수도 있고, SpreadsheetCell 배열에서 SpreadsheetCell을 찾을 수도 있습니다.
이 함수는 두 가지 방식으로 호출할 수 있습니다. 하나는 꺽쇠괄호 안에 타입 매개변수를 명시적으로 지정하는 것이고, 다른 하나는 주어진 인수를 바탕으로 컴파일러가 타입 매개변수를 알아서 추론하도록 타입을 생략하는 것입니다.
예를 들면 다음과 같습니다.
int myInt{ 3 }, intArray[]{ 1,2,3,4 };
const size_t sizeIntArray{ std::size(intArray) };
size_t res;
res = Find(myInt, intArray, sizeIntArray); // calls Find<int> by deduction
res = Find<int>(myInt, intArray, sizeIntArray); // calls Find<int> explicitly
if (res != NOT_FOUND) { std::cout << res << std::endl; }
else { std::cout << "Not found" << std::endl; }
double myDouble{ 5.6 }, doubleArray[]{ 1.2, 3.4, 5.7, 7.5 };
const size_t sizeDoubleArray{ std::size(doubleArray) };
// calls Find<double> by deduction
res = Find(myDouble, doubleArray, sizeDoubleArray);
// calls Find<double> explicitly
res = Find<double>(myDouble, doubleArray, sizeDoubleArray);
if (res != NOT_FOUND) { std::cout << res << std::endl; }
else { std::cout << "Not found" << std::endl; }
// res = Find(myInt, doubleArray, sizeDoubleArray); // does not compile
// calls Find<double> explicitly, even with myInt
res = Find<double>(myInt, doubleArray, sizeDoubleArray);
앞서 구현한 Find() 함수는 배열의 크기를 매개변수 중에 하나에 반드시 지정해주어야 합니다. 간혹, 컴파일러가 배열의 크기를 정확히 아는 경우가 있습니다. 대표적인 예로 스택 기반 배열을 사용할 때입니다. 이러한 배열에 대해 Find()를 호출할 때 배열의 크기에 대한 인수를 생략할 수 있다면 편리할 것입니다.
이럴 때는 다음과 같이 함수 템플릿을 이용하면 됩니다. 이 코드는 Find() 대한 호출을 단순히 이전 Find() 함수 템플릿으로 포워딩하기만 하면 됩니다. 또한 코드에 나온 것처럼 함수 템플릿도 클래스 템플릿처럼 비타입 매개변수를 받게 만들 수 있습니다.
template<typename T, size_t N>
size_t Find(const T& value, const T(&arr)[N])
{
return Find(value, arr, N);
}
Find()를 이렇게 구현하는 문법은 조금 복잡하지만, Find()를 사용하는 방법은 다음과 같이 간단합니다.
int myInt{ 3 }, intArray[]{ 1, 2, 3, 4 };
size_t res{ Find(myInt, intArray) };
클래스 템플릿의 메소드 정의와 마찬가지로 함수 템플릿을 사용하는 코드는 이 템플릿의 프로토타입뿐만 아니라 저의 코드도 접근할 수 있어야 합니다. 따라서 함수 템플릿을 여러 소스파일에서 사용한다면 함수 템플릿을 정의하는 코드를 헤더 파일에 넣어두고나, 위에서 설명한 것처럼 명시적으로 인스턴스화하는 것이 좋습니다.
당연히 함수 템플릿의 템플릿 매개변수도 클래스 템플릿처럼 디폴트값을 지정할 수 있습니다.
3.1 함수 템플릿 오버로딩
이론적으로 C++에서는 클래스 템플릿 특수화를 작성하는 것처럼 함수 템플릿도 특수화할 수 있습니다. 하지만 함수 템플릿 특수화는 overload resolution에 포함되지 않고, 따라서 예상치 못하게 동작할 수 있습니다.
대신, non-template 함수로 함수 템플릿을 오버로딩할 수 있습니다. 예를 들어, 위에서 정의한 Find() 함수를 오버로딩하여 const char* C 스타일 스트링을 operator== 대신 strcmp()로 비교하도록 할 수 있습니다.
size_t Find(const char* value, const char** arr, size_t size)
{
for (size_t i = 0; i < size; i++) {
if (strcmp(arr[i], value) == 0) {
return i;
}
}
return NOT_FOUND;
}
오버로딩된 함수는 다음과 같이 사용할 수 있습니다.
const char* word{ "two" };
const char* words[] { "one", "two", "three", "four" };
const size_t sizeWords{ std::size(words) };
size_t res{ Find(word, words, sizeWords) }; // calls non-template function
만약 다음과 같이 명시적으로 템플릿 타입 파라미터를 지정해준다면, const char*로 오버로딩된 함수가 아닌 T=const char*인 함수 템플릿이 호출됩니다.
3.2 클래스 템플릿의 friend 함수 템플릿
함수 템플릿은 클래스 템플릿에서 연산자를 오버로딩할 때 유용합니다. 예를 들어 Grid 클래스 템플릿에 덧셈 연산자(operator+)를 오버로딩해서 두 그리드를 더하는 기능을 추가하고 싶을 수 있습니다. 덧셈의 결과로 나오는 Grid의 크기는 두 피연산자 중 작은 Grid의 크기에 맞춥니다. 그리고 두 셀 모두 실제로 값이 들어 있을 때만 더합니다.
그럼 이런 기능을 제공하는 operator+를 독립 함수 템플릿으로 만드는 경우를 생각해봅시다. 구현 코드는 다음과 같으며 Grid.h에 추가해야 합니다.
template<typename T>
Grid<T> operator+(const Grid<T>& lhs, const Grid<T>& rhs)
{
size_t minWidth{ std::min(lhs.getWidth(), rhs.getWidth()) };
size_t minHeight{ std::min(lhs.getHeight(), rhs.getHeight()) };
Grid<T> result{ minWidth, minHeight };
for (size_t y = 0; y < minHeight; ++y) {
for (size_t x = 0; x < minWidth; ++x) {
const auto& leftElement{ lhs.m_cells[x][y] };
const auto& rightElement{ rhs.m_cells[x][y] };
if (leftElement.has_value() && rightElement.has_value()) {
result.at(x, y) = leftElement.value() + rightElement.value();
}
}
}
return result;
}
optional이 실제로 값을 포함하는지 확인하기 위해서 has_value() 메소드를 사용하고, 값을 가져올 때는 value() 메소드를 호출합니다.
이 함수 템플릿은 모든 타입의 Grid에 적용할 수 있습니다. 단, 그리드에 저장할 원소의 타입이 덧셈 연산을 지원해야 합니다. 이렇게 구현하면 Grid 클래스의 private 멤버인 m_cells에 접근한다는 문제가 있습니다. 물론 public 메소드인 at()을 사용해도 되지만, 여기서는 함수 템플릿을 클래스 템플릿의 friend로 만드는 방법에 대해 알아보겠습니다.
이를 위해 덧셈 연산자를 Grid 클래스의 friend로 만듭니다. 그런데 Grid 클래스와 operator+가 모두 템플릿입니다. 실제로 원하는 바는 operator+를 특정한 타입 T에 대해 인스턴스화한 것이 T 타입에 대한 Grid 템플릿 인스턴스의 friend가 되게 만드는 것입니다.
이를 구현하면 다음과 같습니다.
// Grid 템플릿에 대한 전방 선언
template<typename T> class Grid;
// 템플릿화한 operator+에 대한 프로토타입
template<typename T>
Grid<T> operator+(const Grid<T>& lhs, const Grid<T>& rhs);
template<typename T>
class Grid
{
public:
// .. 코드 생략
friend Grid<T> operator+<T>(const Grid<T>& lhs, const Grid<T>& rhs);
// .. 코드 생략
};
이처럼 friend로 선언하는 과정은 조금 복잡합니다. 이 템플릿을 T 타입으로 인스턴스화한 것에 대해 operator+를 T 타입으로 인스턴스화한 것이 friend가 되어야 합니다. 다시 말해 클래스 인스턴스와 함수 인스턴스 사이의 friend 관계가 1:1 대응되게 해야 합니다. 이때 operator+에 명시적으로 <T>를 지정한 부분이 중요합니다. 이렇게 하면 컴파일러는 operator+를 템플릿으로 취급합니다.
3.3 템플릿 매개변수 추론 +
컴파일러는 함수 템플릿에 전달된 인수를 보고 템플릿 매개변수의 타입을 추론합니다. 추론할 수 없는 템플릿 매개변수는 반드시 명시적으로 지정해주어야 합니다.
예를 들어 다음 코드에 나온 add() 함수 템플릿은 템플릿 매개변수를 3개(리턴값의 타입, 피연산자 두 개의 타입) 받습니다.
template<typename RetType, typename T1, typename T2>
RetType add(const T& t1, const T2& t2) { return t1 + t2; }
이렇게 작성한 함수 템플릿에 매개변수 3개를 모두 지정하는 예는 다음과 같습니다.
auto result{ add<long long, int, int>(1, 2) };
그런데 템플릿 매개변수인 T1과 T2는 이 함수의 매개변수이기 때문에 컴파일러는 T1과 T2의 타입을 추론합니다. 그래서 add()를 호출할 때 리턴값에 대한 타입만 지정해도 됩니다.
auto result{ add<long long>(1, 2) };
물론 추론할 매개변수가 목록의 마지막에 있을 때만 이렇게 할 수 있습니다. 예를 들어 함수 템플릿에 다음과 같이 정의된 경우를 살펴보겠습니다.
template<typename T1, typename RetType, typename T2>
RetType add(const T1& t1, const T2& t2) { return t1 + t2; }
이런 경우에는 RetType을 반드시 지정해야 하는데, 이는 컴파일러가 이 타입을 추론할 수 없기 때문입니다. 그런데 RetType이 두 번째 매개변수에 있기 때문에 T1도 명시적으로 지정해주어야 합니다.
auto result{ add<int, long long>(1, 2) };
리턴 타입에 대한 매개변수에도 디폴트값을 지정할 수 있습니다. 그러면 add()를 호출할 때 타입을 하나도 지정하지 않아도 됩니다.
template<typename RetType = long long, typename T1, typename T2>
RetType add(const T& t1, const T2& t2) { return t1 + t2; }
...
auto result{ add(1, 2) };
3.4 함수 템플릿의 리턴 타입
방금 살펴본 add() 함수 템플릿에서 리턴값의 타입도 컴파일러가 추론하면 참 편합니다. 실제로 가능은 합니다. 하지만 리턴 타입은 템플릿 타입 매개변수에 따라 결정됩니다. 이 문제는 어떻게 해결할 수 있을까요?
예를 들어 다음과 같이 템플릿화된 함수가 있다고 해봅시다.
template<typename T1, typename T2>
RetType add(const T1& t1, const T2& t2) { return t1 + t2; }
여기서 RetType은 반드시 t1 + t2 표현식의 타입으로 지정해야 합니다. 그런데 T1과 T2를 모르기 때문에 이 표현식의 타입도 모릅니다.
C++14부터는 컴파일러가 함수의 리턴 타입을 자동으로 추론하는 옵션이 추가되었습니다. 따라서 add()를 그냥 다음과 같이 구현하면 됩니다.
template<typename T1, typename T2>
auto add(const T1& t1, const T2& t2) { return t1 + t2; }
여기서 auto로 표현식의 타입을 추론하면 레퍼런스와 const가 사라집니다. 반면 decltype은 이를 제거하지 않습니다.
add() 함수 템플릿을 더 살펴보기 전에 먼저 auto와 decltype의 차이점을 알아보도록 하겠습니다. 예를 들어 다음과 같이 템플릿이 아닌 일반 함수가 있다고 해봅시다.
const std::string message{ "Test" };
const std::string& getString() { return message; }
getString()을 호출한 결과를 다음과 같이 auto 타입 변수에 저장할 수 있습니다.
auto s1{ getString() };
auto에 의해 레퍼런스와 const가 사라지기 때문에 s1의 타입은 string이 되면서 복사 연산이 발생합니다. const 레퍼런스를 사용하려면 다음과 같이 이 타입이 레퍼런스와 const라는 것을 명시적으로 지정해주어야 합니다.
const auto& s2{ getString() };
또 다른 방법으로는 decltype을 사용하는 것입니다. decltype을 사용하면 const나 레퍼런스가 제거되지 않습니다.
decltype(getString()) s3{ getString() };
이렇게 하면 s3의 타입은 const string&이 됩니다. 그런데 getString()을 두 번이나 작성해서 코드 중복이 발생합니다. 만약 getString() 대신 좀 더 복잡한 형태의 표현이라면 코드가 상당히 지저분해집니다.
이 문제는 다음과 같이 decltype(auto)로 해결할 수 있습니다.
decltype(auto) s4{ getString() };
s4 역시 const string& 타입이 됩니다.
그럼 이제 다시 add() 함수 템플릿으로 돌아가서 더 살펴보겠습니다.
이번에는 add()에서 const와 레퍼런스가 사라지지 않도록 decltype(auto)로 지정해봅니다.
template<typename T1, typename T2>
decltype(auto) add(const T1& t1, const T2& t2) { return t1 + t2; }
C++14 이전에는, 즉, 함수의 리턴 타입 추론 기능과 decltype(auto)가 지원되기 전에는 이를 C++11부터 추가된 decltype(expreesion) 구문으로 해결했습니다. 그러므로 예를 들어 C++14 이전에는 다음과 같이 작성했을 것입니다.
template<typename T1, typename T2>
decltype(t1+t2) add(const T1& t1, const T2& t2) { return t1 + t2; }
하지만 이렇게 작성하면 안됩니다. t1과 t2를 프로토타입의 시작 부분에 적었는데, 아직 t1과 t2의 타입을 모르기 때문입니다. 컴파일러의 의미 분석기(semantic analyzer)가 매개변수 목록을 끝까지 훑어본 뒤에야 t1과 t2의 타입을 정확히 알 수 있습니다.
이 문제는 alternative function syntax(대체 함수 구문)으로 해결할 수 있습니다. 여기서 리턴 타입을 매개변수 목록 뒤에 지정한다는 점에 주목합니다(이를 후행 리턴 타입,trailing return type 이라고 합니다). 그러면 매개변수의 이름과 각각의 타입 그리고 t1 + t2의 타입을 알 수 있습니다.
template<typename T1, typename T2>
auto add(const T1& t1, const T2& t2) -> decltype(t1+t2)
{
return t1 + t2;
}
하지만 최선 버전의 C++에서는 자동 리턴 타입 추론 기능과 decltype(auto)를 지원하기 때문에 대체 함수 구문보다는 이 두 가지 기능 중 하나를 활용하는 것이 좋습니다.
4. 변수 템플릿
C++14부터 클래스 템플릿, 메소드 템플릿, 함수 템플릿뿐만 아니라 변수 템플릿(variable template)도 제공합니다.
문법은 다음과 같습니다.
template<typename T>
constexpr T pi{ T{ 3.141592653589793238462643383279502884 } };
이 코드는 pi값에 대한 변수 템플릿으로서, 특정한 타입의 파이 변수를 생성하려면 다음과 같이 작성합니다.
float piFloat{ pi<float> };
auto piLongDouble{ pi<long double> };
그러면 지정한 타입으로 표현할 수 있는 범위에 맞게 파이값을 구할 수 있습니다.
변수 템플릿도 다른 템플릿과 마찬가지로 특수화할 수 있습니다.
'프로그래밍 > C & C++' 카테고리의 다른 글
[C++] 알고리즘 (Algorithms) (2) (0) | 2022.02.26 |
---|---|
[C++] 알고리즘 (Algorithms) (1) (0) | 2022.02.26 |
[C/C++] 가변 인자 리스트 (0) | 2022.02.23 |
[C++] Lambda Expression (람다 표현식) (0) | 2022.02.23 |
[C++] Function Object (함수 객체) (0) | 2022.02.22 |
댓글