Menu

카테고리

Delegate는 대리자라는 뜻을 가지고 있습니다. C#에 있는 기능인데 리턴값이나 인자가 같은 함수, 멤버 함수, 람다, functor 등을 종류에 상관없이 서로 일관된 방식으로 호출할 수 있게 해줍니다. C++에선 std::function으로 이런 기능을 사용할 수 있지만, 아래에서 소개할 몇 가지 단점 때문에 새로 만들어 보기로 합니다.

개요

함수, functor, 클래스 메서드와 같이 호출 가능한 것들을 통일된 방식으로 다루기 위해선 delegate가 필요합니다. C++ STL의 <functional> 헤더에 std::function을 사용할 수 있지만 다음과 같은 단점을 가지고 있습니다.

  • 기본 크기가 크다 – std::function은 할당 오버헤드를 줄이기 위해 크기가 작은 callable 객체들은 스택에 저장하고 큰 객체들은 동적할당을 통해 힙에 저장하는 방식을 취하고 있습니다. 하지만, 메모리를 거의 잡아먹지 않는 callable 객체(static 함수, 멤버함수, non-capturing 람다 등)을 담는 std::function은 사용하지 않는 메모리가 더 많습니다. 예: MSVC에서 x86 40바이트, x64 64바이트.
  • 멤버 함수를 호출할 수 없다 – std::function을 통해 멤버함수를 호출하는 방법은 존재하지만 std::bind나 람다를 사용해야 하므로 미관상 보기 좋은 코드가 되기 힘듭니다.
  • 서로 비교할 수 없다 – std::function은 내부 callable 객체를 쉽게 비교하기 어렵습니다.

이 중에서 크기가 크고 비교가 안된다는 단점이 크게 다가와서 이번에 만들어 볼 delegate는 메모리와 호출 성능, 멤버 함수의 바인딩을 우선순위로 둡니다.

호출 가능한 것들의 분류

C++에서 존재할 수 있는 호출 가능한 것들의 종류는 다양합니다.

  • 전역 함수
  • static 멤버함수
  • 멤버함수
  • const 멤버함수
  • functor
  • 람다

먼저 정적(static)이냐 비정적이냐로 우선적으로 분류할 수 있습니다. 일반적인 함수, static 멤버 함수, non capturing lambda등이 static에 해당하고 멤버 함수, capturing lambda, functor 등과 같이 인스턴스가 필요한 것들이 non static에 해당합니다.

그 다음 비정적 함수에선 객체를 소유하고 있는 대상이 무엇인가에 따라 또다시 분류합니다. lambda나 functor는 상태를 delegate가 내부적으로 소유하고 있어야 하기 때문에 stateful이라고 구분합니다. (비정적) 멤버 함수는 소유권이 delegate에 있지 않은 외부의 인스턴스를 참조하기 때문에 instance라고 구분합니다.

stateful의 경우 delegate 내부적으로 메모리를 관리해줘야 하기 때문에 추가 고려가 필요합니다.

가상함수에 관해서 더 최적화할 수 있는 부분이 있습니다. C++에서 가상함수의 포인터는 동적 바인딩이 작동하도록 만들어졌습니다. 하지만 delegate는 생성할 때부터 인스턴스가 이미 결정되어 있기 때문에 가상함수의 동적 바인딩은 필요 없습니다. 따라서 가상 함수 테이블을 풀어내서 성능을 향상하는 방법도 사용해 보겠습니다.

구현 목표와 메모리 고려

64바이트 기준으로 설명합니다. delegate에 필요한 데이터의 최소 크기를 고려하면 static 호출만 가능하면 함수 포인터만 저장하면 되므로 8바이트이면 충분합니다. non-static을 바인딩하려면 함수 포인터와 인스턴스 포인터가 필요하여 16바이트가 필요합니다. stateful을 바인딩하면 객체를 저장하기 위한 동적할당이 필요할 수 있습니다.

최종적으로는 가능한 한 작은 메모리 풋프린트를 목표로 하며, stateful 객체는 동적할당을 사용하되 이를 감지하여 적절히 해제합니다.

멤버 함수 바인딩과 가상함수 최적화

멤버 함수 포인터는 클래스나 컴파일러에 따라 크기가 달라질 수 있고, 일반 포인터로의 변환이 불가능한 경우가 있습니다. 이 문제를 해결하기 위해 멤버 함수 포인터를 정규화하는 기법을 사용합니다.

가상함수의 경우 delegate 생성 시점에 호출 대상이 정해져 있으므로 vtable 인덱스를 계산하여 실제 함수 포인터를 얻어내고 이를 사용하면 호출 오버헤드를 줄일 수 있습니다. 다만 이 부분은 컴파일러/아키텍처 의존적이므로 GCC/MSVC 대상에서만 안전하게 동작하도록 구현을 분기합니다.

구체적 구현

여기서는 핵심 구현 파일 delegate.hpp의 주요 부분과 데모 main.cpp를 제공합니다.

delegate.hpp (요약 발췌)

#pragma once

#include <type_traits>
#include <utility>
#include <cstring> // memcmp
#include <assert.h>

#if defined(__GNUC__) || defined(__GNUG__)
#define DEL_USES_GCC
#define DEL_INLINE inline
#elif defined(_MSC_VER)
#define DEL_USES_MSVC
#define DEL_INLINE __forceinline
#endif

#if defined(DEL_USES_GCC)
template <class Virtual_ptr>
ptrdiff_t _virtual_indexof(Virtual_ptr vfptr) {
    typedef struct {
        void** functionPtr;
        size_t offset;
    } MP;

    MP& t = *(MP*)&vfptr;
    if ((intptr_t)t.functionPtr & 1) {
        return (t.functionPtr - (void**)1);
    } else return -1;
}
#elif defined(DEL_USES_MSVC)
// MSVC-specific implementation (파싱 등) ...
#endif

// _Simplify_func, _Closure_ptr, Delegate 클래스 정의 등이 이어집니다.
// Delegate는 내부적으로 _Closure_ptr을 사용하여 inst와 fptr을 보관하고
// bind_static, bind_method, bind_stateful 등을 통해 다양한 callable을 바인딩합니다.

구현은 GCC/MSVC 별로 분기 처리하고 있으며, 가상함수 인덱스 추출과 멤버 함수 포인터 정규화 등 플랫폼 의존적 코드가 포함됩니다.

주요 동작

  • bind_method(Obj* ptr, Method to_bind) : 멤버함수를 바인딩합니다. 만약 Obj가 polymorphic이면 _virtual_indexof로 인덱스를 구해 vtable에서 직접 함수 포인터를 꺼내 옵니다.
  • bind_static(Static_func to_bind) : static 함수 바인딩을 위해 static_stub 같은 트릭을 사용합니다.
  • bind_stateful(Stateful obj) : capturing lambda나 functor 같은 상태를 가진 객체를 바인딩합니다. 크기가 작으면 내부에 저장하고 크면 동적할당을 사용합니다.
  • operator()invoke()를 통해 호출합니다. invoke()는 디버그시 빈 delegate 호출에 대해 assert 처리합니다.

데모: main.cpp

#include <iostream>
#include "delegate.hpp"

using namespace std;

void static_function(int a) {
    cout << "invoking static_function, a = " << a << endl;
}

class A {
public:
    void member_function(int a) const {
        cout << "invoking member_function, a = " << a << endl;
    }

    void virtual_function(int a) {
        cout << "invoking virtual_function, a = " << a << endl;
    }
};

int main() {
    A inst1, inst2;
    int b = 78978;

    Delegate<void(int)> del0 = static_function;
    auto del1 = make_delegate(&inst1, &A::member_function);
    auto del1_dup = make_delegate(&inst1, &A::member_function);
    Delegate<void(int)> del2;
    del2.bind(&inst2, &A::member_function);
    auto del3 = make_delegate(&inst1, &A::virtual_function);
    Delegate<void(int)> del4 = [](int a) { cout << "invoking non_capturing_lambda, a = " << a << endl; };
    Delegate<void(int)> del5 = [&b](int a) { cout << "invoking capturing_lambda, a = " << a << ", b = " << b << endl; };

    del0.invoke(0);
    del1.invoke(1);
    del2.invoke(2);
    del3.invoke(3);
    del4(4);
    del5(5);

    if (del1 == del1_dup) cout << "del1 == del1_dup\n";
    if (del1 == del2) cout << "del1 == del2\n";

    return 0;
}

정리 및 고려사항

  • Calling Convention, CV Qualifier, lambda/functor 비교 연산, is_virtual<T> 구현, 모든 컴파일러에서 동작하는 _virtual_indexof 구현 같은 항목은 추가 개선 고려 대상입니다.
  • 현재 구현은 GCC, MSVC (x86/x64) 중심으로 최적화되어 있으며, 다른 컴파일러/아키텍처에서는 동작하지 않을 가능성이 높습니다.

참고

  1. Member Function Pointers and the Fastest Possible (CodeProject) — https://www.codeproject.com/Articles/7150/Member-Function-Pointers-and-the-Fastest-Possible
  2. Detect the vtable index of a specific virtual function (StackOverflow) — https://stackoverflow.com/questions/5635212/detect-the-the-vtable-index-ordinal-of-a-specific-virtual-function-using-visual
  3. How to get direct function pointer to a virtual member function (StackOverflow) — https://stackoverflow.com/questions/20520756/how-to-get-direct-function-pointer-to-a-virtual-member-function
  4. Fast C++ Delegate / Boost.Function drop-in replacement (CodeProject) — https://www.codeproject.com/Articles/18389/Fast-C-Delegate-Boost-Function-drop-in-replacement

원문 출처: https://www.dandevlog.com/all/programming/351/ (작성일: 2023-03-10)