Modern C++/Algorithm

[C++11 ~] Predicate와 Functor 그리고 Lamda Expression

dev-ohdam 2024. 11. 14. 00:27

 C++ 언어를 활용해 코딩 테스트 문제를 풀거나 프로젝트를 진행하는 과정에서 유용한 기능을 얻기 위해 <algorithm> 헤더를

활용해야 하는 상황이 자주 발생한다.

 

이때 이 함수들을 효과적으로 활용하기 위해선 Predicate에 관해 제대로 알고 넘어가야 한다. 

 오늘은 이 Predicate에 관한 내용과 Predicate를 구현하기 위한 다양한 방법인 Functor 와 Lamda에 관해 자세하게 다뤄볼 것이다.

0. Predicate

 Predicate라는게 뭘까?라는 질문을 받는다면, 나는 간단하게 한 문장으로 표현할 것이다.

어떠한 조건을 판별하는 함수처럼 동작하는 것들. "함수 처럼 동작하는 것들"라고 다소 추상적으로 표현했는데, 그 이유는 Predicate를 작성할 때 Fuctor, Lamda, 함수 정의 등 다양한 Callable Object로 구현하기 때문이다.

 

 Predicate를 대표적으로 활용하는 경우는 std::find_if 혹은 std::count_if와 같은 std::ooo_if 시리즈나 

대표적인 정렬 함수인 std::sort가 있을 것이다.

 

 각 알고리즘 함수 별로 요구되는 조건 판별 방식이 다르기 때문에, Predicate를 구현할 땐, 조건 판별에 필요한 매개 변수와 연산을 고려해 작성해주어야 한다. 

std::find_if는 함수 명에서 대충 예측할 수 있듯이, 항등 연산(==)을 사용해 Predicate를 구현해야 한다.

std::count_if 선언부

 

1. Callable Unit VS Callable Object

 

 이전에 thread와 관련된 내용을 다루며 Callable Unit이라는 용어를 사용했는데, 사실 이 표현을 실제로 자주 사용하진 않는다. 

필자는 Callable Unit의 의미를 함수 혹은 함수나 메소드처럼 호출될 수 있는 모든 객체들을 호칭하는 광범위한 개념으로서

사용한다. 하지만 좀 더 정확한 표현은 Callable Object가 맞다.

 

 오늘 설명할 Lamda와 Functor는 모두 이 Callable Object에 해당한다.

2. Functor

 Functor는 보통 객체 지향 프로그래밍에서 함수처럼 호출될 수 있는 객체들을 말한다.

우리가 보통 함수를 호출할 때는 "함수명()" 형태로 코드를 작성한다. 이와 마찬가지로 어떤 객체에 대해, 호출 연산자( () )를 통해 특정한 기능을 호출할 수 있다면 그 객체는 Functor이다. 

 

 구현하는 방법은 정말 간단하다. 호출 연산자를 오버로딩한 클래스를 구현하면 된다.

대표적인 Functor로는 greater와 less가 있다.(std::sort를 오름차순 내림차순으로 정렬하고자 할 때, Predicate에 넣는다.)

 

class Functor
{
	bool operator()(auto arg) 
    {
    	std::cout << "나는 Functor 다.\n";
    }
}

 

vector<int> range{ 2,1,4,3,5 };
std::sort(begin(range), end(range), greater<int>{});
// sort 내부에서 인자로 넘겨준 greater<int> 객체의 ()연산자를 호출한다.

3. 이름 없는 Functor(Functoid),  Lamda 표현식

 그런데 Functor의 형태로 구현하는 건 다소 불편하다. 왜? 어떤 Predicate를 작성하기 위해선 내가 기능에 맞는 적절한 클래스 이름을 구상해줘야 한다. 그뿐만 아니라, 그리고 호출 연산자 오버로딩도 선언해줘야 한다. 무엇보다 코드 작성에도 많은 텍스트 공간을 차지한다. 즉 배보다 배꼽이 더 큰 상황이 (Functor를 활용하는 것보다 Functor 구현에 더 많은 자원을 투자) 발생한다. 그래서 C++11부터 등장한 개념이 Lamda Expression(이하 Lamda)이다.

 

  아래 코드만 비교해 봐도 얼마나 간편해졌는지 알 수 있다.

(Lamda에 관한 문법은 아래에서 다루겠다.)

[num = 2](auto elm) { return (elm % num == 1);}; // lamda expression
class IsOdd 
{
	int num = 2;
public:
	bool operator()(auto elm) 
    {
      return (elm % num == 1);
    }
} // functor implement

 

3 - 1. (중요) Lamda 함수는 잘못된 표현

 다른 블로그를 보면 Lamda 함수라고 보편적으로 표현하는데, Lamda는 함수의 형태를 띌 뿐, 함수가 아니다.

 

 오히려 Functor에 가깝다. Lamda 식을 작성하면 컴파일러에 의해 Functor와 같은 형태로 정의되고 생성된다. 이때 우리는 Lamda에 이름을 붙이지 않음에도 불구하고, 컴파일러가 알아서 고유한 식별자를 부여한다. 그리고 우리가 작성한 람다 식의 Body 내용은 Functor의 호출 연산자 오버로딩에 inline 형태로 작성된다. 

 

그래서 필자는 Lamda 함수라는 표현은 매우 부적절하다고 생각한다. 그렇기 때문에 그냥 Lamda라고 호칭하겠다. 

참고로 Functoid(Functor + oid)는 정식 표현은 아니고, 보다 쉬운 비유를 위해 사용하는 비공식 표현이다. Functor와 비슷한 것

정도로 해석하면 될 것 같다. 그리고 대제목에 표현했 듯, 그냥 이름 없는 Functor라고 생각해도 무방하다.

 

3 - 2.  Lamda 표현식의 변화 과정 (C++11 ~ C++17)

 Lamda는 C++11부터 등장했기 때문에 그 변화 과정을 알 필요가 있다. 왜냐면 간혹 일부 회사에서는 Legacy 코드를 사용하는

경우도 있기 때문이다. (특히, 게임 회사의 경우 하나의 프로젝트를 장기간 서비스하는 경우가 많기 때문에 더욱 그렇다.)

 

 C++11에 등장한 Lamda는 다소 제약 사항이 많이 존재한다.

일단 기본적으로 모든 C++ 버전의 Lamda는 컴파일러가 자동으로 타입을 유추해 준다. 다만 C++11의 Lamda는 단일문의 경우에만 가능하다. 하지만 다소 복잡한 정의를 가진 Lamda 식의 경우(Body에 다중 분기를 가진 경우), 다 void로 처리한다. 

 그런 경우 ->반환타입을 명시해 주면 반환값을 적절하게 처리할 수 있다.

auto lamda11 = []()->bool { if(...) return false; else return true;};

 

 C++14는 이런 부분을 다소 보완하여 단일문이 아니더라도 타입을 유추할 수 있다.

다만 모든 분기에 관해 반환되는 값의 타입이 동일해야 한다. (특정 분기에선 bool을 다른 분기에선 int를 반환해선 안된다는 뜻.)

 

auto lamda14 = []() { if(...) return true; else return false;} // ->반환타입이 더 이상 필요하지 않음.

 

C++17부터는 각 분기에 관해 다양한 타입들을 반환할 수 있다.

 

3 - 3. Lamda Capture - Value & Reference

 앞서 필자가 언급했던 Lamda와 Functor의 유사성을 떠올린다면 사실 Lamda Capture를 이해하는 것도 순조롭다.

Capture 기능은 Lamda의 내부 정의에서 static, global 변수를 제외한 나머지 지역 변수들에 관해 접근할 수 없기 때문에 등장한 r기능이다. 

 

  Lamda 표현식에서 대괄호 ([]) 부분에 & 혹은 = 를 사용해 외부의 지역변수를 캡처해 올 수 있다. = 는 value의 형태로, &는  reference의 형태로 캡처해 온다. 만약 & 또는 = 을 사용하지 않고 변수를 캡처 해온다면 기본적으론 value의 형태로 캡처해온다.

 

 아래 코드를 통해 캡처의 기본적인 사용 예시를 확인해 볼 수 있다.

int x = 100; // original x

auto defaultCapture =      [x]() {std::cout << x << "\n" }; // default: capture by value. x = 100
auto captureByValue =      [=x]() { std::cout << x << "\n"}; // capture by value. x = 100
auto captureByValueAlias = [innerX=x]() {std::cout << innerX << "\n"};
auto captureByReference =  [&x]() {std::cout << ++x << "\n"}; // capture by reference. x = 101


auto captureByValueAll =     [=]() {/*모든 지역변수를 value 형태로 캡처.*/};
auto captureByReferenceAll = [&]() {/*모든 지역변수를 reference 형태로 캡처.*/};

 

 

 value 형태로 캡처한다면, 원본값의 복사본이 생성되며 Body에서는 복사본을 사용한다. 아까 언급했던 컴파일러의 Lamda 처리 방식을 생각하면, Functor 내부에 데이터 멤버를 하나 추가하는 것과 동일하다. [innerX=x]처럼 별칭도 만들어 줄 수 있다.

 

(아래 pusedo code를 확인하면 이해가 쉬울 것이다.)

class captureByValue
{
	int innerX;
public:
	captureByValue(int x) : innerX(x) {}
    void operator() const { std::cout << innerX << "\n"; }
} // how the compiler create functor

 

 이때 innerX(= 캡처된 값)는 const 속성을 가지며 수정될 수 없다. mutable 키워드를 통해 값을 변화시킬 수 있다. 하지만 이는

복사된 값이기 때문에 원본값에 영향을 주진 않는다.  

int x = 100;
auto lm = [innerX = x]() mutable { ++innerX; }; 

lm(); // x: 100, innerX: 101
lm(); // x: 100, innerX: 102

 

 여기서 주의해야 할 점은 innerX는 Functor 내부의 데이터멤버이기 때문에, 매 호출마다 진행되는 연산에 대해 그 결괏값이 유지가 된다.

 

reference 형태로 캡처하면, 당연히 Body에 구현된 내용에 따라 원본 값이 변형될 수 있다. 그렇기 때문에 주의해야 한다.

 

3 - 4 Lamda Capture - This

 이건 최근에 공부하며 새롭게 학습하게 된 개념인데, 클래스와 함께 Lamda를 활용하는 경우, this 캡처를 통해 클래스 멤버 데이터를 참조 형태로

활용할 수 있다.

 

 만약 capture by value를 원한다면 *this로 캡처하면 된다. 마찬가지로 원본 클래스 데이터 멤버에 아무 영향을 끼치지 않는다.

 

 필자는 아직까진 게임 프로그래밍이나 코딩 테스트에서 어떻게 활용할 수 있을진 감이 잡히진 않는다. 하지만 뭔가 어떤 플레이어의 이벤트 액션을 스케쥴링하거나, 게임 서버의 비동기 처리를 수행하는 과정에서 유용하게 활용할 수 있을 것 같다. 아래는 게임 서버에서 게임 오브젝트의 액션을 스케쥴링하는 코드를 대충 구상해본 것이다. 사실 실제로 이렇게 구현하진 않을 것 같다.

class GameServer 
{ 
public:
 GameServer() 
 { 
  // 플레이어 이벤트 핸들러 초기화 
  playerActions["attack"] = [this](int playerId) { this->handleAttack(playerId); };
  // this 캡처를 통해 클래스의 private 멤버 함수인 handleAttack에 접근하는 모습을 볼 수 있음.
 }
 void handleEvent(const std::string& action, int playerId)
 { 
   if (playerActions.find(action) != playerActions.end())
   { 
    playerActions[action](playerId); 
   } 
 } 
private:
 void handleAttack(int playerId) 
 { 
  std::cout << "Player " << playerId << " attacks!" << std::endl; // 공격 로직 처리 
 } 
 std::unordered_map<std::string, std::function<void(int)>> playerActions; 
};

 

마무리

 아무튼 Predicate를 제대로 활용하기 위한 목적을 시작으로, Lamda, Functor에 관해 다시 한번 쭉 복습을 진행해 보았다.

그리고 새롭게 알게 됐었던 Lamda this Capture에 관해 게임 프로그래밍과 연관되어 생각해보기도 했다. 

 

 이 글을 보는 학습자도 코딩 테스트를 위해 알고리즘 함수들을 공부하며 직접 활용해 보거나, 본인들이 진행하는 프로젝트에 실제로 적용해 보며 활용하면 아마 더 빨리 익숙해질 수 있을 것이다.