Notice
Recent Posts
Recent Comments
Link
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
Archives
Today
Total
관리 메뉴

로또

34. 인터페이스 상속과 구현 상속의 차이를 제대로 파악하고 구별하자. 본문

책/Effective C++

34. 인터페이스 상속과 구현 상속의 차이를 제대로 파악하고 구별하자.

아롱로또 2023. 12. 2. 12:11

다루는 내용

(public) 상속은 함수 인터페이스 상속과 함수 구현의 상속으로 나뉜다. 인터페이스만을 상속하거나, 인터페이스와 구현을 함께 상속하여 오버라이드 가능하게 하거나, 인터페이스와 구현을 함께 상속받되 오버라이드를 막고 싶을 수도 있을 것이다. 이러한 상황에 효과적인 방법을 알아보자.

 

추상 클래스 Shape

class Shape{
public:
	virtual void draw() const = 0; // 순수 가상 함수
	virtual void error(const string& msg); // 가상 함수
	int GetObjectID() const; // 
};

 

해당 클래스의 세 함수를 통해 인터페이스 상속과 구현 상속의 차이를 알아보자.

순수 가상 함수 draw

순수 가상 함수의 특징은 다음과 같다.

  1. 어떤 순수 가상 함수를 물려받은 파생 클래스가 해당 순수 가상 함수를 다시 선언해야 한다.
  2. 순수 가상 함수는 전형적으로 추상 클래스 안에서 정의를 갖지 않는다.

이는 순수 가상 함수를 선언하는 목적이 "파생 클래스에게 함수의 인터페이스만을 물려주려는 것" 임을 알 수 있다.

순수 가상 함수 draw는 "Shape 계통의 모든 객체는 그리기(draw)가 가능해야 한다." 라고 요구한다. Shape가 draw의 기본 구현을 제공하려고 해도, 어떤 도형을 그려야 할 지 알 수 없으므로 파생 클래스에서 정의하도록 만드는 것이다.

 

일반적으로 순수 가상 함수는 구현이 없다고 알고 있지만, 사실 다음과 같이 순수 가상 함수에 대해 구현을 제공할 수 있다.

class Shape{
public:
	virtual void draw() const = 0; // 순수 가상 함수
};

class Rectangle: public Shape{
public:
	void draw() const override;
};

void Shape::draw() const{
	cout << "Shape draw call" << endl;
}

void Rectangle::draw() const{
	cout << "Rectangle draw call" << endl;
}

int main(){
	Rectangle r;
	r.Shape::draw(); // 추상 클래스의 객체를 생성할 수 없으므로 이와 같이 호출한다.
	r.draw();
	return 0;
}

순수 가상 함수의 구현을 제공하는 방법

단순 가상 함수 error

단순 가상 함수의 특징은 다음과 같다.

  1. 파생 클래스로 하여금 함수의 인터페이스를 상속하게 한다.
  2. 파생 클래스 쪽에서 오버라이드할 수 있는 함수의 구현부도 제공한다.

단순 가상 함수를 선언하는 목적은 파생 클래스로 하여금 함수의 인터페이스뿐만 아니라 함수의 기본 구현도 물려받게 하는 것이다.

단순 가상 함수 error는 실행 중 에러와 마주쳤을 때 호출될 함수를 제공하는 것은  모든 클래스가 해야 할 일이지만 각 클래스마다 꼭 맞는 방법으로 에러를 처리할 필요는 없다는 의미이다. 에러가 발생해도 특별히 할 일이 없는 클래스라면 Shape 클래스에서 기본으로 제공하는 error 함수를 사용해도 된다.

 

단순 가상 함수에서 함수 인터페이스와 기본 구현을 한꺼번에 지정하는 것은 위험할 수도 있다.

XYZ 항공사에서 "비행 방식이 동일한" A모델 비행기와 B모델 비행기를 운용한다고 가정하고 클래스를 만들어보자.

class Airport{...}; // 공항

class Airplane{ // 비행기 기본 클래스
public:
	virtual void fly(const Airport& destination);
};

void Airplane::fly(const Airport& destination){
	// 주어진 목적지로 비행기를 날려 보내는 기본 동작 원리를 가진 코드,
}

class ModelA: public Airplane{...}; // 비행기 모델 A
class ModelB: public Airplane{...}; // 비행기 모델 B

Airplane::fly() 는 가상 함수로 선언되어 모든 비행기는 fly 함수를 지원해야 한다고 주장한다. 또한 모델이 다른 비행기는 fly 함수에 대한 구현을 다르게 요구할 수 있음을 고려하여 기본적인 비행 원리만 Airplane::fly() 에서 제공하여 ModelA와 ModelB가 이를 물려받을 수 있도록 하였다.

 

위와 같은 고전적인 객체지향 설계는 다음과 같이 많은 장점을 갖는다.

  1. 클래스 사이의 공통 사항으로 둘 수 있는 특징이 명확해진다.
  2. 코드가 중복되지 않는다.
  3. 이후 기능 개선이 가능하다.
  4. 장기적인 유지 보수가 쉬워진다.

이러한 훌륭한 설계에, XYZ 항공사는 많은 돈을 벌어들여 새로운 비행기 모델 C를 추가하였다. 하지만 모델 C는 이전의 모델 A, B과는 완전히 다른 비행 방식을 갖고 있다. XYZ 항공사의 프로그래머들은 C 모델을 위한 클래스를 기존의 클래스 계통에 추가하였지만 다음과 같이 fly 함수를 재정의하는 것을 잊었다.

class ModelC: public Airplane{
	... // fly 함수의 재정의가 존재하지 않음
}
...

Airplane* pa = new ModelC;
...
pa->fly(); // Airplane::fly()가 호출된다.

ModelC는 이전까지의 모델들과는 fly 방식이 달라서, Airplane::fly() 로는 전혀 날 수 없을 것이다. 즉, ModelC는 Airplane::fly()의 기본 동작을 원하지 않는다. 그럼에도 불구하고 해당 동작을 물려받는다는 것이 문제이다.

이러한 문제는 "가상 함수의 인터페이스와 그 가상 함수의 기본 구현을 잇는 연결 관계를 끊어버림"으로써 해결할 수 있다.

class Airplane{
public:
	virtual void fly(const Airport& destination) = 0; // 순수 가상 함수로 변경
protected:
	void DefaultFly(const Airport& destination);
};

void Airplane::DefaultFly(const Airport& destination){
	// 주어진 목적지로 비행기를 날려 보내는 기본 동작 원리를 가진 코드
};

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);
	}
};
class ModelC: public Airplane{
public:
	virtual void fly(const Airport& destination){
		// ModelC 전용 fly 구현
	}
};

이제, fly 함수가 Airplane 클래스의 순수 가상 함수로 되어있어 ModelC에서는 fly 함수를 재정의하지 않을 수 없을 것이다. 이전의 설계보다는 좋아졌다 느껴지지만, 중요하지 않은 관계로 얽힌 함수 이름들이 클래스의 네임스페이스를 더럽힌다는 것을 이유로 마음에 들지 않을 수 있다. 이를 해결하기 위해 앞서 말한 순수 가상 함수를 구현하는 방법을 사용할 수 있다.

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){
		// ModelC 전용 fly 구현
	}
};

 

비가상 함수 GetObjectID

멤버 함수가 비가상 함수라는 것의 의미는 다음과 같다.

  1. 이 함수는 파생 클래스에서 다른 행동이 일어날 것으로 가정하지 않았다.
  2. 클래스 파생에 상관없이 변하지 않는 동작을 지정한다.

비가상 함수를 선언하는 목적은 파생 클래스가 함수 인터페이스와 더불어 그 함수의 필수적인 구현을 물려받게 하는 것이다.

즉, "Shape 계통 객체의 식별자는 계산하는 방법은 항상 동일하여 실제 계산 방법은 Shape::GetObjectID()의 정의에서 결정되고 파생 클래스는 이를 변경할 수 없다." 를 의미한다. 비가상 함수는 클래스 파생에 상관없는 불변동작이다.

 

결론

순수 가상 함수, 단순 가상 함수, 비가상 함수의 여러 차이점으로 우리는 파생 클래스가 물려받았으면 하는 것들을 정밀하게 지정할 수 있다. 판단에 따라 인터페이스마 상속시켜도 되고, 인터페이스와 기본 구현을 함께 상속시킬 수도 있으며, 아니면 인터페이스와 필수 구현을 상속시킬 수 있다.

 

이것만은 잊지 말자!

  • 인터페이스 상속은 구현 상속과 다르다. public 상속에서, 파생 클래스는 항상 기본 클래스의 인터페이스를 모두 물려받는다.
  • 순수 가상 함수는 인터페이스 상속만을 허용한다.
  • 단순 가상 함수는 인터페이스 상속과 더불어 기본 구현의 상속도 가능하도록 지정한다.
  • 비가상 함수는 인터페이스 상속과 더불어 필수 구현의 상속도 가하도록 지정한다.