Programming Language/C++

[Modern C++] 함수 객체와 람다식

lumana 2024. 6. 28. 23:30

함수 객체와 람다식

  • 함수 객체 : 함수처럼 작동하는 객체 (function object/functor)
  • 예제 코드를 보기 전에 알아야 하는 내용이 있다.
bool f(int x) {
    //
}

int main() {
        f(1);
}
  • f(1)에서의 소괄호 ()는 연산자임을 기억하자.
    • (물론 f는 객체가 아니긴 하지만) f.operator()(1); 이런 식으로 연산자를 호출해서 작동을 한다.
  • 그래서 소괄호 연산자에 대해서도 오버로딩을 해줄 수가 있다.
#include <iostream>
using namespace std;

class Equals {
   public:
    Equals(int value) : value(value) {}
    bool operator()(int x) const {
        return x == value;
    }

   private:
    int value;
};

int main() {
    Equals eq(123);
    cout << eq(12) << endl;
    cout << eq(123) << endl;
}
  • Equals eq(123)에서 소괄호 (123)은 소괄호 연산자가 아니라, 생성자이다.
  • eq(12)는 Equals의 소괄호 연산자()를 호출하라는 의미이다
    • 이 때 매개변수로 12를 받는다
  • 함수 객체의 좋은점 : 같은 함수 객체로 여러가지 함수를 호출할 수 있다.
class Equals {
   public:
    Equals(int value) : value(value) {}
    bool operator()(int x) const {
        return x == value;
    }

        bool operator()(int x, int y) const {
            return x + y == value
        }

   private:
    int value;
};

int main() {
    Equals eq(123);
    cout << eq(12, 111) << endl;
    cout << eq(123) << endl;
}
  • 메인함수에서 같은 객체를 통해서 서로 다른 함수를 호출하고 있다.
  • 함수 포인터랑 다른점 : 변수를 가지고 있다. (상태를 가지고 있다.)
int main() {
    Equals eq(123), eq2(234);
    cout << eq2(123) << endl; // false
    cout << eq2(234) << endl; // true
}

함수 포인터를 함수 객체로 바꾸기

#include <iostream>
using namespace std;

class Func {

};

int square(int x) { return x * x; }
int myFunc(int x) { return x * (x - 15) / 2; }

int arrFnMin(const int arr[], int n, int (*f)(int)) {
    int min = f(arr[0]);
    for (int i = 1; i < n; i++) {
        if (f(arr[i]) < min) {
            min = f(arr[i]);
        }
    }
    return min;
}

int main() {
    int arr[7] = { 3, 1, -4, 1, 5, 9, -2};
    cout << arrFnMin(arr, 7, square) << endl;
    cout << arrFnMin(arr, 7, myFunc) << endl;
}
  • 함수 포인터를 알고 있으니 int를 매개변수로 받아서 int를 리턴해주는 여러 함수를 Func이라는 하나의 클래스로 표현할 수 있을 것 같지만 불가능 하다
    • square()에 해당하는 클래스와 myFunc()에 해당하는 클래스가 따로 있어야 한다
  • 참고 : 아래 두 개 코드는 동일하게 작동한다.
class A {
        public:
        // 생략
} a;
class A {
        public:
        //생략
};

A a;
  • square()와 myFunc()를 함수 객체로 만들어 주었다.
class Square {
   public:
    int operator()(int n) { return n * n; }
} square;  // Square타입의 함수 객체 square 생성

class MyFunc {
   public:
    int operator()(int n) { return n * (n - 15) / 2; }
} myFunc;  // MyFunc타입의 함수 객체 myFunc 생성

// 두 함수를 함수 객체로 만들어준다
// int square(int x) { return x * x; }
// int myFunc(int x) { return x * (x - 15) / 2; }

int arrFnMin(const int arr[], int n, int (*f)(int)) { 
/* 생략 */
  • 그리고 나서 보니, arrFnMin의 int (*f)(int) 자리에 어떤 것을 매개변수로 받아야 할지 고민이 될 것이다.
    • Square를 넣어야 할까? MyFunc를 넣어야 할까?
    • 기존에 알던 내용을 기반으로 생각해보면, Square와 MyFunc의 부모 클래스를 만들어서 오버라이딩 해주면 된다.
    • 다른 방법에 대해서 배워보자
  • functional 라이브러리를 이용해보자
#include <iostream>
#include <functional>
using namespace std;

/* 중략 */

int arrFnMin(const int arr[], int n, function<int(int)> f) {
    int min = f(arr[0]);
    for (int i = 1; i < n; i++) {
        if (f(arr[i]) < min) {
            min = f(arr[i]);
        }
    }
    return min;
}
  • square와 myFunc 모두 int를 매개변수로 받아 int를 반환해주고 있다.
  • function<int(int)> f
    • function<int(int)>인 이유는
      • (int) : int를 매개변수로 받아
      • int를 반환
  • function<int(int)>타입인 f 객체는 int를 매개변수로 받고 int를 반환해주는 객체를 가리킬 수 있게 된다
  • 여기에 치명적인 단점이 있다
    • 성능(Performance)가 안좋아서 function이라는 클래스 템플릿은 성능상 사용하지 않는 것이 좋다
  • 더 나은 해결책을 찾아보자 : template 이용
#include <iostream>
#include <functional>
using namespace std;

class Square {
   public:
    int operator()(int n) { return n * n; }
} square;  // Square타입의 함수 객체 square 생성

class MyFunc {
   public:
    int operator()(int n) { return n * (n - 15) / 2; }
} myFunc;  // MyFunc타입의 함수 객체 myFunc 생성

template<typename T>
int arrFnMin(const int arr[], int n, T f) {
    int min = f(arr[0]);
    for (int i = 1; i < n; i++) {
        if (f(arr[i]) < min) {
            min = f(arr[i]);
        }
    }
    return min;
}

int main() {
    int arr[7] = {3, 1, -4, 1, 5, 9, -2};
    cout << arrFnMin(arr, 7, square) << endl;
    cout << arrFnMin(arr, 7, myFunc) << endl;
}
  • function<int(int)>를 T, 즉 f 자체의 임의의 타입으로 받겠다는 것이다
  • arrFnMin(arr, 7, square)를 호출하면
    • int arrFnMin(const int arr[], int n, T f)에서 T에 square가 들어간 함수가 생성되어 호출될 것이다.
      • f의 타입은 Square가 된다.
      • Square 클래스 안에 소괄호() 연산자가 존재하므로, f(arr[0]) 이런 것들이 가능해진다
  • 장점
    • 클래스 템플릿의 경우 모든 것이 컴파일 타임에 모든게 결정되므로 성능에 대한 걱정을 하지 않아도 된다. 효율적으로 작동할 것이다
    • arrFnMin(arr, 7, square)에서 굳이 세 번째 매개변수에 함수 객체를 넘기지 않고 함수 포인터를 넘길 수도 있다
      • 함수 포인터를 사용하는 경우에도 같은 템플릿을 사용해서 처리할 수 있다.
  • 아직까지 불편한 점이 아직 남아있다
    • 우리가 하고 싶은 것은 제곱(square)과 같이 특정 연산을 하는 함수를 arrFnMin 함수에 넘겨주고 싶었다
    • n을 제곱해주는 함수라는 것을 알려주기 위해서 많은 작업을 하게 된다
      • Square 라는 클래스를 만들고, 소괄호 연산자 오버로딩도 하고... 해야 한다
#include <iostream>
#include <functional>
using namespace std;

int square(int n) {return n * n;}

class MyFunc {
   public:
    int operator()(int n) { return n * (n - 15) / 2; }
} myFunc;  // MyFunc타입의 함수 객체 myFunc 생성

template<typename T>
int arrFnMin(const int arr[], int n, T f) {
    int min = f(arr[0]);
    for (int i = 1; i < n; i++) {
        if (f(arr[i]) < min) {
            min = f(arr[i]);
        }
    }
    return min;
}

int main() {
    int arr[7] = {3, 1, -4, 1, 5, 9, -2};
    cout << arrFnMin(arr, 7, square) << endl;
    cout << arrFnMin(arr, 7, myFunc) << endl;
}
  • 위에서 함수 포인터를 이용한 코드 동작 과정을 보면
    • 먼저 위에서 square 함수가 어떤 함수인지(제곱을 리턴해 주는 함수)를 먼저 알려준 다음
      • "이런 기능을 하는 함수가 있는데, 이름이 square 이다" 라고 선언하고
    • cout << arrFnMin(arr, 7, square)에서 square를 넘겨주게 된다
  • 우리는 arrFnMin(arr, 7, square) 여기에다가 이러한 기능을 하는 함수다라고 바로 알려주고 싶다.
    • 그래서 등장한게 람다식(익명 함수)이다
    • 람다식(익명 함수) : 이름이 없는 함수
      • 여기서는 square라는 이름이 없고, 제곱하는 내용만 들어있는 함수
int main() {
    int arr[7] = {3, 1, -4, 1, 5, 9, -2};
        // 람다식
    cout << arrFnMin(arr, 7, [](int n) -> int {return n * n;}) << endl;
    cout << arrFnMin(arr, 7, myFunc) << endl;
}
  • int 형 매개변수를 n을 받고
    • (int n)
  • int를 반환하는 함수이고
    • -> int
  • 함수의 내용물이 n * n을 리턴하는 함수이다.
    • {return n * n;}
  • 함수의 본문은 있지만, 이름이 없는 구조라는 걸 알 수 있다.
  • myFunc도 람다식으로 바꿔서 코드를 완성해보자
#include <functional>
#include <iostream>
using namespace std;

template <typename T>
int arrFnMin(const int arr[], int n, T f) {
    int min = f(arr[0]);
    for (int i = 1; i < n; i++) {
        if (f(arr[i]) < min) {
            min = f(arr[i]);
        }
    }
    return min;
}

int main() {
    int arr[7] = {3, 1, -4, 1, 5, 9, -2};
    cout << arrFnMin(arr, 7, [](int n) -> int { return n * n; }) << endl;
    cout << arrFnMin(arr, 7, [](int n) -> int { return n * (n - 15) / 2; }) << endl;
}
  • 람다식도 일반 함수처럼 타입이 존재하지만, 익명함수라서 밝혀주지 않는 것이다
  • 람다식을 저장하고 싶은 경우가 생길텐데, 이 때 타입에 대한 정보를 알아야 한다
    • ??? a = [](int n) -> int { return n * n; }
    • 다행이도 캡쳐가 없는 람다식은 함수 포인터로 변환이 될 수 있다.
      • 캡처 : 여기서 대괄호[] 안에 들어가는 것
      • 람다식의 자세한 문법에 대해서는 아래에서 다룰게요.
int main() {
    int arr[7] = {3, 1, -4, 1, 5, 9, -2};
    int (*fp)(int) = [](int n) -> int { return n * n; };
    cout << arrFnMin(arr, 7, [](int n) -> int { return n * n; }) << endl;
    cout << arrFnMin(arr, 7, [](int n) -> int { return n * (n - 15) / 2; }) << endl;
}
  • 캡처가 있을 때도 담고 싶으면, auto 라는 키워드를 사용할 수 있다.
    • auto는 타입을 알아서 컴파일러가 추정할 수 있게 해준다
    • ex) auto a = arr[1]이면 auto가 int로 지정된다
    • 람다 표현식으로 런타임에 생성된 객체를 Closure(클로저)라고 한다
int main() {
    int arr[7] = {3, 1, -4, 1, 5, 9, -2};
    auto fp = [](int n) -> int { return n * n; };
    cout << arrFnMin(arr, 7, [](int n) -> int { return n * n; }) << endl;
    cout << arrFnMin(arr, 7, [](int n) -> int { return n * (n - 15) / 2; }) << endl;
}

 

다음과 같이 초기화 할 수도 있다.

auto f_11{
     [](int a, int b) -> int {return a + b;}
 };

 

참조) 두들낙서 C/C++ 강좌

 

람다식 문법

[ capture clause ] (parameters) -> return-type  
{   
   definition of method   
}

 

일반적으로 단순히 값을 반환하는 경우 return-type을 적지 않아도 컴파일러가 알아서 명시해준다

하지만 조건문 등을 통해서 return 값이 복잡하게 다뤄지는 경우 return-type을 명시해줘야 한다

 

캡쳐

람다식과 일반 함수와 다른점이라면,

일반 함수는 일반적으로 함수의 scope 외부에 존재하는 변수에 대해서 접근할 수 없다.(물론 참조형 매개변수를 사용하는 경우를 제외하고 말하는 것이다)

이와 다르게 람다식은 외부 변수에 대해 접근할 수 있다. 이를 외부 변수를 캡쳐한다고 말한다. 

그리고 대괄호 [ capture clause ] 에서 어떻게 캡쳐를 할 것인지 정하게 된다

 

캡쳐 방식은 여러 개가 있다.

1. 아에 캡쳐를 하지 않는 경우

[]

2. 참조(Reference)로 모든 외부 변수를 캡쳐하는 경우

[&]

3. 값(value)로 모든 외부 변수를 캡쳐하는 경우

[=]

4. 특정 변수 A만 Reference로 캡쳐하는 경우

[&A]

5. 특정 변수 A만 Value로 캡쳐하는 경우

[A]

6. 특정 변수 A는 Reference로, 나머지는 변수는 Value로

[=, &A]

7. 특정 변수 A는 Value로, 나머지 변수는 Reference로

[&, A]

8. 특정 변수A는 Value로, 특정 변수 B는 Reference로, 나머지는 캡쳐 X

[A, &B]

9. 람다 표현식을 사용한 개체의 포인터를 가져온다

[this]

10. 람다 표현식을 사용한 개체 자체를 복사해서 가져온다(C++17)

[*this]

 

C++17까지는 =에서 this포인터를 암시적으로 가져왔지만, C++20부터는 this포인터를 명시적으로 가져와야 한다

 

람다 캡쳐 초기화

C++14부터 람다가 포함된 영역에 변수들이 존재해야 할 필요 없이 캡처 조항에 새로운 변수를 도입하고 초기화할 수 있다. 

클로저가 생성될 때 캡쳐하므로, 람다 표현식을 여러 번 호출해도 값이 유지된다