[C++] 항목 35: 가상 함수 대신 쓸 것들도 생각해 두는 자세를 시시때때로 길러 두자

업데이트:     Updated:

카테고리:

태그:

이 글은 아래의 책을 자세히 정리한 후, 정리한 글을 GPT에게 요약을 요청하여 작성되었습니다.
이펙티브 C++ 제3판, 스콧 마이어스 저자, 곽용재 번역

📦 6. 상속, 그리고 객체 지향 설계

👉🏻 항목 35: 가상 함수 대신 쓸 것들도 생각해 두는 자세를 시시때때로 길러 두자

✅ 기본적인 가상 함수 접근법

게임 캐릭터의 체력을 계산하는 함수를 다음과 같이 구현할 수 있다:

class GameCharacter {
public:
    // 캐릭터의 체력을 반환하는 단순 가상 함수
    virtual int healthValue() const;
    ...
};

// 체력을 계산하는 기본 알고리즘 제공
int GameCharacter::healthValue() const { ... }

하지만 가상 함수가 항상 최선의 선택은 아니다. 상황에 따라 더 나은 대안들이 있다.


✅ 방법 1: 비가상 인터페이스(NVI) 관용구를 통한 템플릿 메서드 패턴

class GameCharacter {
public:
    // 일반 함수이므로 재정의 불가
    int healthValue() const {
        ... // 사전 동작 수행
        int retVal = doHealthValue(); // 실제 동작 수행
        ... // 사후 동작 수행
        return retVal;
    }
    ...

private:
    // 가상 함수이므로, 파생 클래스는 재정의 가능
    // 체력을 계산하는 기본 알고리즘 제공
    virtual int doHealthValue() const { ... }
};

핵심 아이디어:

  • healthValue()는 이제 일반 함수이다.
  • private 가상 함수인 doHealthValue()가 간접적으로 호출된다.
  • 이를 비가상 함수 인터페이스(NVI) 관용구라고 부르며, 템플릿 메서드 디자인 패턴의 한 형태이다.

장점:

  • healthValue()는 가상 함수 doHealthValue()랩퍼(wrapper) 역할을 한다.
  • 사전/사후 동작을 통해 doHealthValue()가 안전하게 실행된다.
    • 예: 뮤텍스 잠금, 로그 정보 기록, 클래스의 불변속성 검증, 함수의 사전/사후 조건 확인

설계 특징:

  • doHealthValue()는 파생 클래스에서 재정의는 가능하지만, 직접 호출은 불가능하다.
  • 함수를 언제 호출하는지는 기본 클래스의 권한이다.
  • 반드시 private일 필요는 없다:
    • protected 멤버여야 할 경우도 있고 (항목 27의 Window::onResize())
    • public 멤버여야 할 경우도 있다 (항목 7의 다형성 기본 클래스의 소멸자)

✅ 방법 2: 함수 포인터로 구현한 전략 패턴

class GameCharacter;

// 체력을 계산하는 기본 알고리즘
int defaultHealthCalc(const GameCharacter& gc);

class GameCharacter {
public:
    // 함수 포인터
    // HealthCalcFunc == int (*)(const GameCharacter&);
    typedef int (*HealthCalcFunc)(const GameCharacter&);

    // 묵시적 형변환 불가한 생성자
    explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc)
    : healthFunc(hcf) {}

    int healthValue() const { return healthFunc(*this); }
    ...

private:
    HealthCalcFunc healthFunc;
};

class EvilBadGuy : public GameCharacter {
public:
    explicit EvilBadGuy(HealthCalcFunc hcf = defaultHealthCalc)
    : GameCharacter(hcf) { ... }
    ...
};

// 다른 동작을 하는 체력 계산 함수들
int loseHealthQuickly(const GameCharacter&);
int loseHealthSlowly(const GameCharacter&);

int main() {
    // 같은 타입이지만, 체력 계산이 다르게 됨
    EvilBadGuy ebg1(loseHealthQuickly);
    EvilBadGuy ebg2(loseHealthSlowly);
}

장점:

  • 전략 패턴을 사용하여 체력 계산 로직을 분리했다.
  • 각 객체마다 다른 체력 계산 함수를 가질 수 있다.
  • 게임 실행 도중, 체력 계산 함수를 동적으로 변경할 수 있다.

단점:

  • 체력 계산 함수 내에서 GameCharacter 계통 객체의 public 멤버가 아닌 데이터에 접근할 수 없다.
  • 접근 가능하게 하려면 클래스의 캡슐화를 약화시켜야 한다.

✅ 방법 3: function으로 구현한 전략 패턴

함수 포인터 대신 함수 객체를 사용하는 방법이다.

class GameCharacter;
int defaultHealthCalc(const GameCharacter& gc);

class GameCharacter {
public:
    // 함수호출성 개체
    // GameCharacter와 호환되는 모든 것을 받을 수 있고,
    // int와 호환되는 모든 타입의 객체를 반환한다
    typedef function<int (const GameCharacter&)> HealthCalcFunc;

    explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc)
    : healthFunc(hcf) {}

    int healthValue() const { return healthFunc(*this); }
    ...

private:
    HealthCalcFunc healthFunc;
};

핵심:

  • function<int (const GameCharacter&)>int (*)(const GameCharacter&)와 비슷하지만, 더 강력하다.
  • 대상 시그니처와 호환되는 함수호출성 개체를 어떤 것이든 가질 수 있다.
    • 호환된다: 시그니처와 동일한 타입이거나, 해당 타입으로 암시적 변환이 가능한 타입

다양한 사용 예시:

// 반환 타입이 int가 아닌 short인 함수도 호환됨!
short calcHealth(const GameCharacter&);

// 함수 객체
struct HealthCalculator {
    int operator()(const GameCharacter&) const { ... }
};

class GameLevel {
public:
    // 반환 타입이 int가 아닌 float인 멤버 함수도 호환됨!
    float health(const GameCharacter&) const;
    ...
};

class EvilBadGuy : public GameCharacter { ... };
class EyeCandyCharacter : public GameCharacter { ... };

int main() {
    // 일반 함수 포인터 사용
    EvilBadGuy ebg1(calcHealth);

    // 함수 객체 사용
    EyeCandyCharacter ecc1(HealthCalculator());

    GameLevel currentLevel;
    ...
    // 멤버 함수 포인터 사용 (bind로 감싸야 함)
    EvilBadGuy ebg2(
        bind(&GameLevel::health, currentLevel, _1)
    );
}

function이 받을 수 있는 것들:

  • 일반 함수 포인터: int (*)(const GameCharacter&)
  • 람다: [](const GameCharacter&) { ... }
  • 함수 객체: struct Foo { int operator()(const GameCharacter&) const; };
  • 멤버 함수 포인터: std::bind로 감싸서 사용

bind 사용법:

EvilBadGuy ebg2(bind(&GameLevel::health, currentLevel, _1));
  • &GameLevel::health → 멤버 함수 포인터
  • currentLevel → 어떤 GameLevel 객체를 쓸지 지정
  • _1 → 플레이스홀더로서, EvilBadGuy의 체력 계산 함수 호출 시 전달될 GameCharacter 자리

✅ 방법 4: “고전적인” 전략 패턴

class GameCharacter;

class HealthCalcFunc {
public:
    ...
    virtual int calc(const GameCharacter& gc) const { ... }
    ...
};

HealthCalcFunc defaultHealthCalc;

class GameCharacter {
public:
    explicit GameCharacter(HealthCalcFunc *phcf = &defaultHealthCalc)
    : pHealthCalc(phcf)
    {}

    int healthValue() const { return pHealthCalc->calc(*this); }
    ...

private:
    HealthCalcFunc* pHealthCalc;
};

특징:

  • HealthCalcFunc 클래스 계통을 확장에 열려있도록 만들어두었다.
  • 전략 패턴의 전통적인 구현 형태이다.

클래스 구조:

GameCharacter 계통   <- (Has-a)   HealthCalcFunc 계통
├── EvilBadGuy                    ├── SlowHealthLoser
└── EyeCandyCharacter             └── FastHealthLoser
  • GameCharacter는 HealthCalcFunc 객체를 포함(Composition) 한다.
  • 한쪽 클래스 계통에 속해 있는 가상 함수를 다른 쪽 계통에 있는 가상 함수로 대체한 형태이다.

✅ 네 가지 방법 요약

  1. 비가상 인터페이스(NVI) 관용구 사용
    • private 영역에 있는 가상 함수를 일반 public 멤버 함수로 감싸서 호출한다.
    • 템플릿 메서드 패턴의 한 형태이다.
  2. 가상 함수를 함수 포인터 데이터 멤버로 대체
    • 전략 패턴의 핵심이다.
  3. 가상 함수를 function 데이터 멤버로 대체
    • 호환되는 시그니처를 가진 함수호출성 개체를 사용할 수 있도록 한다.
    • 전략 패턴의 한 형태이다.
  4. 한쪽 클래스 계통에 속해 있는 가상 함수를 다른 쪽 계통에 있는 가상 함수로 대체
    • 전략 패턴의 전통적인 구현 형태이다.

🧐 정리

  1. 가상 함수 대신에 쓸 수 있는 다른 방법으로 NVI 관용구전략 패턴이 있다.
    이 중 NVI 관용구는 템플릿 메서드 패턴의 한 예이다.
  2. 객체에 필요한 기능을 멤버 함수에서 클래스 외부의 비멤버 함수로 옮기면,
    그 클래스의 public 멤버가 아닌 것들을 접근할 수 없어진다.
  3. function 객체는 일반화된 함수 포인터처럼 동작한다.
    이 객체는 주어진 대상 시그니처와 호환되는 모든 함수호출성 개체를 지원한다.

Cpp 카테고리 내 다른 글 보러가기

댓글남기기