C++ 타입 소거(Type Erasure): std::function 내부 원리와 Concept·Model 직접 구현

이 글의 핵심

std::function에 람다와 함수 포인터를 모두 담을 수 있는 이유는 내부에서 구체 타입을 숨기는 타입 소거가 일어나기 때문입니다. 이 글은 이 구조를 직접 구현해 보며 any_cast 실패, function 복사 시 힙 할당 비용, any와 variant 중 무엇을 고를지 같은 실무 판단 기준을 정리합니다.

Type Erasure란?

타입 소거(Type Erasure)는 구체 타입 정보를 숨기고, 필요한 동작만 남긴 하나의 인터페이스로 여러 타입을 다루는 기법입니다. 상속 관계가 없어도 다형성을 얻을 수 있으며, 표준 라이브러리의 std::function, std::any가 이 방식으로 만들어져 있습니다.

#include <any>
#include <iostream>
#include <string>

int main() {
    std::any a = 10;
    std::cout << std::any_cast<int>(a) << '\n';          // 10

    a = 3.14;
    std::cout << std::any_cast<double>(a) << '\n';       // 3.14

    a = std::string("Hello");
    std::cout << std::any_cast<std::string>(a) << '\n';  // Hello
}

std::any는 가장 극단적인 형태의 타입 소거입니다. 어떤 타입이든 담을 수 있지만, 꺼낼 때 정확한 타입을 다시 알려 줘야 하고 담긴 값으로 할 수 있는 일이 거의 없습니다. 이 글에서 주로 다루는 것은 그 중간 형태로, “draw()를 할 수 있는 것이면 무엇이든”처럼 필요한 동작만 남기고 나머지 타입 정보를 지우는 방식입니다. std::function<int(int, int)>가 “int 두 개를 받아 int를 반환하는 호출 가능한 것이면 무엇이든”을 담는 것과 같은 원리입니다.

이렇게 하면 기존 타입을 고치지 않고도 공통 인터페이스로 묶을 수 있습니다. 가상 함수 방식에서는 Circle이 Shape를 상속해야 하지만, 타입 소거에서는 Circle이 draw()만 갖고 있으면 되므로 서드파티 라이브러리의 타입처럼 상속 관계를 바꿀 수 없는 타입도 같은 컨테이너에 넣을 수 있습니다.

// 가상 함수: Circle이 Shape를 상속해야 함
class Shape {
public:
    virtual void draw() const = 0;
    virtual ~Shape() = default;
};
class Circle : public Shape {
public:
    void draw() const override {}
};
std::vector<std::unique_ptr<Shape>> shapes;   // 포인터로 다룸

// 타입 소거: Circle은 Shape를 모름
class Circle2 {
public:
    void draw() const {}
};
std::vector<Drawable> drawables;   // Drawable은 아래에서 구현
drawables.push_back(Circle2{});    // 값으로 담음

Type Erasure란? 다이어그램(주요 항목: Drawable 외부 인터페이스, DrawableConcept 추상인터페이스, DrawableModel Circle, DrawableModel Rectangle, Circle 객체, Rectangle 객체)

특징가상 함수타입 소거
대상 타입의 상속필요불필요
구현 코드 양적음많음 (Concept·Model·외부 클래스)
호출 비용가상 호출 1회가상 호출 1회
생성 비용객체 생성 방식에 따라 다름보통 힙 할당 1회
사용 방식포인터·참조로 다룸값처럼 복사·이동 (구현한 경우)

호출 비용 행은 흔히 오해되는 부분입니다. 아래 수동 구현을 보면 알 수 있듯 타입 소거도 내부적으로는 가상 함수를 씁니다. 차이는 가상 함수 계층을 사용자 대신 라이브러리 안에 숨긴다는 것입니다. 그래서 호출 비용은 가상 함수와 같고, 추가 비용은 객체를 감쌀 때의 힙 할당 정도입니다. 대신 얻는 것이 값 의미론입니다. 가상 함수 방식은 std::vector<std::unique_ptr<Shape>>처럼 포인터를 다뤄야 하지만, 타입 소거 방식은 std::vector<Drawable>에 값처럼 넣고 꺼낼 수 있으며, int 같은 기본 타입까지 같은 인터페이스로 묶을 수 있습니다.

std::function

#include <functional>
#include <iostream>

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

struct Multiply {
    int operator()(int a, int b) const { return a * b; }
};

int main() {
    std::function<int(int, int)> f1 = add;                                   // 함수 포인터
    std::function<int(int, int)> f2 = [](int a, int b) { return a * b; };   // 람다
    std::function<int(int, int)> f3 = Multiply{};                            // 함수 객체

    std::cout << f1(2, 3) << '\n';  // 5
    std::cout << f2(2, 3) << '\n';  // 6
    std::cout << f3(2, 3) << '\n';  // 6
}

함수 포인터, 람다, 함수 객체는 C++ 타입 시스템에서 전부 다른 타입입니다. 람다는 하나하나가 컴파일러가 만든 고유한 클래스라, 똑같이 생긴 람다 두 개도 타입이 다릅니다. 그런데도 셋을 같은 std::function<int(int, int)> 변수에 담을 수 있는 것은, std::function이 생성될 때 넘어온 구체 타입을 기억하는 내부 모델 객체를 만들고 바깥에는 호출 시그니처만 남기기 때문입니다. 아래 수동 구현과 “함수 래퍼” 예제가 바로 그 내부 구조입니다.

std::function을 쓸 때 알아 둘 제약이 두 가지 있습니다. 비어 있는 std::function을 호출하면 std::bad_function_call 예외가 나고, 담을 callable이 복사 가능해야 합니다. std::unique_ptr을 캡처한 람다처럼 이동만 가능한 callable은 std::function에 넣을 수 없어 “use of deleted function” 에러가 나는데, C++23의 std::move_only_function이 이 문제를 해결합니다.

수동 Type Erasure 구현

Drawable이 unique_ptr로 DrawableConcept를 가리키고 DrawableModel<T>가 Circle과 Rectangle을 감싸 이를 구현하는 구조

그림: Drawable은 unique_ptr만 들고 있고, 템플릿 DrawableModel가 Circle이나 Rectangle을 감싸 draw()를 구현한다. 두 클래스가 서로 상속하지 않아도 같은 컨테이너에 담을 수 있는 이유다.

#include <iostream>
#include <memory>
#include <vector>

class Drawable {
private:
    struct DrawableConcept {
        virtual void draw() const = 0;
        virtual ~DrawableConcept() = default;
    };

    template<typename T>
    struct DrawableModel : DrawableConcept {
        T object;

        explicit DrawableModel(T obj) : object(std::move(obj)) {}

        void draw() const override {
            object.draw();
        }
    };

    std::unique_ptr<DrawableConcept> self;

public:
    template<typename T>
    Drawable(T obj) : self(std::make_unique<DrawableModel<T>>(std::move(obj))) {}

    void draw() const {
        self->draw();
    }
};

class Circle {
public:
    void draw() const { std::cout << "원 그리기\n"; }
};

class Rectangle {
public:
    void draw() const { std::cout << "사각형 그리기\n"; }
};

int main() {
    std::vector<Drawable> shapes;
    shapes.push_back(Circle());
    shapes.push_back(Rectangle());

    for (const auto& shape : shapes) {
        shape.draw();
    }
}

이 구현이 타입 소거 패턴의 전형입니다. 세 부분으로 나누어 보면 이해가 쉽습니다.

  • Concept(DrawableConcept)는 지원할 동작(draw)을 순수 가상 함수로 선언한 내부 인터페이스입니다. 사용자는 이 클래스를 볼 수 없습니다.
  • Model(DrawableModel<T>)은 템플릿으로 어떤 T든 감싸서 Concept를 구현합니다. draw()는 object.draw()를 그대로 호출할 뿐이라, T에 draw() const만 있으면 상속 없이 동작합니다. 이 자리가 “덕 타이핑”을 컴파일 타임에 검사하는 곳이기도 합니다. draw()가 없는 타입을 넣으면 “‘class Foo’ has no member named ‘draw’” 에러가 DrawableModel 내부를 가리키며 납니다.
  • 외부 클래스(Drawable)는 템플릿 생성자로 아무 타입이나 받아 Model을 만들고, unique_ptr<Concept>로 보관합니다. 생성자가 끝나는 순간 T라는 정보는 사라지고 “draw할 수 있는 무언가”만 남습니다. 이것이 “타입을 지운다”는 뜻입니다.

이 구현을 그대로 쓰면 곧 만나는 함정이 있습니다. 첫째, unique_ptr 멤버 때문에 Drawable은 이동만 가능하고 복사가 안 됩니다. vector<Drawable>에 넣는 것은 되지만 Drawable d2 = d1;은 컴파일되지 않습니다. 복사가 필요하면 아래 “복사 지원하기”처럼 clone()을 추가해야 합니다.

둘째, 더 교묘한 문제로 생성자를 최적화하려고 template<typename T> Drawable(T&& obj)처럼 전달 참조로 바꾸는 순간 템플릿 생성자가 복사·이동 생성자를 가로챕니다. Drawable d2 = d1;에서 d1이 비const 좌측값이면 T = Drawable&인 템플릿이 const Drawable&를 받는 복사 생성자보다 더 정확히 일치해 선택되고, Drawable을 다시 DrawableModel<Drawable>로 감싸려다 알아보기 어려운 에러를 쏟아 내거나, 복사 가능한 구현이라면 에러 없이 감싼 것을 또 감싸는 이상한 객체가 만들어집니다. 위 코드처럼 값으로 받는 Drawable(T obj)는 “첫 매개변수가 X 자신인 생성자 시그니처는 템플릿에서 만들어지지 않는다”는 표준 규칙 때문에 T = Drawable로 인스턴스화되지 않아 이 문제를 피합니다. 전달 참조를 쓴다면 C++20의 template<typename T> requires (!std::same_as<std::remove_cvref_t<T>, Drawable>)처럼 자기 자신은 제외하는 제약을 거는 것이 정석입니다.

복사 지원하기

Concept에 자기 복제 함수를 추가하면 외부 클래스도 복사할 수 있게 됩니다.

class Drawable {
    struct DrawableConcept {
        virtual void draw() const = 0;
        virtual std::unique_ptr<DrawableConcept> clone() const = 0;
        virtual ~DrawableConcept() = default;
    };

    template<typename T>
    struct DrawableModel : DrawableConcept {
        T object;
        explicit DrawableModel(T obj) : object(std::move(obj)) {}
        void draw() const override { object.draw(); }
        std::unique_ptr<DrawableConcept> clone() const override {
            return std::make_unique<DrawableModel>(object);  // T의 복사 생성자 사용
        }
    };

    std::unique_ptr<DrawableConcept> self;

public:
    template<typename T>
    Drawable(T obj) : self(std::make_unique<DrawableModel<T>>(std::move(obj))) {}

    Drawable(const Drawable& other) : self(other.self->clone()) {}
    Drawable& operator=(const Drawable& other) {
        if (this != &other) self = other.self->clone();
        return *this;
    }
    Drawable(Drawable&&) noexcept = default;
    Drawable& operator=(Drawable&&) noexcept = default;

    void draw() const { self->draw(); }
};

복사 생성자를 직접 선언하면 이동 생성자가 암시적으로 만들어지지 않으므로, 위처럼 이동 연산도 = default로 명시해야 vector 재할당 때 복사 대신 이동이 일어납니다. 이동된 뒤의 Drawable은 self가 비어 있으므로, 그 객체에 draw()를 호출하면 널 포인터 역참조가 된다는 점도 기억해 두세요.

예시 1: 콜백 시스템

#include <functional>
#include <iostream>
#include <vector>

class EventSystem {
private:
    std::vector<std::function<void(int)>> handlers;

public:
    template<typename Func>
    void subscribe(Func&& handler) {
        handlers.push_back(std::forward<Func>(handler));
    }

    void trigger(int value) {
        for (auto& handler : handlers) {
            handler(value);
        }
    }
};

void globalHandler(int x) {
    std::cout << "전역 함수: " << x << '\n';
}

struct Printer {
    void operator()(int x) const {
        std::cout << "함수 객체: " << x << '\n';
    }
};

int main() {
    EventSystem events;
    events.subscribe(globalHandler);
    events.subscribe([](int x) { std::cout << "람다: " << x << '\n'; });
    events.subscribe(Printer());
    events.trigger(42);
}

EventSystem은 핸들러를 어떤 형태로 받을지 전혀 신경 쓰지 않습니다. subscribe를 템플릿으로 만들고 std::forward로 넘긴 덕분에 임시 람다는 이동되고, 이름 있는 함수 객체는 복사되어 std::function 안에 저장됩니다. 이런 콜백 목록은 타입 소거가 가장 자연스럽게 쓰이는 곳입니다.

이 구조에서 실제로 문제가 되는 것은 해제입니다. std::function은 서로 비교할 수 없어서(operator==가 없음) 등록한 람다를 나중에 찾아서 지울 방법이 없습니다. 보통 subscribe가 id나 구독 토큰을 반환하게 하고, 토큰으로 해제하도록 설계합니다. 또 람다가 this나 지역 변수를 참조로 캡처한 채 등록되면, 객체가 먼저 파괴된 뒤 trigger가 호출될 때 댕글링 참조로 크래시합니다. 이벤트 시스템에서 생기는 크래시의 흔한 원인이 바로 이것이라, 등록한 쪽의 소멸자에서 반드시 구독이 해제되도록 RAII 토큰을 쓰는 것이 가장 확실한 해결책입니다.

예시 2: 타입 안전 any

#include <iostream>
#include <memory>
#include <string>

class TypeSafeAny {
private:
    struct Holder {
        virtual ~Holder() = default;
    };

    template<typename T>
    struct HolderImpl : Holder {
        T value;
        explicit HolderImpl(T v) : value(std::move(v)) {}
    };

    std::unique_ptr<Holder> content;

public:
    template<typename T>
    TypeSafeAny(T value) : content(std::make_unique<HolderImpl<T>>(std::move(value))) {}

    template<typename T>
    T* get() {
        if (auto* holder = dynamic_cast<HolderImpl<T>*>(content.get())) {
            return &holder->value;
        }
        return nullptr;
    }
};

int main() {
    TypeSafeAny a = 42;

    if (int* p = a.get<int>()) {
        std::cout << *p << '\n';  // 42
    }
    if (std::string* p = a.get<std::string>()) {
        std::cout << *p << '\n';
    } else {
        std::cout << "타입 불일치\n";
    }
}

이 TypeSafeAny는 std::any의 원리를 보여 줍니다. Concept인 Holder에는 동작이 하나도 없고(가상 소멸자뿐) 값만 담습니다. 동작이 없으니 꺼낼 때 원래 타입을 물어보는 수밖에 없고, 여기서는 dynamic_cast로 “혹시 HolderImpl<T>인가?”를 확인합니다. 그래서 std::any류의 타입 소거는 정확히 같은 타입으로만 꺼낼 수 있습니다. int를 넣고 long으로 꺼내면 변환 가능한 타입인데도 실패합니다. 이 구현은 dynamic_cast를 쓰므로 RTTI가 꺼진 빌드(-fno-rtti)에서는 동작하지 않고, 실제 std::any 구현은 typeid 비교나 타입별 고유 주소 같은 더 가벼운 방법으로 타입을 확인합니다.

예시 3: 플러그인 시스템

#include <iostream>
#include <memory>
#include <string>
#include <vector>

class Plugin {
private:
    struct PluginConcept {
        virtual void execute() = 0;
        virtual std::string getName() const = 0;
        virtual ~PluginConcept() = default;
    };

    template<typename T>
    struct PluginModel : PluginConcept {
        T plugin;
        explicit PluginModel(T p) : plugin(std::move(p)) {}
        void execute() override { plugin.execute(); }
        std::string getName() const override { return plugin.getName(); }
    };

    std::unique_ptr<PluginConcept> self;

public:
    template<typename T>
    Plugin(T plugin) : self(std::make_unique<PluginModel<T>>(std::move(plugin))) {}

    void execute() { self->execute(); }
    std::string getName() const { return self->getName(); }
};

struct AudioPlugin {
    void execute() { std::cout << "오디오 처리\n"; }
    std::string getName() const { return "AudioPlugin"; }
};

struct VideoPlugin {
    void execute() { std::cout << "비디오 처리\n"; }
    std::string getName() const { return "VideoPlugin"; }
};

int main() {
    std::vector<Plugin> plugins;
    plugins.push_back(AudioPlugin());
    plugins.push_back(VideoPlugin());

    for (auto& plugin : plugins) {
        std::cout << plugin.getName() << ": ";
        plugin.execute();
    }
}

“플러그인 시스템”이라는 이름에는 주의가 필요합니다. 이 예제의 플러그인은 모두 같은 프로그램에서 컴파일되는 타입이라, 템플릿 생성자가 컴파일 시점에 PluginModel<AudioPlugin>을 만들 수 있습니다. 공유 라이브러리(.so, .dll)로 따로 빌드해 런타임에 불러오는 진짜 플러그인이라면 템플릿이 경계를 넘을 수 없으므로, 결국 C 함수로 노출된 팩토리와 순수 가상 인터페이스(또는 C ABI 함수 테이블)를 쓰게 됩니다. 타입 소거가 빛나는 곳은 한 프로그램 안에서 서로 관련 없는 타입들을 같은 컨테이너에 모아야 할 때입니다.

std::any 활용과 함정

#include <any>
#include <iostream>
#include <map>
#include <string>

class PropertyBag {
private:
    std::map<std::string, std::any> properties;

public:
    template<typename T>
    void set(const std::string& key, T value) {
        properties[key] = std::move(value);
    }

    template<typename T>
    T get(const std::string& key) {
        return std::any_cast<T>(properties[key]);
    }
};

int main() {
    PropertyBag props;
    props.set("width", 800);
    props.set("title", std::string("My App"));

    std::cout << props.get<int>("width") << '\n';
    std::cout << props.get<std::string>("title") << '\n';
}

PropertyBag은 편리하지만 함정이 세 개 숨어 있습니다. 첫째, "title"을 std::string("My App")이 아니라 "My App"으로 넘기면 T가 const char*로 추론되어 저장되므로, get<std::string>이 std::bad_any_cast를 던집니다. std::any는 넣을 때의 정확한 타입을 기억하기 때문입니다. 둘째, get이 properties[key]를 쓰므로 없는 키를 조회하면 빈 any가 맵에 새로 삽입된 뒤 캐스트가 실패합니다. 조회 함수가 컨테이너를 바꾸는 것은 예상하기 어려운 부작용이라 find나 at을 써야 합니다. 셋째, 값을 복사해 반환하므로 큰 객체라면 매번 복사 비용이 듭니다. 이런 이유로 설정처럼 들어갈 타입이 정해져 있다면 std::map<std::string, std::variant<int, double, std::string>>이 더 안전합니다. 타입 목록이 컴파일 타임에 정해지고, std::visit로 모든 경우를 처리하도록 강제할 수 있습니다.

타입이 확실하지 않을 때는 예외 대신 포인터 버전의 any_cast로 확인합니다.

std::any a = 42;

// std::string s = std::any_cast<std::string>(a);  // std::bad_any_cast 예외

if (int* p = std::any_cast<int>(&a)) {   // 타입이 다르면 nullptr
    std::cout << *p << '\n';
}

std::function에 큰 객체를 캡처할 때

std::vector<int> bigData(1000000);

// 값 캡처: 벡터 전체가 람다 안으로 복사되고, std::function을 복사할 때마다 다시 복사됨
std::function<void()> f1 = [bigData]() { /* ... */ };

// 참조 캡처: 복사는 없지만 bigData가 f2보다 오래 살아야 함
std::function<void()> f2 = [&bigData]() { /* ... */ };

값 캡처한 람다는 bigData 전체를 람다 객체 안에 복사하고, std::function은 그 람다를 내부 버퍼에 담을 수 없어 힙에 할당합니다. 그리고 std::function 자체를 복사할 때마다 람다와 캡처한 벡터도 통째로 복사됩니다. 콜백 목록을 값으로 넘기거나 vector<function<...>>가 재할당될 때(이동 생성자가 noexcept가 아니어서 복사로 대체되는 구현이라면) 이 비용이 반복됩니다.

참조 캡처는 bigData가 f2보다 오래 산다는 보장이 있을 때만 올바른 해결책입니다. f2를 멤버 변수나 이벤트 목록에 저장하고 bigData가 먼저 사라지면 댕글링 참조가 됩니다. 소유권을 람다로 넘기고 싶다면 [data = std::move(bigData)]로 이동 캡처하고, 여러 콜백이 함께 써야 한다면 std::shared_ptr로 감싸 포인터만 캡처하는 편이 안전합니다. 이동 캡처한 람다도 std::function을 복사하는 순간 벡터가 복사되므로, 복사가 잦다면 std::move_only_function이나 shared_ptr 방식을 고려하세요.

실무 패턴

메시지 큐

#include <cstdint>
#include <iostream>
#include <memory>
#include <queue>
#include <string>
#include <vector>

class Message {
private:
    struct MessageConcept {
        virtual void process() = 0;
        virtual ~MessageConcept() = default;
    };

    template<typename T>
    struct MessageModel : MessageConcept {
        T message;
        explicit MessageModel(T msg) : message(std::move(msg)) {}
        void process() override { message.process(); }
    };

    std::unique_ptr<MessageConcept> self_;

public:
    template<typename T>
    Message(T msg) : self_(std::make_unique<MessageModel<T>>(std::move(msg))) {}

    void process() { self_->process(); }
};

struct TextMessage {
    std::string content;
    void process() const { std::cout << "텍스트: " << content << '\n'; }
};

struct ImageMessage {
    std::vector<std::uint8_t> data;
    void process() const { std::cout << "이미지: " << data.size() << " bytes\n"; }
};

int main() {
    std::queue<Message> queue;
    queue.push(TextMessage{"Hello"});
    queue.push(ImageMessage{{1, 2, 3}});

    while (!queue.empty()) {
        Message msg = std::move(queue.front());
        queue.pop();
        msg.process();
    }
}

메시지 큐에 타입 소거를 쓰면 TextMessage와 ImageMessage가 공통 기반 클래스 없이 값으로 한 큐에 들어갑니다. 큐가 Message 객체를 소유하므로 new/delete를 직접 쓸 일도 없습니다. 각 메시지 타입은 process() 하나만 제공하면 되고, 새 메시지 종류를 추가해도 큐 코드는 바뀌지 않습니다. Message가 이동 전용이라 꺼낼 때는 위처럼 front()를 이동한 뒤 pop()합니다. 여러 스레드가 이 큐를 쓴다면 std::queue는 스레드 안전하지 않으므로 뮤텍스로 감싸야 합니다.

메시지 종류가 닫혀 있다면(모든 종류를 알고 있고 자주 늘지 않는다면) std::variant<TextMessage, ImageMessage>가 더 나은 선택일 수 있습니다. 힙 할당이 없고, std::visit가 모든 종류를 처리했는지 컴파일러가 검사해 줍니다. 타입 소거는 종류가 열려 있어서 큐를 정의하는 쪽이 모든 메시지 타입을 미리 알 수 없을 때 가치가 있습니다.

커맨드 패턴

#include <memory>
#include <utility>
#include <vector>

class Command {
private:
    struct CommandConcept {
        virtual void execute() = 0;
        virtual void undo() = 0;
        virtual ~CommandConcept() = default;
    };

    template<typename T>
    struct CommandModel : CommandConcept {
        T command;
        explicit CommandModel(T cmd) : command(std::move(cmd)) {}
        void execute() override { command.execute(); }
        void undo() override { command.undo(); }
    };

    std::unique_ptr<CommandConcept> self_;

public:
    template<typename T>
    Command(T cmd) : self_(std::make_unique<CommandModel<T>>(std::move(cmd))) {}

    void execute() { self_->execute(); }
    void undo() { self_->undo(); }
};

struct AddCommand {
    int& value;
    int delta;
    void execute() { value += delta; }
    void undo() { value -= delta; }
};

int main() {
    int value = 0;
    std::vector<Command> history;
    history.push_back(AddCommand{value, 10});
    history.back().execute();  // value = 10
    history.back().undo();     // value = 0
}

커맨드 패턴은 실행 취소(undo) 기록을 남기려면 서로 다른 커맨드 타입을 한 vector에 쌓아야 하므로 타입 소거와 잘 맞습니다. 이 예제에서 조심할 부분은 AddCommand가 int& value로 대상을 참조한다는 점입니다. 기록이 대상 객체보다 오래 살아남는 구조라면(문서를 닫았는데 undo 기록은 남아 있는 경우 등) 참조가 댕글링됩니다. 또 참조 멤버가 있는 구조체는 복사 대입 연산자가 암시적으로 삭제되므로, AddCommand 자체를 vector에 담아 정렬하거나 대입하려 하면 컴파일 에러가 납니다(Command로 감싸면 힙에 있는 객체를 포인터로 옮기므로 문제가 없습니다). 실무에서는 대상을 id나 핸들로 가리키고 실행 시점에 찾아오는 방식이 더 안전합니다.

함수 래퍼

template<typename Signature>
class Function;

template<typename R, typename... Args>
class Function<R(Args...)> {
private:
    struct Callable {
        virtual R call(Args... args) = 0;
        virtual ~Callable() = default;
    };

    template<typename F>
    struct CallableImpl : Callable {
        F func;
        explicit CallableImpl(F f) : func(std::move(f)) {}
        R call(Args... args) override {
            return func(std::forward<Args>(args)...);
        }
    };

    std::unique_ptr<Callable> callable_;

public:
    template<typename F>
    Function(F func) : callable_(std::make_unique<CallableImpl<F>>(std::move(func))) {}

    R operator()(Args... args) {
        return callable_->call(std::forward<Args>(args)...);
    }
};

// 사용
Function<int(int, int)> add = [](int a, int b) { return a + b; };
std::cout << add(2, 3) << '\n';  // 5

template<typename R, typename... Args> class Function<R(Args...)>는 부분 특수화로 함수 타입 int(int, int)를 반환 타입 R과 매개변수 목록 Args...로 분해합니다. std::function<int(int, int)>라는 익숙한 문법이 이렇게 만들어집니다.

이 최소 구현과 실제 std::function의 가장 큰 차이는 작은 객체 최적화(SBO, small buffer optimization)입니다. 이 구현은 캡처 없는 람다 하나를 담을 때도 make_unique로 힙 할당을 하지만, 표준 구현은 객체 안에 작은 버퍼를 두고 함수 포인터나 캡처 없는 람다처럼 크기가 작은 callable은 그 버퍼에 직접 저장해 힙 할당을 피합니다. 버퍼 크기와 조건은 구현마다 다릅니다. libstdc++는 포인터 두 개 크기(16바이트)이면서 예외 없이 이동 가능한 callable만 버퍼에 넣고, 다른 구현은 더 큰 버퍼를 쓰기도 합니다. 타입 소거를 직접 구현해 성능이 문제가 된다면 가장 먼저 도입하는 최적화가 이것입니다.

std::function에 캡처 크기가 다른 람다를 담았을 때 힙 할당 횟수를 operator new 계측으로 센 실행 결과

그림: 실제 실행 결과 — operator new를 가로채 센 힙 할당 횟수. 이 환경(libc++)에서는 캡처가 16바이트까지는 객체 안 버퍼에 들어가 할당이 0회이고, 24바이트부터 1회다. 임계값은 표준 라이브러리 구현마다 다르다. (환경: macOS 27.0.1 · Apple clang 21.0.0)

성능과 대안

std::function 호출은 간접 호출 한 번이라 호출 자체의 비용은 가상 함수 호출과 비슷합니다. 실제로 비용이 드러나는 것은 호출보다 인라인이 막힌다는 점입니다. 정렬 비교 함수처럼 한 줄짜리 함수가 수백만 번 호출되는 핫 루프에서는, 인라인되지 않으면 컴파일러가 루프 전체를 최적화하지 못해 차이가 커집니다. 그래서 std::sort처럼 성능이 중요한 알고리즘은 비교 함수를 std::function이 아니라 템플릿 매개변수로 받습니다. 정확한 차이는 CPU와 컴파일러에 따라 크게 달라지므로 필요하면 직접 측정하세요.

타입 소거를 쓰기 전에 다른 선택지를 먼저 따져 볼 만합니다.

  • 호출하는 쪽에서 타입이 컴파일 타임에 정해진다면 템플릿 매개변수로 받는 것이 가장 빠르고 단순합니다.
  • 들어올 타입 목록이 닫혀 있다면 std::variant와 std::visit가 힙 할당 없이 같은 일을 하고, 빠뜨린 경우를 컴파일러가 잡아 줍니다.
  • 타입들을 직접 고칠 수 있고 포인터로 다뤄도 괜찮다면 평범한 가상 함수 상속이 코드가 가장 짧습니다.
  • 정적 다형성이 필요하다면 CRTP도 선택지입니다.

직접 구현이 필요할 만큼 특수한 경우가 아니라면 std::function, std::move_only_function, std::any를 먼저 쓰는 편이 좋습니다. 타입 소거를 더 깊이 다룬 자료로는 Klaus Iglberger의 “C++ Software Design”, Sean Parent의 발표 “Inheritance Is The Base Class of Evil”, Boost.TypeErasure 라이브러리 문서가 있습니다.

참고 자료



같이 보면 좋은 글