KoreanFoodie's Study

[C++ 함수형 프로그래밍] 파셜 애플리케이션과 커링 본문

R & D/Software Engineering

[C++ 함수형 프로그래밍] 파셜 애플리케이션과 커링

GoldGiver 2023. 9. 28. 12:32

함수형 프로그래밍 패러다임에 대해 알아보며 이를 C++ 를 이용한 소프트웨어 개발에 어떻게 적용하면 좋을지 알아보겠습니다.

핵심 :

1. 파셜 애플리케이션은 N 개의 인자를 가진 람다에서 하나의 인자를 바인딩해 N-1 개의 인자를 가지는 람다로 변환하는 연산을 의미한다.

2. 커링은 N 개의 인자를 가진 함수를 하나의 인자를 가진 함수 N 개로 분해하는 과정을 의미한다.

3. 파셜 애플리케이션과 커링을 이용하면 중복을 제거할 수 있고, 더 범용적인 코드를 생성할 수 있다.

우리는 이번 글에서 파셜 애플리케이션과 커링을 배우며, 이 두 가지가 어떻게 연관되었는지 알아볼 것이다.

 

파셜 애플리케이션

파셜 애플리케이션이란, N 개의 인자를 가지는 람다에서 하나의 인자를 바인딩해 N-1 개의 인자를 가지는 람다를 만드는 연산을 의미한다.

예를 들어, 인자 두 개를 더하는 람다를 이용해, 주어진 인자에 1을 더해주는 파셜 애플리케이션을 만들 수 있다.

auto add = [](auto i, auto j) { return i + j; };
auto increment = [](auto i) { return add(i, 1); };

 

혹은 bind 를 이용해 간단하게 표현할 수도 있다.

using namespace std::placeholders;

// add 의 두 번째 인자로 1 을 바인딩
// _1 은 플레이스 홀더로, increment 람다의 첫 파라미터이다.
auto increment = bind(add, _1, 1);

다만 bind 는 컴파일 시간에서 높은 오버헤드가 있다는 것을 기억해 두자. 😅 참고로, 두 인자 모두 바인딩이 가능하다!

만약 3 개의 인자를 더하는 함수를 만들고, 10과 20을 더해준다고 하면, 다음과 같이 쓸 수 있을 것이다 :

// 로직상, 바인딩이 어떤 변수에 붙어도 결과는 동일할 것이다.
auto add10And20 = bind(addThree, _1, 10, 20);
//auto add10And20 = bind(addThree, 10, _1, 20);
//auto add10And20 = bind(addThree, 10, 20, _1);

그럼 파셜 애플리케이션을 클래스 메서드에 적용하려면 어떻게 해야 할까?

 

클래스 메서드에서의 파셜 애플리케이션

기존처럼 bind 함수를 이용하는 것은 비슷하나, 클래스 메서드를 사용할 때는 첫 번째 인자가 클래스의 인스턴스여야 한다. 예시를 보자.

class Dummy
{
private:
    int i;
    int j;

public:
    Dummy(int _i, int _j) : i(_i), j(_j) {}
    int add() { return i + j; }
};

int main()
{
    // dummy 객체를 바로 넣어준다
    Dummy dummy(7, 1);
    auto add1 = bind(&Dummy::add, dummy);
    cout << add1() << endl;

    // placeholder 로 넘겨줄 수 있도록 설정
    auto add2 = bind(&Dummy::add, _1);
    cout << add2(Dummy(7, 1)) << endl;
}

잘 생각해보면... 사실 add 는 클래스 내부 변수 2개를 더하는 함수인데, main 함수에서 바인딩한 버전은 인자를 하나만 받는 것을 알 수 있다.

이같은 방식은 add 를 인자를 받아 구현한 버전에서도 적용할 수 있다. 아래 예제 코드를 보자.

class Dummy2
{
private:
    int i;

public:
    Dummy2(int _i) : i(_i) {}
    int addTo(int j) { return i + j; }
};

int main()
{
    // dummy 객체에 placeholder 하나를 넘기기
    Dummy2 dummy2(10);
    auto addTo = bind(&Dummy2::addTo, dummy2, _1);
    cout << addTo(1) << endl;

    // dummy 와 더할 인자를 둘 다 placeholder 로 만든다
    auto addTo2 = bind(&Dummy2::addTo, _1, _2);
    cout << addTo2(Dummy2(10), 1) << endl;
}

 

 

커링

드디어 함수형 프로그래밍의 인싸, 커링에 대해 알아보자.

커링은 N 개의 인자를 가진 함수를 하나의 인자를 가진 N개의 함수로 분해하는 과정을 말한다.

먼저 우리가 줄기차게 사용했던 add 람다를 분해해 볼 것이다. 😂

auto add = [](const int  i, const int j) { return i + j; };

어떻게 하면 좋을까? 🤔 사실, 우리는 람다에서 '캡쳐' 라는 아주 유용한 도구가 있음을 알고 있다. 이를 이용하면...

auto curryAdd = [](const int i)
{
    return [i](const int j)
    {
        return i + j;
    };
};

int main()
{
    cout << curryAdd(1)(2) << endl;
}

위의 curryAdd 를 간단히 유도할 수 있다! 😁

 

커링과 파셜 애플리케이션

우리는 이전에 인자의 값을 1 증가시키는 increment 람다를 add 함수를 통해 만들었다. 인자의 갯수를 2개에서 1개로 감소시켰으니 이것은 파셜 애플리케이션인데... 사실 커링을 이용해서 이를 정의할 수도 있다.

// 파셜 애플리케이션으로 구현
auto increment = bind(add, 1, _1);

// 커링으로 구현
auto increment2 = curryAdd(1);

함수는 기본적으로 커링이 가능하다. 즉, 함수형 프로그래밍 언어에서는 다음과 같이 표현이 된다.

increment = add 1

C++ 에서는 커링이 기본적으로 적용되지 않지만 파셜 애플리케이션을 통해 커링을 구현할 수 있다.

auto curryAndPartialApplication = [](const int i)
{
    return bind(add, i, _1);
};

/***/

cout << curryAndPartialApplication(10)(12) << endl;

그렇다면 다수의 인자를 가진 함수에도 커링을 쉽게 적용할 수 있을까?

 

다수의 인자를 가진 함수의 커링

예를 들어, 3개의 인자를 더하는 함수를 커링한다고 해 보자. 단순히 람다 함수로 표현하면 다음과 같을 것이다.

auto addThree = [](auto i, auto j, auto k) {return i + j + k; };

auto curryAddThree = [](const int i)
{
    return [i](const int j)
    {
        return [i, j](const int k)
        {
            return i + j + k;
        };
    };
};

/***/

cout << curryAddThree(1)(2)(3) << endl;

그리고 파셜 애플리케이션으로 만들려면.. 험난하다 😅

일단 개념적으로는 아래와 같이 만들고 싶다 :

bind(bind(bind(addThree, _1), _1, _1)

그럼 실제로는 바인딩된 값이 아래와 같이 저장되어야 하는데...

auto partialThree = bind(bind(bind(addThree, ?, ?, _1), ?, _1), _1)

'?' 값은 이전에 바운딩한 값으로 대체되어야 하지만 현재 문법 시스템에서는 작동하지 않는다.

대신, 아래와 같은 방식으로 비슷하게 구현할 수는 있다.

먼저, 인자가 1개인 녀석을 커링해 보자.

auto simpleCurry1 = [](auto f)
{
    return f;
};

그럼 2개인 녀석은 어떻게 될까?

auto simpleCurry2 = [](auto f)
{
    return [=](int i) {return bind(f, i, _1); };
};

마찬가지로, 인자가 3개와 4개인 녀석도 아래와 같이 표현할 수 있다.

auto simpleCurry3 = [](auto f)
{
    return [=](int i, int j) {return bind(f, i, j, _1); };
};

auto simpleCurry4 = [](auto f)
{
    return [=](int i, int j, int k) {return bind(f, i, j, k, _1); };
};

 

그럼 curryN 집합을 다음과 같이 만들 수 있다.

auto curry3 = [](auto f)
{
    return curry2(simpleCurry3(f));
};

auto curry4 = [](auto f)
{
    return curry3(simpleCurry4(f));
};

 

이제 addThree 및 addFour 버전을 테스트해 보자.

cout << curry3(addThree)(1)(2)(3) << endl;
cout << curry4(addFour)(1)(2)(3)(4) << endl;

 

그런데 문득, 이런 생각이 들 수 있다. 만약 인자를 (1)(2)(3).. 이런 식으로 넣으면, 실제 연산은 1 + 2 + 3... 이런 식으로 이루어질까, 아니면 3 + 2 + 1... 이런 식으로 이루어질까?

잘 생각해보면, simpleCurry 에서 bind 된 함수를 리턴할 때, 앞 인자를 먼저 받는 것을 확인할 수 있다. 그러므로, 연산은 순차적으로 이루어진다. 만약 연산이 역순으로 이루어지길 원한다면, 다음과 같이 simpleCurry 에서 바인딩되는 placeholder 의 위치를 뒤바꾸면 될 것이다!

// 순차적으로 계산
auto simpleCurry3 = [](auto f)
{
    return [=](int i, int j) {return bind(f, i, j, _1); };
};

// 역순으로 계산
auto simpleCurry3Reversed = [](auto f)
{
    return [=](int i, int j) {return bind(f, _1, j, i); };
};

 

아래 람다를 통해 테스트해 보면, 연산이 순차적으로 이루어지는 것을 확인할 수 있다.

auto paramFour = [](int i, int j, int k, int l)
{
    return -i + j * k / l;
};

int main()
{
    // 0
    cout << curry4(paramFour)(1)(2)(3)(4) << endl;

    // 2
    cout << curry4(paramFour)(4)(3)(2)(1) << endl;
}

우리는 이제 위의 curryN 집합에 들어갈 함수를 템플릿으로 바꾸어 추후에도 계속 활용할 수 있을 것이다. 뭐, add 를 바꾸고 기존 커리 함수의 인자를 auto 로 바꿔주면 되는데...

template <typename T>
auto add = [](T i, T j) { return i + j; };

auto simpleCurry2 = [](auto f)
{
    return [=](auto i) {return bind(f, i, _1); };
};

auto curry2 = [](auto f)
{
    return simpleCurry2(f);
};

/***/

cout << curry2(add<int>)(1)(2) << endl;

템플릿 형식은 위와 같이 사용하면 될 것이다 😉

 

추후 코드 재활용을 위해 다음과 같이 접은 글에 샘플 코드를 담아두겠다 🤣

더보기
#pragma region CurryN
auto simpleCurry1 = [](auto f)
{
    return f;
};

auto simpleCurry2 = [](auto f)
{
    return [=](auto i) {return bind(f, i, _1); };
};

auto simpleCurry3 = [](auto f)
{
    return [=](auto i, auto j) {return bind(f, i, j, _1); };
};

auto simpleCurry4 = [](auto f)
{
    return [=](auto i, auto j, auto k) {return bind(f, i, j, k, _1); };
};

auto Curry2 = [](auto f)
{
    return simpleCurry2(f);
};

auto Curry3 = [](auto f)
{
    return Curry2(simpleCurry3(f));
};

auto Curry4 = [](auto f)
{
    return Curry3(simpleCurry4(f));
};
#pragma endregion

/***/

cout << Curry2(add<int>)(1)(2) << endl;
cout << Curry3(addThree)(1)(2)(3) << endl;
cout << Curry4(addFour)(1)(2)(3)(4) << endl;

 

 

파셜 애플리케이션과 커링을 활용한 중복 제거

우리는 이전에 덧셈을 하는 람다 add 를 구현한 후, 이를 이용해 1을 더하는 increment 함수를 만들었다. 그런데 만약 정수형이 아닌, + 연산을 지원하는 모든 타입에 대해 increment 를 적용하고 싶다고 하면 어떻게 될까?

template<typename T, T one>
auto increment = bind(add, _1, one);

int main()
{
	cout << increment<int, 1>(10) << endl;
}

이 add 메서드는 타입에 덧셈 연산자가 존재하는 한 타입을 가리지 않고 연산이 정상적으로 이루어질 것이다!

 

마지막으로, 파셜 애플리케이션을 적용한 예시를 보며 글을 마무리하겠다.

예를 들어, 덧셈을 하되 20 이 되면 다시 0 부터 시작하는 함수를 만들고 싶다고 하자. 일반적으로는 다음과 같이 짤 것이다.

auto addWarpped = [](auto i, auto j) {return (i + j) % 20; };

그런데 파셜 애플리케이션을 사용하면 다음과 같이 일반화할 수 있다.

auto addWrapped = [](auto i, auto j, auto wrapAt) 
{
	return (i + j) % wrapAt; 
};

auto add = bind(addWrapped, _1, _2, 20);

template<typename T, T one>
auto increment = bind<T>(add, _1, one);

int main()
{
	cout << increment<int, 1>(20) << endl;
}

add 를 파셜 애플리케이션으로 활용하여, 특정 타입의 중복을 제거할 수 있다!

Comments