본문 바로가기
프로그래밍/C & C++

[C++] Class Templates

by 별준 2022. 12. 29.

References

  • Ch2, C++ Templates The Complete Guide 2nd

Contents

  • Stack 클래스 템플릿 구현 및 사용, 부분 사용
  • Friends
  • Specialization (특수화)
  • Partial specialization
  • Default class template arguments
  • Type aliases
  • Class template argument deduction
  • Templatized aggregates

[C++] Function Templates

지난 포스팅인 함수 템플릿에 이어서 이번 포스팅에서는 클래스 템플릿에 대해서 살펴보도록 하겠습니다. 클래스도 함수처럼 하나 이상의 타입으로 파라미터화될 수 있는데, 특정 타입의 요소를 관리하는 STL의 컨테이너 클래스가 이러한 특성을 사용하는 전형적인 예입니다. 클래스 템플릿을 사용하면 어떤 타입을 다룰지 모르는 상황에서도 컨테이너 클래스를 구현할 수 있습니다. 이번 포스팅에서는 클래스 템플릿의 예시로 스택(stack) 자료구조를 사용하여 알아봅니다.

Stack 클래스 템플릿 구현

함수 템플릿과 마찬가지로 헤더 파일에 다음과 같이 Stack<>을 선언하고 정의합니다.

/* stack.hpp */
#pragma once
#include <vector>
#include <cassert>

template<typename T>
class Stack
{
private:
    std::vector<T> elems;       // elements

public:
    void push(T const& elem);   // push element
    void pop();                 // pop element
    T const& top() const;       // return top element
    bool empty() const {        // return whether the stack is empty
        return elems.empty();
    }
};

template<typename T>
void Stack<T>::push(T const& elem)
{
    elems.push_back(elem); // append copy of passed elem
}

template<typename T>
void Stack<T>::pop()
{
    assert(!elems.empty());
    elems.pop_back(); // remove last element
}

template<typename T>
T const& Stack<T>::top() const
{
    assert(!elems.empty());
    return elems.back(); // return copy of last element
}

보다시피 이 클래스 템플릿은 C++ 표준 라이브러리인 vector<>의 클래스 템플릿을 사용하여 구현했습니다. 따라서 메모리 관리나, 복사 생성자, 할당 연산자 등은 구현할 필요없이 클래스 템플릿의 인터페이스에만 집중하면 됩니다.

 

클래스 템플릿 선언

클래스 템플릿의 선언 방식은 함수 템플릿의 선언과 유사합니다. 클래스 템플릿을 선언하기 전에 먼저 타입 파라미터로 사용할 식별자를 선언해야 하며, 여기서도 주로 T를 사용합니다.

template<typename T>
class Stack {
...
};

물론 여기서도 typename이 아닌 class를 사용할 수 있습니다.

 

클래스 템플릿 내부에서 다른 일반적인 타입처럼 T를 멤버와 멤버 함수를 선언할 때 사용할 수 있습니다. 여기서는 요소를 저장할 벡터를 만들 때 벡터 요소의 타입을 T로 사용했으며, 상수 T 참조자를 인자로 받는 멤버 함수 push()와 T를 반환하는 멤버 함수 pop()을 선언할 때도 T를 사용합니다.

 

이 클래스의 타입은 Stack<>이며, T는 템플릿 파라미터입니다. 따라서 템플릿 인자를 추론할 수 있는 경우를 제외하면, 이 클래스의 타입을 쓸 때는 Stack<T>로 표시해야 합니다.

 

클래스 템플릿 선언 내에서 템플릿 인자(<T>)가 뒤따라 오지 않는 클래스 이름을 사용한다는 것은 자신의 인자로 템플릿 파라미터를 사용하는 클래스라는 것을 의미합니다. 예를 들어, 복사 생성자나 할당 연산자를 선언할 때 다음과 같이 할 수 있습니다.

template<typename T>
class Stack {
	...
    Stack(Stack const&);            // copy constructor
    Stack& operator=(Stack const&); // assignment operator
};

위 코드는 다음과 같습니다.

template<typename T>
class Stack {
	...
    Stack(Stack<T> const&);               // copy constructor
    Stack<T>& operator=(Stack<T> const&); // assignment operator
};

하지만, <T>를 사용하는 것은 특수 템플릿 파라미터를 특별히 취급하는 것처럼 보여서 첫 번째 형태를 사용하는 편이 더 낫습니다.

 

그러나, 클래스 외부에서는 <T>를 써주어야 합니다.

template<typename T>
bool operator==(Stack<T> const& lhs, Stack<T> const& rhs);

클래스 타입이 아닌 이름만 필요하다면 Stack만을 사용해야 하는데, 생성자나 소멸자의 이름을 명시할 때가 이에 해당합니다.

 

멤버 함수 구현

클래스 템플릿의 멤버 함수를 정의하려면 멤버 함수가 템플릿이라는 것을 명시해야 하고, 클래스 템플릿의 full type 한정자를 사용해야 합니다. 따라서, Stack<T>의 push() 멤버 함수의 구현은 다음과 같이 작성해야 합니다.

template<typename T>
void Stack<T>::push(T const& elem)
{
    elems.push_back(elem);
}

 

물론 멤버 함수를 클래스 템플릿의 선언 내에 인라인 함수처럼 구현할 수도 있습니다.

template<typename T>
class Stack {
    ...
    void push(T const& elem) {
        elems.push_back(elem);
    }
    ...
};

 

Stack 템플릿 사용

C++17 이전까지는 클래스 템플릿의 객체를 사용하려면 템플릿 인자를 꼭 명시해야 했습니다. 아래 예제는 클래스 템플릿 Stack<T>를 어떻게 사용하는지 보여줍니다.

#include "stack.hpp"
#include <iostream>
#include <string>

int main()
{
    Stack<int> intStack;            // stack of ints
    Stack<std::string> stringStack; // stack of strings

    // manipulate int stack
    intStack.push(7);
    std::cout << intStack.top() << '\n';

    // manipulate string stack
    stringStack.push("hello");
    std::cout << stringStack.top() << '\n';
    stringStack.pop();
}

Stack<int> 타입을 선언하고 있으므로, 클래스 템플릿 내에서는 T 대신 int가 사용됩니다. 따라서, intStack은 int 타입의 벡터를 가지며, Stack<int> 타입에서 호출된 모든 멤버 함수의 코드가 인스턴스화됩니다. 또한, Stack<std::string>을 선언하고 사용하면 string의 벡터를 사용하는 객체가 생성되고, 여기에서 사용된 멤버 함수의 코드가 모두 인스턴스화됩니다.

 

여기서 주의할 점은 실제 호출된 멤버 함수에 대한 코드만이 인스턴스화된다는 것입니다. 클래스 템플릿에서 멤버 함수는 사용될 때만 인스턴스화되며, 이를 통해 시간과 공간을 절약합니다. 이에 대해서는 잠시 뒤 아래에서 조금 더 살펴보도록 하겠습니다.

 

위의 사용 예제 코드에서는 기본 생성자, push(), top()이 int와 string에 대해 인스턴스화되었고, pop()은 오직 string 타입에 대해서만 인스턴스화되었습니다. 만약 클래스 템플릿에 static member가 있다면 각 타입에 대해 단 한 번만 인스턴스화됩니다.

인스턴스화된 클래스 템플릿 타입은 다른 타입들과 동일한 방식으로 사용할 수 있습니다. const나 volatile을 붙여서 사용을 한정할 수 있고, 배열을 만들거나 그에 대한 참조형을 만들 수도 있습니다. 또한, typedef나 using을 사용하여 타입 정의의 일부로 사용할 수도 있습니다.

 


클래스 템플릿 부분 사용(Partial Usage)

클래스 템플릿은 보통 인스턴스화되는 템플릿 인자에 대해 여러 연산을 적용합니다 (생성 및 소멸 포함). 따라서, 템플릿 인자는 클래스 템플릿의 모든 멤버 함수에 필요한 모든 연산을 제공해야 된다고 느낄 수 있습니다. 하지만, 그렇지 않습니다. 템플릿 인자는 실제로 사용되는 모든 필요한 연산만을 제공하면 됩니다. 사용될 수 있는 연산이 아닌 실제로 사용되는 연산이라는 점에 주의합니다.

 

예를 들어, Stack<> 클래스의 각 요소에 대해 operator<<를 호출하여 스택의 모든 요소를 출력하는 printOn() 멤버 함수가 있다고 가정해봅시다.

template<typename T>
class Stack {
    ...
    void printOn()(std::ostream& strm) const {
        for (T const& elem : elems) {
            strm << elem << ' '; // call << for each element
        }
    }
};

이렇게 선언 및 정의되어 있더라도 operator<<가 없는 요소에 대해 Stack<> 클래스를 사용할 수 있습니다.

Stack<std::pair<int,int>> ps; // note: std::pair<> has no operator<< defined
ps.push({4,5}); // OK
ps.push({6,7}); // OK
std::cout << ps.top().first << '\n';  // OK
std::cout << ps.top().second << '\n'; // OK

물론 위의 경우, printOn()을 호출하면 해당 요소(std::pair<int,int>)에 대해서는 인스턴스화할 수 없다고 에러가 발생할 것 입니다.

 

Concepts

템플릿이 인스턴스화되기 위해서 필요한 연산은 어떻게 알 수 있을까요?

템플릿 라이브러리에서 반복적으로 사용하는 제약 사항의 집합을 concept(컨셉)이라고 부르곤 합니다. 예를 들어, C++ 표준 라이브러리는 random access iterator와 default constructible과 같은 컨셉을 사용합니다. C++17까지에서도 컨셉은 문서상(주석 등)에서만 표현됩니다. 제약 사항을 지키지 못하는 경우 끔찍한 에러 메세지가 발생하기 때문에 이는 상당히 큰 문제입니다.

 

몇 년에 걸쳐서 컨셉의 정의와 검증을 언저 자체의 기능으로 지원하기 위해 여러 가지 방식과 시도가 있었다고 하지만, C++17까지 표준화된 것은 없다고 합니다. C++20에서부터 concept이라는 것이 도입되었다고 합니다.

 

하지만 C++11에서부터는 static_assert라는 키워드와 몇몇 타입 특질을 사용하여 적어도 기본적인 제약 사항은 검사할 수 있습니다. 아래 코드를 살펴보겠습니다.

template<typename T>
class C
{
    static_assert(std::is_default_constructible<T>::value,
        "Class C requires default-constructible elements");
    ...
};

만약 기본 생성자가 필요하다면 이 assert문이 없더라도 컴파일에 실패할 것 입니다. 하지만, 이때의 에러 메세지에는 인스턴스화를 처음 시작한 지점에서부터 에러가 검출된 실제 템플릿 정의에 이르는 전체 템플릿 인스턴스화 기록 모두가 포함됩니다.

 

하지만 T라는 타입의 객체가 특정 멤버 함수를 제공하는지, < 연산자를 사용해 비교할 수 있는지 등을 검사하려면 조금 더 복잡한 코드가 필요합니다. 이에 대해서는 추후 다른 포스팅에서 다룰 예정입니다 (ch 9).

 


Friends

Stack의 요소를 printOn()으로 출력하는 것보다는 Stack에 대한 operator<<를 구현하는 편이 더 좋습니다. 그러나 보통 << 연산자는 비멤버 함수로 구현하되, 내부에서 printOn()을 호출하도록 합니다.

template<typename T>
class Stack {
    ...
    void printOn()(std::ostream& strm) const {
        ...
    }
    
    friend std::ostream& operator<<(std::ostream& strm, Stack<T> const& s) {
        s.printOn(strm);
        return strm;
    }
};

여기서 operator<<가 클래스 Stack<>을 위한 함수 템플릿이 아닌 필요한 경우 클래스 템플릿과 같이 인스턴스화되는 일반적인 함수라는 것에 유의합니다.

 

하지만 friend 함수를 선언한 후, 뒤에서 정의하려고 하면 문제가 조금 복잡해집니다. 이럴 때 사용할 수 있는 방법은 다음과 같이 두 가지가 있습니다.

1. 새로운 함수 템플릿을 선언합니다.

template<typename T>
class Stack {
    ...
    template<typename U>
    friend std::ostream& operator<<(std::ostream&, Stack<U> const&);
};

2. Stack<T>를 위한 출력 연산자가 템플릿이라고 전방 선언(forward declaration)할 수 있습니다.

template<typename T>
class Stack;
template<typename T>
std::ostream& operator<<(std::ostream&, Stack<T> const&);

이렇게 전방 선언하고, 이 함수를 프렌드로 선언합니다.

template<typename T>
class Stack {
    ...
    friend std::ostream& operator<< <T>(std::ostream&, Stack<T> const&);
};

함수 이름 operator<< 뒤에 <T>가 있는다는 것에 유의합시다. 따라서, 멤버가 아닌 함수 템플릿의 특수화(specialization)를 friend로 선언한 것입니다. <T>가 없으면 새로운 비템플릿 함수를 선언하게 됩니다.

 

어쨋든, Stack의 요소가 operator<<를 정의하지 않더라도, 여전히 이 클래스를 사용할 수 있습니다. 하지만 이런 경우에 operator<<를 호출하면 에러가 발생합니다.

 


Specializations of Class Templates

클래스 템플릿을 특정 템플릿 인자로 특수화할 수 있습니다. 함수 템플릿의 오버로딩처럼 클래스 템플릿을 특수화하려면 특정 타입에 맞게 구현하여 인스턴스화된 클래스 템플릿이 잘못 동작할 수 있는 부분을 미리 수정할 수 있습니다. 그러나 클래스 템플릿을 특수화하려면 모든 멤버 함수를 특수화해야 합니다. 하나의 멤버 함수만 특수화할 수 있지만, 멤버 함수를 하나라도 특수화한다면 전체 클래스는 특수화할 수 없습니다.

 

클래스 템플릿을 특수화할 때는 template<>을 먼저 쓰고, 클래스를 선언하며, 그 뒤에 클래스 템플릿에 어떤 타입으로 특수화할 것인지 명시합니다. 이 타입은 클래스 이름 뒤에 명시되며, 템플릿 인자로 사용하게 됩니다.

template<>
class Stack<std::string> {
    ...
};

특수화하는 경우, 멤버 함수의 모든 정의는 일반 멤버 함수처럼 정의되어야 하고, T 대신 특수화된 타입을 사용해야만 합니다.

void Stack<std::string>::push(std::string const& elem)
{
    elems.push_back(elem);
}

 

아래 코드는 Stack<>을 std::string으로 특수화한 예를 보여줍니다.

#include "stack.hpp"
#include <deque>
#include <string>
#include <cassert>

template<>
class Stack<std::string>
{
private:
    std::deque<std::string> elems;

public:
    void push(std::string cosnt&);
    void pop();
    std::string const& top() const;
    bool empty() const {
        return elems.empty();
    }
};

void Stack<std::string>::push(std::string const& elem)
{
    elems.push_back(elem);
}

void Stack<std::string>::pop()
{
    assert(!elems.empty());
    elems.pop_back();
}

std::string const& Stack<std::string>::top() const
{
    assert(!elems.empty());
    return elems.back();
}

스택의 요소를 관리하던 벡터 대신 덱(deque)을 사용했습니다. 바꾼다고 특별히 좋아지는 부분은 없습니다.

 


Partial Specialization

클래스 템플릿은 부분적으로 특수화할 수 있습니다. 특정 환경에서만 필요한 특별한 구현을 명시할 수 있는데, 일부 템플릿 파라미터는 여전히 사용자가 지정해주어야 합니다. 아래 예제 코드는 포인터에 대한 Stack<> 클래스를 부분 특수화한 코드입니다.

#include "stack.hpp"

// partial specialization of class Stack<> for pointers
template<typename T>
class Stack<T*>
{
private:
    std::vector<T*> elems;

public:
    void push(T*);
    T* pop();
    T* top() const;
    bool empty() const {
        return elems.empty();
    }
};

template<typename T>
void Stack<T*>::push(T* elem)
{
    elems.push_back(elem);
}

template<typename T>
T* Stack<T*>::pop()
{
    assert(!elems.empty());
    T* p = elems.back();
    elems.pop_back();   // remove last element
    return p;           // and return it (unlikde in the general case)
}

template<typename T>
T* Stack<T*>::top() const
{
    assert(!elems.empty());
    return elems.back();
}

코드를 보면 T로 파라미터화 되어 있긴 하지만, 포인터를 위해 특수화된(Stack<T*>) 클래스 템플릿이라는 것을 알 수 있습니다.

 

특수화에서는 다른 인터페이스를 제공할 수도 있다는 점에 주의합시다. 위의 예제 코드에서는 pop()에서 저장된 포인터를 반환하기 때문에 포인터가 new로 생성되었다면 클래스 템플릿을 사용하는 측에서 메모리 누수 책임이 있으며, 사용자가 delete를 해주어야 합니다.

Stack<int*> ptrStack;  // stack of pointers (special implementation)

ptrStack.push(new int{42});
std::cout << *ptrStack.top() << '\n';
delete ptrStack.pop();

 

Partial Specialization with Multiple Parameters

클래스 템플릿은 여러 템플릿 파라미터 사이의 관계를 특수화할 수도 있습니다. 예를 들어, 다음 클래스 템플릿을 살펴보겠습니다.

template<typename T1, typename T2>
class MyClass {
    ...
};

여기서 아래와 같은 다양한 부분 특수화를 작성할 수 있습니다.

// partial specialization: both template parameters have same type
template<typename T>
class MyClass<T, T> {
    ...
};

// partial specialization: second type is 
template<typename T>
class MyClass<T, int> {
    ...
};

// partial specialization: both template parameters are pointer types
template<typename T1, typename T2>
class MyClass<T1*, T2*> {
    ...
};

MyClass<int, float> mif;    // uses MyClass<T1, T2>
MyClass<float, float> mff;  // uses MyClass<T, T>
MyClass<float, int> mfi;    // uses MyClass<T, int>
MyClass<int*, float*> mp;   // uses MyClass<T1*, T2*>

만약 선언할 때 하나 이상의 부분 특수화가 일치한다면 해당 선언은 모호하다는 에러를 발생시킵니다.

MyClass<int, int> m1;   // ERROR: matches MyClass<T, T> and MyClass<T, int>
MyClass<int*, int*> m2; // ERROR: matches MyClass<T, T> and MyClass<T1*, T2*>

 

위 코드에서 두 번째 선언과 같은 모호함을 해결하기 위해 같은 타입의 포인터에 대한 부가적인 부분 특수화를 제공할 수 있습니다.

template<typename T>
class MyClass<T*, T*> {
    ...
};

부분 특수화에 대해서는 ch16에서 자세히 살펴볼 예정입니다.

 


Default Class Template Arguments

클래스 템플릿에서 템플릿 파라미터의 기본값을 지정할 수 있습니다. 이때 기본값은 함수 템플릿에서와 마찬가지로 앞서 나온 템플릿 파라미터를 참조할 수도 있습니다. 예를 들어, 위에서 정의한 Stack<>에서 두 번째 템플릿 파라미터로 요소들을 관리할 컨테이너를 정의하되 std::vector<>를 기본값으로 정하는 것 입니다.

/* stack3.hpp */
#pragma once
#include <vector>
#include <cassert>

template<typename T, typename Cont = std::vector<T>>
class Stack
{
private:
    Cont elems;

public:
    void push(T const& elem);
    void pop();
    T const& top() const;
    bool empty() const {
        return elems.empty();
    }
};

template<typename T, typename Cont>
void Stack<T, Cont>::push(T const& elem)
{
    elems.push_back(elem);
}

template<typename T, typename Cont>
void Stack<T, Cont>::pop()
{
    assert(!elems.empty());
    elems.pop_back();
}

template<typename T, typename Cont>
T const& Stack<T, Cont>::top() const
{
    assert(!elems.empty());
    return elems.back();
}

두 개의 템플릿 파라미터가 있으므로, 멤버 함수를 정의할 때 두 파라미터를 모두 명시해주어야 합니다.

 

이렇게 정의한 스택 클래스 템플릿은 이전의 구현과 동일한 방식으로 사용할 수 있습니다. 요소 타입으로 첫 번째 인자만 전달한다면 해당 타입을 요소로 갖는 컨테이너로 벡터를 사용합니다. 또한, 프로그램 내에서 Stack 객체를 선언할 때 컨테이너를 따로 지정할 수도 있습니다.

#include "stack3.hpp"
#include <iostream>
#include <deque>

int main()
{
    // stack of ints
    Stack<int> intStack;

    // stack of doubles using a stD::deque<> to manage the elements
    Stack<double, std::deque<double>> dblStack;

    // manipulate int stack
    intStack.push(7);
    std::cout << intStack.top() << '\n';
    intStack.pop();

    // manipulate double stack
    dblStack.push(42.42);
    std::cout << dblStack.top() << '\n';
    dblStack.pop();
}

 


Type Aliases

타입에 새로운 이름을 정의하면 편리하게 클래스 템플릿을 사용할 수 있습니다.

 

Typedefs and Alias Declarations

완전한 타입에 새로운 이름을 정의하는 방법에는 두 가지가 있습니다.

1. typedef 키워드

typedef Stack<int> IntStack; // typedef
void foo(IntStack const& s); // s is stack of ints
IntStack istack[10];         // istack is array of 10 stacks of ints

2. using 키워드 (since C++11)

using IntStack = Stack<int>; // alias declaration
void foo(IntStack const& s);
IntStack istack[10];

2번 방식을 alias declaration(별칭 선언)이라고 부릅니다.

 

두 가지 방식 모두 이미 존재하는 타입에 대해 새로운 이름을 정의하는 방식입니다. 따라서 typedef든, using을 사용하든 IntStack과 Stack<int>는 모두 같은 타입을 가리키며 서로 혼용할 수 있습니다.

별칭 선언(using)으로 생성된 새로운 이름은 type alias(타입 별칭)이라고 부릅니다.

 

Alias Templates

typedef와 달리 using은 템플릿화하여 타입들을 편리한 이름으로 쓸 수 있도록 해줍니다. C++11부터 지원되는 기능이며 이를 alias template(별칭 템플릿)이라고 부릅니다.

 

아래의 별칭 템플릿 DequeStack은 요소 타입 T로 파라미터화되었으며, 요소들을 std::deque<>에 저장하는 Stack을 가리킵니다.

template<typename T>
using DequeStack = Stack<T, std::deque<T>>;

그러므로, 클래스 템플릿과 별칭 템플릿은 모두 파라미터화된 타입으로 쓰일 수 있습니다. DequeStack<int>와 Stack<int, std::deque<int>>는 같은 타입을 나타냅니다.

 

Alias Templates for Member Types

별칭 템플릿(alias template)은 클래스 템플릿의 멤버인 타입을 줄여 표현할 때 특히 유용합니다.

struct C {
    typedef ... iterator;
    ...
};

또는

struct MyType {
    using iterator = ...;
    ...
};

라고 정의한 코드 뒤에 아래와 같이 정의하면,

template<typename T>
using MyTypeIterator = typename MyType<T>::iterator;

아래의 코드 대신,

typename MyType<T>::iterator pos;

아래와 같이 간단하게 쓸 수 있습니다.

MyTypeIterator<int> pos;

 

Type Traits Suffix_t

C++14부터, 표준 라이브러리에서는 타입을 도출하는 모든 타입 특질(type trait)에 대한 shortcut을 제공하기 위해 위 기법을 사용합니다. 예를 들어,

std::add_const_t<T> // since C++14

위 코드는 아래의 코드와 동일합니다.

typename std::add_const<T>::type // since C++11

표준 라이브러리에서는 다음과 같이 정의되어 있습니다.

namespace std {
    template<typename T>
    using add_const_t = typename add_const<T>::type;
}

 


Class Template Argument Deduction

C++17 이전에는 클래스 템플릿을 사용할 때, (기본값을 갖지 않는 한) 모든 템플릿 파라미터 타입을 전부 나열해야만 했습니다. C++17부터는 템플릿 인자를 명시적으로 표시해야 한다는 제약 사항이 사라졌는데, 생성자가 모든 (기본값을 갖지 않는) 템플릿 파라미터를 추론할 수 있는 경우에만 템플릿 인자를 명시적으로 지정하지 않아도 됩니다.

 

예를 들어, 위에서 살펴봤던 코드에서 템플릿 인자를 명시하지 않고 복사 생성자를 사용할 수 있습니다.

Stack<int> intStack1;             // stack of strings
Stack<int> intStack2 = intStack1; // OK in all versions
Stack intStack3 = intStack1;      // OK since C++17

 

일부 초기 인자를 생성자에게 전달함으로써, 스택의 요소 타입을 추론하도록 지원할 수 있습니다. 예를 들어, 하나의 요소로 초기화할 수 있는 스택을 작성할 수 있습니다.

template<typename T>
class Stack {
private:
    std::vector<T> elems;
    
public:
    Stack() = default;
    Stack(T const& elem) : elems({elem}) {}
    ...
};

위와 같이 정의하면 다음과 같이 선언할 수 있습니다.

Stack intStack = 0; // Stack<int> deduced since C++17

스택을 정수 0으로 초기화하여 템플릿 파라미터 T를 int로 추론할 수 있고, 따라서 Stack<int>가 인스턴스화됩니다

위에서 정의한 하나의 요소를 받는 생성자의 인자인 elem은 중괄호에 둘러싸여 elems로 전달됩니다. 따라서, elems 벡터는 elem을 인자로 받는 initializer list(초기화자 목록)으로 초기화됩니다. 중괄호가 없다면 요소의 값만큼의 요소를 저장할 수 있는 벡터로 초기화됩니다.

 

Deduction with String Literals

원칙적으로 위에서 정의한 스택을 문자열 리터럴(string literal)로 초기화할 수 있습니다.

Stack stringStack = "bottom"; // Stack<char const[7]> deduced since C++17

그러나 여기에는 몇 가지 문제가 있습니다. 일반적으로 템플릿 타입 T를 참조자로 전달하는 경우, 파라미터에는 형 소실(decay)이 발생하지 않습니다. decay라는 의미는 link를 참조바랍니다. 여기서는 raw array type이 대응하는 raw pointer type으로 자동으로 변환되지 않는다는 것을 의미합니다. 즉, 위 코드는 실제로 아래와 같이 초기화되며,

Stack<char const[7]>

T의 자리에 char const[7]을 사용합니다. 따라서, 크기가 다른 문자열은 다른 타입이기 때문에 push할 수 없습니다.

 

그러나 템플릿 타입 T에 값으로 전달하면, 파라미터는 형 소실됩니다. 즉, raw array type이 raw pointer type으로 변환됩니다. 따라서, 호출 파라미터 T는 char const*로 추론되어 스택 클래스는 Stack<char const*>로 추론됩니다.

그렇기 때문에 아래와 같이 인자를 값으로 받는 생성자를 선언하면,

template<typename T>
class Stack {
private:
    std::vector<T> elems;
    
public:
    Stack(T elem)        // initialize stack with one element by value
      : elems({elem}) {} // to decay on calss template argument deduction
    ...
};

위와 같이 정의한 클래스 템플릿 스택을 아래와 같이 초기화해도 잘 동작하게 됩니다.

Stack stringStack = "bottom"; // Stack<char const*> deduced since C++17

 

참고로 불필요한 복사를 피하기 위해서 elem을 이동시키는 것이 성능상 더 좋습니다.

template<typename T>
class Stack {
private:
    std::vector<T> elems;
    
public:
    Stack(T elem)
      : elems({std::move(elem)}) {}
    ...
};

 

Deduction Guides

값으로 호출되는 생성자를 선언하는 대신 다른 방법도 존재합니다. 컨테이너에서 포인터를 다루는 것은 문제의 원인이 되는 경우가 많기 때문에 컨테이너 클래스에서 raw character pointer로 자동으로 추론하는 것을 비활성화해야 합니다.

 

이는 구체적인 deduction guides를 정의하여 클래스 템플릿 인자 추론을 추가하거나 수정할 수 있습니다. 예를 들어, 문자열 리터럴 또는 C 문자열이 전달되는 경우, 스택이 std::string으로 인스턴스화되도록 할 수 있습니다.

Stack(char const*) -> Stack<std::string>;

이 가이드는 클래스 정의와 동일한 스코프에 있어야 합니다. 보통, 위 구문은 클래스 정의에 이어서 작성합니다. 예를 들면, 아래와 같이 작성할 수 있습니다.

/* stack.hpp */
#pragma once
#include <vector>
#include <string>
#include <cassert>

template<typename T>
class Stack
{
private:
    std::vector<T> elems;       // elements

public:
    Stack() = default;
    Stack(T const& elem) : elems({elem}) {}
    void push(T const& elem);   // push element
    void pop();                 // pop element
    T const& top() const;       // return top element
    bool empty() const {        // return whether the stack is empty
        return elems.empty();
    }
};

template<typename T>
void Stack<T>::push(T const& elem)
{
    elems.push_back(elem); // append copy of passed elem
}

template<typename T>
void Stack<T>::pop()
{
    assert(!elems.empty());
    elems.pop_back(); // remove last element
}

template<typename T>
T const& Stack<T>::top() const
{
    assert(!elems.empty());
    return elems.back(); // return copy of last element
}

Stack(char const*) -> Stack<std::string>;

 

이렇게 정의하면, C++17부터 아래와 같이 선언하여 char const[7]이 아닌 std::string으로 인스턴스화시킬 수 있습니다.

Stack stringStack{"bottom"}; // OK: Stack<std::string> deduced since C++17

 

그러나, 언어 규칙으로 인해 복사 초기화(=을 사용한 초기화)는 불가능합니다.

 


Templatized Aggregates

Aggregate classes 또한 템플릿이 될 수 있습니다. Aggregate class는 user-provided, explicit, or inherited 생성자가 없고, private or protected nonstatic data member도 없고, 어떠한 가상 함수, virtual/private/protected 베이스 클래스도 없는 클래스 또는 구조체(class or struct)를 의미합니다.

 

예를 들어, 다음과 같은 클래스/구조체가 이에 해당됩니다.

template<typename T>
struct ValueWithComment {
    T value;
    std::string comment;
}

이렇게 정의한 구조체는 어떠한 클래스 템플릿으로 객체를 선언할 수 있고, aggregate(집합)처럼 사용할 수 있습니다.

ValueWithComment<int> vc;
vc.value = 42;
vc.comment = "initial value";

 

C++17부터는 이러한 aggregate class template에 대해 deduction guide를 정의할 수 있습니다.

ValueWithComment(char const*, char const*) -> ValueWithComment<std::string>;

ValueWithComment v2 = {"hello", "initial value"};

ValueWithComment는 추론을 수행하는 어떠한 생성자도 없기 때문에 deduction guide 없이는 초기화가 불가능합니다.

 

표준 라이브러리 클래스인 std::array<> 또한 aggregate(집합)이며, 요소 타입과 크기로 파라미터화됩니다. C++17 표준 라이브러리 또한 이에 대해 deduction guide를 정의하고 있습니다.

 

댓글