C++/Advanced C++

[Functional C++] 함수형 프로그래밍 (1) 불변성과 순수 함수

로파이 2022. 10. 3. 23:00

C++으로 함수형 프로그래밍을 작성하는 방법을 알아본다.

 

함수형 프로그래밍이 필요한 이유

 

짝수의 합, 홀수의 합, 모든 합을 구하는 예시 프로그램

struct Sums {
	int evenSum;
	int oddSum;
	int totalSum;
};

const Sums sums(const vector<int>& numbers) {
	Sums theTotals;
	for (auto iter = numbers.begin(); iter != numbers.end(); ++iter){
		int number = *iter;
		if (number % 2 == 0) theTotals.evenSum += number;
		if (number % 2 == 1) theTotals.oddSum += number;
		theTotals.totalSum += number;
	}

	return theTotals;
}

numbers 컨테이너를 순회하면서 if 문을 통해 값을 조건 검사하고 각 합계를 구하는 변수에 더하고 있다.

 

복잡한 for 루프와 각 루프 내에서 어떤 일을 하고 있는 지 코드 외견상 명확하지 않다. 단지 멤버 변수에 number 값을 더하는 연산을 봄으로써 로직이 어떠한 합계를 구하고 있다는 것을 알게 될 것이다.

 

좀 더 부분적으로 기능을 추상화하여 중복 코드를 제거하고 가독성을 높이고 단순화할 수 는 없을까

template<class UnaryPredicate>
const vector<int> filter(const vector<int>& input, UnaryPredicate filterFunction)
{
	vector<int> filtered;
	copy_if(input.begin(), input.end(), back_inserter(filtered), filterFunction);

	return filtered;
}

const int sum(const vector<int>& input) {
	return accumulate(input.begin(), input.end(), 0);
}

static bool isEven(int n) { return n % 2 == 0; }
static bool isOdd(int n) { return n % 2 == 1; }

const Sums sumsWithFunctionalLoopsSimplified(const vector<int>& numbers) {
	Sums theTotals;
	theTotals.evenSum = sum(filter(numbers, isEven));
	theTotals.oddSum = sum(filter(numbers, isOdd));
	theTotals.totalSum = sum(numbers);

	return theTotals;
}

위와 같이 새롭게 기능을 분리하고 루프문을 제거한 코드로 재탄생을 시킬 수 있다. 컨테이너의 원소를 조건적으로 filter하여 그 결과를 sum으로 받아 최종 합계 Sums를 결과로 내보낸다. 물론, C++ STL 라이브러리의 copy_if, accumulate 등 함수형 프로그래밍을 위한 고급 함수를 사용한 부분도 있다.

 

함수형 프로그래밍의 특징

 

불변성 immutable

함수형 프로그래밍의 특징으로 다음과 같은 특징을 갖는 것을 불변성이라고 한다.

 

 1. 함수는 반환하기 전까지 함수 인자를 변화시키지 않는다.
 2. 함수는 이 클래스에 속한 멤버 변수 데이터를 변화시키지 않는다.

 

vs OOP (Object Oriented Programming) 프로그래밍

기존 객체 지향 프로그래밍에서 보았던 특징은 크게 다음과 같다.

  • 캡슐화
  • 상속성
  • 다형성

그 중 상속이라는 특징은 큰 함수, 거대 클래스,깊은 상속 관계로 인해 유지보수하기 어려운 (어디를 고쳐야할 지 모르겠는) 혹은 새로운 기능을 추가하기가 굉장히 까다롭게 복잡한 구조를 가지게 되었다는 것이다. 

 

이로 인해 상속과 같은 객체 관계를 끊어내는 것 혹은 약하게 만드는 것이 중요해졌고 그 중 '인터페이스'는 객체간의 결합도를 느슨하게 하기 위한 매우 추상화된 상속 방법으로 생각되었다.

 

함수형 프로그래밍은 객체 지향 프로그래밍에서 가지고 있던 객체간 결합도를 끊어내기 위해 객체의 기능을 불변성을 가지는 함수로 분리해낸다.

 

모듈 확장성과 중복 제거

처음 예시 코드에서 루프를 순회하면서 컨테이너의 각 원소의 값을 조건 검사하여 if 분기마다 합계를 구했던 로직은

  1. filter : 컨테이너의 원소 중 조건에 맞는 원소로 이루어진 새로운 컨테이너를 만든다.
  2. sum : 컨테이너의 모든 원소의 합을 구한다.
  3. sums : input(int 형 vector)로부터 짝수 합, 홀수 합, 전체 합을 구하는 함수

로 기능적으로 분리되어 거대했던 기존 sums로직이 간결한 코드를 가지게 되었고 filter와 sum과 같은 함수는 재사용 가능성이 높은 함수로 재탄생하였다. 

 

순수 함수 pure function

1. 동일한 인자 값을 넣으면 항상 동일한 결과 값을 반환한다.
2. 부작용이 없다.

 

LightBulb라는 객체를 받아 스위치를 키고 끄는 함수를 예시로 든다. 순수 함수가 아닌 것들을 먼저 보자

// switchOn이라는 파라미터가 명시적이지 않다.
void switchLightVague(LightBulb light) {
	if (switchOn) light.turnOff();
	else light.turnOn();
}

위 함수는 switchOn이라는 변수에 의해 내용이 결정되고 있지만 함수의 파라미터로 사용되고 있지 않다.

// 여전히 LightBulb 객체의 상태를 변화시킨다.
void switchLightSideEffect(bool switchOn, LightBulb light) {
	if (switchOn) light.turnOff();
	else light.turnOn();
}

이제 switchOn이라는 변수에 의해 light의 상태가 변경된다. 하지만 그 값에 의해 LightBulb 객체 상태가 함수 내부에서 변경되고 있는 부작용을 가지고 있다.

LightState signalForBulb(bool switchIsOn) {
	return switchIsOn ? LightState::TurnOff : LightState::TurnOn;
}
// 스위치가 켜지거나 꺼지거나 하는 상황을 알린다.
sendSingalToLightBulb(signalForBulb(switchIsOn))

따라서 내부 상태를 직접 변경하지 말고 결과를 반환함으로써 LightBulb 상태를 직접 변경하지 말아야한다. 여기서는 더 이 상 LightBulb 객체는 쓰이지 않는다.

 

순수 함수를 C++으로 작성하는 방법

  • 순수 함수는 부작용이 없다. 순수 함수가 클래스 일부라면 static 혹은 const가 된다.
  • 순수 함수는 파라미터를 변화시키지 않는다. 모든 파라미터는 const, const&, const*const
  • 순수 함수는 항상 값을 반환한다. 출력 파라미터를 통해서도 값을 반환할 수 있다.

순수 함수 작성

static으로 작성한 방법

class Number {
public:
	// 인자가 없는 순수 함수
	static int zero() { return 0; }
	// 인자를 하나 갖는 순수 함수
	static int increment(const int value) { return value + 1; }
	// 인자를 둘 이상 갖는 순수 함수
	static int add(const int first, const int second) { return first + second; }
	// 포인터 타입에 대하여
	static int incrementValueFromPointer(const int* const value) { return *value + 1; }
};

 

다음 함수는 순수 함수가 아니다.

// value를 증가시킬 뿌만 아니라 입력 파라미터도 변경하고 있다.
// 여전히 부작용이 존재한다.
static int increment2(int value) { return ++value; }

입력 파라미터 값을 변경하고 있기 때문이다. 포인터 변수를 매개변수로 받을 때도 포인터 자체를 변경하지 않도록 주의한다.

 

대체적으로 const 참조 매개변수를 받는 const 함수를 사용하는 방법과 static 함수로 표현하는 방법이 있다.

// 동일한 입력에 항상 동일한 결과 값이 반환된다.
// 부작용이 없다.
// 입력 파라미터 값을 변경하지 않는다.
class PureFunction {
public:
	// 값에 의한 전달 클래스
	static int increment(const int value);
	int increment(const int value) const;

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

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

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

 

람다

C++에서 함수형 프로그래밍을 작성하는 강력한 방법은 람다를 사용하는 것이다.

람다는 입력 매개변수를 통해 함수를 구성하는 것 뿐만아니라 선언된 스코프에서 ;'캡처 가능한' 지역변수를 캡처하여 함수 내에서 사용하는 것이 가능하다.

 

람다의 원형

auto hello = []() { cout << "hello world !" << endl; };
hello();

람다는 []의 표현으로 같은 스코프에서 보이는 변수를 값 혹은 참조로 캡처할 수 있다. 캡처한다는 의미는 해당 변수를 람다 함수 내용에 사용할 수 있다는 것이다.

()의 표현으로는 입력 매개변수를 사용할 수 있다.

 

값으로 캡처한 변수

int value = 1;
// 값 복사에 의해 캡쳐한 변수는 변경할 수 없다.
auto increment = [=]() { return ++value; }

위 [=]의 뜻은 모든 보이는 지역 변수를 값으로 캡처한다는 뜻이다. 참조에 의한 캡처는 [&]를 사용한다.

값으로 캡처한 변수는 함수 내에서 변경할 수 없으므로 위 코드는 컴파일 에러를 나오게 한다.

함수형 프로그래밍에서 참조에 의한 캡처는 변수의 상태를 변경시키므로 불변성을 위반할 수 있기 때문에 값에 의한 참조를 주로 사용한다.

 

입력 매개변수의 사용

함수에서 const 참조를 사용하는 것을 지향했던 것 처럼 람다에서도 불변성을 지키도록 해준다.

auto inc1 = [](const int value) { return value + 1;  }; // 변수 값
auto inc2 = [](const int& value) { return value + 1; }; // 변수 참조
auto inc3 = [](const int* const value) { return *value + 1; }; // 포인터 값
auto inc4 = [](const int* const& value) { return *value + 1; }; // 포인터 참조

 

람다를 클래스 내에서 사용하기

class ImaginaryNumber {
private:
	int real;
	int imaginary;
public:
	ImaginaryNumber() : real(0), imaginary(0) {};
	ImaginaryNumber(int real, int imaginary) : real(real), imaginary(imaginary) {}

ImaginaryNumber 클래스에 대해 real과 imaginary 변수를 가지고 문자열로 정보를 출력하는 람다 함수를 다양한 방식으로 만들어본다.

 

멤버 변수로 사용하기 (std::function<T>이라는 functional 헤더에 포함된 함수 객체를 사용했다.)

	// 데이터 멤버를 람다로
	function<string()> toStringLambda = [this]() {
		return to_string(real) + " + " + to_string(imaginary) + "I";
	};

 

정적 변수로 사용하기

	// 정적 변수를 람다로 활용하기
	static function<string(const ImaginaryNumber&)> toStringLambdaStatic;
};    
//...
function<string(const ImaginaryNumber&)>
ImaginaryNumber::toStringLambdaStatic = [](const ImaginaryNumber& number) {
	return to_string(number.real) + " + " + to_string(number.imaginary) + "i";
}

 

정적 함수를 람다로 변환하여 반환하기

	// 정적 함수를 람다로 변환하기
	static string toStringStatic(const ImaginaryNumber& number) {
		return to_string(number.real) + " + " + to_string(number.imaginary) + "i";
	}

	string toStringUsingLambda() {
		auto toStringLambdaLocal = ImaginaryNumber::toStringStatic;
		return toStringLambdaLocal(*this);
	}

 

 

여전히 람다 함수는 ImaginaryNumber라는 클래스를 매개변수로 받아 결합도가 존재한다. 완전한 자유를 얻으려면 ImaginaryNumber를 표현하는 real과 imaginary 값만 받아 문자열로 출력하는 함수로 재설계해야할 것이다.

// ImaginaryNumber 클래스와 커플링을 제거한 코드
auto toImaginaryString = [](auto real, auto imaginary) {
	return to_string(real) + " + " + to_string(imaginary) + "i";
}