C++ 함수 객체(펑터): 상태를 가진 호출 객체, STL 알고리즘 활용, 람다와의 차이

이 글의 핵심

람다가 나온 뒤에도 함수 객체가 쓰이는 이유는 타입에 이름을 붙여 재사용하거나 템플릿 인자로 넘기기 쉽기 때문입니다. 다만 STL 알고리즘은 펑터를 값으로 받아 복사하는 경우가 많아, 무거운 멤버를 가진 펑터는 성능을 떨어뜨리고 상태를 가진 펑터는 의도치 않은 복사로 버그를 만들기 쉽습니다. 비교, 필터, 누적기, 변환기 예제와 표준 함수 객체, 통계 수집과 캐싱 패턴까지 정리했습니다.

함수 객체란?

함수 객체(Functor)는 operator()를 오버로드한 클래스입니다. 함수처럼 호출할 수 있으며, 상태를 유지할 수 있어 일반 함수보다 유연합니다.

#include <iostream>
using namespace std;

// 타입 정의
struct Adder {
    int operator()(int a, int b) const {
        return a + b;
    }
};

int main() {
    Adder add;

    cout << add(2, 3) << endl;  // 5
    cout << add(10, 20) << endl;  // 30
}

함수 객체를 쓰는 이유는 크게 세 가지입니다. 멤버 변수에 상태를 담아 호출 사이에 유지할 수 있고, 호출 대상이 타입으로 정해져 있어 컴파일러가 인라인하기 쉬우며, sort·find_if·transform 같은 STL 알고리즘에 그대로 넘길 수 있습니다. 아래는 상태를 전역 변수에 둘 수밖에 없는 일반 함수와, 객체 안에 상태를 가진 함수 객체를 비교한 코드입니다.

// 일반 함수: 상태를 함수 바깥(전역)에 둬야 함
int counter = 0;
void increment() {
    counter++;
}

// 함수 객체: 상태를 객체 안에 유지
class Counter {
    int count_ = 0;
public:
    int operator()() {
        return ++count_;
    }
};

Counter c;
c();  // 1
c();  // 2

함수 포인터와의 차이는 성능에서 주로 드러납니다. std::sort에 함수 포인터를 넘기면 템플릿은 “bool(*)(int,int) 타입의 포인터”로 인스턴스화되므로, 포인터 값이 무엇인지는 타입에 담기지 않습니다. 컴파일러가 상수 전파로 포인터 값을 추적하지 못하면 비교는 간접 호출(indirect call)로 남고, 인라이닝과 그에 따른 최적화가 막힙니다. 반면 함수 객체는 operator()가 호출되는 타입이 컴파일 타임에 이미 확정되어 있으므로(템플릿 인자나 정적 타입으로), 컴파일러가 함수 본문을 호출 지점에 직접 삽입하는 인라이닝을 적극적으로 수행할 수 있습니다. std::sort처럼 비교 함수를 원소 수에 비례해 많이 호출하는 알고리즘에서는 이 차이가 눈에 띄는 성능 차이로 이어질 수 있습니다. 다만 최적화 컴파일러가 함수 포인터 호출도 인라인하는 경우가 있으므로, 실제 차이는 프로파일러로 확인하는 편이 정확합니다.

int addFunc(int a, int b) { return a + b; }

// 함수 포인터: 간접 호출이 될 수 있음
int (*funcPtr)(int, int) = addFunc;
funcPtr(2, 3);

// 함수 객체: 인라인 가능
Adder adder;
adder(2, 3);  // 인라인 가능

상태 유지

함수 객체가 일반 함수보다 유연한 가장 큰 이유는 호출 사이의 상태를 자연스럽게 유지할 수 있다는 점입니다. 일반 함수는 상태를 기억하려면 전역 변수나 static 지역 변수처럼 함수 바깥의 공유 상태에 의존해야 하지만, 함수 객체는 멤버 변수에 상태를 담고 있으므로 인스턴스별로 독립적인 상태를 가질 수 있습니다. 아래 Counter는 호출될 때마다 내부 count를 증가시키고, getCount()로 현재까지 누적된 호출 횟수를 조회할 수 있습니다. 같은 클래스로 여러 인스턴스를 만들면 각각 독립적으로 카운트를 유지하므로, 전역 카운터처럼 여러 호출자가 상태를 공유해 생기는 버그를 피할 수 있습니다.

#include <iostream>
using namespace std;

class Counter {
private:
    int count = 0;

public:
    int operator()() {
        return ++count;
    }

    int getCount() const {
        return count;
    }
};

int main() {
    Counter counter;

    cout << counter() << endl;  // 1
    cout << counter() << endl;  // 2
    cout << counter() << endl;  // 3

    cout << "총 호출: " << counter.getCount() << endl;
}

STL 알고리즘

STL 알고리즘 대부분은 조건이나 연산을 함수 객체(또는 함수 포인터, 람다)로 받도록 설계되어 있습니다. 순회 같은 뼈대는 알고리즘이 맡고, 무엇을 세거나 지울지 판단하는 로직만 호출자가 넘기는 구조입니다. 아래 IsEven은 정수가 짝수인지 판별하는 술어(predicate) 함수 객체입니다. count_if에 넘기면 조건을 만족하는 원소 개수를 세고, remove_if에 넘기면 조건을 만족하지 않는 원소들을 앞쪽으로 당겨 채운 뒤 새 논리적 끝을 반환하므로, 그 위치부터 erase하면 짝수가 실제로 제거됩니다(erase-remove 관용구). remove_if가 원소를 뒤로 “몰아내는” 것은 아니며, 반환된 위치 뒤쪽 값은 지정되지 않은 상태라 읽으면 안 됩니다. 같은 IsEven 인스턴스를 여러 알고리즘에서 공유할 수 있다는 점이 람다를 매번 새로 쓰는 것과 비교해 재사용성 측면의 장점입니다.

#include <algorithm>
#include <iostream>
#include <vector>

using namespace std;

struct IsEven {
    bool operator()(int x) const {
        return x % 2 == 0;
    }
};

int main() {
    vector<int> v = {1, 2, 3, 4, 5, 6};

    // count_if
    int evenCount = count_if(v.begin(), v.end(), IsEven());
    cout << "짝수 개수: " << evenCount << endl;

    // remove_if
    v.erase(remove_if(v.begin(), v.end(), IsEven()), v.end());

    for (int x : v) {
        cout << x << " ";  // 1 3 5
    }
}

실전 예시

아래 네 가지 예시는 정렬 기준, 필터링, 누적 집계, 값 변환에 함수 객체를 적용한 것입니다.

예시 1: 비교 함수

std::sort의 세 번째 인자로 비교 함수 객체를 넘기면 원하는 기준으로 정렬할 수 있습니다. CompareByAge와 CompareByName처럼 정렬 기준별로 별도의 함수 객체를 만들어두면, 같은 데이터를 여러 기준으로 정렬해야 할 때 호출부에서 원하는 객체만 골라 넘기면 되므로 코드가 명확해집니다. 비교 함수 객체는 반드시 엄격한 약순서(strict weak ordering)를 만족해야 합니다. a < b처럼 같은 값끼리 비교하면 false를 내야 하며, a <= b처럼 같은 값에 true를 반환하는 비교자를 sort에 넘기면 정의되지 않은 동작이 되고, 실제로 범위 밖을 읽어 크래시가 나기도 합니다.

#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
using namespace std;

struct Person {
    string name;
    int age;
};

struct CompareByAge {
    bool operator()(const Person& a, const Person& b) const {
        return a.age < b.age;
    }
};

struct CompareByName {
    bool operator()(const Person& a, const Person& b) const {
        return a.name < b.name;
    }
};

int main() {
    vector<Person> people = {
        {"Charlie", 30},
        {"Alice", 25},
        {"Bob", 35}
    };

    // 나이순 정렬
    sort(people.begin(), people.end(), CompareByAge());

    for (const auto& p : people) {
        cout << p.name << " (" << p.age << ")" << endl;
    }
}

예시 2: 필터

RangeFilter는 생성자에서 최소·최대 범위를 받아 멤버로 저장해 두고, operator()가 호출될 때마다 그 범위 안에 값이 있는지 검사합니다. 이렇게 “생성 시점에 파라미터를 고정하고, 호출 시점에는 그 파라미터로 판단만 수행”하는 구조는 람다의 캡처와 본질적으로 같은 역할을 하지만, 함수 객체는 타입이 명시적이라 함수 시그니처나 템플릿 파라미터로 이름을 그대로 쓸 수 있다는 차이가 있습니다. 이 예제는 같은 filter 인스턴스를 find_if와 수동 순회 양쪽에서 재사용합니다.

#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;

class RangeFilter {
private:
    int min, max;

public:
    RangeFilter(int min, int max) : min(min), max(max) {}

    bool operator()(int value) const {
        return value >= min && value <= max;
    }
};

int main() {
    vector<int> v = {1, 5, 10, 15, 20, 25, 30};

    // 10-20 범위
    RangeFilter filter(10, 20);

    auto it = find_if(v.begin(), v.end(), filter);
    if (it != v.end()) {
        cout << "첫 번째 매칭: " << *it << endl;  // 10
    }

    // 모두 찾기
    for (int x : v) {
        if (filter(x)) {
            cout << x << " ";
        }
    }
}

예시 3: 누적기

std::for_each는 순회하며 각 원소에 대해 함수 객체를 호출한 뒤, 그 함수 객체를 값으로 반환한다는 특징이 있습니다. Accumulator는 이 특징을 이용해 순회하는 동안 내부 상태(sum)를 누적시키고, 순회가 끝난 뒤 반환된 객체에서 getSum()으로 최종 결과를 꺼내는 패턴을 보여줍니다. 이는 std::accumulate로도 구현할 수 있지만, 합계 외에 여러 통계를 동시에 계산해야 할 때는 이렇게 상태를 가진 함수 객체 하나로 여러 값을 한 번의 순회에서 모으는 편이 더 효율적입니다.

#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;

class Accumulator {
private:
    int sum = 0;

public:
    void operator()(int value) {
        sum += value;
    }

    int getSum() const {
        return sum;
    }
};

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

    Accumulator acc = for_each(v.begin(), v.end(), Accumulator());

    cout << "합계: " << acc.getSum() << endl;  // 15
}

예시 4: 변환기

std::transform은 입력 범위의 각 원소에 함수 객체를 적용한 결과를 출력 범위에 써 넣는 알고리즘입니다. Multiplier는 생성자에서 배율(factor)을 받아 저장해 두고, 호출될 때마다 그 배율을 곱한 값을 반환합니다. Multiplier(10)처럼 즉석에서 임시 객체를 만들어 바로 넘길 수 있다는 점에서 사용성은 람다와 거의 비슷하지만, 함수 객체는 이렇게 클래스로 정의해 두면 다른 배율로도 얼마든지 재사용할 수 있는 범용 부품이 된다는 차이가 있습니다.

#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;

class Multiplier {
private:
    int factor;

public:
    Multiplier(int f) : factor(f) {}

    int operator()(int value) const {
        return value * factor;
    }
};

int main() {
    vector<int> v = {1, 2, 3, 4, 5};
    vector<int> result(v.size());

    transform(v.begin(), v.end(), result.begin(), Multiplier(10));

    for (int x : result) {
        cout << x << " ";  // 10 20 30 40 50
    }
}

표준 함수 객체

매번 IsEven이나 CompareByAge 같은 함수 객체를 직접 정의할 필요는 없습니다. <functional> 헤더는 사칙연산, 비교 연산 등 자주 쓰이는 연산을 이미 함수 객체로 구현해 제공합니다. std::plus<int>()는 두 값을 더하는 함수 객체로 accumulate와 함께 쓰면 단순 합산을 명시적으로 표현할 수 있고, std::greater<int>()는 < 대신 > 비교를 수행하므로 sort에 넘기면 오름차순 대신 내림차순으로 정렬됩니다. 이런 표준 함수 객체를 활용하면 사소한 연산 하나를 위해 별도의 구조체를 만드는 수고를 덜 수 있습니다.

C++14부터는 타입 인자를 비운 std::less<>, std::greater<>, std::plus<>처럼 쓰는 투명(transparent) 함수 객체를 권장합니다. operator()가 템플릿이라 인자 타입을 호출 시점에 추론하므로, greater<int>를 쓰다가 원소 타입을 long long으로 바꿨을 때 비교 과정에서 값이 잘리는 실수를 막아 줍니다. 더 실용적인 이점은 연관 컨테이너의 이종 조회(heterogeneous lookup)입니다. std::map<std::string, int, std::less<>>로 선언하면 m.find("key")를 호출할 때 문자열 리터럴로 임시 std::string을 만들지 않고 바로 비교하므로, 조회가 잦은 코드에서 불필요한 할당이 사라집니다. 기본값인 std::less<std::string>에서는 매번 임시 문자열이 생성됩니다.

#include <functional>
#include <algorithm>
#include <iostream>
#include <numeric>
#include <vector>
using namespace std;

int main() {
    vector<int> v = {3, 1, 4, 1, 5};

    // plus
    int sum = accumulate(v.begin(), v.end(), 0, plus<int>());
    cout << sum << endl;  // 14

    // greater (내림차순)
    sort(v.begin(), v.end(), greater<int>());

    for (int x : v) {
        cout << x << " ";  // 5 4 3 1 1
    }
}

함수 객체 vs 람다

C++11 이후 람다 표현식이 도입되면서, 간단한 함수 객체는 대부분 람다로 대체할 수 있게 되었습니다. 실제로 컴파일러는 람다를 operator()를 가진 익명 클래스(클로저 타입)로 변환하기 때문에, 성능 측면에서는 둘 사이에 근본적인 차이가 없습니다. 아래 코드는 같은 동작을 하는 Adder 함수 객체와 람다를 나란히 보여주는데, 캡처하는 상태가 단순하고 로직이 짧다면 람다 쪽이 훨씬 간결합니다. 다만 여러 곳에서 재사용해야 하거나, 템플릿 인자로 타입 이름을 명시적으로 지정해야 하는 경우에는 여전히 이름 있는 함수 객체 클래스가 유리합니다.

std::sort에 람다, 함수 객체, 함수 포인터, std::function을 넘긴 시간 비교

그림: 실측 결과 — 람다와 함수 객체는 타입에 호출 대상이 박혀 있어 인라인되지만, 함수 포인터와 std::function은 간접 호출이 남아 같은 정렬이 더 느리다.

#include <iostream>
using namespace std;

// 함수 객체
struct Adder {
    int factor;
    Adder(int f) : factor(f) {}
    int operator()(int x) const { return x + factor; }
};

// 람다 (간결)
auto adder = [factor = 10](int x) { return x + factor; };

int main() {
    Adder add10(10);
    cout << add10(5) << endl;  // 15

    cout << adder(5) << endl;  // 15
}

정리하면, 함수 객체는 타입에 이름이 있어 헤더에 두고 여러 곳에서 재사용하거나 템플릿 인자·컨테이너의 비교자 타입으로 쓰기 쉽고, 멤버 변수와 접근 함수로 상태를 드러내기도 좋습니다. 람다는 쓰는 자리에서 바로 정의하고 캡처로 주변 변수를 가져올 수 있어, 한 번 쓰고 마는 짧은 로직에 더 잘 맞습니다.

자주 발생하는 문제

문제 1: const 누락

STL 알고리즘은 종종 함수 객체를 값으로 전달받아 내부에서 const 참조나 복사본으로 다룹니다. 이때 operator()에 const가 붙어 있지 않으면 const 객체나 const 참조를 통해 호출하는 곳에서 컴파일 오류가 납니다. 상태를 변경하지 않는 함수 객체라면 operator()를 const로 선언하는 것이 관례입니다.

// const 없음: std::set의 비교자로 쓰면 컴파일 에러
struct ByAbs {
    bool operator()(int a, int b) {
        return std::abs(a) < std::abs(b);
    }
};
std::set<int, ByAbs> s;  // s.find(3) 등에서 const 호출이 필요

// const 추가
struct ByAbsFixed {
    bool operator()(int a, int b) const {
        return std::abs(a) < std::abs(b);
    }
};

이 문제가 실제 에러로 드러나는 대표적인 곳은 std::set·std::map의 비교자입니다. 연관 컨테이너는 find() 같은 const 멤버 함수 안에서도 비교자를 호출해야 하므로, 비교자의 operator()가 const가 아니면 libstdc++에서는 static assertion failed: comparison object must be invocable as const 같은 메시지가 나옵니다(표준 라이브러리 버전에 따라 템플릿 에러로 길게 나오기도 합니다). std::sort처럼 비교자를 복사해서 비const로 호출하는 알고리즘에서는 컴파일이 되기 때문에, 같은 펑터가 sort에서는 되고 set에서는 안 되는 이유가 여기에 있습니다.

const 없는 operator() 비교자를 std::set에 쓰면 컴파일 오류가 나고 std::sort에서는 통과하는 실행 결과

그림: 실제 실행 결과 — operator()에 const가 없으면 std::set에서는 const ByValue를 호출할 수 없다는 컴파일 오류가 나고, 같은 펑터가 std::sort에서는 그대로 동작한다. 오류 문구는 표준 라이브러리마다 다르다(libc++은 no matching function, libstdc++은 static assertion). (환경: macOS 27.0.1 · Apple clang 21.0.0 (libc++) · -std=c++20)

문제 2: 상태 변경

operator()를 const로 유지하면서도 내부 상태를 변경해야 하는 경우가 있습니다. 캐싱된 결과를 갱신하거나, Counter처럼 호출 횟수를 세는 로직이 대표적인 예입니다. 이럴 때는 논리적으로는 객체가 상수처럼 보이지만 실제로는 내부 구현 세부사항이 바뀌는 멤버에 mutable 키워드를 붙여, operator()의 const성을 유지하면서도 해당 멤버만 예외적으로 수정할 수 있게 합니다. 이는 “논리적 상수성(logical constness)“과 “비트 단위 상수성(bitwise constness)“을 구분하는 C++의 대표적인 관용구입니다.

// operator()는 const로 두고, 바뀌는 멤버만 mutable
class Counter {
private:
    mutable int count = 0;

public:
    int operator()() const {
        return ++count;
    }
};

상태를 가진 펑터에는 더 근본적인 함정이 있습니다. 알고리즘이 펑터를 값으로 받아 복사하기 때문에, 원본 객체의 상태는 바뀌지 않습니다. Counter c; std::generate_n(out, 3, c); 뒤에 c의 카운트를 확인해 보면 여전히 0입니다. 알고리즘 안에서 증가한 것은 복사본이기 때문입니다. for_each가 펑터를 반환값으로 돌려주는 것도 이 복사본의 최종 상태를 꺼낼 수 있게 하려는 설계입니다.

더 위험한 경우는 술어(predicate)에 상태를 두는 것입니다. 예를 들어 “세 번째 원소만 지우는” 펑터를 호출 횟수로 구현해 remove_if에 넘기면, 구현에 따라 술어가 내부에서 한 번 더 복사되어 카운트가 처음부터 다시 시작되고 세 번째와 여섯 번째 원소가 함께 지워질 수 있습니다. 표준은 알고리즘이 술어를 몇 번 복사할지 보장하지 않으므로, 술어는 같은 입력에 항상 같은 결과를 내는 순수 함수여야 한다는 것이 오래된 권고(Effective STL 항목 39)입니다. 이 버그는 한 표준 라이브러리에서는 우연히 맞게 동작하다가 다른 구현에서 결과가 달라지는 식으로 드러나기 때문에 찾기가 어렵습니다. 상태가 꼭 필요하다면 상태를 펑터 바깥에 두고 참조로 가리키거나(std::ref, 참조 캡처 람다), 인덱스 기반 반복문으로 바꾸는 편이 안전합니다.

문제 3: 복사 비용

STL 알고리즘의 많은 구현은 함수 객체를 값으로 전달받아 내부에서 필요할 때마다 복사합니다. 함수 객체가 std::vector처럼 큰 데이터를 멤버로 갖고 있으면, 알고리즘이 내부적으로 복사를 할 때마다 그 큰 데이터도 함께 복사되어 예상치 못한 성능 저하를 일으킬 수 있습니다. std::ref(또는 std::cref)로 감싸서 넘기면 실제 복사되는 것은 참조 래퍼(가벼운 포인터 수준)뿐이므로, 무거운 상태를 가진 함수 객체를 다룰 때는 이 패턴을 기억해 둘 필요가 있습니다.

#include <functional>  // std::ref

// 큰 멤버를 가진 펑터: 알고리즘에 값으로 넘기면 data까지 복사됨
struct HeavyFunctor {
    vector<int> data;

    void operator()(int x) const {
        // data를 참조해 x를 처리
    }
};

HeavyFunctor heavy;
// std::ref로 감싸면 복사되는 것은 reference_wrapper뿐
for_each(v.begin(), v.end(), ref(heavy));

실무 패턴

패턴 1: 통계 수집

Statistics는 앞서 본 누적기 패턴을 확장해, 개수·합계·최솟값·최댓값을 한 번의 순회로 동시에 계산합니다. for_each가 순회 후 함수 객체를 반환한다는 특징을 이용해, 별도의 전역 변수나 참조 캡처 없이도 여러 통계량을 하나의 객체 안에 안전하게 모을 수 있습니다. 데이터를 통계마다 따로 순회하는 것보다 메모리를 한 번만 읽으므로 큰 데이터에서 유리합니다.

#include <climits>   // INT_MAX, INT_MIN

class Statistics {
    int count_ = 0;
    int sum_ = 0;
    int min_ = INT_MAX;
    int max_ = INT_MIN;

public:
    void operator()(int value) {
        count_++;
        sum_ += value;
        min_ = std::min(min_, value);
        max_ = std::max(max_, value);
    }

    double average() const {
        return count_ > 0 ? static_cast<double>(sum_) / count_ : 0.0;
    }

    int min() const { return min_; }
    int max() const { return max_; }
    int count() const { return count_; }
};

// 사용
std::vector<int> data = {10, 20, 30, 40, 50};
Statistics stats = std::for_each(data.begin(), data.end(), Statistics());

std::cout << "평균: " << stats.average() << '\n';  // 30
std::cout << "최소: " << stats.min() << '\n';      // 10
std::cout << "최대: " << stats.max() << '\n';      // 50

패턴 2: 조건부 변환

ConditionalTransform은 조건을 검사하는 술어와 실제 변환을 수행하는 함수를 각각 std::function으로 받아 멤버에 저장해 두었다가, 호출 시점에 조건이 참일 때만 변환을 적용하는 함수 객체입니다. 이렇게 두 개의 콜러블(callable)을 조합해 하나의 함수 객체를 구성하는 방식은 전략 패턴(Strategy Pattern)을 함수 객체 수준에서 구현한 것으로 볼 수 있으며, 조건과 변환 로직을 서로 다른 조합으로 바꿔 끼우면서 재사용할 수 있다는 장점이 있습니다. 다만 std::function은 타입 소거를 위해 내부적으로 힙 할당을 할 수 있으므로, 호출 빈도가 매우 높은 핫 패스에서는 템플릿 파라미터로 콜러블 타입을 직접 받는 방식이 더 효율적입니다.

class ConditionalTransform {
    std::function<bool(int)> predicate_;
    std::function<int(int)> transform_;

public:
    ConditionalTransform(
        std::function<bool(int)> pred,
        std::function<int(int)> trans
    ) : predicate_(pred), transform_(trans) {}

    int operator()(int value) const {
        if (predicate_(value)) {
            return transform_(value);
        }
        return value;
    }
};

// 사용
std::vector<int> v = {1, 2, 3, 4, 5, 6};

// 짝수만 2배
auto transformer = ConditionalTransform(
    [](int x) { return x % 2 == 0; },
    [](int x) { return x * 2; }
);

std::transform(v.begin(), v.end(), v.begin(), transformer);
// 결과: 1, 4, 3, 8, 5, 12

패턴 3: 캐싱 함수

Memoized는 임의의 콜러블을 감싸 결과를 캐싱해주는 템플릿 함수 객체로, 메모이제이션(memoization) 기법을 함수 객체로 일반화한 예입니다. 내부의 cache_는 mutable로 선언되어 있어 operator()가 const를 유지하면서도 캐시를 갱신할 수 있습니다. 재귀 호출 자체는 캐싱하지 못하지만(재귀 안쪽 호출은 원본 fibonacci 함수를 그대로 호출하므로), 같은 인자로 fib를 여러 번 호출하는 경우에는 두 번째 호출부터 캐시된 값을 즉시 반환해 반복 계산을 피할 수 있습니다.

template<typename Func>
class Memoized {
    Func func_;
    mutable std::map<int, int> cache_;

public:
    Memoized(Func func) : func_(func) {}

    int operator()(int x) const {
        if (auto it = cache_.find(x); it != cache_.end()) {
            return it->second;
        }

        int result = func_(x);
        cache_[x] = result;
        return result;
    }
};

// 사용
int fibonacci(int n) {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

Memoized<int(*)(int)> fib(fibonacci);
std::cout << fib(40) << '\n';  // 첫 호출은 원본 재귀 그대로라 느림
std::cout << fib(40) << '\n';  // 두 번째 호출부터 캐시에서 즉시 반환

이 Memoized를 여러 스레드에서 공유하면 mutable std::map을 동기화 없이 수정하므로 데이터 경쟁이 생깁니다. const 멤버 함수는 동시에 호출해도 안전하다고 기대되는 것이 C++의 관례이기 때문에, mutable 캐시를 두는 클래스는 mutable std::mutex로 조회·삽입을 감싸야 그 기대를 지킬 수 있습니다. 또 캐시가 끝없이 커지므로 입력 범위가 넓다면 크기 제한이나 LRU 정책도 함께 고려해야 합니다.

관련 글: 람다 표현식, std::bind, std::function.

비교자와 해시 함수 객체

Strict Weak Ordering

std::sort, std::map, std::set 등에 넘기는 비교자는 strict weak ordering을 만족해야 합니다. 조건은 다음과 같습니다.

  • comp(a, a)는 항상 false입니다(비반사성).
  • comp(a, b)가 true이면 comp(b, a)는 false입니다(비대칭성).
  • comp(a, b)와 comp(b, c)가 true이면 comp(a, c)도 true입니다(추이성).
  • !comp(a, b) && !comp(b, a)일 때 a와 b를 “동등”하다고 하며, 이 동등 관계도 추이적이어야 합니다.

기본 비교자: std::less, std::greater

#include <algorithm>
#include <functional>
#include <iostream>
#include <vector>
int main() {
    std::vector<int> v = {5, 2, 8, 1, 9};
    std::sort(v.begin(), v.end(), std::less<int>{});     // 오름차순
    std::sort(v.begin(), v.end(), std::greater<int>{});   // 내림차순
    for (int x : v) std::cout << x << " ";   // 9 8 5 2 1
    std::cout << "\n";
    return 0;
}

map/set에 커스텀 비교자

std::map의 세 번째 템플릿 인자가 비교자 타입입니다. 다음은 Person을 나이로만 비교하는 map입니다.

#include <iostream>
#include <map>
#include <string>
struct Person {
    std::string name;
    int age;
};
struct CompareByAge {
    bool operator()(const Person& a, const Person& b) const {
        return a.age < b.age;
    }
};
int main() {
    std::map<Person, int, CompareByAge> ageToId;
    ageToId[{"Alice", 30}] = 1;
    ageToId[{"Bob", 25}] = 2;
    ageToId[{"Charlie", 30}] = 3;  // Alice와 "동등"한 키로 취급되어 값만 3으로 덮어씀
    for (const auto& [p, id] : ageToId)
        std::cout << p.name << " " << p.age << " -> " << id << "\n";
    // Bob 25 -> 2
    // Alice 30 -> 3
    return 0;
}

map은 키의 같음을 operator==가 아니라 비교자로 판단합니다. CompareByAge로는 Alice와 Charlie가 서로 “작지 않으므로” 동등한 키가 되고, ageToId[{"Charlie", 30}]은 새 원소를 넣지 않고 기존 Alice 원소를 찾아 값을 3으로 바꿉니다. 키는 처음 들어간 Alice 그대로 남습니다. 나이가 같은 사람을 따로 저장하려면 std::tie(a.age, a.name) < std::tie(b.age, b.name)처럼 복합 키로 비교하거나, 나이별로 여러 값을 담는 std::multimap을 써야 합니다.

해시 함수 객체 (unordered_map)

std::unordered_map은 해시 함수와 동등 비교가 필요합니다. 표준은 std::pair에 대한 std::hash 특수화를 제공하지 않으므로, pair를 키로 쓰려면 해시 함수 객체를 세 번째 템플릿 인자로 넘겨야 합니다.

#include <functional>
#include <iostream>
#include <string>
#include <unordered_map>
#include <utility>
struct PairHash {
    std::size_t operator()(const std::pair<std::string, int>& p) const {
        std::size_t h1 = std::hash<std::string>{}(p.first);
        std::size_t h2 = std::hash<int>{}(p.second);
        return h1 ^ (h2 + 0x9e3779b9 + (h1 << 6) + (h1 >> 2));
    }
};
int main() {
    std::unordered_map<std::pair<std::string, int>, int, PairHash> m;
    m[{"Alice", 30}] = 1;
    m[{"Bob", 25}] = 2;
    std::cout << m[{"Alice", 30}] << "\n";  // 1
    return 0;
}

두 해시를 단순히 h1 ^ h2로 합치면 (a, b)와 (b, a) 같은 쌍이 같은 값이 되기 쉬워, 위처럼 Boost의 hash_combine과 같은 방식으로 섞는 경우가 많습니다. 동등 비교는 pair의 operator==가 쓰이므로 따로 넘기지 않아도 됩니다.

const 누락, 비교자 규칙 위반, 상태 손실: 함수 객체에서 자주 나는 버그

operator()를 const로 선언하지 않음

std::set/std::map의 비교자로 넘기면 libstdc++에서 “static assertion failed: comparison object must be invocable as const” 에러가 나고, const 객체로 직접 호출하면 “passing ‘const Bad’ as ‘this’ argument discards qualifiers” 에러가 납니다.

std::count_if처럼 predicate를 값으로 받아 비const 복사본으로 호출하는 알고리즘에서는 비const operator()도 컴파일됩니다. 하지만 set::find처럼 const 멤버 함수 안에서 비교자를 호출하는 컨테이너나, predicate를 const 참조로 받는 코드에서는 호출할 수 없습니다. “알고리즘에서는 되는데 컨테이너에 넣으니 안 된다”는 식으로 늦게 드러나기 때문에, 상태를 바꾸지 않는 operator()는 처음부터 const로 선언하는 편이 안전합니다.

// ❌ const 객체에서 호출 불가
struct Bad {
    bool operator()(int x) { return x > 0; }
};
std::count_if(v.begin(), v.end(), Bad());  // 값으로 받으므로 컴파일은 됨
const Bad b;
// b(1);  // 에러: discards qualifiers
// ✅ 상태를 바꾸지 않으면 const
struct Good {
    bool operator()(int x) const { return x > 0; }
};

비교자에서 strict weak ordering 위반

< 대신 <=를 쓰면 comp(a, a)가 true가 되어 비반사성이 깨집니다. 이는 undefined behavior로, std::sort가 범위 밖 메모리를 읽어 크래시가 나거나, map/set에서 같은 값으로 보이는 원소가 두 번 들어가거나 find가 이미 있는 키를 찾지 못하는 식으로 나타납니다. 증상이 데이터에 따라 들쭉날쭉해서 원인을 찾기 어렵다는 점이 이 버그의 가장 큰 문제입니다.

// ❌ comp(a,a)가 true
struct Bad {
    bool operator()(int a, int b) const {
        return a <= b;
    }
};
// ✅ strict weak ordering
struct Good {
    bool operator()(int a, int b) const {
        return a < b;
    }
};

함수 객체를 복사할 때 상태 손실

std::count_if에 상태를 가진 predicate를 넘긴 뒤 원본을 보면 통계가 0으로 남아 있습니다. 알고리즘이 predicate를 복사해서 쓰기 때문에, 상태는 복사본에서만 바뀌고 원본은 그대로입니다.

// 원본 stat은 변경되지 않음
CountAndSum stat;
std::count_if(v.begin(), v.end(), stat);  // stat의 복사본이 사용됨
// ✅ std::ref로 참조 전달
std::count_if(v.begin(), v.end(), std::ref(stat));

std::for_each는 예외적으로 사용한 함수 객체를 반환하므로 stat = std::for_each(v.begin(), v.end(), stat);처럼 결과를 돌려받을 수도 있습니다.

람다를 한 컨테이너에 담으려 할 때

람다 표현식은 내용이 같아도 식마다 고유한 타입을 가지므로, 서로 다른 람다를 한 vector에 넣을 수 없습니다. std::function에 담아 시그니처로 타입을 통일해야 합니다.

// ❌ 두 람다의 타입이 달라 같은 vector에 넣을 수 없음
// std::vector</* ??? */> vec;
// ✅ std::function으로 시그니처 통일
std::vector<std::function<int()>> vec;
vec.push_back([]() { return 1; });
vec.push_back([]() { return 2; });

operator() 오버로드의 모호함

여러 operator()가 같은 정도로 맞는 변환을 요구하면 컴파일러가 하나를 고르지 못하고 “call to ‘operator()’ is ambiguous” 에러를 냅니다.

struct Ambiguous {
    void operator()(int x) const {}
    void operator()(long long x) const {}
};
Ambiguous a;
a(42);    // OK: 42는 int라 정확히 일치하는 operator()(int) 선택
a(42L);   // ❌ long -> int, long -> long long 모두 변환이라 모호
a(42LL);  // OK: operator()(long long)

인자 타입을 명시적으로 캐스트하거나, 오버로드를 하나로 줄이거나, 템플릿 operator() 하나로 받는 방법으로 해결합니다.

이동한 함수 객체를 다시 사용

표준 라이브러리 타입은 이동된 뒤 “유효하지만 지정되지 않은 상태”가 됩니다. 이동된 std::function은 실제 구현에서 보통 비어 있게 되고, 빈 std::function을 호출하면 std::bad_function_call 예외가 던져집니다. 직접 만든 함수 객체도 멤버로 std::function이나 컨테이너를 들고 있다면 같은 문제가 생깁니다.

std::function<int(int)> f = [](int x) { return x + 1; };
auto g = std::move(f);
g(1);   // OK
f(1);   // ❌ f는 지정되지 않은 상태, 비어 있으면 std::bad_function_call

해시 함수가 같은 키에 다른 값을 반환

unordered_map은 동등한 키에 대해 해시 함수가 항상 같은 값을 반환한다고 가정합니다. 이 규칙을 어기면 같은 키가 여러 번 저장되거나 넣은 키를 찾지 못합니다.

// ❌ 호출할 때마다 다른 값
struct BadHash {
    std::size_t operator()(int x) const {
        return std::rand();
    }
};
// ✅ 동등하면 같은 해시
struct GoodHash {
    std::size_t operator()(int x) const {
        return std::hash<int>{}(x);
    }
};

커스텀 동등 비교자(예: 대소문자 무시 문자열 비교)를 쓸 때도 같은 규칙이 적용되어, 해시 함수 역시 대소문자를 무시하고 계산해야 합니다.

참고 자료



같이 보면 좋은 글