KoreanFoodie's Study
[C++ 함수형 프로그래밍] 파셜 애플리케이션과 커링 본문
함수형 프로그래밍 패러다임에 대해 알아보며 이를 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 를 파셜 애플리케이션으로 활용하여, 특정 타입의 중복을 제거할 수 있다!
'R & D > Software Engineering' 카테고리의 다른 글
[C++ 함수형 프로그래밍] 함수형 연산자를 활용한 중복 제거 (0) | 2023.09.28 |
---|---|
[C++ 함수형 프로그래밍] 함수로 설계하기 (feat. 틱택토와 STL) (0) | 2023.09.28 |
[C++ 함수형 프로그래밍] 함수형 합성 (C++ 에서 함수 합성하기) (0) | 2023.09.28 |
[C++ 함수형 프로그래밍] 순수 함수와 람다 (0) | 2023.09.27 |
UML (클래스 다이어그램, 시퀀스 다이어그램) 간단 정리 (0) | 2022.10.14 |