KoreanFoodie's Study

[C++ 함수형 프로그래밍] 순수 함수와 람다 본문

R & D/Software Engineering

[C++ 함수형 프로그래밍] 순수 함수와 람다

GoldGiver 2023. 9. 27. 11:48

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

핵심 :

1. 함수는 값이다. 함수는 인자로 전달할 수 있다. 간단한 함수를 조합해 더 크고 복잡한 함수를 만들 수 있다.

2. 순수 함수의 조합을 통한 설계는 불변성을 보장한다.

3. 람다를 순수 함수처럼 사용하자. 또한 캡쳐에 대해 명확히 알아두자.

사실 필자도 학부 과정 때 프로그래밍 언어 관련 수업을 들으며 함수형 언어를 접했지만, 그 당시에는 프로그래밍 자체에 대한 흥미가 그다지 없어서 그런지 큰 관심을 갖지 않았다. 열심히 하지도 않았고. 😅 그러다 보니 함수형 언어에 대한 지식은 머리 속 어느 구석에 앉아 먼지만을 수집하고 있었다. 그러다 다시 이 책을 읽으면서 함수형 프로그래밍이 매우 흥미롭다는 것을 느꼈고, 얼마나 유용한지에 대해 알게 되었다.

부디 포스팅을 읽는 분들이 비슷한 재미와 통찰을 느낄 수 있길 바라며... 나름의 리뷰를 남긴다. (관심이 생긴다면 책을 한 번 읽어볼 것을 권합니다 😉)

 

함수형 프로그래밍 소개

함수형 프로그래밍을 사용하면 중복을 제거하고 코드를 단순화할 수 있으며, 디자인을 단순화시킬 수 있다.

말만 들으면 잘 와닿지 않을 수 있다. 그래서 간단한 예시를 보면, C++ 에서 복잡한 루프를 통해 작업을 하던 것을 단순한 함수의 조합으로 표현할 수 있다.

예를 들어, 어떤 벡터에 들어 있는 정수에 대해, 짝수, 홀수, 모든 수를 각각 더한 3 개의 값을 결과값으로 반환하고 싶다고 하자.

만약 기존 명령형 방식을 차용한다면, 아마도 for loop 과 if 문을 늘려나가며 이를 해결할 것이다. 하지만 함수형으로 프로그래밍하면 다음과 같이 만들 수도 있다.

Sums theTotals
{
    sum(filter(numbers, isEven)),
    sum(filter(numbers, isEven)),
    sum(numbers)
}

 Sums 는 결과가 담길 클래스(혹은 구조체)이며, sum 은 합산하는 함수, filter 는 Predicate 를 받아 filtered 된 결과를 반환한다. 그리고 sum 은 목록을 합산해주는 함수이다. 세세한 구현은 직접 해 보도록 하자 😅

 

 

순수 함수 이해하기

C++ 에서 함수는 기본적으로 상태의 변화를 수반한다. 이를 방지하는 방법은 함수에 '눈치껏' const 를 붙여주는 것이다.

Effective C++ 에서 스콧 마이어 선생님이 '낌새만 보이면 const 를 들이대 보자!'고 조언하신 것처럼, 생각이 있는 프로그래머가 짠 코드에서는 심심치 않게 const 를 자주 찾아볼 수 있다.

const 를 밥 먹듯이 붙이는 이유는 최적화도 때문인 것도 있지만, 상태를 잘못 변화시키는 실수를 줄임으로써 유지보수에 있어 유리하게 대응할 수 있기 때문이다.

 

그런데 함수형 프로그래밍의 빌딩 블록은 순수 함수이며, 순수 함수를 활용하면 상태 변화에 의한 혼동이 줄어든다. 순수 함수는 다음 두 가지 제약 조건을 가진 함수를 의미한다.

  • 동일한 인자 값을 넣으면 항상 동일한 결과 값을 반환한다.
  • 입력 파라미터 값을 변경하지 않는다.

이러한 특징 덕에, 순수 함수는 부작용이 없다.

함수형 프로그래밍은 순수 함수를 조합해 가며 이러한 불변성을 매우 잘 지키는 방식으로 프로그램을 작성하겠다는 것을 의미한다. 순수 함수가 상태 변화를 일으키지 않는 특징 덕분에, 다형성과 모듈 확장성이라는 장점을 최대한으로 활용할 수 있는데, 이는 다른 함수에 함수를 전달할 때 잘 드러난다. 사실 STL 의 고차원 함수를 보면 이러한 특징을 살펴볼 수 있다.

 

C++ 에서 순수 함수의 불변성을 따라하려면 약간의 복잡한 사고 과정을 거쳐야 한다.

함수가 값에 의한 전달을 하는지, 참조에 의한 전달을 하는지, 전달 인자가 포인터인지 아닌지에 따라 const 를 이곳 저곳에 붙여야 한다. 예를 들어 다음과 같다 :

// 값에 의한 전달 클래스 함수
int increment(const int value) const;

// 참조에 의한 전달 클래스 함수
int increment(const int& value) const;

// 값에 의한 포인터 전달 클래스 함수
const int* increment(const int* value) const;

// 참조에 의한 전달 클래스 함수
const int* increment(const int* const& value) const;

이것 말고도, 예시는 더 쓸 수 있다. 참고로, 포인터는 기본적으로 '값에 의한 복사' 가 이루어진다는 것을 명심하자.

 

 

람다 파헤치기

람다는 변수와 값에 적용할 수 있는 순수 함수일 뿐이다. 잘 알고 있겠지만.. 예시를 보자.

auto add = [](int first, int second) { return first + second; };

 

캡쳐를 활용하면, 다음과 같이 쓸 수도 있다.

int first = 0;
auto add2 = [first](int second) {return first + second; };
auto add3 = [&first](int second) {return first + second; };

 

포인터도 캡쳐할 수 있다.

int* firstPtr = new int(5);
auto add4 = [firstPtr](int second) {return *firstPtr + second; };
auto add5 = [&firstPtr](int second) {return *firstPtr + second; };

일단 람다는 기본적으로 기본 값 한정자 [=] 로 캡쳐하는 것이 좋다. 참고로 포인터는 기본적으로 복사된다.

 

하지만 람다가 참조에 의해 전달된 인자를 받게 되면, 순수 함수의 기본적인 원칙을 위배하게 될 수도 있다. 왜냐하면 순수 함수는 파라미터의 값을 변경하면 안되기 때문이다. 이를 방지하기 위해서는 인자에 const 를 다시 도배해 주어야 한다.

인자 뿐만이 아니라 캡쳐도 마찬가지다. 예시를 보자.

int value = 1;

// value 가 2가 된다
auto increment = [&]() {return ++value; };

// 캡펴한 값을 const 타입으로 캐스팅해 캡쳐
auto incrementSafe = [&immutableValue = as_const(value)]()
{return immutableValue + 1; };

사실 코드 길이를 늘려나가는 것보다, 일반적으로는 값에 의한 캡쳐를 사용하는 것이 좋다! 이는 포인터 타입의 경우에도 마찬가지이다.

 

마지막으로, 복소수를 string 으로 출력해 주는 람다를 보면서 이 글을 마무리하자.

class Imaginary
{
private:
	int real;
	int imaginary;

public:
	Imaginary() : real(0), imaginary(0) {}
	Imaginary(int _real) : real(_real), imaginary(0) {}
	Imaginary(int _real, int _imaginary) : real(_real), imaginary(_imaginary) {}

public:
	/** 복소수를 출력해 주는 람다함수 */
	function<string()> toStringLambda = [this]()
	{
		return to_string(real) + " + " + to_string(imaginary) + "i";
	};
};

참고로, this 로 모든 것을 캡쳐하는 것은 별로 좋은 방식이 아니다. 대신, static 함수를 만들고, 이를 변수로 할당해 사용하는 방법도 알아두자 😁

class Imaginary
{
  /* ... */

  static function<string(const Imaginary&)> toStringLambdaStatic;
};

/** 정적 함수로 만들고, 변수로 할당하여 사용 */
function<string(const Imaginary&)> Imaginary::toStringLambdaStatic =
[](const Imaginary& Input)
{
  return to_string(Input.real) + " + " + to_string(Input.imaginary) + "i";
};

int main()
{
  Imaginary im(1, 2);

  auto toStringLambdaLocal = Imaginary::toStringLambdaStatic;
  cout << toStringLambdaLocal(im) << endl;
}
Comments