- 템플릿을 사용하면 비슷비슷한 클래스와 함수가 여러 개 만들어진다. 따라서 템플릿 매개변수에 종속되지 않은 템플릿 코드는 비대화의 원인이 된다.
- 비타입 템플릿 매개변수로 생기는 코드 비대화의 경우, 템플릿 매개변수를 함수 매개변수 혹은 클래스 데이터 멤버로 대체함으로써 비대화를 없앨 수 있다.
- 타입 매개변수로 생기는 코드 비대화의 경우, 동일한 이진 표현구조를 가지고 인스턴스화되는 타입들이 한 가지 함수 구현을 공유하게 만듦으로써 비대화를 감소시킬 수 있다.
매개변수에 독립적인 코드는 템플릿에서 분리
템플릿은 코딩 시간 절약, 코드 중복 회피에 효과가 있다.
하지만, 아무 생각 없이 템플릿을 사용하면 똑같은 내용의 코드와 데이터가 여러 개 중복되어 이진 파일로 만들어지는 코드 비대화가 생길 수 있다.
코드 비대화를 해결할 수 있는 방법을 알아보자.
공통성 및 가변성 분석
함수를 만들고 있다가 다른 함수를 봤는데, 지금 만들고 있는 함수의 구현 중 일부가 다른 함수의 구현에도 똑같이 있다는 사실을 알았다고 가정해 보자.
두 함수로부터 공통 코드를 뽑아내고, 이것을 별도의 새로운 함수에 넣은 후, 이 함수를 기존의 두 함수가 호출하도록 코드를 수정할 수 있다.
클래스의 경우도 비슷하다. 지금 만들고 있는 클래스의 어떤 부분이 다른 클래스와 일치하는 부분이 있다면 공통부분을 양쪽에 두지 않는 것이 맞다.
즉, 공통 부분을 별도의 새로운 클래스에 옮긴 후, 클래스 상속 혹은 객체 합성을 사용해서 원래의 클래스들이 공통부분을 공유하도록 해야 한다.
템플릿을 작성할 경우에도 똑같은 방법으로 코드 중복을 막으면 된다.
하지만, 주의할 점이 하나 있다.
템플릿이 아닌 코드에서는 코드 중복이 명시적이다. 반면, 템플릿 코드에서는 코드 중복이 암시적이다.
하나의 템플릿이 여러 번 인스턴스화될 때 발생할 수 있는 코드 중복이 그 예시이다.
예를 들어 보자.
고정 크기의 정방행렬을 나타내는 클래스 템플릿을 하나 만들고 싶다.
역행렬을 만드는 연산을 지원한다.
template<typename T, std::size_t n>
class SquareMatrix {
public:
...
void invert();
};
이 템플릿은 T라는 타입 매개변수도 받지만, size_t 타입의 비타입 매개변수인 n도 받도록 되어 있다.
비타입 매개변수는 흔하지 않지만 적법한 매개변수이다.
SquareMatrix<double, 5> sm1;
...
sm1.invert(); // SquareMatrix<double, 5>::invert
SquareMatrix<double, 10> sm2;
...
sm2.invert(); // SquareMatrix<double, 10>::invert
invert의 사본이 인스턴스화되는데, 만들어지는 사본의 개수는 두 개다.
이 둘은 같은 함수일 수가 없다. 행렬을 사이즈가 5, 10으로 각각 다르기 때문이다.
하지만 열의 크기를 나타내는 상수만 빼면 두 함수는 완전히 똑같다.
이런 현상이 코드 비대화를 일으키는 일반적인 형태 중 하나이다.
사용하는 값이 5와 10인 것만 다르고 나머지는 똑같기 때문에 그 값을 매개변수로 받는 별도의 함수를 만들고, 그 함수에 5나 10을 매개변수로 넘겨서 호출하게 만들 수 있다.
template<typename T>
class SquareMatrixBase {
protected:
...
void invert(std::size_t matrixsize); // 주어진 크기로 역행렬 만듬
...
};
template<typename T, std::size_t n>
class SquareMatrix: public SquareMatrixBase<T> {
private:
using SquareMatrixBase<T>::invert; // 기본 클래스의 invert가 가려지는 것을 방지
public:
void invert() { this->invert(n); } // invert의 기본 클래스 버전 호출
...
};
행렬의 크기를 매개변수로 받도록 바뀐 invert 함수가 기본 클래스인 SquareMatrixBase에 들어 있는 것을 볼 수 있다.
하지만 SquareMatrix은 그렇지 않다.
따라서 같은 타입의 객체를 원소로 갖는 모든 정방행렬은 오직 한 가지의 SquareMatrixBase 클래스를 공유하게 되는 것이다.
즉, 같은 원소 타입의 정방행렬이 사용하는 기본 클래스 버전의 invert 함수도 오직 한 개의 사본이다.
SquareMatrixBase::invert 함수는 파생 클래스에서 코드 복제를 피할 목적으로만 마련한 장치이기 때문에, public 멤버가 아니라 protected 멤버로 되어 있다는 점도 유의해야 한다.
이 함수의 호출에 드는 추가 비용은 하나도 없어야 한다. 기본 클래스의 invert 함수를 호출하도록 구현된 파생 클래스의 invert 함수가 인라인 함수이기 때문이다.
또, this-> 표기가 붙어 있다. 템플릿화된 기본 클래스의 멤버 함수 이름이 파생 클래스에서 가려지는 문제를 피하기 위한 것인데, using 선언이 있기 때문에 불필요한 부분이기도 하다.
그리고 SquareMatrixBase와 SquareMatrix의 상속관계가 private이다.
기본 클래스를 사용한 데는 순전히 파생 클래스의 구현을 돕기 위한 것 외엔 아무 논리적 관계가 없기 때문이다.
아직 해결하지 못한 문제가 하나 있다.
SquareMatrixBase::invert 함수는 자신이 처리할 데이터가 어떤 것인지 알 수 없다.
파생 클래스 쪽 행렬을 이루는 데이터 위치를 기본 클래스는 알지 못하기 때문에, 이를 기본 클래스로 넘겨주어야 한다.
첫 번째 방법은 SquareMatrixBase::invert 함수가 매개변수를 하나 더 받도록 만드는 것이다.
매개변수는 행렬 데이터가 들어 있는 메모리의 시작주소를 가리키는 포인터일 것이다.
문제없이 동작할 것이다. 하지만, invert 함수 이외에도 비슷한 동작 방식의 함수가 여러 개 있다면 그 함수에도 매개변수가 하나씩 추가되어야 한다.
결국 SquareMatrixBase에게 똑같은 정보를 반복해서 알려주게 된다.
두 번째 방법은 행렬 값을 담는 메모리에 대한 포인터를 SquareMatrixBase가 저장하게 하는 것이다.
template<typename T>
class SquareMatrixBase {
protected:
SquareMatrixBase(std::size_t n, T* pMem): size(n), pData(pMem) {} // 행렬 크기 저장, 행렬 포인터 저장
void setDataPtr(T* ptr) { pData = ptr; } // pData 대입
...
private:
std::size_t size; // 행렬 크기
T* pData; // 행렬 데이터 포인트
};
이렇게 설계하면, 메모리 할당 방법의 결정 권한이 파생 클래스 쪽으로 넘어간다.
파생 클래스를 만드는 사람에 따라, 행렬 데이터를 SquareMatrix 객체 안에 데이터 멤버로 직접 넣는 사람도 있을 수 있다.
template<typename T, std::size_t n>
class SquareMatrix: private SquareMatrixBase<T> {
public:
SquareMatrix(): SquareMatrixBase<T>(n, data) {} // 기본 클래스를 통해 초기화
...
private:
T data[n*n];
};
이렇게 파생 클래스를 만들면 동적 메모리 할당이 필요 없는 객체가 되지만, 객체 자체의 크기가 커질 수 있다.
이 방법이 마음에 들지 않으면 힙에 데이터를 둘 수도 있다.
template<typename T, std::size_t n>
class SquareMatrix: private SquareMatrixBase<T> {
public:
SquareMatrix(): SquareMatrixBase<T>(n, 0), pData(new T[n*n]) // 기본 클래스의 포인터를 null로 설정, 행렬 값의 메모리 할당
{ this->setDataPtr(pData.get()); } // 파생 클래스에 메모리 물려 놓은 후, 포인터의 사본으 기본 클래스로 올려보냄
...
private:
T data[n*n];
};
어느 메모리에 데이터를 저장하느냐에 따라 설계가 달라지긴 하지만, 코드 비대화의 측면에서 아주 효과적이다.
SquareMatrix에 속해 있는 멤버 함수 중 상당수가 기본 클래스 버전을 호출하는 단순 인라인 함수가 될 수 있으며, 똑같은 타입의 데이터를 원소로 갖는 모든 정방행렬들이 행렬 크기에 상관없이 이 기본 클래스 버전의 사본 하나를 공유하게 된다.
동시에, 크기가 다른 SquareMatrix 객체는 각자 공유의 타입을 갖고 있다는 점도 아주 중요하다.
예를 들어, SquareMatrix <double, 5>객체와 SquareMatrix<double, 10> 객체가 똑같이 SquareMatrixBase<double> 클래스의 멤버 함수를 사용하고 있다고 하더라도 이 둘은 타입이 다르기 때문에 SquareMatrix<double,10>을 처리하는 함수가 SquareMatrix<double, 5> 객체를 처리하려 하면 에러가 발생한다.
행렬의 크기가 미리 정해져 있는 invert와 크기를 매개변수로 넘겨받는 invert 함수를 비교해 보면 전자가 더 좋은 코드를 생성할 가능성이 높다.
예를 들어, 전자의 경우, 행렬 크기가 컴파일 시점에 투입되는 상수이기 때문에 상수 전파등의 최적화가 일어난다.
이런 혜택은 후자에서는 얻을 수 없다.
반면, 여러 행렬 크기에 대해 한 가지 버전의 invert를 두도록 만들면 실행 코드의 크기가 작아지는 이점이 있다.
그런데 실행 코드가 작아지면 프로그램의 작업 세트 크기가 줄어들면서 명령어 캐시 내의 참조 지역성도 향상된다.
이렇게 되면 프로그램 실행 속도가 빨라질 수 있는데 이러한 이점은 전자를 택했을 때보다 훨씬 좋다.
두 가지의 경우 중 개발 환경에 더 적합한 방법을 택하면 된다.
효율에 대해 생각해 볼 문제가 하나 더 있는데, 바로 객체의 크기이다.
invert와 비슷한 크기 독립형 버전의 함수를 기본 클래스 쪽으로 아무 생각 없이 옮겨 놓다 보면, 객체의 전체 크기가 늘어나게 된다.
SquareMatrix 객체는 메모리에 생길 때마다 SquareMatrixBase 클래스에 들어 있는 데이터를 가리키는 포인터를 하나씩 갖고 있다.
결국 이것 때문에 SquareMatrix 객체 하나의 크기는 최소한 포인터 하나 크기만큼 낭비된 것이다.
포인트가 필요 없도록 설계할 수 있지만 문제가 생길 수 있다.
예를 들어, 기본 클래스로 하여금 행렬 데이터의 포인터를 protected 멤버로 저장하게끔 만들면 캡슐화 효과가 날아간다.
그뿐 아니라, 자원 관리에서도 문제가 생긴다.
만약 행렬 데이터의 포인터를 저장하는 일은 기본 클래스가 담당하게 하되, 실제로 이 데이터를 저장할 메모리를 할당하는 일은 파생 클래스에서 담당하게 한다면, 포인터의 삭제 여부를 결정하는 문제가 발생한다.
결국 코드 중복보다 더 큰 문제가 생길 수도 있다는 뜻이다.
정리
타입 매개변수가 비대화의 원인이 되는 것은 아니다.
예를 들면, 상당수의 플랫폼에서 int와 long은 이진 표현구조가 동일하다.
그렇기 때문에, 이를테면 vector<int>와 vector<long>의 멤버 함수는 똑같게 나올 수 있다.
즉, 코드 비대화가 발생할 수 있다. 어떤 링커의 경우는 이러한 것들을 합쳐주지만 그렇지 않은 링커도 많다.
비슷한 예로 포인터 타입의 경우가 있다.
대부분의 플랫폼에서 포인터 타입은 똑같은 이진 표현구조를 갖고 있기 때문에, 포인터 타입을 매개변수로 취하는 동일 계열의 템플릿들은 이진 수준에서만 보면 하나의 멤버 함수 집합을 사용해야 한다.
즉, 타입 제약이 엄격한 포인터(T*)를 써서 동작하는 멤버 함수를 구현할 때는 하단에서 타입미정 포인터(void*)로 동작하는 버전을 호출하는 식으로 만들 수 있다.
실제로 C++ 표준 라이브러리의 몇 개의 구현이 이와 같이 되어 있다.