프로그래밍 언어/C & C++ 정리

STL - 제네릭 프로그래밍과 템플릿

게임 개발 2024. 2. 6. 14:49

 

제네릭 프로그래밍 방식

 

 제네릭 프로그래밍 방식에 따라 설계한 소프트웨어 라이브러리는 

기존의 다른 소프트웨어 라이브러리와 비교했을 때 몇 가지 뚜렷한 차이점을 발견할 수 있다.

 

 

  • 기존의 컴포넌트 설계 방식으로 만들어진 컴포넌트의 조합보다는,
    제네릭 프로그래밍 방식에 따라 치밀한 구성과
    상호 교체 가능성을 지니도록 만들어진 컴포넌트들의 조합이 다방면에서 더 유용하다.

  • 데이터베이스나 사용자 인터페이스와 같이 다소 특화된 분야에서
    사용할 컴포넌트를 추가로 개발하고자 할 때,
    제네릭 프로그래밍은 컴포넌트 개발을 위한 근간으로 사용하기에 적당한 설계 방식을 제공한다.

  • 컴파일-타임 매커니즘을 사용하고 
    ( = 가상 함수는 런-타임 메커니즘이며, STL에서는 가상 함수를 사용하지 않는다),
    알고리즘 관련 이슈들에 관해 충분한 주의를 기울였기 때문에, 
    효율성(efficiency)을 희생하지 않고도 컴포넌트의 범용성(generality)을 확보할 수 있었다.
    이는 기존 C++ 라이브러리의 복잡한 상속 구조와 가상 함수의 남용으로 인한
    비효율성과는 극명한 대조를 이루는 것이다.

 

 이러한 차이점들로 인해, 적어도 제네릭 컴포넌트들이 프로그래머들에게 훨씬 더 유용한 것이며,

그리고 알고리즘이나 데이터 구조의 멤버 함수들을 전부 프로그래밍하는 것보다는

제네릭 컴포넌트의 사용을 더 선호하게 될 것이다.

 

이러한 기대가 이러이진다는 것이 그리 쉬운 것은 아니기 때문에 STL이든,

다른 라이브리리든지 간에, 

제네릭 프로그래밍을 상당히 회의적인 시각으로 바라보는 이들도 있을 것이다.

하지만 이 주목할만한 라이브러리를 프로그래밍에 사용하기 시작한다면

STL이야말로 제네릭 프로그래밍이 제시한 약속들을 지킬 수 있는 것이라는 확신을 가지게 될것이다.

 

 

 

템플릿과 제네릭 프로그래밍

 제네릭 프로그래밍에서 컴포넌트의 전용성(adaptability)은 매우 핵심적인 부분에 해당된다.

따라서, 지금부터는 컴포넌트의 전용성이

C++ 템플릿을 통해 어떤 방식으로 지원되고 있는지에 관해 알아보자.

 

C++에는 클래스 템플릿과 함수 템플릿 두 가지의 종류의 템플릿이 있다.

 

클래스 템플릿

 

 클래스 템플릿의 용도는 다양하지만,

그 중에서도 가장 대표적인 것은 전용 가능한 저장 컨테이너를 작성하는 것이다.

(다시 말해, 클래스 템플릿을 이용하면

프로그래머의 구미에 맞게 조정이 가능한 컨테이너를 작성할 수 있다는 것이다.)

아주 간단한 예로, 정수와 문자, 이 두가지 타입의 값을 저장할 수 있는 객체들을 생성한다고 하자.

그러면, 다음과 같은 클래스를 정의할 것이다.

 

class pair_int_char {
public:
	int first;
    	char second;
    	pair_int_char(int x, char y) : first(x), second(y) {}
};

 

 

그런데 타입에는 여러 종류가 있기 때문에,

타입이 달라질 때마다 위와 같은 코드들을 타입별로 반복될 것이다.

하지만, 클래스 템플릿을 사용하면 이러한 것들을 한 개의 클래스로 모두 표현할 수 있다.

 

template <typename T1, typename T2>
class pair {
public:
    T1 first;
    T2 second;
    pair(T1 x, T2 y) : first(x), second(y) {}
};

 

 

위에서는 타입 이름을 T1, T2를 이용하여 클래스를 정의하고 있는데,

이것들을 타입 인자(type parameter)라고 부르며,

이들 타입 인자가 선언된 부분은 다음과 같다.

 

template <typename T1, typename T2>

 

 

이 부분의 의미는 T1과 T2의 위치에 실타입(actual type)을 대신 집어넣으면

이들 타입에 해당하는 pair 클래스 템플릿의 인스턴스화물(instantiation)을 얻을 수 있다는 것을 나타낸다.

 

템프릿 인스턴스화를 통해 만들어진 타입도

일반 타입이 사용되는 위치라면 어디에서나 사용할 수 있다.

 

제네릭 컨테이너 클래스(generic container class)는 용도에 따라 매우 다양하게 전용될 수 있으므로,

방금 소개한 pair의 클래스 템플릿 정의는 그 중 아주 간단한 예제에 불과하다.

그런, 단순히 template 키워드와 타입 인자 리스트를 덧붙였다고 해서

일반 클래스가 제네릭한 클래스로 되는 것은 아니다.

템플릿 클래스의 타입 인자로 사용되는 실타입에 상관없이

템플릿으로부터 생성된 인스턴스화물이 템플릿을 사용하지 않은 것만큼 효율성이 있으려면,

몇 가지를 더 수정해야 한다. pair와 같이 간단한 클래스 템플릿 정의도 좀 더 개선해야 할 부분이 있다.

우선, 앞에서 정의한 pair 클래스 템플릿은 생성자 선언 부분을 다음과 같이 너무 단순하게 작성하였다.

 

pair(T1 x, T2 y) : first(x), second(y) {}

 

 

위 코드의 문제점은 x, y가 값으로 (by value) 전달된다는데 있다.

이렇게 되면 생성자가 호출될 때 객체 복사가 뒤따르게 되며,

특히 x, y의 객체 사이즈가 큰 경우에는, 객체 복사에드는 비용이 만만치 않을 것이다.

따라서, x와 y를 상수 레퍼런스(constant reference)타입으로 선언하여

x,y가 인자로 전달될 때는 주소만 전달되도록 해야 한다.

실제로 STL의 pair 컴포넌트도 다음과 같이 구성되어 있다.

 

template <typename T1, typename T2>
class pair {
public:
    T1 first;
    T2 second;
    pair() : first(T1()), second(T2()){}
    pair(const T1& x, const T2& y) : first(x), second(y) {}
};

 

 

위 코드에서는 디폴트 생성자, pair()가 추가되어 있는데,

이것은 다른 클래스를 정의할 때 pair 객체의 디폴트 초기화를 필요로 할 경우에 사용된다.

클래스에 생성자가 정의되어 있지 않으면, 컴파일러가 자동으로 디폴트 생성자를 정의한다.

하지만 이 같은 경우에서는 디폴트 생성자가 아닌 다른 생성자가 정의되어 있으므로,

프로그래머가 디폴트 생성자를 명시적으로 정의해 주어야 한다.

 

이보다 더 복잡한 클래스를 정의할 때는,

해당 클래스가 전용성과 효율성을 갖추기 위해서 필요한 요소들이 더 많은 편이다.

이 책에서 STL의 구현 방식에 관해서는 자세히 다루지 않을 것이다.

하지만 STL 설계자들은 컴포넌트들이 범용성을 지니되 효율성을 잃지 않도록 많은 심혈을 기울였다.

 

 

함수 템플릿

 

 

제네릭 알고리즘을 정의할 때는 함수 템플릿이 사용된다.

두 정수의 최대 값을 구하는 함수를 하나 작성하면 다음과 같이 구현할 수 있다.

 

 

int intmax(int x, int y)
{
    if (x < y)
    {
        return y;
    }

    else
    {
        return x;
    }
}

 

 

이 함수는 int 타입인 경우에만 최대 값을 구할 수 있다.

하지만, 함수 템플릿을 사용하면 위 함수를 좀 더 범용적인 함수로 쉽게 고칠 수 있다.

 

template <typename T>
T max(T x, T y)
{
    if (x < y)
        return y;
    else
        return x;
}

 

 

클래스 템플릿과 가장 큰 차이점은 템플릿의 타입 인자에 들어갈 타입을

프로그래머가 컴파일러에게 직접 알려줄 필요가 없다는 것이다.

컴파일러는 인자의 타입으로부터 이들의 타입을 자동으로 유추해낼 수 있기 때문이다.

 

    int u = 3, v = 4;
    double d = 4.7;
    cout << max(u, v) << endl;      // u,v가 int 타입이라는 것을 추정해 낸다.
    cout << max(d, 9.3) << endl;    // d가 double 타입아하는 것을 추정해 낸다.

 

함수 템플릿을 선언할 때, 템플릿 인자를 모두 T로 했기 때문에

x와 y로 전달되는 값의 타입이 서로 동일해야 한다.

따라서, 다음 코드는 잘못된 것이다.

 

 

cout << max(u, d) << endl;

 

 

뿐만 아니라, T 타입의 두 인자에 대해 < 연산자가 반드시 정의되어 있어야 한다.

바로 앞전의 예제에서, pair의 클래스 템플릿 정의가 사용된 부분을 연상해서 예시를 들어보자.

 

pair<double, long> pair5(3.1415, 999);

 

 

다음 예제를 이용한 코드는 컴파일 되지 않는다.

왜냐하면 두 개의 pair<double, long> 객체에 대해 operator<가 정의되어 있지 않기 때문이다.

 

return max(pair5, pair5);

 

 

하지만 그 전에 pair<double, long> 타입의 객체에 대해서

operator< 을 다음과 같은 식으로 정의해 두었다면 위 코드는 제대로 컴파일 될 것이다.

 

    bool operator<(const pair<double, long>&x,
        const pair<double, long> &y)
        // x의 첫 번째 멤버와 y의 첫 번째 멤버를 서로 비교한다.
    {
        return x.first < y.first;
    }

 

 

이렇게 컴파일러가 타입을 추정해 내고,

연산자나 함수 호출에 해당하는 오버로드된 함수 정의를 적절히 찾아낼 수 있다는 점 때문에,

템플릿 함수가 제네릭 프로그래밍에서 대단히 유용하게 사용되고 있다는 것이다.

STL 제네릭 알고리즘을 정의할 때 함수 템플릿이 어떻게 활용되는지에 대한 것은

사실 이보다 더 많은 정보를 습득해야 이해할 수 있지만,

간단히 해당 예제에서 감만 잡아보자.

 

max 함수 템플릿도 max 예제에 관한 설명을 마치기 전에,

앞에서 pair 템플릿 클래스의 생성자에 상수 레퍼런스 타입의 인자를 사용했듯이,

max 함수 템플릿도 다음과 같이 개선할 수 있겠다.

 

template <typename T>
const T& max(const T& x, const T& y)
{
    if (x < y)
        return y;
    else
        return x;
}

 

실제로 STL의 max 함수 템플릿도 이와 같이 정의되어 있다.

 

 

멤버 함수 템플릿

 

 

클래스의 멤버 함수는 해당 클래스가 템플릿 클래스가 아니더라도,

템플릿 인자를 가질 수 있다(물론 클래스 수준에서 정의된 템플릿 인자도 사용이 가능하다).

멤버 함수 템플릿은 STL 컨테이너를 정의하는 클래스 템프릿에 사용된다.

예를 들어, STL의 벡터 클래스에는 insert 멤버 함수 템플릿이 정의되어 있는데,

이 멤버 함수 템플릿의 템플릿 인자에는 insert가 사용할 반복자 타입이 지정된다.

 

template<typename T>
class vector {
    // ...
public:
    template <typename InputIterator>
    void insert(iterator position,
        InputIterator first, InputIterator last);
    // ...
};

 

 

 

여기서 iterator는 vector 클래스 내에 정의되어 있는 벡터의 반복자 타입이고,

position은 벡터 객체 내에서 insert 함수가 적용될 위치를 가리키는 반복자이다.

두 번째와 세 번째 인자도 반복자인데, 반드시 벡터 반복자일 필요는 없다.

vector의 insert 멤버 함수는, 리스트와 같은 다른 컨테이너의 반복자를 이용하여

해당 컨테이너에 담긴 원소들의 복사본을 삽입할 때 사용한다.

리스트 반복자는 벡터 반복자와 많이 다르기 때문에,

insert와 같이 다른 컨테이너의 원소를 이용하는 함수는 두 번째와 세 번째 인자를

특정 반복자 타입으로 고정시키지 말고, 템플릿의 타입 인자가 InputIterator인 멤버 함수 템플릿으로 정의해야

insert 멤버 함수를 좀 더 제네릭하게 만들 수 있다.

 

 

템플릿 인자의 명시적 지정

 

 

만약에 문자 배열을 vector<char>로 변환하는 함수를 정의한다고 하자.

이 때, 벡터가 내부에 가지고 있는 문자 시퀀스를

주어진 문자 배열과 동일하도록 만들어야 할 것이다.

 

vector<char> vec(const char s[])
{
    return vector<char>(&s[0], &s[strlen(s)]);
}

 

이 함수에서 사용한 vector<char> 클래스의 생성자는,

바로 앞 절에서 다뤘던 insert함수처럼 멤버 함수 템플릿으로 되어 있다.

 

template<typename InputIterator>
vector(InputIterator first, InputIterator last);

 

마찬가지로, 문자 배열을 list 컨테이너로 변환하는 함수도 다음과 같이 정의할 수 있는데,

 

list<char> lst(const char s[])
{
    return list<char>(&s[0], &s[strlen(s)]);
}

 

 

이는 list<char> 클래스에 다음과 같은 형태의 생성자 멤버 함수 템플릿을 가지고 있기 때문이다.

 

template <typename InputIterator>
list(InputIterator first, InputIterator last);

 

제네릭 프로그래밍의 주된 목적은 함수를 되도록이면 범용적으로 정의하는데 있다.

따라서, 이번에는 하나의 함수로 주어진 문자열을

원하는 STL 컨테이너로 바꿔주는 제네릭 변환 함수를 만들어 보자.

다음과 같이 작성하면 된다.

 

template<typename Container>
Container make(const char s[])
{
    return Container(&s[0], &s[strlen(s)]);
}

 

 

그리고 앞에서 벡터와 리스트의 예어서 보았듯이

STL 컨테이너 클래스는 모두 생성자 멤버 함수 템플릿을 가지고 있기 때문에,

위 함수는 모든 STL 컨테이너에 대해서 제대로 동작할 것이다.

그런데, 여기서 한가지 짚고 넘어가야 할 것이 있다.

위의 정의는 전혀 문제가 없지만, 실제로는 앞서 다른 함수 템플릿을 사용할 때처럼

간단하게 make가 인스턴스화되지 않는다는 점이다.

예를 들어, 아래의 코드는 컴파일 타임 에러가 발생한다.

 

list<char> L = make("Hello, world!"); //잘못되었음

 

에러의 원인은 템플릿 인자인 Container가 함수 인자에서 사용되지 않고,

함수의 리턴 타입에서만 사용된다는데 있다.

이와 같은 경우에는 컴파일러가 자동으로 원하는 타입을 유추해낼 수 없으므로,

다음과 같이 원하느 ㄴ타입이 무엇인지를 명시적으로 알려줘야 한다.

 

list<char> L = make<list<char>>("Hello, world!");

 

 

즉, 클래스 템플릿을 인스턴스화할 때처럼,

타입 인자 Container 대신에 사용할 실타입을 함수 이름 다음에 <와 >를 써서 덧붙여야 한다.

이를 일걸어 템플릿 인자의 명시적 지정(explicit specification of the template argument)이라고 한다.

 

따라서, 이제 문자 배열을 vector<char>로 변환하는 코드는 다음과 같이 작성하면 된다.

 

vector<char> V = make<vector<char>>("Hello, world!");

 

앞으로 컨테이너 연산을 설명하는 예제 프로그램에서,

이 제네릭 make 함수 하나로 주어진 문자 배열을 임의의 컨테이너 타입으로

변화하는 것을 자주 보게 될 것이다.

 

 

디폴트 템플릿 인자

 

 

컨테이너 클래스를 정의할 때 사용되는 C++ 특정 가운데 하나로

디폴트 템플릿 인자(default template parameter)가 있다.

예를 들어, STL vector 클래스의 템플릿 정의는 실제로는 다음과 같이 되어 있다.

 

template <typename T, typename Allocator = allocator<T>>
class vector {
    // ...
};

 

두 번째 템플릿 인자인 Allocator는 allocator<T>를 디폴트 값으로 가진다.

STL의 모든 컨테이너 클래스는 Allocator가 제공하는 메모리 관리 기능을 사용하며,

allocator는 이러한 메모리 관리의 기본 기능을 제공하는 (클래스에 정의되어 있는) 타입이다.

Allocator적으로 지정하지 지정하지 않아도 된다.

즉, vector<int>는 vector<int, allocator<int>>와 동일한 것이다.

 

부분 스페셜라이제이션

 

 

 마지막으로, STL 정의에 사용되는 C++ 템플릿 특징의 하나로 

부분 스페셜라이제이션(partial specialization)이 있다.

이 특징은 제네릭 클래스 템플릿 또는 제네릭 함수 템플릿의 (효율성을 목적으로)

좀 더 특화된, 그렇지만 제네릭한 (즉, 여전히 한 개 이상의 템플릿 인자를 가지고 있는)

버전을 만들고 싶을 때 사용한다. 

 

 

템플릿이 지닌 코드 비대화 (code bloat) 문제

 

 

 한 프로그램 내에서 특정 클래스 템플릿이나 템플릿 함수로부터 만들어진

다양한 버전의 인스턴스화물을 사용하게 되면,

컴파일러가 이들 각각에 맞는 소스 코드를 효율적으로 생성하여,

실행 코드로 컴파일한다 (실제로 소스 코드 수준에서 생성되는 것이 아니라,

코드 표현 방식의 중간 단계에서 생성된다).

이렇게 되면 사용된 각각의 타입에 맞게 복사본이 스페셜라이즈되어,

결과적으로 직접 작성한 스페셜라이즈 코드만큼 효율성이 좋은 코드가 생성된다는 장점이 있다.

하지만 그만큼 잠재적으로 심각한 담점도 있는데,

각기 다른 인스턴스들을 많이 사용하면,

그만큼 많은 수의 복사본이 생성되어 실행 파일의 사이즈가 매우 커지게 된다.

이러한 현상을 "코드 비대화(code bloat)"라고 하며,

STL에 포함되어 있는 클래스 템플릿과 함수들의 범용성 때문에 상황은 더 악화될 수 있다.

왜냐하면, STL에서는 특정 클래스 템플릿과 템플릿 함수로부터 

다양한 종류의 인스턴스가 만들어지도록 되어있고,

이것들을 자주 사용하도록 권장하고 있기 때문이다.

 

다행스럽게도 어느 정도 효율성의 저하 (대부분 그 정도가 미미한 수순이다.)가 있긴 하지만,

코드 비대화 문제의 가장 심각한 결과를 방지할 수 있는 기법들이 있다.

 

 

 

 

해당 게시글은 STL 튜도리얼 레퍼런스 가이드 2판을 참고하여 제작되었습니다.