본문으로 건너뛰기

안동민 개발노트

본문 시작

함수 객체

호출 연산자와 상태를 가진 함수 객체를 만들고 표준 함수 객체·람다를 STL 알고리즘의 동작으로 전달합니다.

알고리즘들의 유연성을 더욱 높여주고, 사용자 정의 동작을 주입할 수 있게 해주는 것이 바로 함수 객체(Function Objects), 또는 줄여서 함수자(Functors)입니다.

이번 장에서는 함수 객체가 무엇인지, 왜 필요한지, 그리고 STL 알고리즘과 함께 어떻게 활용되는지 정리합니다.

또한 C++11 이후 도입된 람다(Lambda) 표현식이 함수 객체와 어떤 관계를 가지는지도 함께 다룹니다.

함수처럼 호출되는 것들은 “상태를 담을 수 있는가”로 갈린다

STL 알고리즘의 비교자·조건자·변환 함수 자리에는 여러 callable을 넣을 수 있습니다. 차이는 상태, 타입 정보, 재사용 범위입니다.

  1. 일반 함수 이름

    있고 재사용이 쉽지만 호출마다 상태를 담기 어렵습니다. bool lessAge(a,b);

  2. 함수 객체 operator()

    멤버 변수로 상태를 보관합니다. CompareBy("name")

  3. 람다 짧

    함수 객체를 즉석에서 만듭니다. 캡처로 상태를 가집니다. [limit](int x){...}

  4. std

    function 서로 다른 callable을 한 타입으로 저장하지만 간접 호출 비용이 있습니다. std::function<bool(int)>


함수 객체(Function Objects)란 무엇인가?

함수 객체는 호출 연산자, 내부 상태, 알고리즘 전달 방식, 람다와의 차이를 기준으로 읽습니다.

함수 객체는 상태를 가진 객체를 함수처럼 호출하게 만든다

operator() 를 정의하면 객체가 알고리즘의 조건자나 변환 함수로 들어갈 수 있습니다. 멤버 변수는 호출 사이에 유지되는 상태입니다.

  1. 정의 class AddAndPrint { int

    value; public: AddAndPrint(int v) : value(v) {} void operator()(int n) const { cout << n + value; } };

  2. 상태

    요소 역할 예시 상태 객체가 기억하는 값 value = 10 호출 함수처럼 실행되는 연산자 obj(3) 전달 알고리즘에 동작을 주입 for_each(..., AddAndPrint(10)) 효과 같은 로직, 다른 상태 재사용 10 더하기 / 100 더하기

요소역할예시
상태객체가 기억하는 값value = 10
호출함수처럼 실행되는 연산자obj(3)
전달알고리즘에 동작을 주입for_each(..., AddAndPrint(10))
효과같은 로직, 다른 상태 재사용10 더하기 / 100 더하기

함수 객체operator() 연산자를 오버로딩하여 함수처럼 호출될 수 있는 객체를 의미합니다.

즉, 객체이면서 동시에 함수처럼 동작하는 개체입니다.

기본 아이디어: 일반 함수나 함수 포인터를 STL 알고리즘에 전달할 수 있지만, 여기에는 몇 가지 한계가 있습니다.

상태 저장 불가: 일반 함수나 함수 포인터는 상태(멤버 변수)를 가질 수 없습니다.

특정 값을 기억하여 연산에 활용해야 하는 경우, 전역 변수를 사용하거나 클로저를 구현해야 하는 등 번거로워집니다.

타입 정보 부족: 함수 포인터는 타입 정보가 부족하여 템플릿 인스턴스화 시 컴파일러 최적화에 불리할 수 있습니다.

함수 객체는 이러한 한계를 극복합니다.

함수 객체는 클래스의 인스턴스이므로 멤버 변수를 가질 수 있어 상태를 유지할 수 있습니다.

또한 함수처럼 호출될 때 operator() 연산자가 호출되므로 일반 함수와 동일한 방식으로 사용할 수 있습니다.

함수 객체 기본 형식
class MyFunctor {
public:
    // operator() 연산자 오버로딩
    반환타입 operator()(매개변수) const {
        // 함수처럼 동작할 코드
        return 결과;
    }
    // 멤버 변수를 통해 상태 저장 가능
    // int someState;
};

// 사용 예시:
MyFunctor obj;
obj(인자); // obj.operator()(인자)와 동일하게 호출

함수 객체의 필요성 및 장점

함수 객체는 주로 STL 알고리즘에 사용자 정의 동작을 주입할 때 그 진가를 발휘합니다.

장점

상태 유지: 멤버 변수를 통해 상태를 저장하고 연산에 활용할 수 있습니다.

타입 안전성 및 최적화: 템플릿 인자로 전달될 때 컴파일러가 해당 타입을 명확히 알 수 있어 더 나은 최적화를 기대할 수 있습니다. (함수 포인터는 런타임에 주소를 찾는 오버헤드가 있을 수 있습니다.)

유연성: 다양한 방식으로 알고리즘의 동작을 커스터마이징할 수 있습니다.

강력함: 람다 표현식의 등장으로 함수 객체 작성이 더욱 간편해졌습니다.

컨테이너의 모든 요소에 특정 값을 더해서 출력하는 함수 객체를 만들어봅시다.

std::for_each와 함수 객체 사용 예시
#include <iostream>
#include <vector>
#include <algorithm> // for_each

// 요소를 더하고 출력하는 함수 객체
class AddAndPrint {
private:
    int _valueToAdd; // 상태 (멤버 변수)

public:
    // 생성자를 통해 초기 상태를 설정
    AddAndPrint(int val) : _valueToAdd(val) {}

    // operator() 오버로딩: 각 요소에 대해 호출될 함수처럼 동작
    void operator()(int n) const { // const를 붙여서 객체의 상태를 변경하지 않음을 명시
        std::cout << n + _valueToAdd << " ";
    }
};

int main() {
    std::vector<int> numbers = {1, 2, 3, 4, 5};

    std::cout << "Original numbers: ";
    for (int n : numbers) {
        std::cout << n << " ";
    }
    std::cout << std::endl;

    std::cout << "Adding 10 and printing: ";
    // AddAndPrint 객체를 생성하여 for_each에 전달
    std::for_each(numbers.begin(), numbers.end(), AddAndPrint(10));
    std::cout << std::endl; // 출력: 11 12 13 14 15

    std::cout << "Adding 100 and printing: ";
    std::for_each(numbers.begin(), numbers.end(), AddAndPrint(100));
    std::cout << std::endl; // 출력: 101 102 103 104 105

    return 0;
}

AddAndPrint 클래스는 _valueToAdd라는 멤버 변수를 가지고 있어 std::for_each가 각 요소를 처리할 때마다 이 값을 활용할 수 있습니다.

이는 일반 함수로는 직접적으로 하기 어려운 작업입니다.


STL이 제공하는 표준 함수 객체

STL은 <functional> 헤더에 미리 정의된 다양한 표준 함수 객체들을 제공합니다.

이들은 주로 연산자(+, -, *, /, <, >, == 등)를 캡슐화한 형태입니다.

산술 연산 함수 객체
  • std::plus<T>: a + b
  • std::minus<T>: a - b
  • std::multiplies<T>: a * b
  • std::divides<T>: a / b
  • std::negate<T>: -a
비교 연산 함수 객체
  • std::equal_to<T>: a == b
  • std::not_equal_to<T>: a != b
  • std::greater<T>: a > b
  • std::less<T>: a < b
  • std::greater_equal<T>: a >= b
  • std::less_equal<T>: a <= b
논리 연산 함수 객체
  • std::logical_and<T>: a && b
  • std::logical_or<T>: a || b
  • std::logical_not<T>: !a
표준 함수 객체 사용 예시
#include <iostream>
#include <vector>
#include <algorithm> // sort, transform
#include <functional> // std::greater, std::multiplies, std::plus

int main() {
    std::vector<int> numbers = {5, 1, 4, 2, 8};

    // std::sort와 std::greater를 사용하여 내림차순 정렬
    std::sort(numbers.begin(), numbers.end(), std::greater<int>());
    std::cout << "Sorted descending: ";
    for (int n : numbers) {
        std::cout << n << " ";
    }
    std::cout << std::endl; // 출력: 8 5 4 2 1

    std::vector<int> numbers2 = {1, 2, 3, 4, 5};
    std::vector<int> result_mult(numbers2.size());
    std::vector<int> result_plus(numbers2.size());

    // std::transform과 std::multiplies를 사용하여 각 요소에 2를 곱하기
    // (transform은 두 개의 입력 범위 또는 한 입력 범위와 한 상수값 사용 가능)
    std::transform(numbers2.begin(), numbers2.end(), result_mult.begin(), std::bind(std::multiplies<int>(), std::placeholders::_1, 2));
    // C++11 이전에는 std::bind1st, std::bind2nd 사용
    // C++11 이후에는 람다가 훨씬 간편

    std::cout << "Multiplied by 2: ";
    for (int n : result_mult) {
        std::cout << n << " ";
    }
    std::cout << std::endl; // 출력: 2 4 6 8 10

    // 두 벡터의 요소 더하기 (std::plus 사용)
    std::vector<int> v_a = {1, 2, 3};
    std::vector<int> v_b = {4, 5, 6};
    std::vector<int> v_sum(v_a.size());

    std::transform(v_a.begin(), v_a.end(), v_b.begin(), v_sum.begin(), std::plus<int>());
    std::cout << "Vector sum: ";
    for (int n : v_sum) {
        std::cout << n << " ";
    }
    std::cout << std::endl; // 출력: 5 7 9

    return 0;
}

람다 표현식 (C++11 이후)

C++11부터 도입된 람다 표현식은 함수 객체를 훨씬 간결하게 만들 수 있는 문법적 설탕(Syntactic Sugar)입니다.

람다는 익명(anonymous) 함수 객체를 즉석에서 생성하는 방법이며, 특히 알고리즘의 세 번째 인자로 작은 함수 객체를 전달할 때 매우 유용합니다.

람다 표현식 기본 형식
[캡처](매개변수) -> 반환타입 {
    // 함수 본문
}
  • [캡처]: 람다 외부의 변수를 람다 내부로 가져오는 방법을 지정합니다.
    • []: 아무것도 캡처하지 않음
    • [var]: var 변수를 값으로 캡처 (복사)
    • [&var]: var 변수를 참조로 캡처
    • [=]: 람다 내부에서 사용되는 모든 외부 변수를 값으로 캡처
    • [&]: 람다 내부에서 사용되는 모든 외부 변수를 참조로 캡처
    • [this]: 현재 객체의 this 포인터를 캡처
  • (매개변수): 람다 함수가 받을 매개변수 목록입니다.
  • -> 반환타입: 반환 타입입니다. 생략 가능하며, 컴파일러가 추론합니다.
  • { 함수 본문 }: 람다 함수가 수행할 코드입니다.
람다는 짧은 함수 객체를 그 자리에서 만드는 문법이다

람다는 캡처한 상태와 호출 코드를 묶어 알고리즘에 넘깁니다. 짧은 조건자나 비교자는 람다가 가장 읽기 쉽습니다.

  1. 캡처 외부 값

    람다 객체 안으로 가져옵니다. [x] 는 복사, [&x] 는 참조입니다.

  2. 매개변수 호출될 때 들어오

    값입니다. 알고리즘이 각 원소를 여기에 넣어 호출합니다.

  3. 본문 실제

    수행할 코드입니다. 비교자라면 bool , 변환이라면 새 값을 반환합니다.

람다 표현식 사용 예시
#include <iostream>
#include <vector>
#include <algorithm> // for_each, sort

int main() {
    std::vector<int> numbers = {1, 2, 3, 4, 5};

    int valueToAdd = 10; // 외부 변수

    std::cout << "Adding " << valueToAdd << " and printing (lambda): ";
    // 람다를 사용하여 AddAndPrint 함수 객체 역할을 대체
    std::for_each(numbers.begin(), numbers.end(), [valueToAdd](int n){ // valueToAdd를 값으로 캡처
        std::cout << n + valueToAdd << " ";
    });
    std::cout << std::endl; // 출력: 11 12 13 14 15

    std::cout << "-------------------------------------------\n";

    std::vector<std::string> names = {"Alice", "Bob", "Charlie", "David"};

    // 길이가 5보다 긴 이름만 출력 (람다 사용)
    std::cout << "Names longer than 5: ";
    std::for_each(names.begin(), names.end(), [](const std::string& name){
        if (name.length() > 5) {
            std::cout << name << " ";
        }
    });
    std::cout << std::endl; // 출력: Charlie David

    // 복잡한 정렬 조건 (람다 사용)
    std::vector<Person> people = {
        {"Alice", 30},
        {"Charlie", 25},
        {"Bob", 35},
        {"Amy", 25}
    };

    std::cout << "Original people:\n";
    for (const auto& p : people) {
        std::cout << p << " ";
    }
    std::cout << std::endl;

    // 나이가 같으면 이름으로, 다르면 나이로 정렬
    std::sort(people.begin(), people.end(), [](const Person& p1, const Person& p2){
        if (p1.age != p2.age) {
            return p1.age < p2.age; // 나이가 다르면 나이 기준
        }
        return p1.name < p2.name; // 나이가 같으면 이름 기준
    });
    std::cout << "Sorted by age then name:\n";
    for (const auto& p : people) {
        std::cout << p << " ";
    }
    std::cout << std::endl; // 출력: [Amy, 25] [Charlie, 25] [Alice, 30] [Bob, 35]

    return 0;
}

람다는 특히 짧은 함수 객체가 필요한 경우에 매우 강력하고 편리합니다.

복잡한 함수 객체는 여전히 클래스로 정의하는 것이 더 명확할 수 있습니다.

동작의 수명과 재사용 범위에 따라 표현을 고른다

알고리즘에 넘길 동작은 상태, 이름, 재사용성으로 고른다.

  1. operator()

    상태 있는 객체 호출

  2. lambda

    짧은 조건 즉석 작성

  3. standard functor

    less/greater 재사용

  4. std::function

    보관과 타입 소거

구분판단 기준다음 행동
일반 함수상태가 없고 여러 곳에서 재사용이름 있는 함수로 분리
함수 객체호출 사이 상태를 들고 있어야 함멤버 변수 + operator()
람다짧은 조건/비교를 호출 지점 옆에 둠캡처 범위와 수명 확인
std::function서로 다른 callable을 저장해야 함타입 소거 비용 확인

함수 객체는 operator()와 람다, 표준 함수 객체가 알고리즘에 전달되는 방식을 보여 줍니다.

함수 객체와 람다는 알고리즘에 “동작”을 주입하는 객체다

비교자, 조건자, 변환 함수는 모두 알고리즘의 빈칸을 채우는 callable입니다. 상태가 필요하면 객체가 되고, 짧으면 람다로 씁니다.

  1. 조건자 predicate 원소

    남길지 판단합니다. [](int x){ return x > 0; }

  2. 비교자 comparator 두 원소의 순서

    정합니다. [](a,b){ return a.age < b.age; }

  3. 변환 함수 transformer 입력 원소

    새 값으로 바꿉니다. [](int x){ return x * x; }