C++의 강력함은 ‘사용자 정의 타입(Class, struct)’을 ‘기본 타입(int, float 등)’처럼 자연스럽게 다룰 수 있다는 데 있다. 이 철학을 가장 잘 보여주는 기능이 바로 Functor(함수 객체)다.
Functor(함수 객체, 함자)는 ‘상태(State)’를 가지는 함수이자, ‘타입(Type)으로 취급되는 동작이다. 말이 어렵다. 타입으로 취급되는 동작? 동작인데 타입을 동작 타입을 알 수 있다는 의미이다.
본 포스팅에서는 단순한 연산자 오버로딩(operator ())에서 시작해, STL 활용, 그리고 람다(Lambda)에 대한 이해까지 설명한다.
1. 연산자 오버로딩 (Operator Overloading)
C++은 +, -, * 같은 연산자를 클래스 입맛에 맞게 재정의할 수 있다. 컴파일러 입장에서 a + b는 사실 a.operator+(b)라는 함수 호출과 동일하다. 즉, 연산자는 특수한 이름을 가진 함수일 뿐임을 알 수 있다.
2. operator(): 괄호 ()도 연산자다
함수 호출에 사용하는 소괄호 () 역시 함수 호출 연산자(Function Call Operator)라는 엄연한 연산자다. 다른 연산자(이항 연산자 + 등)는 피연산자의 개수가 고정되어 있지만, operator()는 유일하게 매개변수의 개수를 0개부터 N개까지 자유롭게 정의할 수 있다.
class Accumulator {
private:
int sum = 0; // 상태(State) 유지 가능
public:
// 괄호 연산자 오버로딩
int operator()(int x) {
sum += x;
return sum;
}
};
3. 괄호 오버로딩 == Functor
위와 같이 operator()를 구현한 객체를 Functor(Function Object, 함수 객체)라고 부른다. 사용방법만을 보면 일반 함수와 구분되지 않는다.
Accumulator acc; int result = acc(10); // 객체 acc를 함수처럼 호출 (10 더함) result = acc(20); // 기존 상태 유지 (20 더함 -> 총 30)
4. 함수 포인터: C언어의 방식
C++의 Functor를 이해하려면 C언어의 한계를 먼저 봐야 한다. C에서 함수를 변수처럼 다루려면 함수 포인터를 사용한다.
#include <iostream>
// [전략 1] 단순 데이터 출력
void LogData(int sensorValue) {
std::cout << "[Info] 데이터 수신: " << sensorValue << "\n";
}
// [전략 2] 경고 전송 (단점: 임계값 100이 하드코딩 되어 있거나 전역변수 써야 함)
void AlertData(int sensorValue) {
if (sensorValue > 100) {
std::cout << "[Alert] 위험 감지! (" << sensorValue << ")\n";
}
}
int main() { // 함수의 번지를 바꿔켜야 한다.
// 함수 포인터 선언
void (*Processor)(int);
// 1. 로깅 모드
Processor = &LogData;
Processor(50);
// 2. 경고 모드
Processor = &AlertData;
Processor(120);
return 0;
}
위에서 보듯이, 이 방식의 치명적인 단점은 ‘상태(State)’를 가질 수 없다는 것이다. 함수가 실행될 때 내부 변수는 초기화되고, 호출 간에 데이터를 유지하려면 전역 변수나 static 변수를 써야 하는데, 이는 스레드 안전성(Thread Safety) 문제를 야기한다.
5. Functor: 상태(State)를 가진 함수
C 언어에서, 함수 포인터를 사용하여 함수를 변수처럼 다룬것을 확인했지만, Functor를 사용하는 C++에서는 클래스(객체)가 그 역할을 대신할 수 있게 만든다. 아래는 클래스를 이용, 멤버함수를 호출하는 차이를 보이고 있다.
- 일반 객체:
obj.func()형태로 멤버 함수를 명시적으로 호출해야 함. - 펑터(Functor):
obj()형태로 객체 자체를 함수처럼 바로 호출할 수 있음.
그리고 Functor가 단순히 “함수처럼 보이도록 만든다”는 껍데기 보다 중요한 아래의 내부적 이점을 간과하면 안된다.
- 상태(State) 보존: 멤버 변수를 통해 데이터를 유지한다. 캘리브레이션 값, 누적 값 등을 객체 스스로 관리하므로 전역 변수가 필요 없다.
- 인라인(Inline) 최적화: 함수 포인터는 런타임에 주소를 찾아가므로 인라인 최적화가 어렵다. 반면, Functor는 컴파일 타임에 타입이 결정되므로 컴파일러가 코드를 호출 위치에 박아버리는(Inline) 최적화를 수행한다. STL 알고리즘에서 Functor가 함수 포인터보다 압도적으로 빠른 이유다.
7. 활용: std::set 정렬 기준
STL 컨테이너인 std::set은 2 번째 템플릿 인자로 ‘정렬 기준 타입(Type)’을 요구한다. std::set은 생성자 인자가 아니라 템플릿 인자(Template Argument)로 타입을 받기 때문에, 함수 포인터(값)가 아닌 Functor(타입)가 필요하다.
잘못된 예 (Functor 미사용)
bool compareFn(int a, int b) { return a > b; }
// 에러! compareFn은 '함수의 주소(값)'이지 '타입'이 아님.
// 템플릿 안에는 타입이 들어가야 함.
std::set<int, compareFn> s;
올바른 예 (Functor 사용)
Functor는 클래스(구조체)이므로 그 자체로 타입(Type)이 된다.
//std::set
#include <iostream>
#include <set>
struct customFn {
// operator()를 오버로딩하여 비교 로직을 정의.
// lhs > rhs로 설정하면 내림차순(큰 수가 앞으로)으로 정렬.
bool operator() (const int lhs, const int rhs) const {
return lhs > rhs;
}
};
int main() {
// std::set의 두 번째 템플릿 인자로 우리가 만든 비교 구조체의 타입을 전달.
std::set<int, customFn> nums{1,2,3,4,5};
for(const int num: nums) {
std::cout << num << std::endl;
}
return 0;
}
8. 람다(Lambda)와의 관계
아래는 operator()를 사용해서 함수객체를 만들었고 그 사용예를 보이는 예제이다.
#include <iostream>
int main() {
int configThreshold = 50;
// [Functor vs Lambda 비교]
// 이전 방식: SensorFilter myFilter(50);
// 람다 방식: 캡처 [=]를 통해 외부 변수(상태)를 내부로 가져옴
auto myFilter = [threshold = configThreshold](int value) {
return value > threshold;
};
std::cout << "필터링 결과: " << myFilter(60) << "\n"; // True
return 0;
}
9. 정리
- 연산자 오버로딩:
()도 오버로딩 가능한 연산자다. - Functor:
operator()를 재정의하여 함수처럼 동작하는 객체다. - 차별점: 일반 함수와 달리 멤버 변수(상태)를 가지며, 인라인 최적화가 강력하다.
- STL 활용:
std::set,std::map등의 템플릿 인자는 ‘타입’을 요구하므로, 구조체로 감싼 Functor를 넘기는 것이 정석이다.
자동화 시스템이나 고성능 네트워크 처리가 필요한 환경에서, 불필요한 함수 호출 비용을 줄이고 상태를 안전하게 관리하고 싶다면 Functor(혹은 람다)는 선택이 아닌 필수다.