34: 인터페이스 상속과 구현 상속의 차이를 제대로 파악하고 구별하자
상속은 사실 두 가지로 나뉜다. 하나는 함수 인터페이스의 상속이고, 또 하나는 함수 구현의 상속이다. 멤버 함수의 인터페이스(선언)만을 파생 클래스에 상속받고 싶을 때가 분명히 있다. 어쩔 때는 함수의 인터페이스 및 구현을 모두 상속받고 그 상속받은 구현이 오버라이드가 가능하게 만들었으면 하는 일도 있다. 반대로, 인터페이스와 구현을 상속받되 어떤 것도 오버라이드할 수 없도록 막고 싶은 경우도 있다.
class Shape {
public:
virtual void draw() const = 0;
virtual void error(const string& msg);
int objectID() const;
};
class Rectangle : public Shape {};
class Ellipse : public Shape {};
Shape가 이 클래스로부터 (public 상속에 의해) 파생된 클래스에 대해 미치는 영향은 가히 절대군주와 맞먹는다. 멤버 함수 인터페이스는 항상 상속되게 되어 있기 때문이다. is-a 관계이므로 기본 클래스에 해당되는 모둔 것들이 파생 클래스에도 해당되어야 한다. 따라서 어떤 클래스에서 동작하는 함수는 그 클래스의 파생 클래스에서도 동작해야 맞다.
위의 Shape 클래스에는 세 개의 함수가 선언되어 있다. 세 함수는 선언된 방법도 각기 다르다. 선언이 다르다는 것에 어떤 속뜻이 있는 것일까?
순수 가상 함수
draw는 순수 가상 함수이다. 순수 가상 함수의 가장 두드러진 특징은 두 가지이다. 첫째, 어떤 순수 가상 함수를 물려받은 구체 클래스가 해당 순수 가상 함수를 다시 선언해야 한다. 둘째, 순수 가상 함수는 전형적으로 추상 클래스 안에서 정의를 갖지 않는다. 이 두가지를 하나로 모아 보면 다음과 같은 결론이 나온다.
- 순수 가상 함수를 선언하는 목적은 파생 클래스에게 함수의 인터페이스만을 물려주는 것이다.
Shape 계통은 모두 그리기가 가능해야 한다라는 요구사항은 그리 이상할 것이 없지만, 기본 구현을 제공하는 것은 shape 클래스 차원에서 어떻게 할 수가 없다. 타원을 그리는 알고리즘은 직사각형을 그리는 알고리즘과 같을래야 같을 수가 없다.
사실 순수 가상 함수에도 정의를 제공할 수 있다. 단, 구현이 붙은 순수 가상 함수를 호출하려면 반드시 클래스 이름을 한정자로 붙여 주어야만 한다는 점이다.
Shape* ps = new Shape;
Shape* ps1 = new Rectangle;
ps1->draw(); // Rectangle::draw 호출
Shape* ps2 = new Ellipse;
ps2->draw(); // Ellipse::draw 호출
ps1->Shape::draw(); // Shape::draw를 호출
ps2->Shape::draw(); // Shape::draw를 호출
단순(비순수) 가상 함수
단순 가상 함수는 파생 클래스로 하여금 함수의 인터페이스를 상속하게 한다는 점은 똑같지만, 파생 클래스 쪽에서 오버라이드할 수 있는 함수 구현부도 제공한다는 점이 다르다.
- 단순 가상 함수를 선언하는 목적은 파생 클래스로 하여금 함수의 인터페이스뿐만 아니라 그 함수의 기본 구현도 물려받게 하자는 것이다.
Shape::error 함수의 경우를 생각해 보자. 실행 중에 에러와 마주쳤을 때 자동으로 호출될 함수를 제공하는 것은 모든 클래스가 해야 하는 일이지만, 그렇다고 각 클래스마다 그때그때 꼭 맞는 방법으로 에러를 처리할 필요는 없다는 것이다. 에러가 생기더라도 특별히 해주는 일이 없는 클래스라면, Shpae 클래스에서 기본으로 제공되는 에러 처리를 그냥 써도 된다.
단순 가상 함수에서 함수 인터페이스와 기본 구현을 한꺼번에 지정하도록 내버려 두는 것은 위험할 수도 있다. 비행기 A 모델과 B 모델이 있고, 이 두 모델은 비행 방식이 같다고 가정하자.
class Airport {}; // 공항을 나타내는 클래스
class Airplane {
public:
virtual void fly(const Airport& destination);
};
void Airplane::fly(const Airport& destination)
{
// 주어진 목적지로 비행기를 날려 보내는 기본 동작 원리를 가진 코드
}
class ModelA : public Airplane {};
class ModelB : public Airplane {};
Airplane::fly 함수는 가상 함수로 선언되어 있다. 모든 비행기는 fly 함수를 지원해야 한다는 점을 나타내야 하기 때문이다. 또 모델이 다른 비행기는 원칙상 fly 함수에 대한 구현을 저마다 다르게 요구할 수 있다는 사실을 알고 있다는 뜻도 된다. 하지만 ModelA 및 ModelB 클래스에 대해 똑같은 코드를 작성하지는 말아야 하므로, 기본 적인 비행 원리를 Airplane::fly 함수의 본문으로 제공하여 물려받을 수 있도록 하였다.
지극히 고전적인 객체 지향 설계이다. 두 클래스가 하나의 공통 특징을 공유하고 있다. 클래스 사이의 공통 사항으로 둘 수 있는 특징이 명확해지고, 코드가 중복되지 않으며, 이후의 기능 개선의 통로도 열려 있게 되는데다가, 장기적인 유지보수도 쉬워진다.
C 모델 클래스 계통을 추가하였다. 그런데 그만 fly 함수를 재정의하는 것을 잊어버리고 말았다.
class ModelC : public Airplane {
// fly 함수가 선언되지 않음
};
Airport PDX();
Airplane* pa = new ModelC;
pa->fly(PDX); // Airplane::fly 함수가 호출된다.
ModelC 클래스는 이 기본 동작을 원한다고 명시적으로 밝히지 않았는데도 이 동작을 물려받는 데 걸림돌이 없다는 점이다. 파생 클래스에서 요구하지 않으면 주지 않는 방법을 살펴보자. 가상 함수의 인터페이스와 그 가상 함수의 기본 구현을 잇는 연결 관계를 끊어 버리는 것이다.
class Airplane {
public:
virtual void fly(const Airport& destination) = 0;
protected:
void defaultFly(const Airport& destination);
};
void Airplane::defaultFly(const Airport& destination)
{
// 주어진 목적지로 비행기를 날려 보내는 기본 동작 원리를 가진 코드
}
이 가상 함수가 바로 fly 함수의 인터페이스를 제공하는 역할을 맞게 된다. defaultFly 함수가 기본 구현을 맡는 함수가 된다. ModelA 및 modelB 등에서는 fly 함수의 본문 안에서 그냥 이 defaultFly 함수를 인라인 호출하기만 하면 된다.
class ModelA : public Airplane {
public:
virtual void fly(const Airport& destination)
{
defaultFly(destination);
}
};
class ModelB : public Airplane {
public:
virtual void fly(const Airport& destination)
{
defaultFly(destination);
}
};
이제는 ModelC 클래스가 자신과 맞지 않는 기본 구현을 우연찮게 물려받을 가능성은 없어졌다.
Airplane::defaultFly 함수에 대해서도 알아보자. 이 함수는 protected 멤버이다. Airplane 및 그 클래스의 파생 클래스만 내부적으로 사용하는 구현 세부사항이기 때문에 그런 것이다. 비행기를 사용하는 사용자는 '비행기가 날 수 있다'라는 점만 알면 될뿐, '비행 동작이 어떻게 구현되는가'는 신경 쓰지 말아야 한다.
또 다른 중요사항은 비가상 함수라는 점이다. 파생 클래스 쪽에서 이 함수를 재정의해선 안 되기 때문이다.
defaultFly 같음 함수를 별도로 마련하는 아이디어를 별로 좋아하지 않는 사람도 더러 있다. 클래스의 네임스페이스가 더러워진다는 것이 한 가지 이유이다. 어떤 식으로 해결할까? 방법적으로는 순수 가상 함수가 구체 파생 클래스에서 재선언되어야 한다는 사실을 활용하되, 자체적으로 순수 가상 함수의 구현을 구비해 두는 것이다.
class Airplane {
public:
virtual void fly(const Airport& destination) = 0;
};
// 순수 가상 함수의 구현
void Airplane::fly(const Airport& destination)
{
// 주어진 목적지로 비행기를 날려 보내는 기본 동작 원리를 가진 코드
}
class ModelA : public Airplane {
public:
virtual void fly(const Airport& destination)
{
Airplane::fly(destination);
}
};
class ModelB : public Airplane {
public:
virtual void fly(const Airport& destination)
{
Airplane::fly(destination);
}
};
class ModelC : public Airplane {
public:
virtual void fly(const Airport& destination);
};
void ModelC::fly(const Airport& destination)
{
// 주어진 목적지로 ModelC 비행기를 날려 보내는 코드
}
fly 함수가 선언부 및 정의부의 두 쪽으로 나뉜 것이다. 선언부는 이 함수의 인터페이스를 지정하고, 정의부는 이 함수의 기본 동작을 지정한다. 하지만 fly와 defaultFly가 하나로 합쳐지는 바람에 함수 양쪽에 각기 다른 보호 수준을 부여할 수 있는 융통성은 날아가고 말았다.
비가상 함수
멤버 함수가 비가상 함수로 되어 있다는 것은, 이 함수는 파생 클래스에서 다른 행동이 일어날 것으로 가정하지 않았다는 뜻이다. 실제로, 비가상 멤버 함수는 클래스 파생에 상관없이 변하지 않는 동작을 지정하는 데 쓰인다.
- 비가상 함수를 선언하는 목적은 파생 클래스가 함수 인터페이스와 더불어 그 함수의 필수적인 구현(mandatory implementation)을 물려받게 하는 것이다.
비가상 함수는 클래스 파생에 상관없는 불변동작과 같기 때문에, 파생 클래스에서 재정의할 수 있는 수준의 것이 절대로 아니다.
결정적인 실수
순수 가상 함수, 단순 가상 함수, 비가상 함수의 선언문이 가진 차이점 덕택에, 파생 클래스가 물려받았으면 하는 것들은 정밀하게 지정할 수 있다. 멤버 함수를 선언할 때, 클래스에서 가장 흔히 발견되는 결정적인 실수 두 가지를 알아보자.
첫 번째 실수는 모든 멤버 함수를 비가상 함수로 선언하는 것이다. 이렇게 하면 파생 클래스를 만들더라도 기본 클래스의 동작을 특별하게 만들 만한 여지가 없어지게 된다. 처음부터 클래스 파생을 염두에 두지 않은 클래스라면 비가상 함수들만 모아두는 게 맞다.
가상 함수의 비용 때문에 마음에 안 든다면 80-20 법칙을 생각하자. 어지간한 프로그램에서는 전체 실행 시간의 80%가 소모되는 부분이 전체 코드의 20%밖에 되지 않는다는 법칙이다. 함수 호출 중 80%를 가상 함수로 두더라도 프로그램의 전체 수행 성능에는 가장 약하게 느낄 수 있을 만큼의 손실도 생기지 않는다는 뜻이다.
또 한 가지 실수는 모든 멤버 함수를 가상 함수로 선언하는 것이다. 물론 인터페이스 클래스처럼 맞을 경우도 있다. 분명히 파생 클래스에서 재정의가 안 되어야 하는 함수도 분명히 있을 것이다. 그리고 이런 함수가 있으면 반드시 비가상 함수로 만들어 둠으로써 입장을 확실히 밝히는 것이 제대로 된 자세다. 클래스 파생에 상관없는 불변동작을 갖고 있어야 한다면 그렇게 해야 한다.
이것만은 잊지 말자!
- 인터페이스 상속은 구현 상속과 다르다. public 상속에서, 파생 클래스는 항상 기본 클래스의 인터페이스를 모두 물려받는다.
- 순수 가상 함수는 인터페이스 상속만을 허용한다.
- 단순(비순수) 가상 함수는 인터페이스 상속과 더불어 기본 구현의 상속도 가능하도록 지정한다.
- 비가상 함수는 인터페이스 상속과 더불어 필수 구현의 상속도 가하도록 지정한다.
'C++ > Effective C++' 카테고리의 다른 글
[Effective C++] 36: 상속받은 비가상 함수를 파생 클래스에서 재정의하는 것은 절대 금물! (0) | 2023.11.05 |
---|---|
[Effective C++] 35: 가상 함수 대신 쓸 것들도 생각해 두는 자세를 시시때때로 길러 두자 (1) | 2023.11.04 |
[Effective C++] 33: 상속된 이름을 숨기는 일은 피하자 (0) | 2023.11.02 |
[Effective C++] 32: public 상속 모형은 반드시 "is-a(...는 ...의 일종이다)"를 따르도록 만들자 (1) | 2023.10.31 |
[Effective C++] 31: 파일 사이의 컴파일 의존성을 최대로 줄이자 (1) | 2023.10.31 |