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

상속, 그리고 객체 지향 설계, 네 번째 이야기

Posted by SungBeom on December 23, 2019 · 18 mins read

가상 함수를 이용한 설계

게임 개발팀에서 게임에 등장하는 캐릭터를 클래스로 설계하는 작업을 한다고 가정합시다. 캐릭터의 체력이 얼마나 남았는지를 나타내는 정수 값을 반환하는 healthValue라는 이름의 멤버 함수를 제공하기로 정합니다. 체력을 어떻게 계산하는지는 캐릭터마다 다를 것이 뻔하므로, 이 함수를 가상 함수로 선언하는 것이 확실한 설계일 것 같습니다.
healthValue 함수가 순수 가상 함수로 선언되지 않은 것으로 보아, 체력치를 계산하는 기본 알고리즘이 제공된다는 사실을 알 수 있습니다.

1
2
3
4
5
class GameCharacter {
public:
    virtual int healthValue() const();  // 캐릭터의 체력치를 반환하는 함수로서,
    ...                                 // 파생 클래스는 이 함수를
};                                      // 재정의할 수 있습니다.
cs

누가 뭐라 할 것도 없는, 너무나 당연한 설계입니다. 당연 그 자체의 설계이기 때문에, 이것 말고 적당한 다른 방법을 떠올리는 것도 잘 안 됩니다. 다른 방법이 없는지 생각해 봅시다.

비가상 인터페이스 관용구를 통한 템플릿 메서드 패턴

이번 이야기는 "가상 함수는 반드시 private 멤버로 두어야 한다"고 주장하는 소위 '가상 함수 은페론'으로 시작하려고 합니다. 이 이론이 제안하는 더 괜찮은 설계는, healthValue를 public 멤버 함수로 그대로 두되 비가상 함수로 선언하고, 내부적으로는 실제 동작을 맡은 private 가상 함수를 호출하는 식으로 만드는 것입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class GameCharacter {
public:
    int healthValue() const            // 파생 클래스는 이제 이 함수를
    {                                  // 재정의할 수 없습니다.
        ...                            // "사전" 동작을 수행합니다.
        int retVal = doHealthValue();  // 실제 동작을 수행합니다.
        ...                            // "사후" 동작을 수행합니다.
        return retVal;
    }
    ...
private:
    virtual int doHealthValue() const  // 파생 클래스는 이 함수를
    {                                  // 재정의할 수 있습니다.
        ...                            // 캐릭터의 체력치 계산을 위한
    }                                  // 기본 알고리즘 구현
};
cs

코드를 보면 아시겠지만, 멤버 함수의 본문이 클래스 정의 안에 들어가 있습니다. 이렇게 하면 암시적으로 인라인 함수로 선언되지요. 여기까지가 기본 설계입니다. 사용자로 하여금 public 비가상 멤버 함수를 통해 private 가상 멤버 함수를 간접적으로 호출하게 만드는 방법으로, 비가상 함수 인터페이스(non-virtual interface: NVI) 관용구라고 많이 알려져 있지요. 사실 이 관용구는 템플릿 메서드(Template Method)라 불리는 고전 디자인 패턴을 C++ 식으로 구현한 것입니다.

NVI 관용구의 이점은 코드에 주석문으로 써둔 "사전 동작" 및 "사후 동작"에 전부 다 들어 있습니다. 가상 함수가 호출되기 전에 어떤 상태를 구성하고 가상 함수가 호출된 후에 그 상태를 없애는 작업이 관용구에 쓰이는 비가상 함수를 통해 공간적으로 보장된다는 뜻입니다. 뮤텍스 잠금을 건다든지, 로그 정보를 만든다든지, 클래스의 불변속성과 함수의 사전조건이 만족되었나를 검증하는 작업 등이 "사전" 동작의 예입니다. 뮤텍스 잠금을 푼다든지, 함수의 사후조건을 점검하고 클래스의 불변속성을 재검증하는 작업 등이 "사후" 동작의 예입니다.

NVI 관용구를 쓰면 private 가상 함수를 파생 클래스에서 재정의하게 될 텐데, 이것은 설계상의 모순이 아닙니다. 가상 함수를 재정의하는 일은 어떤 동작을 어떻게 구현할 것인가를 지정하는 것이고, 가상 함수를 호출하는 일은 그 동작이 수행될 시점을 지정하는 것입니다. NVI 관용구에서는 파생 클래스의 가상 함수 재정의를 허용하기 때문에, 어떤 기능을 어떻게 구현할지를 조정하는 권한은 파생 클래스가 갖게 되지만, 함수를 언제 호출할지를 결정하는 것은 기본 클래스만의 고유 권한입니다.

따져 보면 NVI 관용구에서 가상 함수는 엄격하게 private 멤버일 필요가 없습니다. 어떤 클래스 계통의 경우엔, 파생 클래스에서 재정의되는 가상 함수가 기본 클래스의 대응 함수(같은 이름의 가상 함수)를 호출할 것을 예상하고 설계된 것도 있는데, 이런 경우에 적법한 함수 호출이 되려면 그 가상 함수가 private 멤버가 아니라 protected 멤버이어야 합니다.
간혹 가상 함수가 심지어 public 멤버이어야 할 때도 있지만(다형성 기본 클래스의 소멸자가 그 예입니다), 여기까지 오면 사실 NVI 관용구를 적용하는 의미가 없지요.

함수 포인터로 구현한 전략 페턴

앞에서 보셨듯이 NVI 관용구는 public 가상 함수를 대신할 수 있는 꽤 괜찮은 방법이지만, 클래스 설계의 관점에서 보면 눈속임이나 다름없습니다. 어쨌든 게임 캐릭터의 체력치를 계산하는 데 가상 함수를 사용하는 것은 여전하니까요. 조금 더 극적인 설계 쪽으로 가 본다면, 캐릭터의 체력치를 계산하는 작업은 캐릭터의 타입과 별개로 놓는 편이 맞을 것입니다. 다시 말해, 체력치 계산이 구태여 어떤 캐릭터의 일부일 필요가 없다는 말이죠. 한 예로, 각 캐릭터의 생성자에 체력치 계산용 함수의 포인터를 넘기게 만들고, 이 함수를 호출해서 실제 계산을 수행하도록 하면 되지 않을까요?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class GameCharacter;  // 전방 선언
 
// 체력치 계산에 대한 알고리즘을 구현한 함수
int defaultHealthCalc(const GameCharacter& gc);
 
class GameCharacter {
public:
    typedef int (*HealthCalcFunc)(const GameCharacter&);
 
    explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc)
    : healthFunc(hcf)
    {}
 
    int healthValue() const
    { return healthFunc(*this); }
    ...
private:
    HealthCalcFunc healthFunc;
};
cs

이 방법은 디자인 패턴인 전략(Strategy) 패턴의 단순한 응용 예입니다. GameCharacter 클래스 계통에 가상 함수를 심는 방법과 비교하면, 꽤 재미있는 융통성을 갖고 있습니다. 같은 캐릭터 타입으로부터 만들어진 객체(인스턴스)들도 체력치 계산 함수를 각각 다르게 가질 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
class EvilBadGuy: public GameCharacter {
public:
    explicit EvilBadGuy(HealthCalcFunc hcf = defaultHealthCalc)
    : GameCharacter(hcf)
    { ... }
    ...
};
 
int loseHealthQuickly(const GameCharacter&);  // 다른 동작 원리로 구현된
int loseHealthSlowly(const GameCharacter&);   // 체력치 계산 함수들
 
EvilBadGuy ebg1(loseHealthQuickly);           // 같은 타입인데도 체력치 변화가
EvilBadGuy ebg2(loseHealthSlowly);            // 다르게 나오는 캐릭터들
cs

게임이 실행되는 도중에 특정 캐릭터에 대한 체력치 계산 함수를 바꿀 수 있습니다. 예를 들어 GameCharacter 클래스에서 setHealthCalculator라는 멤버 함수를 제공하고 있다면 이를 통해 현재 쓰이는 체력치 계산 함수의 교체가 가능해지는 것이죠.

체력치 계산 함수가 이제 GameCharacter 클래스 계통의 멤버 함수가 아니라는 점은, 체력치가 계산되는 대상 객체의 비공개 데이터는 이 함수로 접근할 수 없다는 뜻도 됩니다. 예를 들어, defaultHealthCalc 함수는 EvilBadGuy 객체의 public 멤버가 아닌 부분을 건드릴 수 없습니다. 그 캐릭터의 public 인터페이스로 얻은 정보만을 사용해서 캐릭터의 체력치를 계산할 수 있게 되어 있다면 문제가 없겠지만, 정확한 계산을 위해서 public 멤버가 아닌 정보를 써야 할 경우에는 문제가 발생합니다. 사실 이 부분은 클래스 내부의 기능(말하자면 멤버 함수를 통해)을 그 클래스의 바깥에 있는 동등한 기능(말하자면 비멤버 비프렌드 함수 혹은 다른 클래스의 비프렌드 멤버 함수를 통해)으로 대체하려고 하면 언제든 생기는 고민거리입니다.

public 영역에 없는 부분을 비멤버 함수도 접근할 수 있게 하려면 그 클래스의 캡슐화를 약화시키는 방법밖에는 없다는 것이 일반적인 법칙입니다. 이를테면 비멤버 함수를 프렌드로 선언해 놓는다든지, 지금처럼 부득이한 이유가 아니면 숨겨 놓는 것이 더 나을지도 모르는 세부 구현사항에 대해 접근자 함수를 public 멤버로 제공하는 일 등이 있겠습니다.

tr1::function으로 구현한 전략 패턴

템플릿과 암시적 인터페이스에 대해 어색하지 않은 사람이라면 함수 포인터 기반의 방법이 뭔가 꽉 막혀 보일 수 있습니다. 그냥 함수처럼 동작하는 다른 타입을 쓰고, 반환 값도 int로 바꿀 수 있는 임의의 타입이면 충분하거든요. tr1::function 타입의 객체를 써서 기존의 함수 포인터(healthFunc가 그 예죠)를 대신하게 만드는 순간 이 모든 것이 시원하게 사라집니다. tr1::function 계열의 함수호출성 개체(callable entity)(풀어서 말하면 함수 포인터, 함수 객체 혹은 멤버 함수 포인터)를 가질 수 있고, 이들 개체는 주어진 시점에서 예상되는 시그니처와 호환되는 시그니처를 갖고 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class GameCharacter;
int defaultHealthCalc(const GameCharacter& gc);
 
class GameCharacter {
public:
    typedef std::tr1::function<int (const GameCharacter&)>
        HealthCalcFunc;
 
    explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc)
    : healthFunc(hcf)
    {}
 
    int healthValue() const
    { return healthFunc(*this); }
    ...
private:
    HealthCalcFunc healthFunc;
};
 
cs

HealthCalcFunc는 tr1::function 함수 템플릿을 인스턴스화한 것에 대한 typedef 타입입니다. tr1::function을 인스턴스화하기 위해 매개변수로 쓰인 "대상 시그니처"를 그대로 읽으면 "const GameCharacter에 대한 참조자를 받고 int를 반환하는 함수"입니다. 이렇게 정의된 tr1::function 타입(다시 말해 HealthCalcFunc 타입)으로 만들어진 객체는 앞으로 대상 시그니처와 호환되는 함수호출성 개체를 어떤 것도 가질 수 있습니다.
여기서 '호환된다(compatible)'라는 말은, 함수호출성 개체의 매개변수 타입이 const GameCharacter&이거나 const GameCharacter&으로 암시적 변환이 가능한 타입이며, 변환 타입도 암시적으로 int로 변환될 수 있다는 뜻입니다.

바로 앞에서 살펴본 설계(GameCharacter가 함수 포인터를 물게 했던)와 비교하면, 지금 설계도 사실 크게 다른 것은 없습니다. 다른 점이 있다면 GameCharacter가 이제는 tr1::function 객체, 그러니까 좀더 일반화된 함수 포인터를 물게 된다는 거죠.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
short calcHealth(const GameCharacter&);          // 체력치 계산 함수입니다.
                                                 // 반환 타입이 int가 아닙니다.
struct HealthCalculator {                        // 체력치 계산용 함수 객체를
    int operator()(const GameCharacter&const   // 만들기 위한 클래스
    { ... }
};
 
class GameLevel {
public:
    float health(const GameCharacter&const;    // 체력치 계산에 쓰일 멤버
    ...                                          // 함수입니다. 반환 타입이
};                                               // int가 아닙니다.
 
class EvilBadBoy: public GameCharacter {
    ...
};
 
class EyeCandyCharacter: public GameCharacter {  // 또 하나의 캐릭터 타입
    ...                                          // 생성자는 EvilBadGuy와
};                                               // 똑같다고 가정합니다.
 
EvilBadGuy ebg1(calcHealth);                     // 체력치 계산을 위한
                                                 // 함수를 사용하는 캐릭터
EyeCandyCharacter eccl(HealthCalculator());      // 체력치 계산을 위한
                                                 // 함수 객체를 사용하는 캐릭터
GameLevel currentLevel;
...
EvilBadGuy ebg2 {                                // 체력치 계산을 위한
    std::tr1::bind(&GameLevel::health,           // 멤버 함수를 사용하는 캐릭터
        currentLevel, _1)
};
cs

위의 정의문이 말하는 바는, ebg2의 체력치를 계산하기 위해 GameLevel 클래스의 health 멤버 함수를 써야 한다는 것입니다. 현재, GameLevel::health 함수는 매개변수 하나(GameCharacter에 대한 참조자)를 받는 것으로 선언되어 있지만, 실제로는 두 개를 받습니다. GameLevel 객체 하나를 암시적으로 받아들이니까 말이죠. 이 객체는 this 포인터가 가리키는 것입니다. 하지만 GameCharacter 객체에 쓰는 체력치 계산 함수가 받는 매개변수는 체력치가 계산되는 GameCharacter 객체, 그것 하나뿐입니다. 만일 ebg2의 체력치 계산에 GameLevel::health 함수를 쓰려고 한다면, 어떻게든 "때려 맞추어야(adapt)" 할 것입니다. 매개변수 두 개(GameCharacter 및 GameLevel)를 받는 함수를 매개변수 한 개(GameCharacter)만 받는 함수로 바꿔야 한단 말이죠. 지금의 예제 코드에서는 ebg2의 체력치 계산에 쓸 GameLevel 객체로서 currentLevel만을 쓸 생각이므로, 우리는 GameLevel::health 함수가 호출될 때마다 currentLevel이 사용되도록 "묶어" 준 것입니다. tr1::bind는 바로 이 묶기 작업을 맡았습니다. 다시 말해, ebg2의 체력치 계산 함수는 항상 currentLevel만을 GameLevel 객체로 쓴다고 지정한 것이죠. "_1"은 "ebg2에 대해 currentLevel과 묶인 GameLevel::health 함수를 호출할 때 넘기는 첫 번째 자리의 매개변수"를 뜻합니다. 어쨌든 우리는 함수 포인터 대신에 tr1::function을 사용함으로써, 사용자가 게임 캐릭터의 체력치를 계산할 때, 시그니처가 호환되는 함수호출성 개체는 어떤 것도 원하는 대로 구사할 수 있도록 융통성을 활짝 열어 줬다 이겁니다.

"고전적인" 전략 패턴

더 전통적인 방법으로 구현한 전략 패턴을 보여드릴까 합니다. 체력치 계산 함수를 나타내는 클래스 계통을 아예 따로 만들고, 실제 체력치 계산 함수는 이 클래스 계통의 가상 멤버 함수로 만드는 것입니다. 상속 계통의 최상위 클래스가 GameCharacter이고 EvilBadGuy 및 EyeCandyCharacter는 여기서 갈라져 나온 파생 클래스이며, 한편 HealthCalcFunc는 SlowHealthLoser 및 FastHealthLoser 등을 파생 클래스로 거느린 최상위 클래스입니다. 그리고 GameCharacter 타입을 따르는 모든 객체는 HealthCalcFunc 타입의 객체에 대한 포인터를 포함하지요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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;
};
cs

이 방법은 "표준적인" 전략 패턴 구현 방법에 친숙한 경우에 빨리 이해할 수 있다는 점에서 매력적입니다. 게다가 HealthCalcFunc 클래스 계통에 파생 클래스를 추가함으로써 기존의 체력치 계산 알고리즘을 조정/개조할 수 있는 가능성을 열어 놓았다는 점도 플러스이고요.

요약

비가상 인터페이스 관용구(NVI 관용구)를 사용합니다: 공개되지 않은 가상 함수를 비기상 public 멤버 함수로 감싸서 호출하는, 템플릿 메서드 패턴의 한 형태입니다.
가상 함수를 함수 포인터 데이터 멤버로 대체합니다: 군더더기 없는 전략 패턴의 핵심만을 보여주는 형태입니다.
가상 함수를 tr1::function 데이터 멤버로 대체하여, 호환되는 시그니처를 가진 함수호출성 개체를 사용할 수 있도록 만듭니다: 역시 전략 패턴의 한 형태입니다.
한쪽 클래스 계통에 속해 있는 가상 함수를 다른 쪽 계통에 속해 있는 가상 함수로 대체합니다: 전략 패턴의 전통적인 구현 형태입니다.


정리

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