본문 바로가기

함수 객체

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

앞 글에서 std::sort에 비교 함수를, std::count_if에 조건을 넘겼습니다. 알고리즘은 원소를 순회하는 일을 맡고, 각 원소에 무엇을 할지는 이렇게 넘겨받은 호출 가능한 것이 정합니다. 함수 대신 함수처럼 호출할 수 있는 객체를 넘길 수도 있는데, 이것을 함수 객체(function object) 또는 펑터(functor)라고 합니다.

이 글은 함수 객체를 만드는 방법, 상태를 가진 함수 객체가 알고리즘 안에서 복사되는 방식, 표준 라이브러리가 제공하는 함수 객체, 그리고 함수 객체를 간단히 만드는 람다와 여러 종류의 호출 가능한 것을 담는 std::function을 다룹니다.

함수 객체란

클래스에 함수 호출 연산자 operator()를 정의하면, 그 클래스의 객체를 obj(인자)처럼 함수처럼 호출할 수 있습니다. 연산자 오버로딩 기초에서 본 연산자 함수의 하나이며, 보통의 함수와 달리 멤버 변수에 설정을 담아 둘 수 있습니다.

add_and_print.cpp
#include <algorithm>
#include <iostream>
#include <vector>

class AddAndPrint {
private:
    int valueToAdd;

public:
    explicit AddAndPrint(int v) : valueToAdd(v) {}

    void operator()(int n) const {
        std::cout << n + valueToAdd << ' ';
    }
};

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

    std::for_each(numbers.begin(), numbers.end(), AddAndPrint(10));
    std::cout << '\n';
    std::for_each(numbers.begin(), numbers.end(), AddAndPrint(100));
    std::cout << '\n';
}
11 12 13 14 15
101 102 103 104 105

AddAndPrint(10)은 더할 값 10을 기억하는 객체를 만들고, std::for_each가 원소마다 이 객체를 호출합니다. 같은 클래스로 더할 값만 바꾼 객체를 여러 개 만들 수 있으므로, 전역 변수 없이 설정을 바꿀 수 있습니다. 원소를 값으로 받아 출력할 뿐이므로 numbers의 원소는 바뀌지 않습니다.

함수 객체는 타입마다 서로 다른 클래스이므로, 알고리즘 템플릿에 넘기면 컴파일러가 어떤 operator()를 부를지 정확히 압니다. 그래서 호출을 본문으로 펼치는 최적화가 쉬운 편입니다. 함수 포인터도 컴파일러가 대상을 알면 같은 최적화를 할 수 있지만, 실행 중에 어떤 함수를 가리킬지 정해진다면 그러기 어렵습니다.

상태를 가진 함수 객체

함수 객체는 호출될 때마다 멤버를 바꿔 상태를 쌓을 수도 있습니다. 하지만 알고리즘은 함수 객체를 값으로 받으므로, 상태가 쌓이는 것은 넘긴 객체가 아니라 그 복사본입니다.

stateful_sum.cpp
#include <algorithm>
#include <iostream>
#include <vector>

struct Sum {
    int total = 0;
    void operator()(int n) { total += n; }
};

int main() {
    std::vector<int> values{1, 2, 3};
    Sum initial;
    Sum result = std::for_each(values.begin(), values.end(), initial);
    std::cout << initial.total << ' ' << result.total << '\n';
}
0 6

아래 그림처럼 std::for_each는 initial의 복사본을 받아, 원소 1, 2, 3을 차례로 처리하며 복사본의 total을 1, 3, 6으로 늘립니다. 원래의 initial은 0 그대로이고, std::for_each가 마지막에 돌려주는 복사본을 result로 받아야 6을 얻을 수 있습니다. 넘긴 객체에 직접 쌓고 싶다면 std::ref(initial)처럼 <functional>의 참조 래퍼로 감싸 넘깁니다.

알고리즘에 넣을 callable은 상태와 보관 계약으로 고른다

순차 for_each의 Sum 복사본이 1→3→6으로 누적되고 initial은 0으로 남는 새 본문 실험과 연결되어 복사본 상태 변화가 핵심이다.

C++ · CALLABLE CHOICE

알고리즘은 순회하고 callable은 동작을 정의한다

상태·재사용·수명·저장 경계를 기준으로 호출 가능한 표현을 고른다. 함수 객체와 lambda는 본문의 예시이며, 일반 함수와 std::function은 선택 기준을 넓힌 비교이다.

순차 for_each의 복사된 함수 객체 상태와 원래 객체를 구분한다새본문Sum예제5노드4간선. 초기Sum0→값매개변수로복사된Sum0→원소1,2,3의순차호출로상태1,3,6→반환객체6. 초기객체에서별도간선은그대로0인원객체로향한다. original이6으로바뀌는간선이나반환값이원본상태를다시덮는간선은없다. executionpolicy없는해당for_each예제에한정하며기존4호출가능타입비교는보조설명으로그대로보존한다.원래 Sum 객체initial.total = 0값 매개변수의 복사순차 for_each에 전달복사본 total = 0각 원소로 호출1 → 2 → 3total: 1 → 3 → 6반환받은 객체result.total = 6원래 객체는 유지initial.total = 0별도 객체execution policy 없는 예제모든 알고리즘의 보편 규칙 아님
순차 for_each의 복사된 함수 객체 상태와 원래 객체를 구분한다새본문Sum예제5노드4간선. 초기Sum0→값매개변수로복사된Sum0→원소1,2,3의순차호출로상태1,3,6→반환객체6. 초기객체에서별도간선은그대로0인원객체로향한다. original이6으로바뀌는간선이나반환값이원본상태를다시덮는간선은없다. executionpolicy없는해당for_each예제에한정하며기존4호출가능타입비교는보조설명으로그대로보존한다.원래 Sum 객체initial.total = 0값 매개변수의 복사순차 for_each에 전달복사본 total = 0각 원소로 호출1 → 2 → 3total: 1 → 3 → 6반환받은 객체result.total = 6원래 객체는 유지initial.total = 0별도 객체execution policy 없는 예제모든 알고리즘의 보편 규칙 아님

일반 함수 · 상태 없는 재사용

이름 있는 함수를 여러 알고리즘에 전달한다.

void printNumber(int n);

함수 자체는 인스턴스별 멤버 상태를 갖지 않는다.

함수 객체 · 이름 있는 상태

operator()와 멤버로 정책과 상태를 묶는다.

AddAndPrint(10)

반복되는 정책에 이름을 주고 상태를 보관한다.

lambda · 호출 지점의 지역 동작

알고리즘 옆에 짧은 동작을 적고 필요하면 값을 캡처한다.

[valueToAdd](int n) {
  std::cout << n + valueToAdd;
}

참조 캡처는 참조 대상의 수명을 확인한다.

std::function · 공통 저장 경계

서로 다른 복사 가능한 callable을 같은 함수 서명으로 보관한다.

std::function<void(int)>

type erasure가 필요한 저장 경계에서 비용을 검토한다.

즉시 호출에는 구체 타입을 유지할 수 있다. 참조 캡처나 보관은 callable과 참조 대상의 수명을 함께 확인한다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
원 도표의 제목·노드·관계·조건·주석
범위원문
관계·조건 설명새 본문의 Sum 예제는 5개 노드와 4개 간선으로 이루어진다. 초기 Sum 0→값 매개변수로 복사된 Sum 0→원소 1,2,3의 순차 호출로 상태 1,3,6→반환 객체 6으로 이어진다. 초기 객체에서 별도 간선은 그대로 0인 원래 객체로 향한다. original이 6으로 바뀌는 간선이나 반환값이 원본 상태를 다시 덮는 간선은 없다. execution policy가 없는 해당 for_each 예제에 한정하며 기존 4가지 호출 가능 타입 비교는 보조 설명으로 그대로 보존한다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
함수 객체 복사

initial의 복사본으로 순차 for_each를 시작한다.

  • initial.total: 0
  • 작업 복사본.total: 0
  • 알고리즘은 값으로 전달받은 함수 객체에 상태를 누적한다.
호출 누적

복사본이 1·2·3을 차례로 처리한다.

  • 작업 복사본.total: 1 → 3 → 6
  • initial.total: 0
  • 각 호출은 해당 복사본의 total에 입력 값을 더한다.
결과 객체 반환

알고리즘의 반환값을 result로 받는다.

  • result.total: 6
  • initial.total: 0
  • 반환되는 것은 누적한 객체이며 원래 initial을 참조로 바꾼 것이 아니다.

알고리즘에 넣을 callable은 상태와 보관 계약으로 고른다

순차 for_each의 Sum 복사본이 1→3→6으로 누적되고 initial은 0으로 남는 새 본문 실험과 연결되어 복사본 상태 변화가 핵심이다.

C++ · CALLABLE CHOICE

알고리즘은 순회하고 callable은 동작을 정의한다

상태·재사용·수명·저장 경계를 기준으로 호출 가능한 표현을 고른다. 함수 객체와 lambda는 본문의 예시이며, 일반 함수와 std::function은 선택 기준을 넓힌 비교이다.

순차 for_each의 복사된 함수 객체 상태와 원래 객체를 구분한다새본문Sum예제5노드4간선. 초기Sum0→값매개변수로복사된Sum0→원소1,2,3의순차호출로상태1,3,6→반환객체6. 초기객체에서별도간선은그대로0인원객체로향한다. original이6으로바뀌는간선이나반환값이원본상태를다시덮는간선은없다. executionpolicy없는해당for_each예제에한정하며기존4호출가능타입비교는보조설명으로그대로보존한다.원래 Sum 객체initial.total = 0값 매개변수의 복사순차 for_each에 전달복사본 total = 0각 원소로 호출1 → 2 → 3total: 1 → 3 → 6반환받은 객체result.total = 6원래 객체는 유지initial.total = 0별도 객체execution policy 없는 예제모든 알고리즘의 보편 규칙 아님
순차 for_each의 복사된 함수 객체 상태와 원래 객체를 구분한다새본문Sum예제5노드4간선. 초기Sum0→값매개변수로복사된Sum0→원소1,2,3의순차호출로상태1,3,6→반환객체6. 초기객체에서별도간선은그대로0인원객체로향한다. original이6으로바뀌는간선이나반환값이원본상태를다시덮는간선은없다. executionpolicy없는해당for_each예제에한정하며기존4호출가능타입비교는보조설명으로그대로보존한다.원래 Sum 객체initial.total = 0값 매개변수의 복사순차 for_each에 전달복사본 total = 0각 원소로 호출1 → 2 → 3total: 1 → 3 → 6반환받은 객체result.total = 6원래 객체는 유지initial.total = 0별도 객체execution policy 없는 예제모든 알고리즘의 보편 규칙 아님

일반 함수 · 상태 없는 재사용

이름 있는 함수를 여러 알고리즘에 전달한다.

void printNumber(int n);

함수 자체는 인스턴스별 멤버 상태를 갖지 않는다.

함수 객체 · 이름 있는 상태

operator()와 멤버로 정책과 상태를 묶는다.

AddAndPrint(10)

반복되는 정책에 이름을 주고 상태를 보관한다.

lambda · 호출 지점의 지역 동작

알고리즘 옆에 짧은 동작을 적고 필요하면 값을 캡처한다.

[valueToAdd](int n) {
  std::cout << n + valueToAdd;
}

참조 캡처는 참조 대상의 수명을 확인한다.

std::function · 공통 저장 경계

서로 다른 복사 가능한 callable을 같은 함수 서명으로 보관한다.

std::function<void(int)>

type erasure가 필요한 저장 경계에서 비용을 검토한다.

즉시 호출에는 구체 타입을 유지할 수 있다. 참조 캡처나 보관은 callable과 참조 대상의 수명을 함께 확인한다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.

관계도의 보충 설명

원 도표의 제목·노드·관계·조건·주석
범위원문
관계·조건 설명새 본문의 Sum 예제는 5개 노드와 4개 간선으로 이루어진다. 초기 Sum 0→값 매개변수로 복사된 Sum 0→원소 1,2,3의 순차 호출로 상태 1,3,6→반환 객체 6으로 이어진다. 초기 객체에서 별도 간선은 그대로 0인 원래 객체로 향한다. original이 6으로 바뀌는 간선이나 반환값이 원본 상태를 다시 덮는 간선은 없다. execution policy가 없는 해당 for_each 예제에 한정하며 기존 4가지 호출 가능 타입 비교는 보조 설명으로 그대로 보존한다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
함수 객체 복사

initial의 복사본으로 순차 for_each를 시작한다.

  • initial.total: 0
  • 작업 복사본.total: 0
  • 알고리즘은 값으로 전달받은 함수 객체에 상태를 누적한다.
호출 누적

복사본이 1·2·3을 차례로 처리한다.

  • 작업 복사본.total: 1 → 3 → 6
  • initial.total: 0
  • 각 호출은 해당 복사본의 total에 입력 값을 더한다.
결과 객체 반환

알고리즘의 반환값을 result로 받는다.

  • result.total: 6
  • initial.total: 0
  • 반환되는 것은 누적한 객체이며 원래 initial을 참조로 바꾼 것이 아니다.

함수 객체를 돌려준다는 것은 실행 정책 없이 호출한 std::for_each의 규칙입니다. std::remove_if 같은 다른 알고리즘은 조건 객체를 몇 번 복사할지 정해 두지 않으므로, 호출 횟수에 따라 결과가 달라지는 상태를 조건에 두면 안 됩니다. 조건과 비교 함수는 같은 인자에 늘 같은 결과를 내도록 상태 없이 만드는 것이 원칙입니다.

표준 함수 객체

<functional> 헤더에는 기본 연산자를 감싼 함수 객체가 준비되어 있습니다.

종류함수 객체하는 일
산술std::plus, std::minus, std::multiplies, std::divides, std::negatea + b, a - b, a * b, a / b, -a
비교std::less, std::greater, std::equal_to, std::not_equal_to 등a < b, a > b, a == b, a != b
논리std::logical_and, std::logical_or, std::logical_nota && b, a || b, !a
standard_function_objects.cpp
#include <algorithm>
#include <functional>
#include <iostream>
#include <vector>

int main() {
    std::vector<int> numbers{5, 1, 4, 2, 8};
    std::sort(numbers.begin(), numbers.end(), std::greater<int>{});
    for (int n : numbers) { std::cout << n << ' '; }
    std::cout << '\n';

    std::vector<int> a{1, 2, 3};
    std::vector<int> b{4, 5, 6};
    std::vector<int> sum(a.size());
    std::transform(a.begin(), a.end(), b.begin(), sum.begin(), std::plus<int>{});
    for (int n : sum) { std::cout << n << ' '; }
    std::cout << '\n';
}
8 5 4 2 1
5 7 9

std::greater<int>{}는 큰 값이 앞에 오도록 비교하므로 내림차순으로 정렬합니다. 두 범위를 받는 std::transform은 a와 b의 원소를 짝지어 std::plus로 더한 결과를 sum에 씁니다. C++14부터는 std::greater<>{}처럼 타입을 비워 두면 두 인자의 타입을 그대로 받아 비교하는 버전이 쓰입니다.

람다 표현식

함수 객체를 쓸 때마다 클래스를 정의하는 것은 번거롭습니다. C++11의 람다 표현식은 그 자리에서 이름 없는 함수 객체를 만들며, 컴파일러가 operator()를 가진 클래스를 대신 만들어 줍니다. 대괄호 안에 람다 밖의 변수를 적으면 그 변수를 함수 객체의 멤버로 담는데, 이를 캡처(capture)라고 합니다.

lambda_capture.cpp
#include <algorithm>
#include <iostream>
#include <vector>

int main() {
    std::vector<int> numbers{1, 2, 3, 4, 5};
    int valueToAdd = 10;
    std::for_each(numbers.begin(), numbers.end(),
                  [valueToAdd](int n) { std::cout << n + valueToAdd << ' '; });
    std::cout << '\n';

    int offset = 10;
    auto byValue = [offset](int n) { return n + offset; };
    auto byReference = [&offset](int n) { return n + offset; };
    offset = 20;
    std::cout << byValue(1) << ' ' << byReference(1) << '\n';
}
11 12 13 14 15
11 21

첫 람다는 AddAndPrint(10)과 같은 일을 클래스 정의 없이 합니다. [offset]은 람다를 만들 때 offset의 값 10을 복사해 담고, [&offset]은 offset 변수 자체를 참조로 담습니다. 그래서 offset을 20으로 바꾼 뒤 호출하면 값으로 담은 쪽은 10을, 참조로 담은 쪽은 20을 더합니다. 참조로 담은 람다는 그 변수보다 오래 남으면 허상 참조가 되므로, 람다를 저장해 둘 때는 주의합니다. 캡처의 여러 형태는 람다 표현식에서 자세히 다룹니다.

호출 가능한 것을 담아 두기

함수, 함수 포인터, 함수 객체, 람다는 모두 호출할 수 있지만 타입은 제각각입니다. 서로 다른 타입의 호출 가능한 것을 같은 타입의 변수나 컨테이너에 담으려면 <functional>의 std::function을 씁니다.

std_function.cpp
#include <functional>
#include <iostream>
#include <vector>

int twice(int n) { return n * 2; }

int main() {
    int bonus = 5;
    std::vector<std::function<int(int)>> ops{
        twice,
        [](int n) { return n * n; },
        [bonus](int n) { return n + bonus; },
    };

    for (const auto& op : ops) {
        std::cout << op(3) << ' ';
    }
    std::cout << '\n';
}
6 9 8

std::function<int(int)>는 int를 받아 int를 돌려주는 호출 가능한 것이면 무엇이든 담을 수 있어, 보통 함수와 두 람다를 한 vector에 함께 넣었습니다. 대신 std::function은 담은 대상의 실제 타입을 감추기 때문에 호출할 때 간접 호출을 거치고, 큰 대상을 담으면 동적 할당이 일어날 수 있습니다. 그래서 알고리즘에 바로 넘길 때는 람다나 함수 객체를 그대로 넘기고, 콜백을 저장해 두어야 할 때 std::function을 씁니다.

더 깊이: std::function의 한계와 C++23, C++26의 대안

std::function은 담은 대상을 복사할 수 있어야 하므로, 복사할 수 없는 호출 가능한 객체는 담지 못합니다. 예를 들어 std::unique_ptr나 std::packaged_task를 이동해 담은 람다는 이동만 할 수 있어서, std::function에 넣으면 컴파일 오류가 납니다. 또 비어 있는 std::function을 호출하면 std::bad_function_call 예외를 던집니다.

C++23의 std::move_only_function은 이 제한을 없앤 래퍼로, 자기 자신은 이동만 할 수 있는 대신 복사할 수 없는 대상도 담습니다. std::move_only_function<void() const noexcept>처럼 함수 서명에 const나 noexcept 같은 한정자를 적을 수 있으며, 비어 있는 것을 호출하면 예외 대신 정의되지 않은 동작이 됩니다. cppreference의 C++23 지원표에 따르면 libstdc++는 GCC 12부터 이 타입을 제공합니다.

C++26에는 복사할 수 있는 대상만 담되 같은 한정자를 적을 수 있는 std::copyable_function과, 대상을 소유하지 않고 가리키기만 하는 std::function_ref가 더해졌습니다. std::function_ref는 대상을 복사하거나 할당하지 않으므로 콜백을 받는 함수의 매개변수에 알맞지만, 가리키는 대상이 쓰는 동안 살아 있어야 한다는 점은 참조와 같습니다.

정리

  • 함수 객체는 operator()를 정의해 함수처럼 호출할 수 있는 객체이며, 멤버에 설정이나 상태를 담을 수 있습니다.
  • 알고리즘은 함수 객체를 값으로 받아 복사본을 쓰므로, 상태는 넘긴 객체가 아니라 복사본에 쌓입니다.
  • 순차 std::for_each는 쓴 함수 객체를 돌려주지만, 다른 알고리즘의 조건과 비교 함수는 상태 없이 만듭니다.
  • <functional>에는 std::plus, std::greater 같은 표준 함수 객체가 있습니다.
  • 람다는 그 자리에서 함수 객체를 만들며, 값 캡처는 만들 때의 값을, 참조 캡처는 원래 변수를 씁니다.
  • 서로 다른 호출 가능한 것을 한 타입으로 담을 때는 std::function을 쓰고, 알고리즘에는 람다를 그대로 넘깁니다.

연습 문제

1. 다른 값과 참조 래퍼

stateful_sum.cpp의 values를 {4, 5, 6}으로 바꾸면 출력은 무엇인가요? 세 번째 인자를 std::ref(initial)로 바꾸면 initial.total은 얼마가 되나요?

해설 보기

values를 바꾸면 복사본이 4, 9, 15로 쌓이므로 출력은 0 15입니다. std::ref(initial)로 넘기면 알고리즘이 복사하는 것은 initial을 가리키는 참조 래퍼이고 호출은 initial 자체에 일어나므로, initial.total도 15가 됩니다.

2. 캡처한 뒤 바뀐 변수

int base = 1; auto f = [base](int n) { return n + base; }; base = 100; 다음에 f(1)은 무엇을 돌려주나요? 캡처를 [&base]로 바꾸면 어떻게 되나요?

해설 보기

[base]는 람다를 만들 때의 값 1을 복사해 두었으므로 f(1)은 2입니다. [&base]로 바꾸면 호출할 때 base 변수를 읽으므로 101을 돌려줍니다.

3. 상태를 가진 조건

세 번째 원소만 지우려고 int calls = 0;을 참조로 캡처한 조건 [&calls](int) { return ++calls == 3; }을 std::remove_if에 넘기는 것은 좋은 방법인가요?

해설 보기

좋은 방법이 아닙니다. 조건의 결과가 몇 번째 호출인지에 따라 달라지는데, 표준은 std::remove_if가 조건을 원소 수만큼 부른다고 정할 뿐 조건 객체를 자유롭게 복사해도 된다고 허용합니다. 그래서 상태에 기대는 조건은 읽기도 검증하기도 어렵고, calls를 값으로 캡처했다면 복사본마다 횟수가 따로 세어질 수도 있습니다. 특정 위치의 원소를 지우려면 v.erase(v.begin() + 2)처럼 위치로 지웁니다.

이것으로 12장의 표준 템플릿 라이브러리를 마칩니다. 다음 장의 예외 처리의 기본 개념에서는 지금까지 반환값으로 알렸던 오류를, 정상 흐름과 분리해 전달하는 예외를 다룹니다.