C++ template - 5. Fold Expressions

JunTak Lee·2023년 6월 7일
0

C++ Template

목록 보기
6/8

지난 글에서 parameter pack expansion에 관해서 이야기를 했었다
그런데 뭔가..뭔가..부족해보인다
그렇다, 현대인은 바쁘다
점 3개를 분해해보겠다고 뭔 짓거리를 하고 있는건가
현대인은 저렇게 복잡한 code를 적고 있을 시간이 없다
그래서 누군가가 fold expressions란 걸 제시했다


Concepts

Reduces (folds) a parameter pack over a binary operator
https://en.cppreference.com/w/cpp/language/fold

C++17에서 도입된, 그러니까 비교적 최근에 도입된 녀석이다
뭐하는 놈인가 설명을 들어보자
binary operator에 대해서 parameter pack expansion을 대신해주겠단다
그럼 여기서 말하는 binary operator는 뭘까
그냥 우리가 생각하는 그거 전부다이다

  • + - * / % ^ & | = < > << >>
  • += -= *= /= %= ^= &= |= <<= >>= == != <= >=
  • && || , .* ->*

정확히는 32개란다 워메 많기도하다..
그냥 평소에 쓰던 binary operator는 다 된다고 보면 된다

CppReference에 보면 op, pack, init 이렇게 크게 3가지로 나눠서 설명한다
처음에 보고 무슨 소린가 했는데 뭐 크게 어려운 내용은 아니다
하지만 늘 그랬듯 내 방식대로 기초부터 보도록하자

지난글에서 사용했던 예제를 그대로 들고와봤다

template <int _1, int... Args>
struct add {
    static const int value = _1 + add<Args...>::value;
};

template <int _1>
struct add<_1> {
    static const int value = _1;
};

Fold expressions를 사용하면 아래와 같이 간단하게 표현이 가능하다

template <int... Args>
struct add {
    static const int value = (Args + ...);
};

int main() {
    return add<1, 2, 3, 4, 5, 6, 7>::value;
}

// Output
28

Code가 정말 놀라울 정도로 간단해졌다..!
덤으로 가독성까지 굉장히 많이 증가하였다

그렇다면 저번 글에서 Type Check 예제는 어떨까

#include <type_traits>

template <typename _1, typename... Args>
struct is_all_integral {
    static const bool value = 
        std::is_integral<_1>::value &&
        is_all_integral<Args...>::value;
};

template <typename _1>
struct is_all_integral<_1> : std::is_integral<_1> {};
#include <type_traits>

template <typename... Args>
struct is_all_integral {
    static const bool value = 
        (std::is_integral<Args>::value && ...);
};

template <
    typename... Args,
    typename = typename std::enable_if_t
        <is_all_integral<Args...>::value>
>
int add_integral(Args... args) {
    return (args + ...);
}

int main() {
    return add_integral<int, int, int>(1, 2, 3);
    //int _2 = add_integral<int, int, float>(1, 2, 3.f);    ERROR!
}

// Output
6

내친김에 add_integral도 fold expressions을 사용해서 구현해보았다
정말이지 놀라울 정도로 심플해졌다
밤을 새서 좀 많이 졸린 상태라면 이게 파이썬인가하고 헷갈길 정도다
물론 앞서 언급하였던 32개의 binary operator 모두 동일하게 적용이 가능하다


Direction

Cpp에서 상당수의 문법이 그렇듯 이놈도 방향을 탄다
여기서 말하는 방향은 const에서 방향 따지던걸 생각하면 된다
const의 경우 west const, east const, north const, south const가 있다
이놈도 동일하게 right fold와 left fold가 존재한다

위에서는 right fold만을 사용하였다
엄밀히 말하자면 unary right fold긴 한데 이건 다음 문단에서 설명하려고 한다

그렇다면 무슨 차이가 있는 것일까
이 차이를 알기 위해서는 도구의 힘을 빌리는 편이 빠르다
이번엔 두번째 필수 교양 cppinsights.io를 활용해보았다

template<>
int add_integral<int, int, int, void>(int __args0, int __args1, int __args2)
{
  return __args0 + (__args1 + __args2);
}

위에서 다룬 예제를 넣고 돌린 결과다
그래서 left fold는 뭐가 다른걸까

template<>
int add_integral<int, int, int, void>(int __args0, int __args1, int __args2)
{
  return (__args0 + __args1) + __args2;
}

그렇다, 연산되는 순서가 바뀐다
그래서 이게 왜 문제가 되는걸까
적당한 예제가 생각이 안나서 유튜브를 뒤져보았다
Jason Turner 형님이 여기에 적당한 예시를 보여준다
이거 생각안나서 계속 고민했는데 보자마자 바로 이거다 싶었다

#include <iostream>

template <typename... Args>
auto div_right_fold(Args&&... args) {
    return (args / ...);
}

template <typename... Args>
auto div_left_fold(Args&&... args) {
    return (... / args);
}

int main() {
    std::cout << 
        div_right_fold(1.f, 2.f, 3.f) << ' ' <<
        div_left_fold(1.f, 2.f, 3.f) << std::endl;
}

// Output
1.5 0.166667

나눗셈은 연산 순서에 따라 그 결과가 달라지기 마련이다
그러니까 (1 / (2 / 3))((1 / 2) / 3)은 다른 결과를 가져온다
이렇듯 방향성이 중요한 연산에서는 이렇게 구분해서 사용할 수 있다


Binary fold

binary 즉, 두가지에 대한 fold expression을 나타낸다
그러니까 pack말고 init도 들어간다는 거다
아래 간단한 sum 예제를 보도록 하자

template <typename... Args>
int sum(int init, Args&&... args) {
    return (init + ... + args);
}

int main() {
    int num = 10;
    return sum(num, 1, 2, 3);
}

// cppinsights.io
template<>
int sum<int, int, int>(int init, int && __args1, int && __args2, int && __args3)
{
  return ((init + __args1) + __args2) + __args3;
}

parameter pack말고도 init이 앞에 추가된 형태다
의미론적인 부분에서도 보이는대로 해석하면 된다
쉽게 말해 앞에 뭘 더 붙여서 계산할 수 있다는 소리다

근데 여기서 한가지 제약조건이 붙는다
CppReference를 잘보면 이렇게 표현이 되어있다

( pack op ... op init ), ( init op ... op pack )
CppReference

pack... 사이에 붙는 op랑, ...init 사이에 붙는 op가 동일하다
즉, 두 operator가 동일해야한다는 것이다
애초에 init이 붙을 수 있는 자리는 처음 혹은 마지막이다
그런데 이놈들은 그냥 먼저 연산하거나 마지막에 연산해주면 그만이다
다만 편의성을 생각하면 조금 아쉬울 뿐이다..

그리고 아직 한가지가 더 남았는데, 이놈도 방향성이라는게 존재한다

template <typename... Args>
int sum(int init, Args&&... args) {
    return (args + ... + init);
}

int main() {
    int num = 10;
    return sum(num, 1, 2, 3);
}

// cppinsights.io
template<>
int sum<int, int, int>(int init, int && __args1, int && __args2, int && __args3)
{
  return __args1 + (__args2 + (__args3 + init));
}

이놈은 parameter pack의 연산 순서이외에도 init의 연산순서가 달라진다
이번에는 CppReference의 example을 하나 가져와봤다

std::cout Example

아래는 cout을 이용한 예제이다
parameter pack을 모두 출력하고 싶을 때 이렇게 사용하면 된다

#include <iostream>

template <typename... Args>
void print(Args&&... args) {
    (std::cout << ... << args) << std::endl;
}

int main() {
    print(1, 2, 3);
}

// Output
123

직관적으로 해석하면 argument를 순서대로 std::cout에 넣겠다는 것이다
그리고 실제로도 그렇게 동작한다

template<>
void print<int, int, int>(int && __args0, int && __args1, int && __args2)
{
  std::cout.operator<<(__args0).operator<<(__args1).operator<<(__args2).operator<<(std::endl);
}

위 코드는 정식표현을 따르자면, binary left fold이다
그렇다면 binary right fold를 쓰면 어떻게 될까

template <typename... Args>
void print(Args&&... args) {
    (args << ... << std::cout) << std::endl;
}
template <typename... Args>
void print(Args&&... args) {
    std::cout << (args << ... << std::endl);
}

당연하게도 위 두가지 모두 compile 부터 안된다
이처럼 방향에 따라 그 의미가 완전히 달라질 수 있다


Usage

사실 어떻게 사용하냐는 개발자의 몫이긴하다
특히 Cpp같은 언어라면 더더욱이 그렇다
그런데 이놈은 쓰임새가 좀 많이 특이한지라 적어놓는다
물론 다 적을 생각은 없고 적고 싶은것만 적는다

Making bit mask

일반적으로 bit mask를 만들때 shift 연산을 취하고 or 연산을 취한다
그래서 문뜩 이런 생각이 들었다
이걸 fold expression으로 표현할 수 있지 않을까
물론 가능하다

#include <iostream>
#include <bitset>

template <typename... Args>
std::size_t make_bit_mask(Args&&... args) {
    return ((1 << args) | ...);
}

int main() {
    std::size_t bit_mask = make_bit_mask(1, 2, 3, 4, 5);
    std::cout << std::bitset<sizeof(std::size_t)>(bit_mask) << std::endl;
}

// Output
00111110

사실 간단한 문제라 적을까 말까 고민했다
그런데도 적은건 다음과 같은 이유에서이다

  • bit mask는 굉장히 널리 사용된다
  • operator에 우선순위만 부여해준다면 여러개를 섞어서 사용할 수도 있다
  • 내 머리속에서 나온 가장 그럴듯한 example 이다

사실 별이유는 없다
그냥 한번 적어보고 싶었다

Comma operator

어쩌면 우리가 가장 많이 사용하는 operator이지만 잘모르고 있는 operator일수도 있다
말그대로 우리가 parameter 넘길때 사용하는 그 ,가 맞다
그리고 이 comma operator가 어쩌면 fold expression의 가장 하이라이트라고 할 수도 있다

우리는 parameter pack에서 각 argument에 접근하기 위해 expansion을 사용했다
그리고 이게 너무 복잡하고 읽기가 힘드니까 fold expression이 등장했다
근데 사칙연산이나 bit-wise 연산만 가지고는 뭔가 부족해보인다
이때 comma operator을 사용하면 가려움이 시원하게 해결된다

우선 CppReference에서 제공하는 example을 봐보자

#include <vector>
#include <iostream>

template <typename VecT, typename... Args>
void multi_push_back(VecT& vec, Args&&... args) {
    (vec.push_back(std::forward<Args>(args)), ...);
}

int main() {
    std::vector<int> vec;
    multi_push_back(vec, 1, 2, 3, 4, 5);

    for (auto& ele : vec) {
        std::cout << ele << ' ';
    }
    std::cout << std::endl;
}

// Output
1 2 3 4 5

이렇게만 보면 이해가 조금 힘드니 CppInsight를 다시 활용해보자

template<>
void multi_push_back<std::vector<int>, int, int, int, int, int>
(std::vector<int, std::allocator<int> > & vec, 
 int && __args1, int && __args2, int && __args3, int && __args4, int && __args5) {
  vec.push_back(std::forward<int>(__args1)) , 
  (vec.push_back(std::forward<int>(__args2)) , 
  (vec.push_back(std::forward<int>(__args3)) , 
  (vec.push_back(std::forward<int>(__args4)) , 
  vec.push_back(std::forward<int>(__args5)))));
}

template instiation으로 인해 좀 많이 복잡해졌는데 아무튼 중요한건 순서대로 넘어간다는거다
그러니까 vec.push_back(std::forward<Args>()) 이 부분에 args가 차례대로 들어간다
이제 단순 binary operator가 아닌 function으로 확장이 가능해진다

원래도 function에 parameter pack을 사용하기 위해 아래와 같은 문법이 있었다

template <typename... Args>
void bar(Args&&... args) {
    //...
}

template <typename... Args>
void foo(Args&&... args) {
    bar(args...);
    bar(std::forward<Args>(args)...);
}

int main() {
    foo<int, int, int>(1, 2, 3);
}

parameter pack 전체를 넘기거나 std::forward와 같은 일부 함수를 사용할 수 있었다
문제는 그 한계가 존재했다는 것이다
때문에 전 포스팅에서 다루었던 것처럼 하나씩 까먹기를 시전해야했다
근데 이제 그럴 필요가 전혀없다

Pointer-to-member access operators

member access operator는 여러가지가 존재한다
member variable이나 function에 접근할때 사용하는 operator 전부다 해당된다
근데 fold expressions에선 pointer-to-member access operators만 된다
여기서 pointer-to-member access operators는 아래와 같다

  • lhs.*rhs
  • lhs->*rhs

그러니까 data member을 가르키는 pointer만 가능하다는 거다
그래서 몇시간 동안이나 이걸 어떻게 써야하나 고민해보았다
그리고 그 결과물은 아래와 같다

#include <iostream>

template <typename T>
class linked_list {
public:
    struct node {
        node* next;
        T value;
    };

    node* head = nullptr;
    node* tail = nullptr;

    linked_list() = default;
    ~linked_list() {
        for (; head->next != nullptr; ) {
            node* next = head->next;
            delete head;
            head = next;
        }
    }

    template <typename Arg>
    void push_back(Arg&& arg) {
        if (head == nullptr) {
            head = new node{nullptr, std::move(arg)};
            tail = head;
        }
        else {
            tail->next = new node{nullptr, std::move(arg)};
            tail = tail->next;
        }
    }

    template <typename... Args>
    auto get_value(Args&&... args) {
        return (head ->* ... ->* args);
    }
};

int main() {
    linked_list<int> list;

    list.push_back(5);
    list.push_back(4);
    list.push_back(3);
    list.push_back(2);
    list.push_back(1);

    typedef decltype(list)::node node_t;
    typedef node_t* node_t::* next_ref_t;
    typedef int node_t::* value_ref_t;

    next_ref_t next_ptr = &node_t::next;
    value_ref_t node_value = &node_t::value;

    std::cout << "The value on third is: " <<
        list.get_value(next_ptr, next_ptr, node_value) << std::endl;
}

코드가 좀 길긴한데 별 내용은 없다
그냥 아주아주 단순한 linked_list를 구현한거다
push_back 밖에 없는 linked_list..

우리가 봐야하는건 get_value 부분이다
우선 get_value의 호출 부분을 살펴보도록 하자

list.get_value(next_ptr, next_ptr, node_value)

이렇게만 써놓으면 이게 뭔 개소린가 싶으니 위에 선언해놓은 놈들로 치환해보자
참고로 linked_list<int>::node 부분은 귀찮은 관게로 node_t로 대체했다

list.get_value(node_t::next, 
               node_t::next,
               node_t::value)

그리고 이걸 다시 get_value로 가져가보자

int get_value(node_t::next, node_t::next, node_t::value) {
	return ((this->head ->* node_t::next) ->* node_t::next) ->* node_t::value;
}

뭔가 보이기 시작하니 다시 처음으로 돌아와 알짜베기만 골라서 적어보자

list.head->next->next->value

아니라고 믿고 싶은데 정말이지 동일하게 동작하는 code다
심지어 clang -O3 기준 동일한 asm을 뱉어낸다
아 그래도 아래와 같이 non-type template parameter를 넘겨서 쓸순 있다..!

...

template <auto node::*... Args>
auto get_value() {
    return (head ->* ... ->* Args);
}

...

return list.get_value<&node_t::next,
                      &node_t::next,
                      &node_t::value>();

뭔가 더 있지 않을까하는 마음에 member function pointer도 시도해보았다
결론부터 말하자면 안된다
될꺼 같아서 별 갖은 수를 생각해보고 실험해봤는데 안된다
그 이유는 뭐 사실 당연하게도 operator ()가 뒤에 안붙어서 그렇다
자세한건 스오플 형님들의 말을 들어보도록하자
https://stackoverflow.com/questions/50912540/pointer-to-member-function-in-fold-expression


마지막 example을 만드는내내 욕이 튀어나왔다
Chat-GPT한테 물어보니 class A 안에 class B 안에 class C 안에 변수 d를 찍으란다
그것보단 차라리 linked_list에서 재귀형태로 돌리는 편이 더 현적으로 보여서 이렇게 썼다
그리고 마지막 example의 node에 원래는 std::unique_ptr 을 사용하려했었다
근데 std::unique_ptr에는 또 operator ->*가 overloading 되어있지 않아 포기했다
어떻게 잘하면 될꺼 같기도한데 이쯤되니 이걸 왜하고 있나 싶다

원래 type deduction이나 auto keyword는 포스팅하지 않아 최대한 자제하려고 했다
근데 점점 문법이 괴랄해지기 시작하면서 거의 반강제적으로 사용하기 시작했다
이걸 explicit하게 선언할수 있기는한데 너무 힘들다..

fold expressions는 굳이 잘써야한다고 생각하지도 않는다
이걸 쓰면 code가 좀 드라마틱하게 깔끔해지기는 하는데 그뿐이다
정말이지 사람들이 잘 안쓰는 문법은 괜히 안쓰는게 아니다
다 이유가 있는법이다

profile
하고싶은거 하는 사람

0개의 댓글