C++ 함수 객체 | 'Functor' 완벽 가이드
이 글의 핵심
C++ 함수 객체의 C++, "Functor", 가이드, 함수 객체란?를 실전 예제와 함께 상세히 설명합니다.
함수 객체란?
함수 객체 (Functor) 는 operator()를 오버로드한 클래스입니다. 함수처럼 호출할 수 있으며, 상태를 유지할 수 있어 일반 함수보다 유연합니다.
// 타입 정의
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
}
왜 필요한가?:
- 상태 유지: 함수 호출 간 상태 저장
- 인라인 최적화: 함수 포인터보다 빠름
- STL 호환: STL 알고리즘과 함께 사용
- 타입 안전: 컴파일 타임 타입 체크
// ❌ 함수 포인터: 상태 유지 불가
// 변수 선언 및 초기화
int counter = 0;
void increment() {
counter++;
}
// ✅ 함수 객체: 상태 유지
class Counter {
int count_ = 0;
public:
int operator()() {
return ++count_;
}
};
Counter counter;
counter(); // 1
counter(); // 2
함수 객체 vs 함수 포인터:
| 특징 | 함수 포인터 | 함수 객체 |
|---|---|---|
| 상태 유지 | ❌ 불가 | ✅ 가능 |
| 인라인 최적화 | ❌ 어려움 | ✅ 가능 |
| 타입 안전 | ✅ 있음 | ✅ 있음 |
| 유연성 | ❌ 낮음 | ✅ 높음 |
| 성능 | 느림 | 빠름 |
함수 포인터를 통한 호출은 컴파일러가 런타임까지 실제로 어떤 함수가 호출될지 알 수 없기 때문에 대부분 간접 호출(indirect call)로 컴파일됩니다. 이는 CPU의 분기 예측을 어렵게 하고, 인라이닝 같은 최적화를 사실상 막아버립니다. 반면 함수 객체는 operator()가 호출되는 타입이 컴파일 타임에 이미 확정되어 있으므로(템플릿 인자나 정적 타입으로), 컴파일러가 함수 본문을 호출 지점에 직접 삽입하는 인라이닝을 적극적으로 수행할 수 있습니다. std::sort처럼 비교 함수를 수백만 번 호출하는 알고리즘에서는 이 차이가 체감할 수 있는 성능 격차로 이어집니다.
// 함수 포인터: 간접 호출
int (*funcPtr)(int, int) = add;
funcPtr(2, 3); // 간접 호출
// 함수 객체: 인라인 가능
Adder adder;
adder(2, 3); // 인라인 가능
상태 유지
함수 객체가 일반 함수보다 강력한 가장 큰 이유는 호출 사이의 상태를 자연스럽게 유지할 수 있다는 점입니다. 일반 함수는 상태를 기억하려면 전역 변수나 static 지역 변수처럼 함수 바깥의 공유 상태에 의존해야 하지만, 함수 객체는 멤버 변수에 상태를 담고 있으므로 인스턴스별로 독립적인 상태를 가질 수 있습니다. 아래 Counter는 호출될 때마다 내부 count를 증가시키고, getCount()로 현재까지 누적된 호출 횟수를 조회할 수 있습니다. 같은 클래스로 여러 인스턴스를 만들면 각각 독립적으로 카운트를 유지하므로, 전역 카운터를 쓸 때 흔히 발생하는 상태 공유 버그를 원천적으로 피할 수 있습니다.
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 알고리즘 대부분은 조건이나 연산을 함수 객체(또는 함수 포인터, 람다)로 받도록 설계되어 있습니다. 이런 설계를 정책 기반(policy-based) 설계라고 부르기도 하는데, 알고리즘의 뼈대(순회, 반복 로직)는 STL이 제공하고 실제 판단 로직만 호출자가 주입하는 구조입니다. 아래 IsEven은 정수가 짝수인지 판별하는 술어(predicate) 함수 객체로, count_if에 넘기면 조건을 만족하는 원소 개수를, remove_if에 넘기면 조건을 만족하는 원소를 벡터 끝으로 몰아내는 데(erase-remove 관용구와 함께) 재사용할 수 있습니다. 같은 IsEven 인스턴스를 여러 알고리즘에서 공유할 수 있다는 점이 람다를 매번 새로 쓰는 것과 비교해 재사용성 측면의 장점입니다.
#include <algorithm>
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
}
}
실전 예시
아래 네 가지 예시는 정렬 기준, 필터링, 누적 집계, 값 변환이라는 STL 알고리즘의 가장 흔한 활용 패턴에 함수 객체를 적용한 것입니다. 각 함수 객체가 operator()의 시그니처를 어떻게 설계했는지, 그리고 상태(멤버 변수)를 어떻게 활용하는지에 주목해서 읽으면 좋습니다.
예시 1: 비교 함수
std::sort의 세 번째 인자로 비교 함수 객체를 넘기면 원하는 기준으로 정렬할 수 있습니다. CompareByAge와 CompareByName처럼 정렬 기준별로 별도의 함수 객체를 만들어두면, 같은 데이터를 여러 기준으로 정렬해야 할 때 호출부에서 원하는 객체만 골라 넘기면 되므로 코드가 명확해집니다. 비교 함수 객체는 반드시 엄격한 약순서(strict weak ordering), 즉 a < b와 같은 비대칭적 관계를 반환해야 하며, 그렇지 않으면 sort가 정의되지 않은 동작을 일으킬 수 있습니다.
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와 수동 순회 양쪽에서 재사용해 코드 중복을 줄입니다.
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로도 구현할 수 있지만, 합계 외에 여러 통계를 동시에 계산해야 할 때는 이렇게 상태를 가진 함수 객체 하나로 여러 값을 한 번의 순회에서 모으는 편이 더 효율적입니다.
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)처럼 즉석에서 임시 객체를 만들어 바로 넘길 수 있다는 점에서 사용성은 람다와 거의 비슷하지만, 함수 객체는 이렇게 클래스로 정의해 두면 다른 배율로도 얼마든지 재사용할 수 있는 범용 부품이 된다는 차이가 있습니다.
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에 넘기면 오름차순 대신 내림차순으로 정렬됩니다. 이런 표준 함수 객체를 활용하면 사소한 연산 하나를 위해 별도의 구조체를 만드는 수고를 덜 수 있습니다.
#include <functional>
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 함수 객체와 람다를 나란히 보여주는데, 캡처하는 상태가 단순하고 로직이 짧다면 람다 쪽이 훨씬 간결합니다. 다만 여러 곳에서 재사용해야 하거나, 템플릿 인자로 타입 이름을 명시적으로 지정해야 하는 경우에는 여전히 이름 있는 함수 객체 클래스가 유리합니다.
// 함수 객체
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가 붙어 있지 않으면 알고리즘 구현에 따라 컴파일 오류가 나거나, 최적화 관점에서 불리해질 수 있습니다. 상태를 변경하지 않는 함수 객체라면 operator()를 const로 선언하는 것이 관례이며, 이렇게 하면 컴파일러가 “이 호출은 객체 상태를 바꾸지 않는다”는 것을 명시적으로 알 수 있어 더 안전하고 최적화 친화적인 코드가 됩니다.
// ❌ const 없음
struct Adder {
int operator()(int x) { // const 없음
return x + 10;
}
};
// STL 알고리즘에서 문제 발생 가능
// ✅ const 추가
struct Adder {
int operator()(int x) const {
return x + 10;
}
};
문제 2: 상태 변경
operator()를 const로 유지하면서도 내부 상태를 변경해야 하는 경우가 있습니다. 캐싱된 결과를 갱신하거나, Counter처럼 호출 횟수를 세는 로직이 대표적인 예입니다. 이럴 때는 논리적으로는 객체가 상수처럼 보이지만 실제로는 내부 구현 세부사항이 바뀌는 멤버에 mutable 키워드를 붙여, operator()의 const성을 유지하면서도 해당 멤버만 예외적으로 수정할 수 있게 합니다. 이는 “논리적 상수성(logical constness)“과 “비트 단위 상수성(bitwise constness)“을 구분하는 C++의 대표적인 관용구입니다.
// 상태 변경 시 const 제거
class Counter {
private:
mutable int count = 0; // mutable
public:
int operator()() const {
return ++count;
}
};
문제 3: 복사 비용
STL 알고리즘의 많은 구현은 함수 객체를 값으로 전달받아 내부에서 필요할 때마다 복사합니다. 함수 객체가 std::vector처럼 큰 데이터를 멤버로 갖고 있으면, 알고리즘이 내부적으로 복사를 할 때마다 그 큰 데이터도 함께 복사되어 예상치 못한 성능 저하를 일으킬 수 있습니다. std::ref(또는 std::cref)로 감싸서 넘기면 실제 복사되는 것은 참조 래퍼(가벼운 포인터 수준)뿐이므로, 무거운 상태를 가진 함수 객체를 다룰 때는 이 패턴을 기억해 둘 필요가 있습니다.
// ❌ 큰 상태
struct HeavyFunctor {
vector<int> data; // 큰 데이터
int operator()(int x) const {
// ...
}
};
// STL 알고리즘이 복사할 수 있음
// ✅ ref 사용
HeavyFunctor heavy;
for_each(v.begin(), v.end(), ref(heavy));
실무 패턴
아래 세 가지 패턴은 각각 통계 집계, 조건부 파이프라인, 결과 캐싱이라는 실무에서 반복적으로 등장하는 문제를 함수 객체로 해결하는 방식을 보여줍니다.
패턴 1: 통계 수집
Statistics는 앞서 본 누적기 패턴을 확장해, 개수·합계·최솟값·최댓값을 한 번의 순회로 동시에 계산합니다. for_each가 순회 후 함수 객체를 반환한다는 특징을 이용해, 별도의 전역 변수나 참조 캡처 없이도 여러 통계량을 하나의 객체 안에 안전하게 모을 수 있습니다. 데이터셋을 여러 번 순회하며 각 통계를 따로 계산하는 것보다 캐시 효율과 성능 면에서 유리합니다.
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'; // 빠름 (캐시)
FAQ
Q1: 함수 객체는 언제 사용하나요?
A:
- 상태 유지: 함수 호출 간 상태 저장
- STL 알고리즘:
sort,find_if,transform등 - 커스텀 비교/필터: 복잡한 로직
class RangeFilter {
int min_, max_;
public:
RangeFilter(int min, int max) : min_(min), max_(max) {}
bool operator()(int x) const { return x >= min_ && x <= max_; }
};
Q2: 람다 vs 함수 객체?
A:
- 람다: 간단한 로직, 인라인 정의
- 함수 객체: 복잡한 로직, 재사용, 명시적 타입
// 람다: 간단
auto isEven = [](int x) { return x % 2 == 0; };
// 함수 객체: 복잡
class Statistics {
int count_, sum_;
public:
void operator()(int x) { count_++; sum_ += x; }
double average() const { return sum_ / count_; }
};
Q3: 함수 객체의 성능은?
A: 인라인화로 함수 포인터보다 빠를 수 있습니다.
// 함수 포인터: 간접 호출
std::sort(v.begin(), v.end(), compareFunc);
// 함수 객체: 인라인 가능
std::sort(v.begin(), v.end(), CompareFunctor());
Q4: 함수 객체를 재사용할 수 있나요?
A: 가능합니다. 여러 알고리즘에 같은 인스턴스를 전달할 수 있습니다.
RangeFilter filter(10, 20);
auto it1 = std::find_if(v1.begin(), v1.end(), filter);
auto it2 = std::find_if(v2.begin(), v2.end(), filter);
Q5: const는 필수인가요?
A: STL 알고리즘 사용 시 권장합니다. 상태를 변경하지 않는 함수 객체는 const로 선언해야 합니다.
// ✅ const 권장
struct IsEven {
bool operator()(int x) const {
return x % 2 == 0;
}
};
// 상태 변경 시 mutable
class Counter {
mutable int count_ = 0;
public:
int operator()() const {
return ++count_;
}
};
Q6: 표준 함수 객체는?
A: <functional> 헤더에 std::plus, std::minus, std::greater 등이 있습니다.
C/C++ 예제 코드입니다.
#include <functional>
std::vector<int> v = {3, 1, 4, 1, 5};
// plus
int sum = std::accumulate(v.begin(), v.end(), 0, std::plus<int>());
// greater (내림차순)
std::sort(v.begin(), v.end(), std::greater<int>());
Q7: 함수 객체는 템플릿으로 만들 수 있나요?
A: 가능합니다.
template<typename T>
struct Less {
bool operator()(const T& a, const T& b) const {
return a < b;
}
};
std::sort(v.begin(), v.end(), Less<int>());
Q8: 함수 객체 학습 리소스는?
A:
- “Effective STL” by Scott Meyers (Item 38-42)
- cppreference.com - Function objects
- “The C++ Standard Library” by Nicolai Josuttis
관련 글: lambda, bind, function.
한 줄 요약: 함수 객체는 operator()를 오버로드하여 함수처럼 호출할 수 있는 클래스입니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 함수 객체(Functor) 완벽 가이드 | operator·상태 보유
- C++ std::function | 콜백·전략 패턴과 함수 객체
- C++ Algorithm Partition | “분할 알고리즘” 가이드
관련 글
- C++ std::function | 콜백·전략 패턴과 함수 객체
- C++ 함수 객체(Functor) 완벽 가이드 | operator·상태 보유
- C++ ADL
- C++ Algorithm Copy
- C++ Algorithm Count
이 글에서 다루는 키워드 (관련 검색어)
C++, functor, 함수객체, operator, STL 등으로 검색하시면 이 글이 도움이 됩니다.