안동민 개발노트 아이콘

안동민 개발노트

12장 : 표준 템플릿 라이브러리

함수 객체

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

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

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


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

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

함수 객체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 포인터를 캡처
  • (매개변수): 람다 함수가 받을 매개변수 목록입니다.
  • -> 반환타입: 반환 타입입니다. 생략 가능하며, 컴파일러가 추론합니다.
  • { 함수 본문 }: 람다 함수가 수행할 코드입니다.
람다 표현식 사용 예시
#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;
}

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

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

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