- 인터페이스 상속은 구현 상속과 다르다. public 상속에서, 파생 클래스는 항상 기본 클래스의 인터페이스를 모두 물려받는다.
- 순수 가상 함수는 인터페이스 상속만을 허용한다.
- 단순 가상 함수는 인터페이스 상속과 더불어 기본 구현의 상속도 가능하도록 지정한다.
- 비가상 함수는 인터페이스 상속과 더불어 필수 구현의 상속도 가하도록 지정한다.
인터페이스 상속과 구현 상속의 차이
상속은 크게 두 가지로 나눌 수 있다. 하나는 함수 인터페이스의 상속이고, 또 하나는 함수 구현의 상속이다.
클래스 설계자의 입장에서 보면, 멤버 함수의 인터페이스만을 파생 클래스에 상속받고 싶을 때가 있다.
어쩔 때는 함수의 인터페이스 및 구현을 모두 상속받고 그 상속받은 구현이 오버라이드가 가능하게 만들고 싶을 때도 있다.
반대로, 인터페이스와 구현을 상속받되 어떤 것도 오버라이드 할 수 없도록 막고 싶은 경우도 있다.
이러한 성택사항들 사이의 차이점을 살펴볼 필요가 있다.
class Shpae {
public:
virtual void draw() const = 0;
virtual void error(const std::string& msg);
int objectID() const;
...
};
class Rectangle: public Shape { ... };
class Ellipse: public Shape { ... };
Shape는 추상 클래스이다. 순수 가상 함수인 draw가 추상 클래스를 결정하는 요소이다.
결국, Shape 클래스의 인스턴스를 만들려고 하면 안 되고, 이 클래스의 파생 클래스만 인스턴스화할 수 있다.
Shape 클래스에 함수가 어떻게 선언되어 있냐에 따라 차이가 나타난다.
함수 타입에 따른 상속
멤버 함수 인터페이스는 항상 상속되게 되어 있다.
public 상속의 의미는 is-a이므로 기본 클래스에 해당하는 것들은 모두 파생 클래스에도 해당되어야 한다.
따라서 어떤 클래스에서 동작하는 함수는 그 클래스의 파생 클래스에서도 동작해야 된다.
Shpae 클래스에는 세 개의 함수가 선언되어 있다.
첫째, draw 함수는 암시적인 표시장치에 현재의 객체를 그린다.
둘째, error 함수는 다른 멤버 함수들이 호출하는 함수로, 이들이 에러를 보고할 필요가 있을 때 사용된다.
셋째, objectID 함수는 주어진 객체에 붙는 유일한 정수 식별자를 반환한다.
세 함수는 선언된 방법도 다르다. draw는 순수 가상 함수이고, error는 단순 가상 함수이다. objectID는 비가상 함수로 선언되어 있다.
class Shpae {
public:
virtual void draw() const = 0;
...
};
순수 가상 함수는 두 가지 특징이 있다.
- 순수 가상 함수를 물려받은 구체 클래스가 해당 순수 가상 함수를 다시 선언해야 한다.
- 순수 가상 함수는 전형적으로 추상 클래스 안에서 정의를 갖지 않는다.
순수 가상 함수를 선언하는 목적은 파생 클래스에게 함수의 인터페이스만을 물려주려는 것이다.
draw 함수의 경우, Shape를 상속한 도형에 따라 구체적인 구현이 달라지지만 Shape에서는 관여할 필요가 없다.
순수 가상 함수에도 정의를 제공할 수는 있다. 단, 구현이 붙은 순수 가상 함수를 호출하려면 반드시 클래스 이름을 한정자로 붙여 주어야만 한다.
Shape *ps = new Shape; // Error, Shape는 추상 클래스
Shape *ps1 = new Rectangle; // OK, Rectangle::draw 호출
ps1->draw();
Shape *ps2 = new Ellipse; // OK, Ellips::draw 호출
ps2->draw();
ps1->Shape::draw(); // Shape::draw 호출
ps2->Shape::draw(); // Shape::draw 호출
이 부분은 단순 가상 함수에 대한 기본 구현을 안전하게 제공하는 메커니즘으로도 활용할 수 있다.
단순 가상 함수는 순수 가상 함수와 몇 가지 다른 면이 있다.
파생 클래스로 하여금 함수의 인터페이스를 상속하는 것은 똑같지만, 파생 클래스 쪽에서 오버라이드 할 수 있는 함수 구현부를 제공한다는 점이 다르다.
단순 가상 함수를 선언하는 목적은 파생 클래스로 하여금 함수의 인터페이스뿐만 아니라 그 함수의 기본 구현도 물려받게 하는 것이다.
Shape::error 함수의 경우를 보자.
class Shape {
public:
virtual void error(const std::string& msg);
...
};
실행 중에 에러와 마주쳤을 때 자동으로 호출될 함수를 제공하는 것은 모든 클래스가 해야 하는 일이지만, 그렇다고 각 클래스마다 맞는 방법으로 에러를 처리할 필요는 없다는 것이다.
에러가 생기더라도 특별히 해주는 일이 없는 클래스라면, Shape 클래스에서 기본으로 제공되는 에러 처리를 그냥 써도 된다.
단순 가상 함수에서 함수 인터페이스와 기본 구현을 한꺼번에 지정하도록 내버려 두는 것은 위험할 수도 있다.
예를 들어, XYZ라는 이름의 가상의 항공사가 있고, 이 항공사의 비행기는 A 모델과 B 모델이 있다.
게다가 이 두 모델은 비행 방식이 똑같다. 따라서 XYZ의 비행기는 다음과 같은 클래스 계통으로 설계할 수 있다.
class Airport { ... };
class Airplane {
public:
virtual void fly(const Airport& dsetination);
...
};
void Airplane::fly(const Airport& destination)
{
// 기본 동작
}
class ModelA: public Airplane { ... };
class ModelB: public Airplane { ... };
Airplane::fly 함수는 가상 함수로 선언되어 있다. 모든 비행기는 fly함수를 지원해야 한다는 점을 나타내야 하기 때문이다.
또 모델이 다른 비행기는 원칙상 fly 함수에 대한 구현을 다르게 요구할 수도 있다.
하지만, ModelA 및 ModelB 클래스에 대해 똑같은 코드를 작성하지는 말아야 하므로, 기본적인 비행 원리를 Airplane::fly 함수에서 제공함으로써 물려받아 사용할 수 있도록 할 수 있다.
두 클래스가 하나의 공통 특징을 공유하고 있으므로, 이 공통 특징을 기본 클래스에 놓고 물려받는 식으로 설계된 것이다.
이렇게 설계하면 클래스 사이의 공통 사항으로 둘 수 있는 특징이 명확해지고, 코드가 중복되지 않고 이후의 기능 개선의 가능성이 있어 장기적인 유지보수도 쉬워진다.
하지만 파생 클래스가 기본 클래스의 가상 함수가 필요하지 않은데도 물려받게 된다.
이러한 문제의 해결법은 가상 함수의 인터페이스와 그 가상 함수의 기본 구현과의 연결을 끊어 버리는 것이다.
class Airplane {
public:
virtual void fly(const Airport& destination) = 0;
...
protected:
void defaultFly(const Airport& destination);
};
void Ariplane::defaultFly(const Airport& destination)
{
// 기본 동작
}
Airplane::fly 함수가 순수 가상 함수로 바뀌었는데, 이 가상 함수가 바로 fly 함수의 인터페이스를 제공하는 역할을 맡게 된다.
기본 구현은 defaultFly 함수에서 구현한다. 기본 동작을 사용하고 싶은 클래스에서는 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); }
...
};
필요 없는 함수를 물려받아 사용할 가능성이 낮아졌다. fly 함수가 Airplane 클래스의 순수 가상 함수로 선언되어 있어서, 파생 클래스에서는 필수로 구현해야 하기 때문이다.
class ModelC: public Airplane {
public:
virtual void fly(const Airport& destination);
...
};
void ModelC::fly(const Airport& destination)
{
// modelc fly
}
defaultFly 함수는 protected 멤버이다. Airplane 및 그 클래스의 파생 클래스만 내부적으로 사용하는 구현 세부사항이기 때문이다. 또한, 비가상 함수이다.
파생 클래스 쪽에서 이 함수를 재정의해서는 안 되기 때문이다.
하지만, 인터페이스 및 기본 구현을 제공하는 함수를 별도로 마련하는 방법을 좋아하지 않을 수 있다.
중요하지 않은 관계인 함수들이 엮여 클래스의 네임스페이스가 오염될 수 있기 때문이다.
다른 방법으로는 순수 가상 함수가 파생 클래스에서 재선언되어야 한다는 사실을 활용하는 방법이 있다.
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& destiantion)
{ Airplane::fly(destination); }
...
};
class ModelB: public Airplane {
public:
virtual void fly(const Airport& dsetination)
{ Airplane::fly(destination); }
...
};
class ModelC: public Airplane {
public:
virtual void fly(const Airport& dsetination);
...
};
void ModelC::fly(const Airport& destination)
{
// ModelC fly
}
별도의 함수인 Airplane::defaultFly 대신 순수 가상 함수인 fly 함수의 본문이 있는 것만 제외하면 이전의 설계와 같다.
fly 함수의 선언부 및 정의부를 나눈 것이다. 선언부는 함수의 인터페이스를 지정하고, 정의부는 함수의 기본 동작을 지정한다.
하지만 fly와 defaultFly가 하나로 합쳐져 보호 수준을 부여할 수 있는 융통성은 없어졌다.
즉, defaultFly를 protected가 아닌 public으로 사용해야 하는 것이다.
마지막으로 볼 함수는 Shape의 비가상 함수인 objectID이다.
class Shape {
public:
int objectID() const;
...
};
멤버 함수가 비가상 함수로 되어 있다는 것은, 이 함수는 파생 클래스에서 다른 행동이 일어날 것을 가정하지 않았다는 뜻이다. 실제로, 비가상 멤버 함수는 클래스 파생에 상관없이 변하지 않는 동작을 지정하는 데 쓰인다.
비가상 함수를 선언하는 목적은 파생 클래스가 함수 인터페이스와 그 함수에 필수적인 구현을 물려받게 하는 것이다.
흔히 하는 실수
순수 가상 함수, 단순 가상 함수, 비가상 함수의 선언문이 가진 차이점 덕분에 파생 클래스가 물려받는 것들에 대한 정밀한 제어를 할 수 있다. 그 과정에서 흔히 일어나는 실수가 두 가지가 있다.
- 모든 멤버 함수를 비가상 함수로 선언하는 것
파생 클래스를 만들더라도 기본 클래스의 동작을 특별하게 만들 만한 여지가 없게 된다. 특히 비가상 소멸자가 문제가 된다.
가상 함수의 비용을 고려한 선택이라 해도 그렇게 하면 파생 클래스를 만드는 의미가 없어진다.
또한, 프로그램에서 전체 실행 시간의 80%가 소모되는 부분의 코드는 전체 코드의 20%밖에 되지 않는다.
즉, 특수한 상황이 아니라면 가상함수를 많이 사용하여도 전체 성능에 크게 영향을 미치지 않는다는 뜻이다.
- 모든 멤버 함수를 가상 함수로 선언하는 것
파생 클래스에서 재정의가 안되어야 하는 함수도 분명 있을 것이다. 불변동작을 잘 정의해서 이를 관리하는 것은 중요하다.