본문 바로가기

책 스터디

[Exceptional C++ Style] 18. 클래스 가상성

가상성이라...책 제목에 대한 번역 참 마음에 안 든다...

이 챕터에서는 가상 함수에 대한 공개(public, protected, private) 수준과 
부모 클래스의 소멸자를 가상 함수로 해야 하는가에 대해 이야기 하고 잇으며, 
그 이야기들로부터 좋은 설계를 위한 지침을 안내하고 있다.

상속 관계와 가상성은 늘 도마 위에 올라 지겹게 보는 내용이지만, 다시 한 번 리마인드하는 것도 나쁘지 않을 듯 하다.

1. 가상 함수들을 언제 public/protected/private으로 해야 할까? 

가상 함수의 공개 여부에 대해 표어를 하나 만들어보면 다음과 같을 것이다.
public은 '드물게', protected는 '가끔', private는 '기본적으로' 
사실 위 표어는 비단 가상 함수뿐 아니라 클래스의 다른 멤버들에 대해서도 마찬가지이다.

꼭 노출해야 할 멤버가 아닌 한, 모든 클래스 멤버들은 기본적으로 비공개로 하는 것이 좋다는 것을 누구나 알고 있다.
특별 케이스로 단순히 자료를 모으는 C 스타일 구조체를 제외하고는, 멤버 변수들은 최대한 그래야 한다.
그것이 바로 좋은 캡슐화이기 때문이다. 

이제 한가지 지침을 끌어내 가상 함수에 대한 이야기들로 국한 시켜보자.
인터페이스는 non-virtual로 하는 것을 선호할 것!

인터페이스는 공개 멤버로 구성된다.
- 위 이야기는 가상 함수들을 가능한 한 공개하지 말자고 하는 것이다.
흥미롭게도, C++ 표준 라이브러리는 이미 이 지침을 상당 부분 따르고 있다.
소멸자를 제외하고, 또 동일한 가상 함수가 클래스 템플릿 특수화에 나타나는 경우를 중복해서 계산하지 않으면,
표준 라이브러리에 있는 가상 함수들의 공개 - 비공개 통계는 6개 vs 142개 이다.

또한, 책에서는 WinFX (Windows longhorn의 프로그래밍 모형)에 대해서도 그 사례를 소개하고 있다.
책을 집필할 당시 WinFX에는 14,000개 이상의 클래스와 100,000개에 가까운 멤버 함수들이 있었다고 한다.

이렇게 큰 덩치의 클래스 라이브러리를 제작할 때는 반드시 지켜져야 하는 코딩 가이드라인이 마련되어 있어야 하고,
그 WinFX의 코딩 가이드라인에서 다음과 같이 이야기하고 있다고 한다.
.NET Framework Design Guideline (2004.01)

가상 함수를 통한 커스텀화는 보호된 메서드들을 통해서 제공하는 것을 권장한다.
...이하 생략... 
그럼, 실제 프로그래밍에서 자주 나타나는 이러한 패턴이 좋은 이유를 알아보자.

전통적으로, 아래 예제와 같이 많은 프로그래머들은 public 가상 함수들을 이용해서 기반 클래스를 작성하는 데 익숙하다.
(나 역시도 사실 이러한 방식에 많이 익숙한 편이다)
// 예제 1 - 흔히 사용되는 기반 클래스의 인터페이스

class Widget
{
public:
    virtual int Process(const Gadget&);
    virtual bool IsDone();
};
public 가상 함수들은 인터페이스임과 동시에 하위 클래스에서 커스텀화 할 수 있는 행동을 지정한다.
문제는 "동시에" 이 둘을 지원함에 있다.
따라서, public 가상 함수들은 요구와 목적이 다른 두 부류의 코드에게 동시에 공헌해야 한다.

1. public 함수를 인터페이스로 접근하는 부류
2. 파생된 클래스들로, 가상 함수들로 자신의 인터페이스를 확장, 커스텀화 한다.

즉, 하나의 public 가상 함수는 인터페이스를 지정하고, 구현 세부(내부적으로 커스텀화할 수 있는 행동)을 지정하는 것이다.

그럼 이렇게 분리되지 못한 두 개의 요구사항을 제대로 분리시키는 방법은 무엇이 있을까?

설계 패턴들 중 이와 매우 비슷한 문제를 다루는 것으로는 Template method 패턴이 있다.
그러나 지금 이 문제는 그 패턴의 문제보다 조금 더 가상 함수의 경우로 좁혀진 것이므로,
NVI(Non Virtual Interface) 패턴을 소개해 보겠다.
// 예제 2 - NVI 패턴 사용

class Widget
{
public:
    // 비가상 함수들로 인터페이스를 지정함
    int Process(const Gadget&);
    bool IsDone();

private:
    // 네이밍 참 마음에 안 드네...암튼...
    // 가상 함수는 private 영역으로 넘겨, 구현 세부는 파생 클래스에서 변경할 수 있도록
    // 그리고 인터페이스와는 분리를 시켰다.
    virtual int DoProcess1(const Gadget&);
    virtual int DoProcess2(const Gadget&);
    virtual bool DoIsDone();
};

위와 같이 NVI 패턴을 사용하면 인터페이스를 안정적으로, 비가상으로 만들 수 있으며,
그러면서도 커스텀화할 수 있는 행동들은 공개가 아닌 비공개 가상 함수들에게 위임할 수 있는 것이다.
즉, 인터페이스 지정과 구현 세부 지정이 깔끔하게 분리된 셈이다.

또한, NVI 패턴에는 여러 장점들이 있는 반면, 두드러지는 단점은 없다고 해도 과언이 아니다.

장점-1. 기반 클래스는 자신의 인터페이스와 방침을 완전히 제어할 수 있고, 변화에 더 안정적이다.

이는 인터페이스/구현부를 분리할 때 인터페이스부가 가지는 당연한 이득이므로 굳이 길게 설명하지 않곘다.

장점-2. 인터페이스와 구현이 좀 더 잘 분리되었으니, 서로 같은 모습을 가질 필요가 없다.

사용자들은 공개된 인터페이스 Process에 대해서만 알고 있으면 된다.
그러면서도, 함수의 세부적인 커스텀화는 두 개의 함수로 나눌 수 있는 유연함도 제공한다.

만약 public 가상 함수 Process를 두 개의 기능으로 분리시킨다면, 이는 인터페이스의 분리와 증가로 이어지게 되는 것이다.
따라서, 사용자는 둘 중 어느 것을 써야 하는지 헤깔리게 되고, 이는 불필요한 복잡성의 증가로 이어진다.

그럼 혹시나 논의될 수 단점들로는 무엇이 있을까?

단점-1. 함수 호출 비용?

Process 함수에서 DoProcess1을 호출하였다고 치자.
이 경우 inline 키워드를 붙인 한 줄짜리 코드로 만들면, 컴파일러는 이를 완전히 최적화한다.

단점-2. 복잡도 증가?

복잡도 측면에서 본다면 유일한 증가는 한 줄짜리 함수 호출의 추가이다.
나머지 부분에서는 복잡도가 전혀 증가할 거리가 없다.

인터페이스 자체는 변할 것이 없고, 따라서 클래스의 공개 함수 개수가 증가할 일도 없다.
시스템 전체로 보면 그 복잡도는 전혀 증가하지 않고 있는 것이다.

이 정도면 비가상 인터페이스의 장점이 무엇인지, 또 무엇이 좋은건지 이해할 수 있을 것이다.
그런데 가상 함수를 공개로 하지 말아야 한다면, protected로 해야할까 private으로 해야할까?

이것은 쉬운 이야기이다.
가상 함수의 목적은 파생 클래스에서 기반 클래스의 기능 명세를 자신만의 것으로 확장/변경하는 데에 있지,
기반 클래스의 함수를 파생 클래스에서 자유롭게 호출하라는 것은 아니다.
따라서, 아래와 같은 지침을 얻을 수 있다.
파생된 클래스가 기반 클래스의 가상 함수 구현을 반드시 차용해야 하는 경우가 아니라면,

가상 함수들을 private로 하는 것을 선호하자! 
하지만, 대부분의 상속 클래스 설계에서 인터페이스가 뚜렷하고, 그 세부 구현이 모두 달라야 할 경우
NVI 패턴도 사용할 경우도 있지만, 순수 가상 함수 형태를 오히려 더 많이 사용하지 않나 생각된다.

이는 어느 것이 더 좋다라기 보다는 구현하려는 클래스의 성격과 형태에 따라 달라질 수 있으므로,
특정 하나의 패턴에 대해서 무조건적으로 신봉하는 것은 바람직하지 않다 할 수 있겠다.

이제 가상 함수의 공개 여부에 대해서는 정리가 된 듯하니, 기반 클래스의 소멸자의 가상 여부에 대한 이야기로 넘어 가자.


2. 기반 클래스 소멸자에 대해...

이제 오래된 질문인 "기반 클래스 소멸자는 가상이어야 하는가?"를 다룰 때가 되었다.

실무에서 여러 프로그래머들과 일을 하다보면, 정론을 알고 있는 경우가 더 많긴 하지만, 간혹 아래와 같이 잘못된 인식을 가지고 있거나 실수를 하는 부류를 만날 수가 있다.

1. 클래스 소멸자는 무조건 가상 함수로 만들어 버리는 부류.

상속 관계에 놓이지 않을 클래스, 즉 다형성 지원을 하지 않아도 되는 클래스를 작성하면서,
소멸자 함수만 가상 함수로 만들면 이 클래스가 지불하지 않아도 되는 virtual table pointer 비용을 지불하게 된다.
class ABC
{
public:
    ~ABC() {}            // 비가상 소멸자
    virtual ~ABC() {}    // 가상 소멸자
}

int main()
{
    // ABC 클래스의 소멸자를 비가상/가상으로 할 경우 클래스의 크기는 아래와 같다.
    // 비가상 : 1 ( 크기가 없으나, 0으로 할 순 없으니 1로 한다)
    // 가상 : 4 (virtual table pointer를 위해 4바이트가 사용됨. 64비트면 8바이트가 됨)
    size_t size = sizeof(ABC);
}

2. 다형적으로 클래스 객체를 생성한 뒤 기반 클래스 포인터를 통해 삭제하면서도 가상 함수로 만들지 않는 부류.

이 경우 파생 클래스 객체를 기반 포인터 형태로 삭제시 결과는 "미정의 결과"이며, 그 미정의 결과는 메모리 누수이다.

상속 관계에 있는 객체가 소멸될 때 소멸자가 호출되는 순서는 생성자의 그것과 반대인 "자식 소멸 -> 부모 소멸"이다.
그런데, 이것이 가상 함수로 되어 있지 않으면, 기반 클래스 포인터 형식이므로 곧장 기반 클래스의 소멸자만 호출된다.
따라서, 자식 클래스에 관련된 메모리(멤버 변수들)는 소멸되지 않아서, 누수가 발생하는 것이다.
class Base {};

class Derived : public Base {};

int main()
{
    Base* pD = new Derived;

    // Base::~Base()가 가상이 아니므로, Derived::~Derived()는 호출되지 않는다.
    delete pD;
}
익히 잘 알고 있는 내용이지만, 다시 한 번 되짚어 보기에도 전혀 부족함이 없는 항목이다.
위 내용을 토대로 클래스 소멸자의 가상화 여부에 대한 지침을 내려보자.
기반 클래스 소멸자는 객체를 다형적으로(즉, 기반 클래스 포인터를 통해) 삭제하려 한다면, 
소멸자는 가상 함수로 작성해야 한다.
또한, 외부에서도 호출될 수 있도록 공개(public)여야 한다.