KoreanFoodie's Study

Effective C++ | 항목 34 : 인터페이스 상속과 구현 상속의 차이를 제대로 파악하고 구별하자 본문

Tutorials/C++ : Advanced

Effective C++ | 항목 34 : 인터페이스 상속과 구현 상속의 차이를 제대로 파악하고 구별하자

GoldGiver 2022. 10. 25. 16:27

C++ 프로그래머의 필독서이자 바이블인, 스콧 마이어스의 Modern Effective C++ 를 읽고 기억할 내용을 요약하고 있습니다. 꼭 읽어보시길 추천드립니다!

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

핵심 :

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

 

클래스 설계자의 입장에서, 멤버 함수의 인터페이스(선언)만을 파생 클래스에 상속받고 싶을 때가 있다. 어쩔 때는 구현을 오버라이드 가능하게 만들고 싶을 때도, 오버라이드 할 수 없도록 막고 싶을 때도 있다. 다음 Shape 클래스 예제 코드를 보며 여러 경우들을 살펴보자.

class Shape
{
public:
	virtual void draw() const = 0;
	virtual void error(const std::string& msg);
	int objectID() const;
	...
};

class Rectangle: public Shape { ... };
class Ellipse: public Shape { ... };

먼저, 순수 가상 함수를 선언하는 목적은 파생 클래스에게 함수의 인터페이스만을 물려주려는 것이다. 왜냐하면 순수 가상 함수는 파생 클래스에서 새롭게 정의해야 하기 때문이다.

물론 순수 가상 함수에도 정의를 제공할 수 있다. 다만, 반드시 클래스 이름을 한정자로 붙여 주어야 한다.

#include <iostream>

class Shape
{
public:
	virtual void draw() const = 0;
	virtual void error(const std::string& msg) {}
	int objectID() const;
};

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

class Rectangle: public Shape 
{
public:
	void draw() const override
	{
		std::cout << "Draw Rectangle" << std::endl;
	} 
};
class Ellipse: public Shape 
{
	public:
	void draw() const override
	{
		
	} 
};



int main()
{
	Shape *ps1 = new Rectangle;
	ps1->Shape::draw(); // Draw Shape

	Rectangle *ps2 = new Rectangle;
	ps2->draw(); // Draw Rectangle
}

 

다음으로, error 함수를 보자. 단순 가상 함수를 선언하는 목적은 파생 클래스로 하여금 함수의 인터페이스뿐만 아니라 그 함수의 기본 구현도 물려받게 하자는 것이다. 즉, "error 함수는 당신이 직접 구현해야 하지만, 굳이 새로 만들 생각이 없다면 Shape 클래스에 있는 기본 버전을 써라" 라는 뜻이다.

만약 fly 함수를 지원하는 Airplane 클래스로부터, Model A, Model B, Model C 를 만든다고 해보자. 이때, A 와 B는 Airplane 의 기본 fly 동작을 그대로 따르지만, C 에서의 fly는 다른 방식으로 동작해야 한다고 가정한다. 그럼 어떤 방식으로 구현해야 할까?

class Airport {};

class Airplane
{
public:
	virtual void fly(const Airport& destination) = 0;
protected:
	void 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)
	{
		// Do something else...
	}
};

위와 같이 defaultFly 함수를 추가로 정의할 수도 있고, fly 를 순수 가상 함수로 만들 수도 있다.

class Airport {};

class Airplane
{
public:
	virtual void fly(const Airport& destination) = 0;
};

void Airplane::fly(const Airport& destination)
{
	// 기본 fly 동작
}

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)
	{
		// Do something else...
	}
};

위처럼 순수 가상 함수를 사용하면, 함수 양쪽에 각기 다른 보호 수준을 부여할 수 있는 융통은 날아가게 된다.

 

이제 비가상함수인 objectID 를 보자.

멤버 함수가 비가상 함수로 되어 있다는 것은, 클래스 파생에 상관없이 변하지 않는 동작을 지정하는 데 쓰인다. 즉, 비가상 함수를 선언하는 목적은 파생 클래스가 함수 인터페이스와 더불어 그 함수의 필수적인 구현(mandatory implementation)을 물려받게 하는 것이다.

위에서 설명한 점들을 주의하며, 클래스를 설계할 때는 '모든 함수를 가상함수로 만들거나', '모든 함수를 비가상 함수로 만드는' 실수를 하지 않도록 하자!

Comments