C++ template - 2. Specialization

JunTak Lee·2023년 5월 18일
0

C++ Template

목록 보기
2/8

Generic Programming을 한창하다보면 특수한 몇가지 경우만 코드를 따로 생성하고 싶은 경우가 있다
혹은 Code는 달라도 하는일은 동일해서 이름을 통일하고 싶은 경우가 있다
이런 난감한 경우를 위해 C++은 template Specialization이란걸 제공한다


Explicit Template Specialization

표현이 좀 긴데 그냥 명시적으로 특수화하겠다는 뜻이다
즉, template argument들의 값을 모두 명시적으로 타나낸 경우를 의미한다
https://en.cppreference.com/w/cpp/language/template_specialization

다음 코드를 살펴보자

template <typename T>
class foo {
    //...
};

template <>
class foo<int> {
    //...
};

foo class는 template parameter를 가지고 있다
그런데 inttype에 대해서만 특수화를 하고 싶은 경우 위와 같이 template parameter를 비우고, Specialization하는 곳에 명시적으로 달아놓으면 된다
Function template이나, member function template 등등 모두 비슷하게 적용된다
다만 class에도 template이 달려있고, member function에도 template이 달려있다면 member function template만 Specialization하는 것은 불가능하다

template <typename T>
void foo_func() {
    //...
}

template <>
void foo_func<char>() {
    //...
}


template <typename T>
struct foo {
    template <typename TT>
    void foo_mem_fn(TT); 
};

template <typename T>
template <typename TT>
void foo<T>::foo_mem_fn(TT) {
    //...
}

// template <typename T>                    ERROR!
// template <>
// void foo<T>::foo_mem_fn<int>(int) {
//     //...
// }

template <>
template <>
void foo<int>::foo_mem_fn<int>(int) {
    //...
}


template <>
struct foo<double> {
    //...
};

여기서 주의할점은 template <>을 적어줘야 한다는 점이다
이걸 안적으면 Compiler는 template어 없다고 생각하고 Error을 출력한다

Example

이렇게만 설명하고 넘어가기엔 설명이 너무 없어보이니 예시를 하나 생각해냈다
아래와 같은 add함수가 있다고 해보자

template <typename T>
T add(const T& _1, const T& _2) {
    return _1 + _2;
}

여기서 +연산자를 사용할 수 있다면 상관이 없다
그런데 만약 그렇지 못한다면 어떻게 될까

#include <iostream>

template <typename T>
T add(const T& _1, const T& _2) {
    return _1 + _2;
}

int main() {
    const char* _1 = "Hello ";
    const char* _2 = "World!";
    std::cout << add<const char*>(_1, _2);
}

// Result
<source>:6:15: error: invalid operands to binary expression ('const char *const' and 'const char *const')
    return _1 + _2;
           ~~ ^ ~~
<source>:13:18: note: in instantiation of function template specialization 'add<const char *>' requested here
    std::cout << add<const char*>(_1, _2);
                 ^
1 error generated.

빨간줄을 맞이한다..PTSD
만약 Type이 내가 작성한 Class라면 + operator overloading을 통해 해결할수도 있다
하지만 이걸 Template Specialization으로 해결할수도 있다

#include <iostream>
#include <string.h>

template <typename T>
T add(const T& _1, const T& _2) {
    return _1 + _2;
}

template <>
const char* add<const char*>(const char* const& _1,
                             const char* const& _2) {
    std::size_t i = 0, len_1 = strlen(_1);
    std::size_t total_len = len_1 + strlen(_2);
    char* str = new char[total_len];
    
    for (; i < len_1; i++) str[i] = _1[i];
    for (; i < total_len; i++) str[i] = _2[i - len_1];
        
    return str;
}

int main() {
    const char* _1 = "Hello ";
    const char* _2 = "World!\n";
    const char* result = add<const char*>(_1, _2);
    std::cout << result;
    delete result;
}

당장에 생각나는게 string literal 밖에 없어서 이렇게 만들어봤다
뭐 이렇게 사용하란 뜻은 아니고, 이렇게도 표현이 가능하구나 정도로 보면된다


Partial Template Specialization

사람이라는게 어떻게 모든걸 다 명확하게 특수화할 수 있는가
때론 부분적으로만 특수화하고 싶거나 해야할때가 있다
이럴때 필요한게 Partial Template Specialization이다
https://en.cppreference.com/w/cpp/language/partial_specialization

이름이 상당히 거창한데, 사실 그냥 Specialization이다
위랑 차이가 있다면 template argument를 몇개 남겨놨다 뿐이다

template <typename _1, typename _2, typename _3>
struct foo {
    //...
};

template <typename _1>
struct foo<_1, int, int> {
    //...
};

이걸보고 나면 왜 template <>을 명시해주어야하는지 조금은 이해가간다

이제 그런 생각을 들수 있다
과연 저기서 template parameter의 순서는 어떻게 되는 것인가
template parameter의 순서는 primary template을 따른다
여기서 primary template은 가장 처음에 명시했던 class를 가리킨다
_1, _2, _3의 순서로 들어간다는 뜻이다
따라서 Partial Specialization된 class의 template parameter 순서는 중요하지 않다

#include <iostream>

template <typename _1, typename _2, typename _3>
struct foo {
    //...
};

template <typename a, typename b>
struct foo<b, int, a> {
    void print() {
        std::cout << typeid(a).name() << "\n" 
            << typeid(b).name() << "\n";
    }
};

int main() {
    foo<double, int, float> ele;
    ele.print();
}

// Result: f d

왜 중요하지 않은가는 Instantiation을 보면 알 수 있다
위 예시에서 afloat가 들어갔고, bdouble이 들어갔다
즉, 위에 template으로 선언되어있는 부분은 "내가 이런 Template Parameter를 쓸거야"하는거다
애초에 CppReference에서 Name Lookup과 관련해서 다음과 같이 설명한다

Partial template specializations는 name lookup시 검색되지 않는다. Primary template만 name lookup시 검색된다

CppReference

사실 생각해보면 당연한것이, template spcialization은 단어 그대로 특수화에 대한 것이지 primary template과 다르면 안된다
하여튼 이런 template spcialization에서 한발자국 더 뻗으면 이런것도 가능하다

template <typename T>
struct foo {};

template <typename _1, typename _2, typename _3>
struct foo<_1(_2, _3)> {
    //...
};

template <typename _1, typename _2>
struct foo<void(_1, _2)> {
    //...
};

std::function에서 영감을 얻어 작성해본 예시이다
물론 std::function은 훨씬 복잡하고 무엇보다 이렇게 생기지 않았다

이제 여기에 STL을 조금 섞어주면 조금은 실용적인 코드를 만들 수 있다
CS에서 국밥마냥 등장하는 행렬도 아래와 같이 만들 수 있다
물론 아주 간단한 구현으로 실제로는 저렇게 해놓으면 써먹기가 애매하다

#include <iostream>
#include <array>

template <typename T, int Row, int Col>
using Matrix = std::array<std::array<T, Col>, Row>;

template <typename T> using Matrix3 = Matrix<T, 3, 3>;
template <typename T> using Matrix4 = Matrix<T, 4, 4>;

using Matrix3f = Matrix3<float>;

//...

int main() {
    Matrix3f mat {{
        {{ 1, 0, 0}},
        {{ 0, 1, 0}},
        {{ 0, 0, 1}}
    }};

    for (int i = 0; i < 3; i++) {
        for (int j = 0; j < 3; j++)
            std::cout << mat[i][j];
        std::cout << std::endl;
    }
}

Function Template Partial Specialization

여기서 한가지 궁금증이 생긴다
function template은 partial specialization이 불가능한 것일까

template <typename _1, typename _2>
void foo(_1 a, _2 b) {
    //...
}

template <typename _1>
void foo<int, _1>(int a, _1 b) {
    //...
}

// Output
<source>:26:6: error: function template partial specialization is not allowed
void foo<int, _1>() {
     ^  ~~~~~~~~~
1 error generated.

아쉽지만 그렇다
근데 사실 잘 생각해보면 필요가 없다
왜냐하면 function overloading이 있기 때문이다

template <typename _1, typename _2>
void foo(_1 a, _2 b) {
    //...
}

template <typename _1>
void foo(int a, _1 b) {
    //...
}

auto main() -> int {
    foo<double, double>(1., 1.);
    foo<double>(1, 1.);
}

위 예시를 본다면 이런생각이 들 수 있다
"저건 template parameter 개수가 다르잖아"
맞다, 근데 틀리다
아직은 뒤로 미루고 있는 template deduction을 적용하면 이야기 달라진다

template <typename _1, typename _2>
void foo(_1 a, _2 b) {
    //...
}

template <typename _1>
void foo(int a, _1 b) {
    //...
}

auto main() -> int {
    foo(1, 1.);
    foo(1., 1.);
}

code를 쓰는 입장에서는 저게 어떠한 function을 invoke할지 관심이 없어도 된다
다시 한번말하지만 "없어도 된다"이다
하는일이 달라진다면 알아야한다

만약 그대로 template specialization을 적용하고 싶을 수 있다
구글링을 하다보니 스오플에서 재미난 방법을 제시했다
이 방법을 조금만 번형하면 아래와 같이 만들 수 있다
https://stackoverflow.com/questions/5101516/why-function-template-cannot-be-partially-specialized

#include <iostream>

struct foo_impl {
private:
    template <typename _1, typename _2>
    struct __foo_impl {
        void operator()(_1 a, _2 b) { 
            std::cout << "_1, _2" << std::endl;
        }
    };

    template <typename _1>
    struct __foo_impl<int, _1> {
        void operator()(int a, _1 b) {
            std::cout << "int, _2" << std::endl;
        }
    };

public:
    template <typename _1, typename _2>
    void operator()(_1 a, _2 b) {
        __foo_impl<_1, _2>()(a, b);
    }
};

template <typename _1, typename _2>
void foo(_1 a, _2 b) {
    foo_impl().operator()<_1, _2> (a, b);
}

int main() {
    foo<double, float>(1., 2.f);
    foo<int, float>(1, 2.f);
}

이게 뭔 조악한 code냐고 할 수 있는데 그냥 이렇게 해보고 싶었다
뭐 이걸 조금만 번형하면 type-erasure이 되는것도 있고, 캡슐화를 하고 싶어서 그런것도 있다
하여튼 이런식으로 한다면 function template partial specialization이 되는 것처럼..? 보인다

아 혹시라도 위 코드가 불필요한 overhead가 발생하는건 아닌지 궁금해할 수 있다
function에서 function에서 function을 호출하고 있기 때문이다
아 그리고 instance가 두개 생겨나는건 덤이고
Clang 16.0.0 --std=c++2b -O3 -march=skylake

main:                                   # @main
        push    r14
        push    rbx
        push    rax
        mov     rbx, qword ptr [rip + std::cout@GOTPCREL]
        lea     rsi, [rip + .L.str]
        mov     edx, 6
        mov     rdi, rbx
        call    std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert
        ...

무자비한 Clang한테는 그딴거 없다
그냥 바로 std::cout을 호출한다
애초에 저렇게 생겨먹은걸 cpp에서 function object라 한다
그리고 cpp에서 Lambda가 대표적인 function object인데, 뭐 당연하지만 최적화 잘해준다

GCC의 경우에는 더 잔인하게 compile 해버린다
GCC 13.1 --std=c++2b -O3 -march=skylake

.LC0:
        .string "_1, _2"
.LC1:
        .string "int, _2"
main:
        sub     rsp, 8
        mov     edx, 6
        mov     esi, OFFSET FLAT:.LC0
        mov     edi, OFFSET FLAT:_ZSt4cout
        call    std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert
        mov     edi, OFFSET FLAT:_ZSt4cout
        call    std::basic_ostream<char, std::char_traits<char> >& std::endl
        mov     edx, 7
        mov     esi, OFFSET FLAT:.LC1
        mov     edi, OFFSET FLAT:_ZSt4cout
        call    std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert
        mov     edi, OFFSET FLAT:_ZSt4cout
        call    std::basic_ostream<char, std::char_traits<char> >& std::endl
        xor     eax, eax
        add     rsp, 8
        ret

Clang에서는 그래도 jump를 썼는데 이놈은 그냥 모든걸 21개의 assembly line으로 끝내버린다
이렇듯 해당부분은 Compiler가 잘 이해하여 optimization 해주는 부분인만큼 걱정할 필요가 없다


사실 개념자체가 그렇게 어렵지는 않다
따라서 굳이 이 부분을 열심히 팔 이유는 없다고 본다
사실 그냥 몇번 써보다 보면 손에 익는다
우리가 고민해야하는 부분은 어떻게 설계를 하는가이다

add의 예시를 한번 생각해보자
과연 저기서 string literal에 대한 add 함수를 만들어야 했을까
누군가는 그렇다고 할 것이고, 누군가는 concat으로 함수 이름을 바꿔야 한다고 할 것이다
또 다른 누군가는 다른 방법을 제시할 것이다
이 부분은 개인의 판단이지 옳고그름이 아니다
우리가 고민해야하는 부분은 이런부분이다

profile
하고싶은거 하는 사람

0개의 댓글