std::sort에 비교 함수를 넘기는 방법은 여러 가지다. 함수 포인터, operator()를 가진 클래스, 람다(lambda), std::function 중 무엇을 넘기든 결과는 같다. 그런데 천만 개를 정렬해 보면 실행 시간은 2배 가까이 벌어진다. 차이는 호출 대상이 타입에 새겨지느냐에서 나온다. 이 글에서는 operator()를 오버로드한 클래스, 즉 함수 객체(function object, functor)를 직접 만들어 보고, 람다·표준 함수 객체·std::function과의 관계와 알고리즘에 넘길 때의 함정을 g++ 13.3 실행 결과로 정리한다.
이 시리즈의 다른 글
- C++ 고급 문법 (2) — 함수 템플릿(Function Template)
- C++ 고급 문법 (3) — inline 함수(Inline Function)
- C++ 고급 문법 (5) — 임시 객체(Temporary Object)
- C++ 고급 문법 (6) — 명시적 캐스팅(Explicit Casting)
operator()로 만드는 함수 객체
함수 호출 연산자를 오버로드하면 객체를 함수처럼 부를 수 있다. 일반 함수와 달리 멤버 변수로 설정값이나 상태를 들고 다닌다.
#include <iostream>
class Multiplier {
int factor_;
public:
explicit Multiplier(int f) : factor_(f) {}
int operator()(int v) const { return v * factor_; } // 함수 호출 연산자
};
class Counter {
int calls_ = 0;
public:
int operator()() { return ++calls_; } // 호출 사이에 상태 유지
};
int main()
{
Multiplier times3(3), times10(10);
std::cout << times3(7) << ' ' << times10(7) << '\n';
Counter c;
c(); c();
std::cout << "calls: " << c() << '\n';
}$ g++ -std=c++20 -Wall -O2 -o basic basic.cpp
$ ./basic
21 70
calls: 3
함수 포인터보다 빠른 이유
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <functional>
#include <random>
#include <vector>
bool less_fn(int a, int b) { return a < b; }
struct LessFunctor { bool operator()(int a, int b) const { return a < b; } };
template <typename Cmp>
void run(const char* name, const std::vector<int>& src, Cmp cmp)
{
auto v = src;
auto t0 = std::chrono::steady_clock::now();
std::sort(v.begin(), v.end(), cmp);
auto ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
std::printf("%-15s %7.1f ms\n", name, ms);
}
int main()
{
std::vector<int> src(10'000'000);
std::mt19937 rng(42);
for (auto& x : src) x = static_cast<int>(rng());
run("function ptr", src, &less_fn);
run("functor", src, LessFunctor{});
run("lambda", src, [](int a, int b) { return a < b; });
run("std::less<>", src, std::less<>{});
run("std::function", src, std::function<bool(int, int)>(LessFunctor{}));
}$ g++ -std=c++20 -Wall -O2 -o bench bench.cpp
$ ./bench
function ptr 713.8 ms
functor 502.9 ms
lambda 510.9 ms
std::less<> 505.4 ms
std::function 954.7 ms
run이 함수 객체 타입마다 따로 인스턴스화되므로 컴파일러는 operator() 본문을 정렬 루프 안에 펼친다. 함수 포인터는 타입이 bool(*)(int,int) 하나뿐이라 실제 대상을 알 수 없어 간접 호출이 남고, std::function은 타입 소거(type erasure)를 거치는 간접 호출이 하나 더 붙는다.
| 호출 가능 객체 | 타입에 대상이 드러나나 | 인라인 | 상태 보관 |
|---|---|---|---|
| 함수 포인터 | 아니오 (시그니처만) | 어려움 | 불가 |
| 함수 객체 / 람다 | 예 (객체마다 고유 타입) | 쉬움 | 멤버 변수 / 캡처 |
std::less<> 등 표준 함수 객체 | 예 | 쉬움 | 없음 |
std::function | 아니오 (시그니처만) | 어려움 | 가능 (내부 저장 또는 힙) |
람다는 함수 객체다
람다 표현식은 컴파일러가 이름 없는 클래스(클로저 타입, closure type)를 만들고 그 객체를 생성하는 문법이다. 캡처한 값이 멤버 변수가 되므로 크기에 그대로 드러난다.
#include <functional>
#include <iostream>
#include <string>
// 아래 람다와 같은 일을 하는 손으로 쓴 클래스
struct AddN {
int n;
int operator()(int v) const { return v + n; }
};
int main()
{
int n = 5;
std::string tag = "x";
auto add_n = [n](int v) { return v + n; }; // 컴파일러가 AddN 같은 클로저 타입을 만든다
auto no_cap = [](int v) { return v + 1; };
auto by_ref = [&n](int v) { return v + n; };
auto two_cap = [n, tag](int v) { return tag + std::to_string(v + n); };
std::cout << add_n(1) << ' ' << AddN{n}(1) << '\n';
std::cout << "sizeof no_cap = " << sizeof(no_cap) << '\n';
std::cout << "sizeof add_n = " << sizeof(add_n) << '\n';
std::cout << "sizeof by_ref = " << sizeof(by_ref) << '\n';
std::cout << "sizeof two_cap = " << sizeof(two_cap) << '\n';
std::cout << "sizeof std::function<int(int)> = " << sizeof(std::function<int(int)>) << '\n';
int (*fp)(int) = no_cap; // 캡처 없는 람다만 함수 포인터로 변환된다
std::cout << fp(41) << '\n';
}$ g++ -std=c++20 -Wall -O2 -o lambda lambda.cpp
$ ./lambda
6 6
sizeof no_cap = 1
sizeof add_n = 4
sizeof by_ref = 8
sizeof two_cap = 40
sizeof std::function<int(int)> = 32
42
캡처가 없는 람다는 빈 클래스라 1바이트이고, 함수 포인터로 변환할 수 있다. 캡처가 있으면 변환 대상 함수가 상태를 받을 곳이 없어 에러다.
int main()
{
int n = 5;
auto add_n = [n](int v) { return v + n; };
int (*fp)(int) = add_n;
return fp(1);
}$ g++ -std=c++20 -Wall -O2 -c lambda_fp_err.cpp
lambda_fp_err.cpp: In function ‘int main()’:
lambda_fp_err.cpp:5:22: error: cannot convert ‘main()::<lambda(int)>’ to ‘int (*)(int)’ in initialization
5 | int (*fp)(int) = add_n;
| ^~~~~
| |
| main()::<lambda(int)>
알고리즘은 함수 객체를 복사한다
표준 알고리즘은 함수 객체를 값으로 받는다. 상태를 쌓는 함수 객체를 넘기면 원본은 그대로다.
#include <algorithm>
#include <functional>
#include <iostream>
#include <vector>
struct Sum {
int total = 0;
void operator()(int v) { total += v; }
};
int main()
{
std::vector<int> v{1, 2, 3, 4};
Sum s;
std::for_each(v.begin(), v.end(), s); // s의 복사본이 누적된다
std::cout << "original : " << s.total << '\n';
Sum r = std::for_each(v.begin(), v.end(), Sum{}); // 반환값으로 회수
std::cout << "returned : " << r.total << '\n';
Sum s2;
std::for_each(v.begin(), v.end(), std::ref(s2)); // 참조로 전달
std::cout << "std::ref : " << s2.total << '\n';
}$ g++ -std=c++20 -Wall -O2 -o copy_state copy_state.cpp
$ ./copy_state
original : 0
returned : 10
std::ref : 10
알고리즘이 내부에서 술어(predicate)를 다시 복사하는 경우도 있어서, 상태를 가진 술어는 결과가 구현에 따라 달라진다.
#include <algorithm>
#include <iostream>
#include <vector>
// "세 번째로 검사한 원소만 제거"하려는 상태 있는 술어(predicate)
struct NthCall {
int n = 0;
bool operator()(int) { return ++n == 3; }
};
int main()
{
std::vector<int> v{1, 2, 3, 4, 5, 6, 7, 8, 9};
v.erase(std::remove_if(v.begin(), v.end(), NthCall{}), v.end());
for (int x : v) std::cout << x << ' ';
std::cout << '\n';
}$ g++ -std=c++20 -Wall -O2 -o remove_if remove_if.cpp
$ ./remove_if
1 2 4 5 7 8 9
3 하나만 지우려 했지만 6도 사라졌다. libstdc++의 remove_if는 첫 대상을 find_if에 넘긴 술어 복사본으로 찾고, 나머지는 카운터가 0에서 다시 시작하는 원본으로 검사하기 때문이다.
표준 함수 객체와 투명 비교자
| 분류 | 함수 객체 (<functional>) |
|---|---|
| 산술 | plus, minus, multiplies, divides, modulus, negate |
| 비교 | equal_to, not_equal_to, less, greater, less_equal, greater_equal |
| 논리 | logical_and, logical_or, logical_not |
| 비트 | bit_and, bit_or, bit_xor, bit_not |
템플릿 인수를 비운 std::less<>(C++14)는 투명 비교자(transparent comparator)다. 컨테이너의 키 타입과 다른 타입으로도 바로 검색할 수 있다.
#include <set>
#include <string>
#include <string_view>
int main()
{
std::set<std::string> plain{"alpha", "beta", "gamma"};
std::string_view key = "beta";
return plain.count(key);
}$ g++ -std=c++20 -Wall -O2 -c transparent_err.cpp 2>&1 | head -4
transparent_err.cpp: In function ‘int main()’:
transparent_err.cpp:9:23: error: no matching function for call to ‘std::set<std::__cxx11::basic_string<char> >::count(std::string_view&)’
9 | return plain.count(key);
| ~~~~~~~~~~~^~~~~
#include <cstdio>
#include <cstdlib>
#include <new>
#include <set>
#include <string>
#include <string_view>
static int g_allocs = 0;
void* operator new(std::size_t n) { ++g_allocs; return std::malloc(n); }
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }
int main()
{
// SSO(짧은 문자열 최적화)에 안 걸리도록 16자 이상 키를 쓴다
std::set<std::string> plain{"config.network.timeout", "config.network.retries"};
std::set<std::string, std::less<>> transparent{"config.network.timeout", "config.network.retries"};
std::string_view key = "config.network.retries";
int before = g_allocs;
for (int i = 0; i < 1000; ++i) plain.count(std::string(key)); // 매번 임시 std::string
std::printf("std::less<std::string> : %d allocs\n", g_allocs - before);
before = g_allocs;
for (int i = 0; i < 1000; ++i) transparent.count(key); // string_view 그대로 비교
std::printf("std::less<> : %d allocs\n", g_allocs - before);
}$ g++ -std=c++20 -Wall -O2 -o transparent transparent.cpp
$ ./transparent
std::less<std::string> : 1000 allocs
std::less<> : 0 allocs
std::less<std::string>는 검색할 때마다 임시 std::string을 만들어 힙 할당이 1000번 일어나고, std::less<>는 할당이 없다.
컨테이너 비교자로 쓰기
#include <iostream>
#include <queue>
#include <set>
#include <string>
#include <vector>
struct ByLength {
bool operator()(const std::string& a, const std::string& b) const
{
return a.size() != b.size() ? a.size() < b.size() : a < b;
}
};
int main()
{
std::set<std::string, ByLength> words{"banana", "fig", "apple", "kiwi"};
for (const auto& w : words) std::cout << w << ' ';
std::cout << '\n';
// C++20: 캡처 없는 람다는 기본 생성 가능하므로 decltype으로 타입만 넘길 수 있다
std::set<int, decltype([](int a, int b) { return a > b; })> desc{3, 1, 4, 1, 5};
for (int x : desc) std::cout << x << ' ';
std::cout << '\n';
std::priority_queue<int, std::vector<int>, std::greater<>> min_heap;
for (int x : {5, 2, 8, 1}) min_heap.push(x);
std::cout << "top: " << min_heap.top() << '\n';
}$ g++ -std=c++20 -Wall -O2 -o set_cmp set_cmp.cpp
$ ./set_cmp
fig kiwi apple banana
5 4 3 1
top: 1
정렬 컨테이너는 비교자를 타입 인수로 받으므로 함수 객체가 자연스럽다. C++20부터 캡처 없는 람다를 decltype으로 바로 넘길 수 있다.
주의사항
| 상황 | 문제 | 대응 |
|---|---|---|
| 상태를 쌓는 함수 객체를 알고리즘에 전달 | 복사본에만 누적되어 원본은 그대로 | for_each 반환값 사용 또는 std::ref |
| 상태 있는 술어 | 알고리즘 내부 복사로 결과가 구현마다 다름 | 술어는 const operator()의 순수 함수로 작성 |
콜백을 std::function으로 받음 | 간접 호출로 인라인이 막히고, 큰 캡처는 힙에 할당될 수 있음 | 저장할 필요가 없으면 템플릿 매개변수나 auto로 받는다 |
std::set<std::string>을 다른 문자열 타입으로 검색 | 매번 임시 문자열 생성 | std::less<> 투명 비교자 |
| 비교자가 엄격한 약순서(strict weak ordering)를 어김 | <= 같은 비교는 정렬·컨테이너에서 미정의 동작 | 항상 < 의미로 작성 |
| 참조 캡처 람다를 저장 | 캡처한 지역 변수가 사라진 뒤 호출하면 dangling | 저장하는 람다는 값 캡처 |
마무리
- 함수 객체는
operator()를 가진 클래스이며, 상태를 들고 다닐 수 있고 타입마다 인스턴스화되어 인라인이 쉽다. - 람다는 컴파일러가 만들어주는 함수 객체이고, 캡처는 멤버 변수가 된다.
- 천만 개 정렬에서 함수 객체·람다는 약 500ms, 함수 포인터 약 720ms,
std::function약 950ms였다. - 알고리즘은 함수 객체를 복사하므로 술어에 상태를 두지 않는다.