전문가를 위한 C++/ 템플릿으로 제네릭 코드 만들기

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)

템플릿 소개

절차형 프로그래밍 패러다임에서는 프로시저나 함수 단위로 프로그램을 작성한다. 그중에서 특히 함수를 많이 사용하는데, 알고리즘을 작성할 때 특정한 값에 의존하지 않게 구현해두면 나중에 임의의 값에 대해 얼마든지 재사용이 가능하기 때문이다. 이렇게 함수를 작성하는 것을 매개변수화 한다고 표현한다.

객체지향 프로그래밍 패러다임은 객체란 개념도 도입했는데, 객체란 데이터와 동작을 하나로 묶은 것으로 함수나 메서드에서 값을 매개변수화하는 방식과는 별개다.

템플릿은 매개변수화 개념을 더욱 발전시켜 값 뿐만 아니라 타입에 대해서도 매개변수화한다. C++에서 기본으로 제공하는 int, double 같은 기본 타입뿐만 아니라 SpreadsheetCell이나 CherryTree처럼 사용자가 정의한 클래스에 대해서도 매개변수화할 수 있다. 템플릿을 이용하면 주어진 값뿐만 아니라 그 값의 타입에 대해서도 독립적인 코드를 ㅈ가성할 수 있다.

템플릿이 제공하는 기능이 뛰어나지만 C++ 템플릿 문법이 상당히 복잡해서 템플릿을 직접 정의하지 않는 프로그래머가 많다.

(C#에서 제네릭과 유사하다)

클래스 템플릿

클래스 템플릿(class template)은 멤버 변수 타입, 메서드의 매개변수 또는 리턴 타입을 매개변수로 받아서 클래스를 만든다. 클래스 템플릿은 주로 객체를 저장하는 컨테이너나 데이터 구조에서 많이 사용한다. 

클래스 템플릿 작성법

템플릿 없이 구현한 Grid 클래스

(템플릿에 대한 내용이 주요하므로 그 외 부분에 대한 설명은 생략)

클래스 정의

class GamePiece
{
public:
virtual std::unique_ptr<GamePiece> clone() const = 0;
};

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);
}

class GameBoard
{
public:
explicit GameBoard(size_t width = kDefaultWidth, size_t height = kDefaultHeight);
GameBoard(const GameBoard& src); // 복제 생성자
virtual ~GameBoard() = default; // 가상 디폴트 소멸자
GameBoard& operator=(const GameBoard& rhs); // 대입 연산자

// 이동 생성자와 대입 연산자를 명시적으로 디폴트로 지정한다.
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 sizt_t kDefaultHeight = 10;

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

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

std::vector<std::vector<std::unique_ptr<GamePiece>>> mCells;
size_t mWidth, mHeight;
};

메서드 정의

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)
{
// 여기 나온 생성자 이니셜라이저는 먼저 적절한 크기의 메모리를 할당하는 작업을 비복제 생성자에 위임한다.

// 그리고 나서 데이터를 복제한다.
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)
{
if (this == &rhs)
{
return *this;
}

GameBoard temp(this);
swap(*this, temp);
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));
}

사용 방법

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;

템플릿으로 구현한 Grid 클래스

앞선 클래스 정의는 아쉬운 점이 몇 가지 있는데 다음과 같다.

첫째, GameBoard는 항상 원소를 포인터로 저장하기 때문에 원소를 값으로 저장할 수 없다.

둘째, 타입 안전성이 떨어진다. 이는 원소를 포인터로 저장하는 문제보다 심각하다. GameBoard는 각 셀을 unique_ptr<GamePiece>로 저장한다. ChessPiece로 저장했던 셀을 요청하기 위해 at()을 호출해도 unique_ptr<GamePiece>로만 리턴한다. 따라서 GamePiece를 ChessPiece로 다운캐스트해야 ChessPiece의 고유 기능을 활용할 수 있다.

셋째, int나 double 같은 기본 타입으로 저장할 수 없다. 셀은 GamePiece를 상속한 타입만 저장할 수 있기 때문에다.

따라서 ChessPiece나 SpreadsheetCell 뿐만 아니라 int, double 같은 타입도 모두 수용하려면 Grid를 제네릭 클래스로 만드는 것이 훨씬 좋다. 이럴 때는 C++에서 제공하는 클래스 템플릿을 이용하여 특정한 타입에 종속되지 않게 클래스를 구현하면 된다. 그러면 클라이언트는 이 템플릿에 저마다 원하는 타입에 맞는 클래스를 인스턴스화해서 사용할 수 있다. 이런 방식을 제네릭 프로그래밍이라 부른다. 

제네릭 프로그래밍의 가장 큰 장점은 타입 안전성이다. 다형성을 이용하면 추상 베이스 클래스로 정의해야 하지만, 클래스 템플릿을 활용하면 클래스 안에 있는 메서드를 비롯한 멤버의 타입을 모두 구체적으로 정의할 수 있다.

예컨대 ChessPiece 뿐만 아니라 TiaTacToePiece도 지원한다고 가정하자.

class TicTacToePiece : public GamePiece
{
public:
virtual std::unique_ptr<GamePiece> clone() const override;
};

std::unique_ptr<GamePiece> TicTacToePiece::clone() const
{
// 복제 생성자를 호출해서 이 인스턴스를 복제한다.
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 클래스에서는 int와 double 같은 타입도 지원해야 하기 때문에 앞서 GameBoard를 구현할 때처럼 다형성 기반의 포인터 전달 방식으로 구현하는 것보다 다형성을 사용하지 않고 값 전달 방식으로 구현하는 것이 유리하다.

하지만 한 가지 단점이 있는데, 포인터 전달 방식과 달리 값 전달 방식을 적용할 때는 셀에 항상 어떤 값이 들어 있어야 하기 때문에 완전히 빈 셀을 만들 수 없다. 이에 반해 포인터 기반으로 구현하면 nullptr로 초기화하는 방식으로 빈 셀을 만들 수 있다. 다행히 C++ 17부터 지원하는 std::optional 을 이용하면 값 전달 방식을 지원하는 동시에 빈 셀도 표현할 수 있다.

template <typename T>
class Grid
{
public:
explicit Grid(size_t width = kDefaultWidth, size_t height = kDefaultHeight);
virtual ~Grid() = default;

// 복제 생성자와 대입 연산자를 명시적으로 디폴트로 지정한다.
Grid(const Grid& src) = default;
Grid<T>& operator=(const Grid& rhs) = default;

// 이동 생성자와 대입 연산자를 명시적으로 디폴트로 지정한다.
Grid(Grid&& src) = default;
Grid<T>& operator=(Grid&& rhs) = default;

std::optional<T>& at(sizt_t x, size_t y);
const std::optional<T>& at(sizt_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;
};

위 코드를 한 줄 씩 살펴보자.

template <typename T>

첫 줄은 뒤에 나올 클래스 정의가 특정한 타입에 적용할 수 있는 템플릿이라고 선언한다. template과 typename은 모두 C++에 정의된 키워드다. 템플릿은 타입을 매개변수로 받는다(이를 매개변수화한다고 표현한다).

이는 함수가 값을 매개변수화하는 방식과 같다. 함수를 호출할 때 지정하는 인수를 매개변수 이름으로 표현하듯 템플릿에 적용할 타입도 템플릿의 매개변수 이름(앞 코드의 T)으로 표현한다.

템플릿 지정자는 문장 전체에 적용된다. 앞에 나온 코드에서는 클래스를 정의하는 코드 전체에 적용된다.

Note) 템플릿 매개변수를 typename 대신 class 키워드로 표기해도 되는데, 그래서 다른 코드나 책에서는 template <class T>라고 표기하는 사례가 있다.

앞서 본 GameBoard 클래스는 mCells란 데이터 멤버를 포인터 원소에 대한 vector의 vector로 구현했다. 그래서 복제 작업을 특수한 코드(복제 생성자와 복제 대입 연산자)로 처리해야 했다. 하지만 템플릿을 이용한 Grid 클래스는 mCells의 타입을 optional 값에 대한 vector의 vector로 정의한다. 그러면 복제 생성자와 대입 연산자를 컴파일러가 만들어주는데, 이렇게 기본으로 생성된 것으로도 충분하다.

하지만 8장에서 설명했듯이 사용자가 직접 소멸자를 정의하면 복제 생성자나 복제 대입자가 자동으로 생성되지 않기 때문에 Grid 클래스 템플릿에서 복제 생성자와 복제 대입 연산자가 자동 생성되도록 명시적으로 디폴트로 지정했다. 마찬가지로 이동 생성자와 이동 대입 연산자도 명싲거으로 디폴트로 선언했다. 복제 대입 연산자를 명시적으로 디폴트로 선언하는 문장은 다음과 같다.

Grid<T>& operator= (const Grid& rhs) = default;

이 문장을 보면 앞서 const GameBoard& 타입으로 선언했던 rhs 매개변수가 const Grid& 타입으로 변경된 것을 알 수 있다. 이 타입을 const Grid<T>& 로 표기해도 된다. 참고로 클래스 정의 코드 안에서 Grid라고만 적어도 컴파일러는 Grid<T>로 해석한다. 하지만 클래스 저으이 밖에서는 반드시 Grid<T>라고 적어야 한다.

클래스 템플릿을 작성할 때는 Grid가 클래스 이름처럼 보이지만 엄밀히 말해 Grid는 템플릿 이름이다. 실제 클래스를 가리킬 때는 int, SpreadsheetCell, ChessPiece 등과 같은 구체적인 타입으로 템플릿을 인스턴스화한 이름으로 표현해야 한다. 따라서 Grid 클래스 템플릿으로 인스턴스화한 실제 클래스를 가리킬 때는 Grid<T>로 표현해야 한다.

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

이제 mCells는 더는 포인터가 아닌 optional 타입의 값으로 저장한다. 따라서 at() 메서드의 리턴 타입을 unique_ptr가 아닌 optional<T>& 또는 const optional<T>&로 변경한다.

Grid 클래스 메서드 정의

Grid 템플릿에서 메서드를 정의할 때는 반드시 템플릿 지정자(template <typename T>)를 앞에 적어야 한다. 예컨대 생성자를 정의하는 코드는 다음과 같다.

template<typename T>
Grid<T>::Grid(size_t width, size_t height)
: mWidth(width), mHeight(height)
{
mCells.resize(mWidth);

for (auto& column : mCells)
{
column.resize(mHeight);
}
}

Note) 템플릿을 정의할 때는 반드시 메서드 구현 코드를 헤더 파일에 적어야 한다. 그래야 컴파일러가 템플릿 인스턴스를 생성하기 전에 메서드 정의를 포함한 클래스 정의 전체를 알 수 있기 때문이다. 참고로 이러한 제약을 우회하는 방법도 있는데 12.2.4 ‘템플릿 코드를 여러 파일로 나누기’에서 소개하겠다.

여기서 :: 기호 앞의 클래스 이름이 Grid가 아닌 Grid<T>인 점에 주목한다. 메서드나 static 데이터 멤버를 정의하는 코드는 반드시 클래스 이름을 Grid<T>와 같이 표기해야 한다. 생성자의 본문은 GameBoard 생성자와 동일하다.

나머지 메서드 정의 코드는 템플릿 지정자와 Grid<T>를 제외하면 GameBoard와 같다.

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));
}

Note) 클래스 템플릿 메서드의 구현 코드를 작성할 때 템플릿 타입 매개변수 T에 대해 디폴트값을 지정하려면 T()와 같이 작성해야 한다. T가 클래스 타입이면 T()는 이 클래스의 디폴트 생성자를 호출하고 T가 기본타입이면 T()는 0을 생성한다. 이렇게 표기하는 방식을 영 초기화 문법이라 부르는데, 구체적인 타입을 모르는 변수에 디폴트값을 지정하는데 유용하다.

Grid 템플릿 사용법

Grid 템플릿으로 Grid 객체를 생성할 때는 타입에 Grid 뿐만 아니라 Grid에 저장할 대상의 타입도 함께 지정해야 한다. 이렇게 클래스 템플릿에 특정한 타입을 지정해서 구체적인 클래스를 만드는 것을 템플릿 인스턴스화(template instantiation)라고 한다. 템플릿 인스턴스화는 다음과 같이 객체를 선언하는 과정에 적용할 수 있다.

Grid<int> myIntGrid;  // int 값을 저장할 Grid 객체 선언. 이때 생성자에 디폴트 인수를 적용한다.
Grid<double> myDoubleGrid(11, 11); // double 값에 대한 11x11 Grid 선언

myIntGrid.at(0, 0) = 10;
int x = myIntGrid.at(0, 0).value_or(0);

Grid<int> grid2(myIntGrid); // 복제생성자
Grid<int> anotherIntGrid;
anotherIntGrid = grid2; // 대입 연산자

여기 나온 myIntGrid, grid2, anotherIntGrid의 타입은 모두 Grid<int>다. 이렇게 만든 Grid 객체에 SpreadsheetCell이나 ChessPiece 객체를 저장하는 코드를 작성하면 컴파일 에러가 발생한다.

value_or()을 사용하는 부분을 주목할 필요가 있다. at() 메서드는 std::optional 레퍼런스를 리턴하는데, optional에 값이 없을 수도 있다. value_or() 메서드는 optional에 값이 있을 때는 그 값을 리턴하고, 없으면 value_or()에 전달한 인수를 리턴한다.

이때 타입을 지정하는 문법이 중요하다. 다음과 같이 작성하면 컴파일 에러가 발생한다.

Grid test;  // 컴파일 에러
Grid<> test;  // 컴파일 에러

컴파일러가 첫 번째 문장을 발견하면 ‘클래스 템플릿을 사용하려면 템플릿 인수 목록을 지정해야 한다’는 에러 메시지가 발생하고 두 번째 문장에 대해서는 ‘템플릿 인수의 수가 너무 적다’는 에러 메시지가 발생한다.

Grid 객체를 받는 함수나 메서드를 선언할 때도 Grid에 저장할 항목의 타입을 구체적으로 지정해야 한다.

void processIntGrid(Grid<int>& grid)
{
// 코드 생략
}

아니면 12.3 절 ‘함수 템플릿’에서 설명하는 함수 템플릿을 이용해서 Grid의 원소 타입을 매개변수로 표현한 함수로 작성한다.

Note) 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<vector<int>> gridOfVectors;
vector<int> myVector{1, 2, 3, 4};
gridOfVectors.at(5, 6) = myVector;

Grid 템플릿 인스턴스화를 통해 객체를 힙에 동적으로 생성할 수도 있다.

auto myGridOnHeap = make_unique<Grid<int>>(2, 2);
myGridOnHeap->at(0, 0) = 10;
int x = myGridOnHeap->at(0, 0).value_or(0);

꺾쇠괄호

C++ 11 이전에는 이중꺾쇠괄호(>>)를 연산자로 취급해서 다음과 같이 작성해야 했다.

std::vector<std::vector<T> > mCells;

C++ 11 이상 버전에서는 꺾쇠괄호 사이에 공백을 넣어야 하는 번거로움이 해결되었으므로 다음과 같이 작성해도 된다.

std::vector<std::vector<T>> mCells;

컴파일러에서 템플릿을 처리하는 방식

컴파일러는 템플릿 메서드를 정의하는 코드를 발견하면 문법 검사만 하고 템플릿 코드를 실제로 컴파일하지 않는다. 템플릿 저으이만 보고서 그 안에서 어떤 타입을 사용할지 알 수 없기 때문이다. 다시 말해 x = y 란 문장에서 x 와 y의 타입을 모르면서 컴파일러가 코드를 생성할 수 없다.

컴파일러가 Grid<int> myIntGrid처럼 템플릿을 인스턴스화하는 코드를 발견하면 Grid 템플릿의 매개변수 T에 int를 대입해서 int 버전의 Grid 클래스를 생성한다. 또한 Grid<SpreadsheetCell> mySpreadsheet 처럼 다른 타입에 대한 템플릿 인스턴스화 코드를 발견하면 SpreadsheetCell 타입에 대한 Grid 클래스를 추가로 생성한다.

이처럼 템플릿 기능이 없을 떄 원소의 타입마다 일일이 클래스를 정의했어야 할 작업을 컴파일러가 대신 해주는 것이다. 템플릿 처리 과정은 복잡하지 않다. 단순 반복적인 ‘copy & paste’와 단어 바꾸기 작업을 자동화한 것에 불과하다. 

만약 클래스 템플릿을 정의하는 코드만 작성하고 특정한 타입에 대해 인스턴스를 만드는 코드를 작성하지 않으면 클래스 메서드를 정의하는 코드는 컴파일되지 않는다.

선택적 인스턴스화

컴파일러는 항상 제네릭 클래스에 있는 모든 가상 메서드에 대한 코드를 생성한다. 하지만 virtual 로 선언하지 않은 다른 메서드는 그중에서 특정 타입에 대해 호출하는 메서드만 컴파일한다. 이를 선택적 인스턴스화(selective instantiation)라 부른다. 앞서 나온 Grid 클래스 템플릿을 이용하는 코드를 main() 함수에서 다음과 같이 작성했다고 하자.

Grid<int> myIntGrid;
myIntGrid.at(0, 0) = 10;

그러면 컴파일러는 int 버전의 Grid에서 제로 인수 생성자, 소멸자, non-const at() 메서드만 컴파일한다. 복제 생성자나 대입 연산자, getHeight()에 대한 코드는 생성하지 않는다.

템플릿에 사용할 타입의 요건

타입에 독립적인 코드를 작성하려면 적용할 타입에 대해 어느 정도 고려해야 한다. 예컨대 Grid 템플릿을 작성할 떄 T에 지정한 타입의 원소는 언제든지 소멸될 수 있다는 점을 고려해야 한다. 앞서 본 Grid 템플릿을 구현할 떄는 고려할 사항이 별로 없지만, 템플릿의 타입 매개변수에 맞게 대입 연산자를 제공해야 한다면 고려할 사항이 많다.

어떤 템플릿을 인스턴스화할 때 그 템플릿에 있는 연산을 모두 지원하지 않으면 컴파일 에러가 발생한다. 그런데 이런 에러 메서지는 상당히 난해하기 표현되어 있다. 그런데 템플릿을 인스턴스화 할 타입이 그 템플릿에 정의된 모든 연산에 적용할 수 없다면 앞서 설명한 선택적 인스턴스화를 이용해서 그중 일부 메서드를 사용하게 만들면 된다.

템플릿 코드를 여러 파일로 나누기

일반적인 클래스는 헤더에 정의를 적고, 메서드는 소스파일에 적고, 객체를 생성할 때는 그 객체가 속한 클래스의 정의가 담긴 헤더 파일을 #include 문에 적어줘야 한다. 링커는 이 문장을 보고 해당 클래스의 메서드를 참조할 경로를 마련해 준다.

하지만 템플릿을 처리하는 과정은 좀 다른데, 말 그대로 ‘템플릿’ 이기 때문에 사용자가 지정한 타입에 대한 메서드를 사용하는 문장이 나올 때마다 컴파일러는 템플릿 정의와 메서드 정의 코드를 모두 볼 수 있어야 코드를 제대로 생성할 수 있다. 이렇게 두 코드를 연결하는 방법은 여러 가지가 있다.

헤더 파일에 템플릿 정의하기

메서드 정의 코드를 클래스 정의 코드가 있는 헤더 파일에 함께 적는 방법이 있다. 그러면 이 템플릿을 사용하는 소스 파일에 #include 문으로 헤더 파일만 불러오면 컴파일러는 클래스 정의와 메서드 정의를 모두 참조할 수 있다. 앞서 본 Grid 예제가 그렇게 처리된다.

또 다른 방법은 템플릿 메서드 정의 코드를 다른 헤더 파일에 적고 그 헤더 파일을 클래스 정의를 담은 헤더 팡리에서 #include 문으로 불러오는 것이다. 이때 메서드 정의가 담긴 헤더를 추가하는 #include 문은 반드시 클래스 정의 코드 뒤에 적어야 한다. 그렇지 않으면 컴파일 에러가 발생한다.

template <typename T>
class Grid
{
// 클래스 정의 코드 생략
};

#include "GridDefinitions.h"

이렇게 정의한 Grid 템플릿을 사용할 때는 Grid.h 헤더 파일만 인클루드하면 된다. 이처럼 메서드 정의와 클래스 정의를 두 헤더 파일에 나눠서 작성하면 클래스 저으이와 메서드 정의를 명확히 구분할 수 있다는 장점이 있다.

소스 파일에 템플릿 정의하기

헤더 파일에 메서드 구현 코드가 어색하면 기존처럼 메서드 저으이 코드를 소스 파일에 작성해도 된다. 그렇다 해도 템플릿을 사용하는 코드에서 메서드 정의 코드를 볼 수 있어야 하는 사실은 변하지 않는다. 이럴 떄는 클래스 템플릿 정의가 있는 헤더 파일에 메서드 구현 코드가 있는 소스 파일을 추가하는 #include 문을 작성하면 된다. 어색해 보이지만 C++에서 정식 지원하는 문법이다.

template <typename T>
class Grid
{
// 클래스 정의 코드 생략
};

#include "Grid.cpp"

이제 Grid.cpp 파일이 프로젝트 빌드 목록에 추가되지 않도록 주의한다. 이 파일은 추가하면 안 될 뿐 아니라 추가할 방법도 없고 따로 컴파일 할 수도 없다. 반드시 헤더 파일의 #include 문에 추가해야 한다.

메서드 구현 코드가 담긴 파일 이름은 마음대로 해도 되는데, 그래서 이 용도로 작성한 소스 파일의 확장자를 .inl로 표기(ex: Grid.inl) 하는 프로그래머도 있다.

클래스 템플릿의 인스턴스화 제한하기

클래스 템플릿을 특정한 타입에만 적용하게 만들고 싶다면 다음에 나온 테크닉을 적용한다. 예컨대 Grid 클래스를 int, double, vector<int>에 대해서만 인스턴스화 할 수 있게 만들고 싶다면 헤더 파일을 다음과 같이 작성한다.

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)
: mWidth(width), mHeight(height)
{
mCells.resize(mWidth);

for (auto& column : mCells)
{
column.resize(mHeight);
}
}

// 나머지 메서드 정의 생략

그러고 나서 이 메서드를 사용할 수 있게 하려면 템플릿에서 허용하는 타입으로 명시적으로 인스턴스화 해둬야 한다. 예컨대 앞서 나온 .cpp 파일의 마지막에 다음과 같이 작성한다.

// 인스턴스화를 허용할 타입을 명시적으로 나열한다.
template class Grid<int>;
template class Grid<double>;
template class Grid<std::vector<int>>;

이렇게 명시적으로 인스턴스화해두면 여기에 나온 타입에 대해서만 빌드하기 때문에 앞에 나온 Grid 클래스 템플릿을 다른 타입으로 인스턴스화할 수 없게 된다.

Note) 이러한 명시적 클래스 템플릿 인스턴스화 기법을 적용하면 클래스 템플릿에 있는 메서드를 실제로 사용하지 않더라도 그 템플릿에 있는 모든 메서드를 컴파일한다.

템플릿 매개변수

앞서 본 Grid 템플릿은 템플릿 매개변수를 하나만 지정했다. 일반적으로 클래스 템플릿을 작성할 떄는 다음과 같이 꺾쇠괄호 안에 매개변수를 나열한다.

template <typename T>

여기서 매개변수 목록을 지정하는 방식은 함수나 메서드에서 매개변수 목록을 작성할 떄와 비슷하다. 함수나 메서드처럼 클래스 템플릿의 매개변수도 원하는 수만큼 지정할 수 있다. 이때 매개변수를 타입 대신 디폴트값으로 지정해도 된다.

비타입 템플릿 매개변수

비타입 매개변수(non-type parameter)란 int나 포인터처럼 함수나 메서드에서 흔히 사용하는 종류의 매개변수를 말한다. 하지만 정수 계열의 타입(char, int, long), 열거 타입, 포인터, 레퍼런스, std::nullptr_t 등 비타입 매개변수로 사용할 수 있다. 참고로 C++ 17부터 auto, auto&, auto* 등도 비타입 매개변수로 사용할 수 있게 됐는데, 이렇게 지정하면 구체적인 타입을 컴파일러가 알아서 지정한다.

앞서 본 Grid 클래스 템플릿 예에서 그리드의 높이와 너비를 생성자에서 지정하지 않고 비타입 템플릿 매개변수로 표현할 수 있다. 이렇게 생성자 대신 템플릿 목록에서 비타입 매개변수를 사용하면 코드를 컴파일하기 전에 값을 알 수 있다는 장점이 있다. 앞서 설명했듯이 컴파일러는 템플릿 메서드의 코드를 생성하기 전에 먼저 템플릿 매개변수를 대입한다. 따라서 코드에서 이차원 배열을 vector에 대한 vector가 아닌 기존 정적 배열로 작성하더라도 크기를 동적으로 조절할 수 있다. Grid 클래스 템플릿 코드를 이렇게 수정하면 다음과 같다.

template <typename T, size_t WIDTH, size_t HEIGHT>
class Grid
{
public:
Grid() = default;
virtual ~Grid() = default;

// 복제 생성자와 대입 연산자를 명시적으로 디폴트로 지정한다.
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> mCells[WIDTH][HEIGHT];
}

이번에는 이동 생성자와 이동 대입 연산자를 명시적으로 디폴트로 지정하지 않았다. C 스타일 배열은 어차피 이동 의미론은 지원하지 않기 때문이다.

여기서 템플릿 매개변수 목록으로 Grid에 저장할 객체의 타입, 그리드의 너비와 높이를 지정해야 한다. 메서드를 정의하는 코드는 다음과 같다.

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 || y >= HEIGHT)
{
throw std::out_of_range("");
}
}

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 mCells[x][y];
}

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));
}

이전에 Grid<T>로 표기했던 부분을 Grid<T, WIDTH, HEIGHT>와 같이 템플릿 매개변수를 세 개 받도록 변경했다. 이렇게 변경한 템플릿은 다음과 같이 사용한다.

Grid<int, 10, 10> myGrid;
Grid<int, 10, 10> anotherGrid;
myGrid.at(2, 3) = 42;
anotherGrid = myGrid;
cout << anotherGid.at(2, 3).value_or(0);

코드가 간결해졌지만 의도와 달리 제약사항이 더 많아졌다. 첫 번째 제약은 높이와 너비 값에 non-const 정수를 지정할 수 없다는 것이다. 다음과 같이 작성하면 컴파일 에러가 발생한다.

size_t height = 10;
Grid<int, 10, height> testGrid; // 컴파일 에러 발생

height를 상수로 정의하면 문제없이 컴파일 된다. 

리턴 타입을 정확히 지정한 constexpr 함수로 표현해도 된다. 예컨대 size_t 타입의 값을 리턴하는 constexpr 함수로 높이에 대한 템플릿 매개변수를 초기화할 수 있다.

constexpr size_t getHeight() { return 10; }
...
Grid<double, 2, getHeight()> myDoubleGrid;

두 번째 제약은 첫 번째보다 심각한데, 수정된 템플릿에서는 높이와 너비가 템플릿 매개변수라서 두 값이 그리드 타입의 일부가 된다. 다시 말해 Grid<int, 10, 10>과 Grid<int, 10, 11>은 서로 다른 타입이 되어서 서로 대입할 수도 없고, 함수나 메서드에 전달할 때도 호환되지 않는다.

타입 매개변수의 디폴트값

너비와 높이를 비타입 템플릿 매개변수로 지정할 떄도 앞에서 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 mCells[x][y];
}

이렇게 작성하면 Grid를 인스턴스화하는 코드를 다음과 같이 다양하게 표현할 수 있다. 템플릿 매개변수를 모두 생략해도 되고, 원소의 타입만 지정해도 되고, 원소의 타입과 너비만 지정해도 되고, 원소의 타입과 너비와 높이를 모두 지정해도 된다.

Grid<> myIntGrid;
Grit<int> myGrid;
Grid<int, 5> anotherGrid;
Grid<int, 5, 5> aFourthGrid;

참고로 클래스 템플릿 매개변수를 모두 생략하더라도 꺾쇠괄호는 반드시 적어야 한다. 클래스 템플릿 매개변수의 디폴트 인수를 지정할 때는 함수나 메서드에 디폴트 인수를 지정할 때와 똑같은 제약사항이 적용된다. 다시 말해 매개변수 목록에서 오른쪽 끝에서 왼쪽 방향으로 중간에 건너뛰지 않고 디폴트 값을 지정해야 한다.

생성자에 대한 템플릿 매개변수 추론 과정

C++ 17부터 클래스 템플릿 생성자에 전달된 인수를 보고 템플릿 매개변수를 자동으로 추론하는 기능이 추가됐다. C++ 17 이전에는 항상 클래스 템플릿에 템플릿 매개변수를 명시적으로 지정해야 했다.

예컨대 표준라이브러리에는 <utility> 헤더에 정의된 std::pair란 클래스 템플릿이 있다. 자세한 내용은 17장에서 설명하고 여기서는 pair는 두 값을 하나로 묶어서 저장한다고만 알아두자. 이를 다음과 같이 템플릿 매개변수로 지정한다.

std::pair<int, double> pair1(1, 2.3);

C++는 템플릿 매개변수를 일일이 적는 번거로움을 덜기 위해 std::make_pair()라는 헬퍼 함수 템플릿을 제공한다. 함수 템플릿은 12.3절에서 자세히 설명한다. 여기서는 함수 템플릿은 항상 전달된 인수를 보고 템플릿 매개변수를 알아서 결정한다는 것만 알아두자. 따라서 make_pair()도 전달된 값을 보고 템플릿 타입 매개변수를 자동으로 알아낸다. 예컨대 다음과 같이 호출하면 컴파일러는 템플릿 매개변수가 pair<int, double> 이라고 유추한다.

auto pair2 = std::make_pair(1, 2.3);

C++ 17부터는 이런 헬퍼 함수 템플릿을 더는 사용할 필요가없다. 생성자에 전달된 인수를 보고 템플릿의 타입 매개변수를 자동으로 알아내기 때문이다. 따라서 pair 클래스 템플릿을 다음과 같이 간단히 작성해도 된다.

std::pair pair3(1, 2.3);

물론 클래스 템플릿의 모든 템플릿 매개변수에 디폴트값을 지정했거나 생성자에서 이 매개변수를 사용할 때만 자동으로 추론할 수 있다.

Note) std::unique_ptr과 shared_ptr는 방금 설명한 타입 추론 기능이 적용되지 않도록 끌 수 있다. std::unique_ptr나 shared_ptr의 생성자에 T*를 전달하면 컴파일러는 <T>나 <T[]> 중 하나를 선택해야 하는데, 잘못 결정하면 치명적인 결과가 발생할 수 있다. 따라서 unique_ptr과 shared_ptr는 각각 make_unique()와 make_shared()를 사용하도록 작성하는 것이 바람직하다.

사용자 정의 추론 방식

템플릿 매개변수를 추론하는 규칙을 사용자가 직접 정할 수도 있다. 고급 주제에 해당하므로 자세히 설명하지 않겠지만 이 기능이 얼마나 강력한지 맛보기 위해 예제를 살펴보겠다. 다음과 같이 작성된 SpreadsheetCell 클래스 템플릿이 있다고 하자.

template<typename T>
class SpreadsheetCell
{
public:
SpreadsheetCell(const T& t) : mContent(t) { }

const T& getContent() const { return mContent; }

private:
T mContent;
};

자동 템플릿 매개변수 추론 기능을 이용하면 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 템플릿_이름(매개변수_목록) -> 추론된_템플릿;

메서드 템플릿

클래스 뿐만 아니라 메서드도 템플릿화 할 수 있다. 이러한 메서드 템플릿은 클래스 템플릿 안에 정의해도 되고, 비템플릿 클래스 안에 정의해도 된다. 메서드를 템플릿으로 제공하면 한 메서드를 다양한 타입에 대한 버전으로 만들 수 있다. 메서드 템플릿은 클래스 템플릿에 복제 생성자와 대입 연산자를 정의할 때 특히 유용하다.

Caution) 가상 메서드와 소멸자는 메서드 템플릿으로 만들 수 없다.

앞서 정의한 원소 타입 하나만 템플릿 매개변수로 받는 버전의 Grid 템플릿을 다시 살펴보자. 이 템플릿은 int와 double을 비롯한 여러 타입에 대해 Grid를 인스턴스화 할 수 있다.

Grid<int> myIntGrid;
Grid<double> myDoubleGrid;

그런데 이렇게 만든 Grid<int>와 Grid<double>은 타입이 서로 다르다. Grid<double> 객체를 받는 함수는 Grid<int> 객체를 인수로 받을 수 없다. 그 이유는 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);

template<typename E>
Grid<T>& operator=(const Grid<E>& rhs);

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 < mWidth; i++)
{
for (size_t j = 0; j < mHeight; j++)
{
mCells[i][j] = src.at(i, j);
}
}
}

위 코드에서 볼 수 있듯이 클래스 템플릿 선언문(T 매개변수가 있는)을 먼저 적고, 그 뒤에 멤버 템플릿(E 매개변수를 가진)을 선언하는 문장을 따로 작성해야 한다. 두 문장을 합칠 수는 없다.

이렇게 생성자 정의 코드 앞에 템플릿 매개변수를 선언하는 문장을 하나 더 추가해야 할 뿐만 아니라 src의 원소에 접근할 때는 반드시 getWidth(), getHeight(), at()과 같은 public 접근자 메서드를 사용해야 한다. 복제할 원본 객체의 타입은 Grid<E>이고, 복제할 대상 객체의 타입은 Grid<T>이기 때문이다. 두 타입은 서로 다르기 때문에 반드시 public 메서드로 다뤄야 한다.

이렇게 템플릿화한 대입 연산자는 const Grid<E>& 타입의 인수를 받아서 Grid<T>& 타입을 리턴한다.

template<typename T>
template<typename E>
Grid<T>& Grid<T>::operator=(const Grid<E>& rhs)
{
// 자기 잣니을 대입할 경우에 대한 예최 처리를 할 필요가 없다. 이 버전의 대입 연산자는 T와 E가 서로 같으면 호출되지 않기 때문이다.

// 복제 후 맞바꾸기 구문
Grid<T> temp(rhs);
swap(temp);
return *this;
}

템플릿 버전의 대입 연산자에서는 자기 대입 여부를 검사할 필요가 없다. 같은 타입끼리 대입하는 연산은 템플릿화하지 않은 원래 버전의 operator=이 처리하기 때문이다.

템플릿 버전의 대입 연산자는 복제 후 맞바꾸기 구문으로 구현했는데, 아직 함수 템플릿을 소개하지 않았기 때문에 여기서는 friend 함수인 swap() 대신 swap() 메서드를 사용했다. 이렇게 하면 같은 타입의 Grid 객체만 맞바꿀 수 있다는 한계가 있다. 그래도 문제가 되지 않는데, 템플릿화한 대입 연산자는 먼저 템플릿화한 복제 생성자를 이용하여 Grid<E> 객체를 Grid<T> 객체(temp)로 변환하기 때문이다. 그러고 나서 swap() 메서드로 Grid<T> 타입인 temp를 this와 맞바꾼다. 물론 this의 타입도 Grid<T>다. 여기 나온 swap() 메서드를 정의하는 코드는 다음과 같다.

template<typename T>
void Grid<T>::swap(Grid<T>& other) noexcept
{
using std::swap;

swap(mWidth, other.mWidth);
 swap(mHeight, other.mHeight);
swap(mCells, other.mCells);
}

비타입 매개변수를 사용하는 메서드 템플릿 

앞서 본 예제에서 WIDTH, HEIGHT를 정수 타입 템플릿 매개변수로 지정하면 높이와 너비가 타입의 일부가 돼버리는 심각한 문제가 있었다. 이렇게 하면 높이와 너비가 다른 그리드에 대입할 수 없다. 그런데 간혹 크기가 다른 그리드끼리 대입하거나 복제해야 할 경우가 있다. 이럴 때는 대상 객체를 원본 객체와 완전히 똑같이 만드는 대신 원본 배열의 높이와 너비 둘 다 대상 배열보다 작다면 서로 겹치는 부분만 복제하고 나머지 부분은 디폴트 값으로 채워 넣는 방식으로 구현할 수 있다. 

대입 연산자와 복제 생성자를 메서드 템플릿으로 만들면 이처럼 크기가 다른 그리드끼리 대입하거나 복제하게 만들 수 있다. 따라서 ㅋ르래스 정의를 다음과 같이 수정한다.

template <typename T, size_t WIDTH = 10, size_t HEIGHT = 10>
class Grid
{
public:
Grid() = default;
virtual ~Grid() = default;

// 복제 생성자와 대입 연산자를 명시적으로 디폴트로 지정한다.
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> mCells[WIDTH][HEIGHT];
}

이렇게 수정한 클래스 정의를 보면 복제 생성자와 대입 연산자에 대한 메서드 템플릿과 swap()이란 헬퍼 메서드를 갖고 있다. 참고로 템플릿 버전이 아닌 기존 복제 생성자와 대입 연산자를 명시적으로 디폴트로 지정했다(소멸자를 직접 정의했기 때문). 두 메서드는 단순히 mCells만 복제하거나 대입하는데, 서로 크기가 같은 그리드끼리 대입하거나 복제할 때는 이렇게 처리 해야 하기 때문이다.

템플릿화한 복제 생성자를 정의하는 코드는 다음과 같다.

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 j = 0; j < HEIGHT; j++)
{
if (i < WIDTH2 && j < HEIGHT2)
{
mCells[i][j] = src.at(i, j);
}
else
{
mCells[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, WIDHT, HEIGHT>::swap(Grid<T, WIDTH, HEIGHT>& other) noexcept
{
using std::sawp;

swap(mCells, other.mCells);
}

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)
{
// 자기 대입에 대한 예외 처리를 할 필요가 없다. T와 E가 서로 같을 때는 이 버전의 대입 연산자가 호출되지 않기 때문이다.

// 복제 후 맞바꾸기 구문
Grid<T, WIDTH, HEIGHT> temp(rhs);
swap(temp);
return *this;
}

클래스 템플릿의 특수화

특정한 타입에 대해 클래스 템플릿을 다른 방식으로 구현하게 만들 수도 있다. 예컨대 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 문을 추가한다.
#include "Grid.h"

template<>
class Grid<const char*>
{
public:
explicit Grid(size_t width = kDefaultWidth, size_t height = kDefaultHeight);
virtual ~Grid() = default;

// 복제 생성자와 대입 연산자를 명시적으로 디폴트로 선언한다.
Grid(const Grid& src) = default;
Grid<const char*>& operator=(const Grid& rhs) = default;

// 이동 생성자와 대입 연산자를 명시적으로 디폴트로 선언한다.
Grid(Grid&& src) = default;
Grid<const char*>& 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 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<std::string>>> mCells;
size_t mWidth, mHeight;
};

이렇게 트굿화할 때는 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* 에 대한 특수화 버전을 적용한다.

템플릿을 특수화하는 것은 상속(파생)과는 개념이 다르다. 특수화할 떄는 클래스 전체를 완전히 새로 구현해야 한다(어떤 코드도 상속하지 않는다) 따라서 상속할 때처럼 메서드 이름과 동작을 똑같이 정의할 필요가 없다. 사실 원본 클래스와 전혀 다르게 작성해도 되는데, 이는 템플릿 특수화 기능의 본래 목적과 다르게 남용하는 것이므로 특별한 이유가 없다면 이렇게 작성하지 않는게 좋다.

const char*에 대한 특수화 버전의 메서드는 다음과 같이 구현한다. 템플릿 정의 코드와 달리 메서드 앞에 template<> 구문을 적지 않아도 된다.

Grid<const char*>::Grid(size_t width, size_t height)
: mWidth(width), mHeight(height)
{
mCells.resize(mWidth);

for (auto& column : mCells)
{
column.resize(mHeight);
}
}

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

const std::optional<std::string>& Grid<const char*>::at(size_t x, size_t y) const
{
verifyCoordinate(x, y);
return mCells[x][y];
}

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));
}

클래스 템플릿 상속하기

클래스 템플릿도 상속할 수 있다. 템플릿을 상속한 파생 클래스도 템플릿이어야 한다. 반면 클래스 템플릿을 특정한 타입으로 인스턴스화한 클래스를 상속할 때는 파생 클래스가 템플릿이 아니어도 된다. 두 경우 중 파생 클래스도 템플릿인 경우를 살펴보자.

#include "Grid.h"

template<typename T>
class GameBoard : public Grid<T>
{
public:
explicit GameBoard(size_t width = Grid<T>::kDefaultWidth, size_t height = Grid<T>::kDefaultHeight);
void move(size_t xsrc, size_t ySrc, size_t xDest, size_t yDest);
}

GameBoard 템플릿은 Grid 템플릿을 상속한다. 템플릿을 상속하는 구문은 베이스 클래스가 Grid가 아닌 Grid<T>라는 점만 빼면 기존 상속 구문과 차이가 없어 보인다. 사실 GameBoard 템플릿은 제네릭 템플릿인 Grid를 곧바로 상속하는 것이 아니라 GameBoard를 특정한 타입에 인스턴스화할 때마다 그 타입에 대해 Grid를 인스턴스화 클래스를 상속하는 것이다. 그래서 템플릿 상속 문법이 일반 클래스 상속과 같은 것이다. 예컨대 GameBoard를 ChessPiece 타입에 대해 인스턴스화 하면 컴파일러는 Grid<ChessPiece>에 대한 코드도 함께 생성한다.

이번에는 생성자와 move() 메서드를 구현하는 코드를 살펴보자. 생성자 이니셜라이저를 보면 베이스 클래스 생성자를 호출할 때 Grid<T>를 사용했다. 참고로 C++의 이름 조회 규칙에 따르면 베이스 클래스 템플릿의 데이터 멤버나 메서드를 가맄리 때 this 포인터나 Grid<T>::를 붙여야 하는데, 이를 강제하는 컴파일러는 많지 않다.

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();

// 이 부분을 다음과 같이 구현해도 된다.
// this->at(xDest, yDest) = std::move(this->at(xSrc, ySrc));
// this->at(xSrc, ySrc).reset();
}

상속과 특수화 비교

  상속 특수화
코드 재사용 O : 파생 클래스는 베이스 클래스에 있는 데이터 멤버와 메서드를 모두 가진다. X: 특수화를 할 때는 필요한 코드를 모두 다시 작성해야 한다.
이름 재사용 X: 파생 클래스의 이름은 반드시 베이스 클래스와 다르게 지어야 한다. O: 특수화 템플릿 클래스의 이름은 반드시 원본과 같아야 한다.
다형성 지원 O: 파생 클래스의 객체를 베이스 클래스의 객체로 표현할 수 있다. X: 템플릿을 인스턴스화한 결과마다 타입이 다르다.

앨리어스 템플릿

11장에서 소개한 타입 앨리어스(using)와 typedef의 개념을 이용하면 특정한 타입을 다른 이름으로 부를 수 있다. 클래스 템플릿에 대해서도 타입 앨리어스를 적용할 수 있다. 예컨대 다음과 같은 클래스 템플릿이 있다고 하자.

template<typename T1, typename T2>
class MyTemplateClass { /* ... */ };

이 클래스 템플릿의 타입 매개변수를 다음과 같이 지정한 인스턴스에 타입 앨리어스를 적용할 수 있다.

using OtherName = MyTemplateClass<int, double>;

여기서 타입 앨리어스 대신 typedef를 사용해도 된다.

또한 타입 매개변수 중에서 일부만 지정하고 나머지 타입은 그대로 템플릿 타입 매개변수 형태로 남겨둘 수 있다. 이를 앨리어스 템플릿(alias template)이라 부른다. 예컨대 다음과 같다.

template<typename T1>
using OtherName = MyTemplateClass<T1, double>;

이런 문장은 typedef로 표현할 수 없다.

함수 템플릿

메서드가 아닌 일반 함수도 템플릿화 할 수 있다. 예컨대 배열에서 값을 하나 찾아서 그 값의 인덱스를 리턴하는 제네릭 함수를 작성해 보자.

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); // 타입 추론을 통해 Find<int>를 호출한다.
res = Find<int>(myInt, intArray, sizeIntArray); // Find<int>를 명시적으로 호출한다.

if (res != NOT_FOUND)
{
cout << res << endl;
}
else
{
cout << "Not Found" << endl;
}

double myDouble = 5.6, doubleArray[] = { 1.2, 3.4, 5.7, 7.5 };
const size_t sizeDoubleArray = std::size(doubleArray);

// 타입 추론을 통해 Find<double>을 호출한다.
res = Find(myDouble, doubleArray, sizeDoubleArray);
// Find<double>을 명시적으로 호출한다.
res = Find<double>(myDouble, doubleArray, sizeDoubleArray);

if (res != NOT_FOUND)
{
cout << res << endl;
}
else
{
cout << "Not Found" << endl;
}

res = Find<double>(myInt, doubleArray, sizeDoubleArray);

SpreadsheetCell cell1(10), cellArray[] = {SpreadsheetCell(4), SpreadsheetCell(10)};
const size_t sizeCellArray = std::size(cellArray);

res = Find(cell1, cellArray, sizeCellArray);
res = Find<SpreadsheetCell>(cell1, cellArray, sizeCellArray);

앞서 구현한 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()를 사용하는 방법은 다음과 같다.

int myInt = 3, intArray[] = { 1, 2, 3, 4 };
size_t res = Find(myInt, intArray);

클래스 템플릿의 메서드 정의와 마찬가지로 함수 템플릿을 사용하는 코드는 이 템플릿의 프로토타입 뿐만 아니라 정의 코드도 접근할 수 있어야 한다. 따라서 함수 템플릿을 여러 소스 파일에서 사용한다면 함수 템플릿을 정의하는 코드를 헤더 파일에 넣어두거나 ‘소스 파일에 템플릿 정의하기’에서 설명한 것처럼 명시적으로 인스턴스화하는 것이 좋다.

함수 템플릿의 템플릿 매개변수도 클래스 템플릿처럼 디폴트 값을 지정할 수 있다.

함수 템플릿의 특수화

클래스 템플릿과 마찬가지로 함수 템플릿도 특수화할 수 있다. 예컨대 operator== 대신 strcmp()로 값을 비교하도록 C 스타일 스트링인 const char*에 특화된 Find() 함수를 만들 수 있다. 예컨대 다음과 같다.

template<>
size_t Find<const char*>(const char* const& value, const char* const* 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*>를 생략해도 된다. 따라서 함수 프로토타입을 다음과 같이 작성할 수 있다.

template<>
size_t Find(const char* const& value, const char* const* arr, size_t size)

그런데 다음 절에서 섦여할 오버로딩을 함께 적용하면 타입 추론 규칙이 복잡해질 수 있다. 따라서 실수를 방지하기 위해서라도 타입을 명시적으로 지정하는 것이 좋다.

특수화한 Find() 함수도 첫 번째 매개변수로 const char* const& 대신 const char* 만 받을 수 있짐나, 특수화되지 않은 버전의 Find()와 인수를 서로 일치시키는 것이 타입을 정확히 유추하는데 도움 된다.

이렇게 특수화한 버전의 Find()를 사용하는 방법은 다음과 같다.

const char* word = "two";
const char* words[] = { "one", "two", "three", "four" };
const size_t sizeWords = std::size(words);
size_t res;

// const char*에 대해 특수화된 버전을 호출한다.
res = Find<const char*>(word, words, sizeWords);

// const char*에 대해 특수화된 버전을 호출한다.
res = Find(word, words, sizeWords);

함수 템플릿 오버로딩

함수 템플릿도 일반 함수처럼 오버로딩 할 수 있다. 예컨대 Find()를 const char*에 대해 특수화하지 않고, const char* 스트링을 처리하는 일반 함수로 만들어도 된다.

size_t Find(const char* const& value, const char* const* 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;

// T=const char*인 템플릿 호출
res = Find<const char*>(word, words, sizeWords);

// 비템플릿(일반) Find() 호출
res = Find(word, words, sizeWords);

이처럼 const char* 타입이 명시적으로 지정될 때뿐만 아니라 타입 추론으로 결정될 때도 함수가 제대로 호출되게 하려면 일반 함수로 오버로딩하지 말고 템플릿 특수화를 이용해야 한다.

함수 템플릿 오버로딩과 특수화 동시에 사용하기

const char*에 대해 템플릿 특수화를 적용한 Find()와 const char*에 대해 일반 함수로 작성한 Find()를 동시에 사용할 수도 있다. 기본적으로 컴파일러는 항상 템플릿 버전보다 일반 함수 버전을 우선시 한다. 하지만 템플릿 인스턴스화를 명시적으로 지정하면 컴파일러는 무조건 템플릿 버전을 선택하나.

const char* word = "two";
const char* words[] = { "one", "two", "three", "four" };
const size_t sizeWords = std::size(words);
size_t res;

// const char*에 특수화된 템플릿 버전을 호출한다.
res = Find<const char*>(word, words, sizeWords);

// 일반 함수로 구현한 Find()를 호출한다.
res = Find(word, words, sizeWords);

클래스 템플릿의 friend 함수 템플릿

함수 템플릿은 클래스 템플릿에서 연산자를 오버로딩할 때 유용하다. 예컨대 Grid 클래스 템플릿에 덧셈 연산자를 오버로딩해서 두 그리드를 더하는 기능을 추가하고 싶을 수 있다. 덧셈의 결과로 나오는 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.mCells[x][y];
const auto& rightElement = rhs.mCells[x][y];

if(leftElement.has_value() && rightElemenet.has_value())
{
result.at(x, y) = leftElment.value() + rightElement.value();
}
}
}
return result;
}

이 함수 템플릿은 모든 타입의 Grid에 적용할 수 있다. 단 그리드에 저장할 원소의 타입이 덧셈 연산을 지원해야 한다. 이렇게 구현하면 Grid 클래스의 private 멤버인 mCells에 접근한다는 문제가 있다. 물론 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+와 <T> 사이를 띄우지 않아도 된다) 이렇게 하면 컴파일러는 operator+를 템플릿으로 취급한다.

템플릿 매개변수 추론에 대한 보충 설명

컴파일러는 함수 템플릿에 전달된 인수를 보고 템플릿 매개변수의 타입을 추론한다. 추론할 수 없는 템플릿 매개변수는 반드시 명시적으로 지정해야 한다.

예컨대 다음 코드에 나온 add() 함수 템플릿은 템플릿 매개변수를 세 개 받는다.

template<typename RetType, typename T1, typename T2>
RetType add(const T1& t1, const T2& t2) { return t1 + t2; }

이렇게 작성한 함수 템플릿에 매개 변수 세 개를 모두 지정하는 예는 다음과 같다.

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 T1& t1, const T2& t2) { return t1 + t2; }
...
auto result = add(1, 2)

함수 템플릿의 리턴 타입

add() 함수 템플릿에서 리턴값의 타입도 컴파일러가 추론해 주면 참 편할 것이다. 실제로 가능한데 리턴 타입은 템플릿 타입 매개변수에 따라 결정된다. 이 문제를 어떻게 해결할 수 있을까? 예컨대 다음과 같이 템플릿화된 함수가 있다고 하자.

template<typename T1, typename T2>
RetType add(const T1& t1, const T2& t2) { return t1 + t2; }

여기서 RetType은 반드시 t1 + t2 표현식의 타입으로 지정해야 한다. 그런데 T1과 T2를 모르기 때문에 이 표현식의 타입도 모른다. 1장에서 설명했듯이 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& 타입이다.

이제 auto와 decltype에 대해 알았으니 앞서 설명하던 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(표현식) 구문으로 해결했다. 그러므로 예를 들어 다음과 같이 작성했을 것이다.

template<typename T1, typename T2>
decltype(t1 + t2) add(const T1& t1, const T2& t2) { return t1 + t2; }

하지만 이렇게 작성하면 안 된다. t1과 t2를 프로토타입의 시작 부분에 적었는데, 아직 t1과 t2의 타입을 모르기 때문이다. 컴파일러의 의미 분석기가 매개변수 목록을 끝까지 훑어본 후에야 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)를 지원하기 때문에 대체 함수 구문 보다는 두 가지 기능 중 하나를 활용하는 것이 좋다.

변수 템플릿

C++ 14부터 클래스 템플릿, 메서드 템플릿, 함수 템플릿 외에 변수 템플릿도 지원한다. 문법은 다음과 같다.

template<typename T>
constexpr T pi = T(3.141592...);

이 코드는 파이 값에 대한 변수 템플릿으로 특정한 타입의 파이 변수를 생성하려면 다음과 같이 작성한다.

float piFloat = pi<float>
long double piLongDouble = pi<long double>;

그러면 지정한 타입으로 표현할 수 있는 범위에 맞게 파이값을 구할 수 있다. 다른 템플릿과 마찬가지로 변수 템플릿도 특수화할 수 있다.

[ssba]

The author

Player가 아니라 Creator가 되려는 자/ suyeongpark@abyne.com

댓글 남기기

이 사이트는 스팸을 줄이는 아키스밋을 사용합니다. 댓글이 어떻게 처리되는지 알아보십시오.