C++ 고급 문법 (4) — 함수 객체(Function Object)

std::sort에 비교 함수를 넘기는 방법은 여러 가지다. 함수 포인터, operator()를 가진 클래스, 람다(lambda), std::function 중 무엇을 넘기든 결과는 같다. 그런데 천만 개를 정렬해 보면 실행 시간은 2배 가까이 벌어진다. 차이는 호출 대상이 타입에 새겨지느냐에서 나온다. 이 글에서는 operator()를 오버로드한 클래스, 즉 함수 객체(function object, functor)를 직접 만들어 보고, 람다·표준 함수 객체·std::function과의 관계와 알고리즘에 넘길 때의 함정을 g++ 13.3 실행 결과로 정리한다.

이 시리즈의 다른 글

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였다.
  • 알고리즘은 함수 객체를 복사하므로 술어에 상태를 두지 않는다.

참고

답글 남기기