C++ 예제로 배우는 소프트웨어 설계 기법 (7) — 템플릿 인자로 전달하는 정책(Template Strategy)

6편에서는 변하는 정책을 가상 함수나 인터페이스 객체로 분리했다. 그런데 단일 스레드에서만 쓰는 리스트도 동기화 정책을 가상 함수로 받으면, 아무 일도 하지 않는 lock()을 매번 간접 호출하는 비용을 낸다. 정책을 실행 중에 바꿀 일이 없다면 템플릿 인자로 받아 컴파일 시간에 결정할 수 있다. 정책 기반 설계(policy-based design)로 불리는 방식이다. 이 글에서는 동기화 정책을 템플릿 인자로 받는 List를 만들고, 가상 함수 전략과의 성능·코드 차이, 빈 정책의 크기, concept으로 정책 조건 걸기를 g++ 13.3으로 확인한다.

이 시리즈의 다른 글

동기화 정책을 템플릿 인자로

#include <cstdio>
#include <mutex>
#include <thread>
#include <vector>

struct NoLock {                                   // 단일 스레드용: 아무것도 안 한다
    void lock() {}
    void unlock() {}
};

struct MutexLock {
    std::mutex m;
    void lock() { m.lock(); }
    void unlock() { m.unlock(); }
};

template <typename T, typename ThreadModel = NoLock>   // 동기화 정책을 템플릿 인자로
class List {
    struct Node { T v; Node* next; };
    Node* head_ = nullptr;
    std::size_t size_ = 0;
    ThreadModel tm_;
public:
    ~List() { while (head_) { Node* n = head_->next; delete head_; head_ = n; } }
    void push_front(const T& v)
    {
        tm_.lock();
        head_ = new Node{v, head_};
        ++size_;
        tm_.unlock();
    }
    std::size_t size() const { return size_; }
};

template <typename L>
void fill_from_threads(L& list)
{
    std::vector<std::thread> ts;
    for (int t = 0; t < 4; ++t) ts.emplace_back([&] { for (int i = 0; i < 100'000; ++i) list.push_front(i); });
    for (auto& t : ts) t.join();
}

int main()
{
    List<int> single;                             // 기본 정책 NoLock
    for (int i = 0; i < 3; ++i) single.push_front(i);
    std::printf("  single: size=%zu\n", single.size());

    List<int, MutexLock> shared;
    fill_from_threads(shared);
    std::printf("  MutexLock from 4 threads: size=%zu\n", shared.size());
}
$ g++ -std=c++20 -Wall -O2 -pthread -o pl policy_list.cpp
$ ./pl
  single: size=3
  MutexLock from 4 threads: size=400000

같은 List 코드에서 NoLock이면 잠금이 사라지고 MutexLock이면 4개 스레드가 동시에 넣어도 40만 개가 정확히 들어간다. STL의 std::allocator, std::less도 같은 방식으로 받는 정책이다.

가상 함수 전략과 비교

#include <chrono>
#include <cstdio>

// (1) 실행 시간 전략: 가상 함수 인터페이스
struct ISync { virtual ~ISync() = default; virtual void lock() = 0; virtual void unlock() = 0; };
struct NoSync : ISync { void lock() override {} void unlock() override {} };

struct CounterV {
    ISync* sync; long n = 0;
    void inc() { sync->lock(); ++n; sync->unlock(); }
};

// (2) 컴파일 시간 전략: 템플릿 인자
struct NoLock { void lock() {} void unlock() {} };
template <typename Sync>
struct CounterT {
    Sync sync; long n = 0;
    void inc() { sync.lock(); ++n; sync.unlock(); }
};

__attribute__((noipa)) void run_v(CounterV& c, long iters) { for (long i = 0; i < iters; ++i) c.inc(); }
__attribute__((noipa)) void run_t(CounterT<NoLock>& c, long iters) { for (long i = 0; i < iters; ++i) c.inc(); }
__attribute__((noipa)) ISync* make_sync() { static NoSync s; return &s; }   // 컴파일러가 타입을 추측하지 못하게

template <typename F>
double ms(F f) { auto t = std::chrono::steady_clock::now(); f(); return std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t).count(); }

int main()
{
    const long N = 200'000'000;
    CounterV v{make_sync()};
    CounterT<NoLock> t;
    double a = ms([&] { run_v(v, N); });
    double b = ms([&] { run_t(t, N); });
    std::printf("  virtual  : %7.1f ms (n=%ld)\n", a, v.n);
    std::printf("  template : %7.1f ms (n=%ld)\n", b, t.n);
}
$ g++ -std=c++20 -Wall -O2 -o bn bench.cpp
$ ./bn
  virtual  :   105.0 ms (n=200000000)
  template :     0.0 ms (n=200000000)
$ ./bn
  virtual  :   104.2 ms (n=200000000)
  template :     0.0 ms (n=200000000)
$ objdump -d --no-show-raw-insn bn | awk "/^[0-9a-f]+ <_Z5run_t/,/ret/" | c++filt
00000000000013b0 <run_t(CounterT<NoLock>&, long)>:
    13b0:	endbr64
    13b4:	test   %rsi,%rsi
    13b7:	jle    13bd <run_t(CounterT<NoLock>&, long)+0xd>
    13b9:	add    %rsi,0x8(%rdi)
    13bd:	ret
$ objdump -d --no-show-raw-insn bn | awk "/^[0-9a-f]+ <_Z5run_v/,/^\$/" | c++filt | grep -E ">:|call"
0000000000001320 <run_v(CounterV&, long)>:
    1376:	call   *%rdx
    138c:	call   *%rax

템플릿 버전은 빈 lock()/unlock()이 인라인되어 루프가 n += iters 한 줄로 접혔다. 가상 함수 버전은 실제 타입을 모르므로 루프마다 call *%rdx 간접 호출이 남는다.

정책이 다르면 다른 타입

#include <mutex>
struct NoLock { void lock() {} void unlock() {} };
struct MutexLock { std::mutex m; void lock() { m.lock(); } void unlock() { m.unlock(); } };
template <typename T, typename TM = NoLock> class List { TM tm_; };

int main()
{
    List<int, NoLock> a;
    List<int, MutexLock> b;
    a = b;                                        // 정책이 다르면 서로 다른 타입
}
$ g++ -std=c++20 -c types_differ.cpp 2>&1 | grep error
types_differ.cpp:10:9: error: no match for ‘operator=’ (operand types are ‘List<int, NoLock>’ and ‘List<int, MutexLock>’)

List<int, NoLock>과 List<int, MutexLock>은 서로 대입할 수도, 한 컨테이너에 담을 수도 없다. 실행 중에 정책을 바꿔야 한다면 6편의 전략 패턴을 쓴다.

빈 정책은 공간을 차지하지 않게

#include <cstdio>
#include <cstdlib>
#include <memory>

struct NoLock { void lock() {} void unlock() {} };
struct NoCheck { static void check(int) {} };

template <typename T, typename Lock, typename Check>
struct Plain {                                    // 빈 정책도 멤버로 두면 최소 1바이트 + 패딩
    T* data; Lock lock; Check check;
};

template <typename T, typename Lock, typename Check>
struct Packed {
    T* data;
    [[no_unique_address]] Lock lock;              // C++20: 빈 멤버가 공간을 차지하지 않게
    [[no_unique_address]] Check check;
};

struct FreeDeleter { void operator()(void* p) const { std::free(p); } };

int main()
{
    std::printf("  Plain<int,NoLock,NoCheck>  = %zu\n", sizeof(Plain<int, NoLock, NoCheck>));
    std::printf("  Packed<int,NoLock,NoCheck> = %zu\n", sizeof(Packed<int, NoLock, NoCheck>));
    // 표준 라이브러리도 정책을 템플릿 인자로 받는다
    std::printf("  unique_ptr<int, FreeDeleter>       = %zu\n", sizeof(std::unique_ptr<int, FreeDeleter>));
    std::printf("  unique_ptr<int, void(*)(void*)>    = %zu\n", sizeof(std::unique_ptr<int, void (*)(void*)>));
}
$ g++ -std=c++20 -Wall -o eb ebo.cpp
$ ./eb
  Plain<int,NoLock,NoCheck>  = 16
  Packed<int,NoLock,NoCheck> = 8
  unique_ptr<int, FreeDeleter>       = 8
  unique_ptr<int, void(*)(void*)>    = 16

빈 클래스 멤버도 주소가 필요해 패딩까지 붙지만, C++20 [[no_unique_address]]나 빈 기반 클래스 최적화(EBO)를 쓰면 0바이트가 된다. unique_ptr도 상태 없는 삭제자는 포인터 크기 그대로지만, 함수 포인터 삭제자는 그만큼 커진다.

concept으로 정책 조건 걸기

#include <concepts>
#include <cstdio>

template <typename P>
concept LockPolicy = requires(P p) { p.lock(); p.unlock(); };   // 정책이 지켜야 할 조건

struct NoLock { void lock() {} void unlock() {} };
struct BadLock { void acquire() {} void release() {} };          // 이름이 다름

template <typename T, LockPolicy TM = NoLock>
class List { TM tm_; public: void push(const T&) { tm_.lock(); tm_.unlock(); } };

int main()
{
    List<int> ok;
    ok.push(1);
    List<int, BadLock> bad;
}
$ g++ -std=c++20 -c concept_policy.cpp 2>&1 | grep -E "error|note: the required" | head -3
concept_policy.cpp:17:22: error: template constraint failure for ‘template<class T, class TM>  requires  LockPolicy<TM> class List’
concept_policy.cpp:5:44: note: the required expression ‘p.lock()’ is invalid
concept_policy.cpp:5:56: note: the required expression ‘p.unlock()’ is invalid

정책이 지켜야 할 조건을 concept으로 명시하면 엉뚱한 정책을 넘겼을 때 어떤 멤버가 빠졌는지 바로 알려준다.

독립적인 정책 조합

#include <cstdio>
#include <cstring>
#include <string>

// 서로 독립인 두 정책: 저장 위치 x 로그 출력
struct HeapStore {
    std::string buf;
    void put(const char* s) { buf += s; }
    const char* data() const { return buf.c_str(); }
};
template <std::size_t N> struct FixedStore {     // 동적 할당 없는 버퍼 (임베디드용)
    char buf[N] = {}; std::size_t len = 0;
    void put(const char* s) { for (; *s && len + 1 < N; ++s) buf[len++] = *s; }
    const char* data() const { return buf; }
};
struct Quiet   { static void trace(const char*) {} };
struct Verbose { static void trace(const char* s) { std::printf("    trace: put \"%s\"\n", s); } };

template <typename Store, typename Log = Quiet>
class Buffer {
    Store st_;
public:
    Buffer& operator<<(const char* s) { Log::trace(s); st_.put(s); return *this; }
    const char* str() const { return st_.data(); }
};

int main()
{
    Buffer<HeapStore> a;
    a << "hello, " << "policy";
    std::printf("  heap/quiet   : %s\n", a.str());

    Buffer<FixedStore<8>, Verbose> b;             // 정책 조합만 바꿔 새 클래스
    b << "hello, " << "policy";
    std::printf("  fixed8/verbose: %s (sizeof=%zu)\n", b.str(), sizeof(b));
}
$ g++ -std=c++20 -Wall -o og orthogonal.cpp
$ ./og
  heap/quiet   : hello, policy
    trace: put "hello, "
    trace: put "policy"
  fixed8/verbose: hello,  (sizeof=16)

저장 위치와 로그처럼 서로 무관한 정책은 따로 받으면 조합 수만큼 클래스를 만들지 않아도 된다. 8바이트 고정 버퍼라 7글자에서 잘렸다.

기준전략 (가상 함수)정책 (템플릿 인자)
———
결정 시점실행 시간컴파일 시간
실행 중 교체가능불가
호출 비용간접 호출, 인라인 어려움인라인 가능, 빈 정책은 0
정책이 다른 객체를 한 컨테이너에가능불가 (타입이 다름)
코드 크기하나정책 조합마다 인스턴스화
정책 조건 검사인터페이스 상속concept

주의사항

상황문제대응
———
정책 조합이 많음조합마다 코드가 생성되어 바이너리 증가자주 쓰는 조합만 별칭(using)으로 고정
빈 정책을 일반 멤버로 둠객체 크기 증가[[no_unique_address]] 또는 EBO
정책 조건이 문서로만 존재템플릿 깊은 곳에서 긴 에러concept으로 명시
정책을 공개 기반 클래스로 상속정책 멤버가 공개 인터페이스에 섞임private 상속 또는 멤버
헤더에 구현 노출정책 변경 시 전체 재컴파일정책과 무관한 부분은 비템플릿 기반 클래스로 분리

마무리

  • 실행 중 바꿀 필요가 없는 정책은 템플릿 인자로 받아 컴파일 시간에 결정한다.
  • 빈 정책 호출은 인라인되어 사라지므로 단일 스레드 사용자가 동기화 비용을 내지 않는다.
  • 정책이 다르면 타입이 달라지므로, 실행 중 교체나 이종 컨테이너가 필요하면 가상 함수 전략을 쓴다.

참고

답글 남기기