[Effective C++]public 상속 모형은 반드시 "is-a(...는 ...의 일종이다)"를 따르도록 만들자

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

Posted by SungBeom on December 20, 2019 · 11 mins read

public 상속의 의미: is-a

여러분이 클래스 D("Derived")를 클래스 B("Base")로부터 public 상속을 통해 파생시켰다면, 여러분은 C++ 컴파일러에게 이렇게 말한 것과 똑같습니다. D 타입으로 만들어진 모든 객체는 또한 B 타입 객체이지만, 그 반대는 되지 않는다고요. 다시 말해 B는 D보다 더 일반적인 개념을 나타내며, D는 B보다 더 특수한 개념을 나타낸다고 알리는 것입니다. 그러니까, B 타입의 객체가 쓰일 수 있는 곳에는 D 타입의 객체도 마찬가지로 쓰일 수 있다고 단전(assert)하는 것이죠. 반면, D 타입이 필요한 부분에 B 타입의 객체를 쓰는 것은 불가능합니다. 모든 D는 B의 일종이지만(D is a B), B는 D의 일종이 아니기 때문입니다.

C++은 public 상속을 이렇게 해석하도록 문법적으로 지원하고 있습니다.

1
2
class Person { ... };
class Student: public Person { ... };
cs

모든 학생들은 사람이지만 모든 사람이 학생은 아니라는 사실은 일상적인 경험을 통해 모두 알고 있습니다. 위의 클래스 계통이 말해 주는 바 그대로죠. 사람에 해당되는 사실은 어떤 것이든(예를 들면, 누구나 생일이 있다는 점) 학생에게도 해당된다고 예상할 수 있는 것입니다. 하지만 학생에 해당되는 모든 것들이(어떤 학교에 다니고 있다는 점 등) 일반적인 사람에게도 해당될 거라고 기대하지는 않습니다. '사람'은 '학생'보다 더 일반적인 개념입니다. 학생은 사람을 더 특수하게 만든 한 종류이고요.

C++로 와서 보면, Person 타입(Person에 대한 포인터 혹은 Person에 대한 참조자도 됩니다)의 인자를 기대하는 함수는 Student 객체(역시, Student에 대한 포인터 혹은 Student에 대한 참조자도 됩니다)도 받아들일 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
void eat(const Person& p);     // 먹는 것은 누구든 합니다.
void study(const Student& s);  // 학과 공부는 학생만 합니다.
 
Person p;                      // p는 Person의 일종입니다.
Student s;                     // s는 Student의 일종입니다.
 
eat(p);                        // 문제 없습니다. p는 Person이니까요.
eat(s);                        // 문제 없습니다. s는 Student이고,
                               // Student는 Person의 일종이니까요.
study(s);                      // 문제 없습니다.
study(p);                      // 에러입니다! p는 Student가 아닙니다.
cs

이 이야기는 public 상속에서만 통합니다. private 상속은 의미 자체가 완전히 다르고, protected 상속은 의미가 아리아리합니다. public 상속과 is-a 관계가 똑같은 뜻이라는 이야기는 꽤 직관적이고 간단하긴 하지만, 그 직관 때문에 판단을 잘못하는 경우도 있습니다. 예를 하나 들자면, 펭귄이 새의 일종이라는 점은 누구나 아는 사실이고, 새라는 개념만 보면 새가 날 수 있다는 점도 사실입니다.

1
2
3
4
5
6
7
8
9
class Bird {
public:
    virtual void fly();       // 새는 날 수 있습니다.
    ...
};
 
class Penguin: public Bird {  // 펭귄은 새입니다.
    ...
};
cs

위의 클래스 계통에 의하면 펭귄은 날 수 있지만, 이것은 맞지 않습니다. 명확치 않은 자연어, 즉 사람의 말에 속은 것입니다. "새는 날 수 있다"는 의미가 모든 종류의 새가 날 수 있다는 것이 아닌, 그저 자체 비행 능력을 가진 동물이 새라서 말했을 뿐입니다. 더 명확히 했다면 날지 않는 새 종류도 있다는 점도 구분이 가능합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Bird {
public:
    ...                       // fly 함수가 선언되지 않았습니다.
};
 
class FlyingBird: public Bird {
public:
    virtual void fly();
    ...
};
 
class Penguin: public Bird {  // fly 함수가 선언되지 않았습니다.
    ...
};
cs

처음에 했던 설계보다 우리가 알고 있는 현실에 더욱 충실한 클래스 구조가 되었습니다. 이 문제에 대해 또 다른 대처 방법도 있습니다. 그 방법이란, 펭귄의 fly 함수를 재정의해서 런타임 에러를 내도록 하자는 거죠.

1
2
3
4
5
6
void error(const std::string& msg);  // 어딘가에 정의되어 있을 것입니다.
 
class Penguin: public Bird {
    virtual void fly() { error("Attempt to make a penguin fly!"); }
    ...
};
cs

위의 코드의 경우는 "펭귄은 날 수 없다"가 아닙니다. "펭귄은 날 수 있다. 그러나 펭귄이 실제로 날려고 하면 에러가 난다"라고 말하는 것입니다. 이제 에러는 프로그램이 실행될 때만 발견할 수 있습니다.

사실 컴파일 에러와 런타임 에러 중에 유효하지 않는 코드를 컴파일 단계에서 막아 주는 인터페이스가 좋은 인터페이스입니다. 즉, 펭귄의 무모한 비행을 컴파일 타임에 거부하는 설계가 그것을 런타임에 뒤늦게 알아채는 설계보다 훨씬 좋다는 말이죠.

이상하고 아름다운 'public 상속' 나라를 체험하기 위해 Square(정사각형) 클래스와 Rectangle(직사각형) 클래스의 예시를 보겠습니다. 당연히 정사각형이 직사각형의 일종이니, 정사각형 클래스가 직사각형 클래스로부터 상속을 받아야 할 것 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Rectangle {
public:
    virtual void setHeight(int newHeight);
    virtual void setWidth(int newWidth);
 
    virtual int height() const;       // 현재의 값을 반환합니다.
    virtual int width() const;
    ...
};
 
void makeBigger(Rectangle& r)         // r의 넓이를 늘리는 함수
{
    int oldHeight = r.height();
    r.setWidth(r.width() + 10);
    assert(r.height() == oldHeight);  // r의 세로 길이가 변하지 않는다는
}                                     // 조건에 단정문을 걸어둡니다.
cs

여기서 위의 단정문이 실패할 일이 없다는 것은 확실합니다. makeBigger 함수는 r의 가로 길이만 변경할 뿐이고, 세로 길이는 바뀌지 않습니다. 이제 public 상속을 써서 정사각형을 직사각형처럼 처리하게끔 허용하는 코드입니다.

1
2
3
4
5
6
7
8
9
10
11
class Square: public Rectangle { ... };
 
Square s;
...
 
assert(s.width() == s.height());  // 이 단정문은 모든 정사각형에 대해
                                  // 참이어야 합니다.
makeBigger(s);                    // 상속된 것이므로, s는 Rectangle의 일종입니다.
                                  // 즉, s의 넓이를 늘릴 수 있습니다.
assert(s.width() == s.height());  // 이번에도 이 단정문이 모든 정사각형에
                                  // 대해 참이어야 합니다.
cs

정사각형의 정의가 그렇듯, 정사각형의 가로 길이는 세로 길이와 같아야 하기에, 당연히 두 번째 단정문도 실패해서는 안 됩니다. 그런데 문제가 생겼습니다. makeBigger 함수가 실행되는 중에, s의 가로 길이는 변하는데 세로 길이는 안 변해야 하고, makeBigger 함수에서 복귀한 후에, s의 세로 길이는 역시 가로 길이와 같아야 합니다.

이처럼 'public 상속' 나라는 육감이 여러분 예상대로 움직여 주지 않는 나라입니다. 작금의 상황에서 우리들의 발목을 잡고 있는 것은, 직사각형 성질 중 어떤 것(가로 길이가 세로 길이에 상관없이 바뀔 수 있습니다)은 정사각형(가로와 세로 길이가 같아야 합니다)에 적용할 수 없다는 점입니다. 그러나 public 상속은 기본 클래스 객체가 가진 모든 것들이 파생 클래스 객체에도 그대로 적용된다고 단정하는 상속입니다. 그런데 직사각형과 정사각형의 경우를 보면 이런 단정은 참이 될 수 없으므로, 이 둘의 관계를 public 상속을 써서 표현하려고 하면 틀리는 것이 당연하지요. 컴파일러 수준에서는 문법적 하자가 없기 때문에 이런 코드가 무사히 통과되지만, 무사통과된 코드가 제대로 동작할 거라는 보장은 없습니다.

클래스들 사이에 맺을 수 있는 관계로 is-a 관계만 있는 것은 아닙니다. 두 가지가 더 있는데, 하나는 "has-a(...는 ...를 가짐)"이고 또 하나는 "is-implemented-in-terms-of(...는 ...를 써서 구현됨)"입니다. C++ 코드를 보다 보면 is-a 이외의 나머지 두 관계를 is-a 관계로 모형화해서 설계가 이상하게 꼬이는 경우가 정말 많습니다. 그러니까 클래스 사이에 맺을 수 있는 관계들을 명확하게 구분할 수 있도록 하고, 이 각각을 C++로 가장 잘 표현하는 방법도 공부해 두는 것이 중요합니다.


정리

public 상속의 의미는 "is-a(...는 ...의 일종)"입니다. 기본 클래스에 적용되는 모든 것들이 파생 클래스에 그대로 적용되어야 합니다. 모든 파생 클래스 객체는 기본 클래스 객체의 일종이기 때문입니다.