본문 바로가기

진리는어디에/C++

[C++] Fold Expression

들어가며

가변 인자 템플릿을 이용하면 파리미터 팩을 풀어서 여러 인자에 대해 공통 연산을 해야 할 경우가 많다. 예를 들어 아래 처럼 모든 가변 인자의 합을 구한다고 했을 때, 전통적인 방법으로는 재귀적(?)으로 템플릿 함수를 정의하고 팩에 들어 있는 값을 순회하는 방법을 사용했다.

template <typename T>
T sum(T v) {
    return v;
}

template <typename T, typename... Args>
T sum(T first, Args... rest) {
    return first + sum(rest...);
}
위에서 재귀적이라는 표현 뒤에 물음표를 달아 놓은 이유는 실제 우리가 만드는 함수는 재귀함수가 아니기 때문이다. 컴파일러는 모든 인자 갯수에 맞는 버전의 sum 함수를 생성하고, 인자 갯수에 따라 다른 버전의 sum함수를 호출 한다. 위와 같은 재귀 처럼 보이는 구조에서는 인자 갯수가 많을 경우 컴파일러에서 생성하는 코드의 량이 많아지고, 그 결과로 결과 바이너리 사이즈가 걷잡을 수 없이 커질 수도 있다.

하지만 C++17 부터는 fold expression이라는 문법이 추가되어 재귀적 함수 작성 없이, 단 한 줄의 코드로 표현 가능하다.

fold expression이란 무엇인가?

cppreference.com에서는 fold expression을 '팩 안에 들어 있는 모든 요소들에 대해 바이너리 오퍼레이션을 적용하여 줄여버리는 것' 이라고 정의하고 있다. 여기서 우리가 주목해야할 단어는 '바이너리 오퍼레이션(binary operation)'과 '줄이다(reduce)'이다.

앞서 언급된 '팩 안의 모든 인자의 합을 구한다'라는 말을 풀어 쓰면, 인자들 끼리 + (바이너리) 연산을 진행하여 하나의 결과를 얻겠다는 것이다. fold expression의 정의와 부합한다. 우리는 sum 함수를 복잡한 재귀 형태에서 좀 더 간단한 형태로 바꿀 수 있을 것 같다.

이번 포스트에서는 이런 fold expression에 대해 알아 보도록 하겠다.

문법

간략하게 fold expression의 표현식 부터 살펴 보자.

auto s = ( ... + args);

fold expression은 괄호 안에 쩜쩜쩜(...)을 사용하고 적용하고자 하는 이항 연산자와 팩 이름을 적어 주면 된다.

요약하면 다음과 같다 :

  • fold expression은 괄호()로 싸여 있어야만 한다.
  • 쩜쩜쩜(...)을 사용한다.
  • 적용할 이항 연산자를 적어 준다.
  • 대상 파라미터 팩의 이름을 적어준다

sum 함수를 위와 같이 fold expression을 이용해 표현하면, 컴파일러는 아래와 비슷한 코드를 생성한다(개념적으로 비슷한 코드다, 실제 생성 되는 코드는 조금 더 뒤에 다루겠다)

template <class ...Ts>
auto sum(Ts ... args)
{
    // args : 1, 2, 3, 4, 5
    
    auto s = (... + args);
    // auto s = 1 + 2 + 3 + 4 + 5; // args 팩에 들어 있는 모든 요소들에 대해 + 이항 연산을 적용, s에 저장
    return s;
}

int main()
{
    std::cout << sum(1, 2, 3, 4, 5) std::endl;
}

 

매우 간단하다. 괄호 안에 쩜쩜쩜(...)과 이항 연산자, 대상 팩 이름만 적어 주기만 하면 컴파일러가 자동으로 모든 요소들에 대해 같은 이항 연산을 적용하는 코드를 생성해 준다.

fold expression의 형태

앞서 fold expression의 간단한 사용법을 살펴 보았다. 이번 섹션에서는 연산자 적용 우선 순위, 쉽게 말해서 fold expression을 앞에서 부터 진행할지, 뒤에서 부터 진행할 지를 조절할 수 있는 방법에 대해 살펴 보도록 하겠다.

fold expression은 아래 처럼 4가지 형태로 사용할 수 있다.

문법 이름 확장 방향
(… op pack) Unary left fold 왼쪽에서 오른쪽으로 진행
(pack op …) Unary right fold 오른쪽에서 왼쪽으로 진행
(init op … op pack) Binary left fold 왼쪽에서 오른쪽으로 진행.
(pack op … op init) binary right fold 오른쪽에서 왼쪽으로 진행
  • fold expression은 반드시 괄호 () 로 묶여야 한다.
  • pack : template parameter pack
  • init : 초기값(eg. 0), 초기값에 대한 설명은 다음 섹션에 다시 다룬다
  • op : 이항 연산자(+ - * / % ^ & | = ...등등)
template<class ... Ts>
auto sum(Ts... args)
{
  // (... op pack) - unary left fold
  auto s1 = (... + args);
  // (pack op ...) - unary right fold
  auto s2 = (args + ...);
  // (init op ... op pack) - binary left fold
  auto s3 = (0 + ... + args);
  // (pack op ... op init)
  auto s4 = (args + ... + 0);
  
  return 0;
}

/* 컴팡일러에 의해 생성된 실제 코드 - https://cppinsights.io/ */
#ifdef INSIGHTS_USE_TEMPLATE
template<>
int sum<int, int, int, int, int>(int __args0, int __args1, int __args2, int __args3, int __args4)
{
  // (... op pack) - 왼쪽에서 부터 괄호가 묶인 형태로 코드 생성
  int s1 = (((__args0 + __args1) + __args2) + __args3) + __args4;
  
  // (pack op ...) - 오른쪽에서 부터 괄호가 묶인 형태로 코드 생성
  int s2 = __args0 + (__args1 + (__args2 + (__args3 + __args4)));
  
  // (init op ... op pack) - 왼쪽 부터 괄호를 묶되 초기값을 적용한다.
  int s3 = ((((0 + __args0) + __args1) + __args2) + __args3) + __args4;
  
  // (pack op ... op init) - 오른쪽 부터 괄호를 묶되 초기값을 적용한다.
  int s4 = __args0 + (__args1 + (__args2 + (__args3 + (__args4 + 0))));
  
  return 0;
}
#endif

위 예제에서 컴파일러가 생성한 코드 영역을 살펴 보면,  쩜쩜쩜(...)에서 부터 팩 이름 쪽으로의 순서로 괄호식이 생성되어 우선순위를 먼저 가지게 하는 것을 볼 수 있다.

fold expression의 초기값

이전 섹션에서 fold expression의 형태중 초기값을 지정하는 Binary left/right fold가 소개 되었었다. 이번 섹션에서는 초기값의 의미와 역할에 대해 살펴 보도록 한다.

가장 먼저 초기값의 역할은 빈 파라미터 팩에 대한 fold expression을 컴파일 에러로 처리하지 않고 초기값을 적용한다는 것이다.

아래 예에서 sum 함수를 호출 할 때, 아무런 인자도 넘기지 않는다고 가정하자. 

template <class ...Args>
int sum(const Args&... args)
{
    return (... + args); // Fold expression
}

int main()
{
    std::cout << sum() << std::endl; // empty parameter list
    return 0;
}

위 코드를 컴파일하면 "unary fold expression has empty expansion for operator '+' with no fallback value" 컴파일 에러를 발생 시킨다. 빈 파라미터 팩으로는 fold express이 불가하다는 뜻이다.

이를 방지하기 위해 기본 값을 지정해 줄 수 있다. 아래 코드는 sum함수 호출 인자가 비어 있다고 하더라도 컴파일 오류를 발생 시키지 않고 기본 값으로 대체한다.

template <class ...Args>
int sum(const Args&... args)
{
    return (0 + ... + args); // Fold expression with init
}

초기값의 활용

초기값은 위 예제들 처럼 단순 정수 뿐만 아니라 다양한 객체들을 사용할 수도 있다. 아래는 팩에 있는 모든 요소를 출력하는 간단한 템플릿 함수다.

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

/* 컴파일러에 의해 생성된 코드 - https://cppinsights.io */
#ifdef INSIGHTS_USE_TEMPLATE
template<>
void print<int, int, int>(const int & __args0, const int & __args1, const int & __args2)
{
  std::cout.operator<<(__args0).operator<<(__args1).operator<<(__args2);
  // (((std::cout << 1) << 2) << 3)
  std::cout.operator<<(std::endl);
}

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

위 예제의 특이한 점은, 기존의 예제와 달리 int와 같이 기본 타입을 초기값으로 사용하는 것이 아닌 객체를 사용하고 있다는 것이다.

초기값으로 다양한 객체를 사용할 수 도 있다.

pack의 이름에 패턴 적용하여 fold express 사용

fold expression에는 pack의 이름 뿐 아니라 pack의 이름을 사용하는 다양한 패턴을 적용 가능하다. 

 

template <class... Args>
void print(Args... args)
{
    ((std::cout << args << " "), ...);
    std::cout << std::endl;
}

아래 4라인의 fold expression을 살펴 보자. 조금 어려워 보이기는 하지만 풀어 보면 다른 fold expression과 동일하다.

이 코드에서 핵심은 팩 이름 args와 args를 사용하는 패턴에 콤마(,) 이항 연산자를 적용하고 있다는 것이다.

  • (pack op ...)
  • 쩜쩜쩜(...)이 오른쪽에 있으므로 Unary right fold
  • pack과 pack을 사용하는 패턴 전체에 콤마(,)연산을 걸어준다.

위 코드는 다음과 같이 컴파일러에 의해 생성된다.

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

위 코드를 간단히 줄여 보면 :

(std::cout << __args0, " "), (std::cout << (__args1), " "), (std::cout<<(__args2), " ");

와 같이 콤마로 이어진 std::cout 문이 생성된 것을 알 수 있다.

마치며

fold expression이라는 거창한 이름을 달고 나왔지만 실상은 복잡하게 생각할것 없는 간단한 편의 기능이다.

  • 지금까지 파라미터 팩의 각 요소를 순회하기 위해 재귀 형식의 템플릿 함수를 만들었다면, fold expression은 괄호에 둘러싸진 표현식 하나로 복잡한 구조의 코드를 대체 할 수 있다.
  • fold expression은 파라미터 팩 안의 모든 요소들에게 지정된 바이너리 오퍼레이션을 적용한다.
  • fold expression은 쩜쩜쩜(...)과 바이너리 오퍼레이터의 위치에 따라 진행 방향이 달라진다.
  • 단순 pack 이름 뿐 아니라, pack에 적용된 패턴도 연산 대상이 된다.

부록 1. 같이 읽으면 좋은 글

 

 

유익한 글이었다면 공감(❤) 버튼 꾹!! 추가 문의 사항은 댓글로!!