C++ 고급 문법 (23) — 인터페이스(Interface)

Java나 C#에는 interface 키워드가 있지만 C++에는 없다. 대신 데이터 멤버 없이 순수 가상 함수와 가상 소멸자만 가진 추상 클래스를 인터페이스로 쓴다. 인터페이스를 두면 코드가 구체 클래스 대신 “이런 동작을 할 수 있는 것”에 의존하게 되어, 구현을 바꾸거나 테스트용 가짜 객체를 끼우기 쉬워진다. C++20부터는 상속 없이 컴파일 시점에 요구 조건을 거는 concept도 인터페이스 역할을 한다. 이 글에서는 g++ 13.3으로 다중 인터페이스 구현, 테스트 대역(test double) 주입, NVI 패턴, concept 기반 인터페이스, 인터페이스를 통한 복사 방지를 확인한다.

이 시리즈의 다른 글

순수 추상 클래스로 만드는 인터페이스

#include <cstdio>
#include <string>
#include <vector>

// 인터페이스: 데이터 없이 순수 가상 함수와 가상 소멸자만
struct Readable {
    virtual ~Readable() = default;
    virtual std::string read() = 0;
};
struct Writable {
    virtual ~Writable() = default;
    virtual void write(const std::string& s) = 0;
};

// 하나의 클래스가 여러 인터페이스를 구현
class MemoryFile : public Readable, public Writable {
    std::vector<std::string> lines_;
    std::size_t pos_ = 0;
public:
    void write(const std::string& s) override { lines_.push_back(s); }
    std::string read() override { return pos_ < lines_.size() ? lines_[pos_++] : "<eof>"; }
};

void produce(Writable& w) { w.write("hello"); w.write("world"); }   // 필요한 능력만 요구
void consume(Readable& r) { for (int i = 0; i < 3; ++i) std::printf("%s\n", r.read().c_str()); }

int main()
{
    MemoryFile f;
    produce(f);
    consume(f);
    std::printf("sizeof(Readable)=%zu sizeof(MemoryFile)=%zu\n", sizeof(Readable), sizeof(MemoryFile));
}
$ g++ -std=c++20 -Wall -o m multi.cpp
$ ./m
hello
world
<eof>
sizeof(Readable)=8 sizeof(MemoryFile)=48

produce는 쓰기만, consume은 읽기만 요구하므로 인터페이스를 작게 나눴다(interface segregation). 데이터가 없는 인터페이스는 다중 상속해도 다이아몬드 문제가 생기지 않고, 인터페이스마다 vptr 하나씩만 붙는다.

의존성 주입과 테스트 대역

#include <cstdio>
#include <string>
#include <vector>

struct Clock {                                   // 외부 의존성을 인터페이스로 추상화
    virtual ~Clock() = default;
    virtual long now_ms() const = 0;
};

struct Mailer {
    virtual ~Mailer() = default;
    virtual void send(const std::string& to, const std::string& body) = 0;
};

class SessionGuard {                             // 테스트 대상: 구체 타입을 모른다
    const Clock& clock_;
    Mailer& mailer_;
    long started_;
public:
    SessionGuard(const Clock& c, Mailer& m) : clock_(c), mailer_(m), started_(c.now_ms()) {}
    void check(const std::string& user)
    {
        if (clock_.now_ms() - started_ > 30'000) mailer_.send(user, "session expired");
    }
};

// 테스트용 가짜 구현
struct FakeClock : Clock {
    long t = 0;
    long now_ms() const override { return t; }
};
struct RecordingMailer : Mailer {
    std::vector<std::string> sent;
    void send(const std::string& to, const std::string& body) override { sent.push_back(to + ": " + body); }
};

int main()
{
    FakeClock clock;
    RecordingMailer mailer;
    SessionGuard g(clock, mailer);

    clock.t = 10'000; g.check("kim");
    std::printf("after 10s: %zu mails\n", mailer.sent.size());
    clock.t = 31'000; g.check("kim");
    std::printf("after 31s: %zu mails (%s)\n", mailer.sent.size(), mailer.sent.back().c_str());
}
$ g++ -std=c++20 -Wall -o mk mock.cpp
$ ./mk
after 10s: 0 mails
after 31s: 1 mails (kim: session expired)

SessionGuard는 실제 시계나 메일 서버 없이도 테스트할 수 있다. 시간을 원하는 값으로 바꾸고, 보낸 메일은 실제로 보내지 않고 기록만 해서 확인했다.

NVI 패턴

#include <cstdio>
#include <stdexcept>

// NVI(Non-Virtual Interface): 공개 함수는 비가상, 구현 지점은 private 가상
class Storage {
public:
    virtual ~Storage() = default;
    void put(int key, int value)                 // 사전·사후 조건을 한 곳에서 강제
    {
        if (key < 0) throw std::invalid_argument("negative key");
        do_put(key, value);
        ++writes_;
    }
    int writes() const { return writes_; }
private:
    virtual void do_put(int key, int value) = 0; // 파생 클래스는 이것만 구현
    int writes_ = 0;
};

class MemStorage : public Storage {
    void do_put(int key, int value) override { std::printf("  mem[%d]=%d\n", key, value); }
};

int main()
{
    MemStorage m;
    Storage& s = m;
    s.put(1, 100);
    try { s.put(-1, 5); } catch (const std::exception& e) { std::printf("  rejected: %s\n", e.what()); }
    std::printf("  writes=%d\n", s.writes());
}
$ g++ -std=c++20 -Wall -o nv nvi.cpp
$ ./nv
  mem[1]=100
  rejected: negative key
  writes=1

공개 인터페이스를 비가상 함수로 두면 인자 검증, 통계, 로깅 같은 공통 규칙을 파생 클래스가 우회할 수 없다. 파생 클래스는 private 가상 함수만 재정의한다(private 가상 함수도 재정의할 수 있다).

concept 기반 인터페이스

#include <concepts>
#include <cstdio>
#include <string>

// 컴파일 타임 인터페이스: 상속 없이 "이런 멤버가 있어야 한다"를 요구
template <typename T>
concept Serializer = requires(const T& s, int v) {
    { s.serialize(v) } -> std::convertible_to<std::string>;
    { T::format_name } -> std::convertible_to<const char*>;
};

struct JsonSer {
    static constexpr const char* format_name = "json";
    std::string serialize(int v) const { return "{\"v\":" + std::to_string(v) + "}"; }
};
struct CsvSer {
    static constexpr const char* format_name = "csv";
    std::string serialize(int v) const { return "v," + std::to_string(v); }
};

void dump(const Serializer auto& s, int v)
{
    std::printf("[%s] %s\n", s.format_name, s.serialize(v).c_str());
}

int main()
{
    dump(JsonSer{}, 7);
    dump(CsvSer{}, 7);
    std::printf("sizeof(JsonSer)=%zu (vptr 없음)\n", sizeof(JsonSer));
}
$ g++ -std=c++20 -Wall -o cp concept.cpp
$ ./cp
[json] {"v":7}
[csv] v,7
sizeof(JsonSer)=1 (vptr 없음)

상속도 vptr도 없이 “이 멤버가 있어야 한다”를 요구한다. 요구 조건을 만족하지 않으면 호출 지점에서 어떤 조건이 빠졌는지 알려준다.

#include <concepts>
#include <string>

template <typename T>
concept Serializer = requires(const T& s, int v) {
    { s.serialize(v) } -> std::convertible_to<std::string>;
    { T::format_name } -> std::convertible_to<const char*>;
};

struct XmlSer {                                   // format_name을 빠뜨림
    std::string serialize(int v) const { return "<v>" + std::to_string(v) + "</v>"; }
};

void dump(const Serializer auto& s, int v) { (void)s.serialize(v); }

int main() { dump(XmlSer{}, 1); }
$ g++ -std=c++20 -Wall -c concept_err.cpp 2>&1 | grep -E 'error|note: the required|format_name' | head -4
concept_err.cpp:16:18: error: no matching function for call to ‘dump(XmlSer, int)’
concept_err.cpp:7:10: note: the required expression ‘T::format_name’ is invalid
    7 |     { T::format_name } -> std::convertible_to<const char*>;
항목가상 함수 인터페이스concept
검사·바인딩 시점실행 시점 디스패치컴파일 시점
구현 클래스인터페이스 상속 필요상속 불필요 (조건만 맞으면 됨)
서로 다른 구현을 한 컨테이너에가능불가 (타입마다 다름)
런타임 교체·플러그인가능불가
오버헤드vptr, 간접 호출없음, 대신 타입마다 코드 생성

인터페이스를 통한 복사 막기

struct Shape {
    virtual ~Shape() = default;
    virtual double area() const = 0;
protected:                                        // 인터페이스 자체를 통한 복사·대입을 막는다
    Shape() = default;
    Shape(const Shape&) = default;
    Shape& operator=(const Shape&) = default;
};
struct Circle : Shape {
    double r = 1;
    double area() const override { return 3.14 * r * r; }
};

int main()
{
    Circle a, b;
    b.r = 2;
    a = b;                                        // 구체 타입끼리는 복사 가능
    Shape& ra = a;
    Shape& rb = b;
    ra = rb;                                      // 인터페이스 참조로 반쪽 대입은 금지
}
$ g++ -std=c++20 -Wall -c copy_slice.cpp 2>&1 | grep error
copy_slice.cpp:21:10: error: ‘constexpr Shape& Shape::operator=(const Shape&)’ is protected within this context

인터페이스의 복사 연산을 protected로 두면 구체 타입끼리의 복사는 그대로 되고, 인터페이스 참조를 통한 반쪽 대입(업캐스팅 편 참고)만 컴파일 에러가 된다.

주의사항

상황문제대응
인터페이스에 가상 소멸자 누락인터페이스 포인터로 delete 시 구현 소멸자 누락virtual ~I() = default;
인터페이스에 데이터 멤버 추가다중 상속 시 중복·다이아몬드 문제상태는 구현 클래스에
모든 기능을 담은 큰 인터페이스구현·가짜 객체 작성 부담역할별로 작게 분리
인터페이스 참조로 대입반쪽 대입복사 연산을 protected 또는 = delete
두 인터페이스에 같은 시그니처 함수재정의 하나가 양쪽을 모두 구현의미가 다르면 이름을 다르게
공개 가상 함수에 공통 검증 로직 중복파생 클래스가 검증을 빠뜨림NVI 패턴

마무리

  • C++ 인터페이스는 데이터 없이 순수 가상 함수와 가상 소멸자만 가진 추상 클래스다.
  • 인터페이스에 의존하게 만들면 구현 교체와 테스트 대역 주입이 쉬워진다.
  • 실행 시점 교체가 필요 없으면 C++20 concept으로 상속 없는 컴파일 시점 인터페이스를 만들 수 있다.

참고

답글 남기기