32: public 상속 모형은 반드시 "is-a(...는 ...의 일종이다)"를 따르도록 만들자
클래스 D를 클래스 B로부터 public 상속을 통해 파생시켰다면 C++ 컴파일러에게 이렇게 말한 것과 같다. D 타입으로 만들어진 모든 객체는 또한 B 타입의 객체이지만, 그 반대는 되지 않는다. 다시 말해 B는 D보다 더 일반적인 개념을 나타내며, D는 B보다 더 특수한 개념을 나타낸다고 알리는 것이다. B 타입의 객체가 쓰일 수 있는 곳에는 D 타입의 객체도 쓰일 수 있다고 단정하는 것이다.
class Person {};
class Student : public Person {};
void eat(const Person& p);
void Study(const Student& s);
Person p;
Student s;
eat(p);
eat(s);
study(s);
study(p); // 에러! p는 Student가 아니다
이 이야기는 public 상속에서만 통한다.
public 상속과 is-a 관계가 꽤 직관적이고 간단하지만 잘못 판단할 수 있다. 예를 들어, 펭귄은 새의 일종이다. 새라는 개념만 보면 새가 날 수 있다는 점도 사실이다. 이를 C++로 표현해보면 다음과 비슷한 코드가 나올 것이다.
class Bird {
public:
virtual void fly();
};
class Penguin : public Bird { // 펭귄은 새다.
};
위의 클래스 계통에 의하면 펭귄은 날 수 있다. 하지만 이것은 맞지 않다. "새는 날 수 있다"라는 의미가, 모든 종류의 새가 날 수 있다고 말한 것은 아니었다. 말을 더 명확히 했다면 날지 않는 새 종류도 있다는 점도 구분할 수 있었을 것이다. 즉, 다음과 같이 조금 더 현실에 가까운 클래스 계통구조를 뽑을 수 있었을 것이다.
class Bird {
// fly 함수가 선언되지 않는다.
};
class FlyingBird : public Bird {
public:
virtual void fly();
};
class Penguin : public Bird {
// fly 함수가 선언되지 않는다.
};
어떤 소프트웨어 시스템의 경우엔 비행 능력이 있는 새와 없는 새를 구분하지 않아도 괜찮을 수 있다. 새의 부리와 날개에만 관심이 있고 비행 가지고는 도통 할 일이 없는 응용프로그램을 만든다면, 두 개의 클래스로 구성된 처음의 계통이 훨씬 만족스러울 것이다. 무릇 최고의 설계는, 제작하려는 소프트웨어 시스템이 기대하는 바에 따라 달라지는 것이다.
이 문제에 대한 또 다른 대처 방법을 보자. 펭귄의 fly 함수를 재정의해서 런타임 에러를 내도록 하는 것이다.
void error(const string& msg);
class Penguin : public Bird {
virtual void fly() { error("Attemp to make a penguin fly!"); }
};
이 경우는 "펭귄은 날 수 없다"가 아니다. "펭귄은 날 수 있다. 그러나 펭귄이 실제로 날려고 하면 에러가 난다"라고 말하는 것이다.
그럼 "펭귄은 날 수 없다"라는 제약사항도 같이 넣어 보도록 하자. Penguin 객체에 대해서는 비행과 관련된 함수를 정의하지 않도록 하면 된다.
class Bird {
// fly 함수가 선언되지 않는다.
};
class Penguin : public Bird {
// fly 함수가 선언되지 않는다.
};
Penguin p;
p.fly(); // 컴파일 에러!
이렇게 하면 컴파일 단계에서 에러가 발생한다. 유효하지 않은 코드를 컴파일 단계에서 막아 주는 인터페이스가 좋은 인터페이스이다. 즉, 펭귄의 무모한 비행을 컴파일 타임에서 거부하는 설계가 그것을 런타임에 뒤늦게 알아채는 설계보다 훨씬 좋다는 말이다.
Square(정사각형) 클래스는 Rectangle(직사각형) 클래스로부터 상속을 받아야 할까? 상속을 받았을 때의 코드를 보자.
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)
{
int oldHeight = r.height();
r.setWidth(r.width() + 10); // r의 가로 길이에 10을 더한다.
assert(r.height() == oldHeight); // r의 세로 길이가 변하지 않는다는
// 조건에 단정문을 걸어둔다.
}
위의 단정문이 실패할 일이 없다는 것은 확실하다. makeBigger 함수는 r의 가로 길이만 변경할 뿐이고, 세로 길이는 바꾸지 않는다.
class Square : public Rectangle {};
Square s;
assert(s.width() == s.height());
makeBigger(s);
assert(s.width() == s.height());
위의 두 번째 단정문도 실패해서는 안 된다. 그러나 문제가 생겼다. 조정이 필요한 단정문들이 한두 개가 아니다.
- makeBigger 함수를 호출하기 전에, s의 세로 길이는 가로 길이와 같아야 한다.
- makeBigger 함수가 실행되는 중에, s의 가로 길이는 변하는데 세로 길이는 안 변해야 한다.
- makeBigger 함수에서 복귀한 후에, s의 세로 길이는 역시 가로 길이와 같아야 한다.
작금의 상황에서 우리의 발목을 잡고 있는 것은, 직사각형의 성질 중 어떤 것은 정사각형에 적용할 수 없다는 점이다. 그러나 public 상속은 기본 클래스 객체가 가진 모든 것들이 파생 클래스 객체에도 그대로 적용된다고 단정하는 상속이다. 그런데 직사각형과 정사각형의 경우를 보면 이런 단정이 참이 될 수 없다. 따라서 이 둘의 관계를 public 상속을 써서 표현하려고 하면 틀리는 것이 단연하다.
이것만은 잊지 말자!
- public 상속의 의미는 "is-a(...는 ...의 일종)"이다. 기본 클래스에 적용되는 모든 것들이 파생 클래스에 그대로 적용되어야 한다. 왜냐하면 모든 파생 클래스 객체는 기본 클래스 객체의 일종이기 때문이다.
'C++ > Effective C++' 카테고리의 다른 글
[Effective C++] 34: 인터페이스 상속과 구현 상속의 차이를 제대로 파악하고 구별하자 (0) | 2023.11.02 |
---|---|
[Effective C++] 33: 상속된 이름을 숨기는 일은 피하자 (0) | 2023.11.02 |
[Effective C++] 31: 파일 사이의 컴파일 의존성을 최대로 줄이자 (1) | 2023.10.31 |
[Effective C++] 30: 인라인 함수는 미주알고주알 따져서 이해해 두자 (0) | 2023.10.29 |
[Effective C++] 29: 예외 안정성이 확보되는 그날 위해 싸우고 또 싸우자! (0) | 2023.10.28 |