본문 바로가기

study/C++

[C++][Effective C++] 32~40. 상속, 그리고 객체 지향 설계

728x90

[Effective C++(3판)]에 대한 내용을 공부하면서 정리한 내용이다.

 

이번에 요약하는 내용은 32~40 항목으로 아래와 같다.

  • 32. public 상속 모형은 반드시 "is-a"를 따르도록 만들자
  • 33. 상속된 이름을 숨기는 일은 피하자
  • 34. 인터페이스 상속과 구현 상속의 차이를 제대로 파악하고 구별하자
  • 35. 가상 함수 대신 쓸 것들도 생각해 두는 자세를 시시때때로 길러 두자
  • 36. 상속받은 비가상 함수를 파생 클래스에서 재정의하는 것은 절대 금물!
  • 37. 어떤 함수에 대해서도 상속받은 기본 매개변수 값은 절대로 재정의하지 말자
  • 38. "has-a" 혹은 "is-implemented-in-terms-of"를 모형화할 때는 객체 합성을 사용하자
  • 39. private 상속은 심사숙고해서 구사하자
  • 40. 다중 상속은 심사숙고해서 사용하자

요약

32. public 상속 모형은 반드시 "is-a"를 따르도록 만들자

public 상속을 통한 구조는 반드시 "is-a" 구조를 따르는 것을 의미한다. Derived 클래스를 Base 클래스로부터 public 상속을 통해 파생시켰다면, 컴파일러에게 Derived 타입으로 만들어진 모든 객체는 Base 타입의 객체다(D is a B)! 그리고 그 역은 성립하지 않는다. 다시 말해, Base 클래스는 Derived 클래스보다 더 일반적인 개념을 나타내고, Derived 클래스는 Base 클래스보다 더 특수한 개념이다.

따라서, Base 타입의 객체가 사용되는 곳에는 Dervied 타입의 객체도 쓰일 수 있다고 단정할 수 있다. 반면에 반대로는 불가능하다.

class CPerson {};
class CStudent : public CPerson {}; // Student is a Person

void eat(const CPerson &p);
void study(const CStudent &s);

int main(void) {
	CPerson p;
	CStudent s;

	eat(p);
	eat(s);		// s는 Person이다.

	study(p);	// p는 Student가 아니다.
	study(s);

	return 0;
}

 

단, C++에서 "is-a" 관계는 public 상속일 때만 해당한다. private 상속은 의미 자체가 완전히 다르고, protected 상속은 의미가 애매모호하다.

 

C++에서 public 상속을 할 때 더 주의해야할 점들이 많다. public 상속은 기본 클래스 객체가 가진 모든 것들이 파생 클래스 객체에도 그대로 적용된다고 단정하는 상속이다. 그런데 직사각형과 정사각형을 예로 들었을 때, 좀 더 일반적인 직사각형의 특징을 갖는 정사각형을 생각해보자. 직사각형의 높이값만 변경하는 멤버 함수가 있다면, 정사각형 파생 클래스에서도 높이값만 변경될 수 있는 문제가 생길 수 있다. 컴파일러 수준에서 문법적 하자가 없는 코드는 런타임에 걸리게 될 가능성이 높다. 이는 예상한 대로 돌아간다고 생각할 수 없다. 이런 문제를 해결하도록 고민을 해야 한다.

 

33. 상속된 이름을 숨기는 일은 피하자

이번 항목은 "유효범위(scope)"에 대해 다룬다. 전역 변수로 선언한 int 타입의 x 변수가 있다고 해보자. 그리고 지역 변수로 선언한 double 타입의 x 변수가 있다고 해보자. double 타입의 x 변수가 선언된 같은 지역에 x에 대한 입력을 받는다면, 지역 변수(double 타입)에 대한 입력을 받게 된다. 두 변수 x가 유효 범위가 다른데, 타입은 중요하지 않다. 이름이 겹치면 이름을 가리는 것이다.

상속에서 말을 해보자. 기본 클래스의 것을 파생 클래스의 멤버 함수 안에서 참조할 때, 컴파일러는 이 대상을 바로 찾을 수 있다. 파생 클래스의 유효범위가 기본 클래스의 유효 범위 안에 포함되어 있기 때문이다. (위에 언급한 변수 x와 비교했을 때, 기본 클래스=전역, 파생 클래스=지역)

 

다음은 상속된 이름 가리기를 무시하고 싶은 경우에 사용할 수 있는 방법이다. 기본 클래스에서 오버로드된 버전의 함수가 여러 개 있다고 해보자. 예를 들어 virtual void func1() = 0; 함수와 오버로드 버전의 virtual void func1(int);이 있다고 해보자. 파생 클래스에서는 이 가운데, 순수 가상 함수인 전자에 대해서만 재정의를 하고 싶을 수 있다.

그러면, 파생 클래스의 다른 멤버 함수에서 오버로드 버전의 기본 클래스의 후자 함수를 호출하면, 정상적으로 동작할까? 안타깝지만 파생 클래스 내부에서 func1(int)의 호출은 '이름 가리기' 때문에 호출 할 수 없다. 파생클래스는 func1()만 재정의해 볼 수 있다. 그리고 이때 인자가 틀려 예상한 대로 동작할 수 없게 된다. Derived::func1()을 호출해야 하는데, 호출부에서는 Base::func1(int)를 호출하기 위해 인자를 넘겼기 때문이다.

그렇다면, 파생 클래스에서 기본 클래스의 함수 중 일부만 재정의하고, 재정의하지 않은 다른 오버로드 버전의 함수는 어떻게 호출할 수 있을까? 답은 using 선언이다. using Base::func1;을 해두면 기본 클래스에서 func1의 이름을 갖는 것들을 파생 클래스에서도 바로 볼 수 있다. using 선언을 하지 않으면, 가려진 이름에 대해서는 상속을 받고 싶어도 받을 수 없다. 파생 클래스에서 재정의한 함수가 기본 클래스의 다른 함수들의 이름을 가려버리기 때문이다. 타입이 다르더라도 말이다. 중요한 건 "이름"이 같은 지다.

 

private 상속을 사용하는 경우라면 말이 달라질 수 있다. 이때는 이름이 가려졌다고 using 선언으로 해결할 수 없다. using 선언을 하면, 그 이름에 해당하는 것들이 모두 파생 클래스로 내려가버리기 때문이다. 이때 전달 함수(forwarding function)이라는 것을 사용한다. 파생 클래스에서 기본 클래스의 함수의 함수 중 재정의할 함수에 대해 정의 시, 기본 클래스의 함수를 명시적으로 호출하는 것이다. (가려진 이름을 갖는 것 중 일부를 볼 수 있다.)

 

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

public 상속은 자세히 들여다보면 두 가지로 나뉜다.

  • 함수 인터페이스의 상속
  • 구현의 상속

이 둘의 차이는 함수 선언과 함수 정의의 차이와 같다.

 

멤버 함수의 인터페이스(선언)만을 파생 클래스에 상속받고 싶을 때가 분명히 있을 것이다. 또 어쩔 때는 함수의 인터페이스와 구현을 모두 상속받고 그 상속받은 구현이 오버라이드가 가능했으면 하는 경우도 있을 것이다. 반대로, 인터페이스와 구현을 상속받되 어떤 것도 오버라이드 할 수 없도록 막고 싶을 때도 있을 것이다. 이런 것들을 직접 경험해보자. 이번 항목에서는 그래픽 응용프로그램에 쓰이는 기하학적 도형을 나타내는 클래스 계통 구조를 예제로 든다.

/* 추상 클래스 (순수 가상 함수를 갖음) */
// 추상 클래스는 그 자체로 인스턴스를 만들 수 없다. 파생 클래스만이 인스턴스화가 가능하다.
class CShape {
public:
	/* 순수 가상 함수 */
	// 1. 순수 가상 함수를 상속받은 구체 클래스는 순수 가상 함수를 반드시 재선언해야 한다.
	// 2. 순수 가상 함수는 추상 클래스 안에서 정의를 갖지 않는다.
	// 즉, 파생 클래스에게 함수의 인터페이스만을 물려주려는 목적
	virtual void draw() const = 0;
	
	/* 가상 함수 */
	// 1. 순수 가상 함수와 같이 파생 클래스에게 인터페이스를 상속하게 한다.
	// 2. 순수 가상 함수와 달리 파생 클래스에서 오버라이드할 수 있는 함수 구현부를 제공한다.
	// 즉, 파생 클래스에게 함수의 인터페이스뿐만 아니라 그 함수의 기본 구현도 물려받게 하는 것이다.
	// 자동으로 호출될 함수를 제공하는 것은 모든 클래스가 해야 하는 일이지만, 특별한 처리를 추가하지 않아도 된다면 기본 클래스에서 기본으로 제공되는 함수를 사용해도 된다.
	virtual void printError(const std::string &sMsg);

	/* 일반 멤버 함수 */
	// 1. 파생 클래스에서 다른 행동이 일어날 것으로 가정하지 않음을 의미한다.
	// 2. 클래스 파생에 상관없이 불변하는 동작을 지정하는데 사용한다.
	// 즉, 파생 클래스가 함수의 인터페이스와 더불어 그 함수의 필수적인 구현을 물려받게 되는 것이다.
	int getObjectId() const;
};

 

인터페이스와 기본 구현을 분리하면서 네임스페이스의 코드가 깔끔해지는 방법이 있다. 순수 가상 함수가 구체 파생 클래스에서 반드시 재선언되어야 한다는 사실을 활용하되, 자체적으로 기본 클래스에서 순수 가상 함수의 구현을 구비해 두는 것이다.

class CAirplane {
public:
	// 순수 가상 함수
	virtual void fly(const CAirport &dest) = 0;
};

void CAirplane::fly(const CAirport &dest) {
	// 순수 가상 함수의 구현
}

class CModelA : public CAirplane {
public:
	// 파생 클래스에서 순수 가상 함수의 재정의 필요
	virtual void fly(const CAirport &destination) {
		// 순수 가상 함수의 기본 구현부 호출
		// 물론 목적에 맞게 직접 구현도 가능
		CAirplane::fly(destination);
	}
};

 

클래스 설계 시 흔히 발생하는 결정적인 실수 두 가지를 확인하도록 하자.

  1. 모든 멤버 함수를 비가상 함수로 선언하는 것
    • 기본 클래스의 동작을 특별하게 만들만한 여지가 없어지게 된다.
  2. 모든 멤버 함수를 가상 함수로 선언하는 것
    • 분명히 파생 클래스에서 재정의가 되지 않아야 할 함수도 있을 것이다.
    • 비가상 함수로 확실히 입장을 밝히는 것도 중요하다.

 

35. 가상 함수 대신 쓸 것들도 생각해 두는 자세를 시시때때로 길러 두자

이번 항목에서는 가상 함수 대신 사용할 수 있는 방법들에 대해 다룬다.

비가상 인터페이스 관용구를 통한 템플릿 메서드 패턴

가상 함수를 private 멤버로 두어야 한다고 주장하는 가상 함수 은폐론을 보자. 이들이 주장하는 것은 기본적으로 제공할 기능은 public 멤버 함수로 비가상 함수로 선언하고, 파생 클래스에서 재정의해야 할 함수는 private 멤버 함수로 가상 함수로 선언하자는 것이다. 파생 클래스에서도 전체적으로 동일하게 동작하되, 클래스마다 내부적으로만 다르게 동작하자는 것이다.

class CGameCharacter {
public:
	// 비가상 함수
	int getHealthValue() const {
		// 사전 작업

		// 실제 기본/파생 클래스마다 처리할 동작
		int iRet = doHealthValue();

		// 사후 작업

		return iRet;
	}

private:
	// 파생 클래스에서 구현 가능한 가상 함수
	virtual int doHealthValue() const {
		// something
	}
};

 

위와 같이 설계하면, 캐릭터의 체력을 구할 때 기본 클래스와 모든 파생 클래스에서 사전 작업과 사후 작업을 동일하게 처리할 수 있다. 다만, 파생 클래스마다 체력 자체를 구하는 방식이 조금씩 다르다면, 각자 상황에 맞게 구현할 수 있다.

사전/사후 작업에는 락을 건다든지, 로그를 남긴다든지 등의 조건을 일관성 있게 넣을 수 있다.

 

public 비가상 멤버 함수로 private 가상 함수를 간접적으로 호출하는 방법으로, 비가상 함수 인터페이스(NVI, Non-Virtual Interface) 관용구로 많이 알려져 있다. NVI 관용구는 템플릿 메서드(Template Method)라 불리는 고전 디자인 패턴을 C++ 식으로 구현한 것이다. 그리고, 여기서의 비가상 함수를 가상 함수의 wrapper로 볼 수 있다.

private 가상 함수는 상황에 따라 protected / public(NVI 관용구의 의미가 없겠지만)을 적절히 선택해서 위치시켜도 된다.

 

함수 포인터로 구현한 전략 패턴

NVI 관용구는 public 가상 함수를 대신할 수 있는 꽤 괜찮은 방법이다. 하지만, 클래스 설계 관점에서 보면 눈속임에 불과하다. 비가상 함수를 호출하지만, 어쨌든 가상 함수를 사용한다는 것은 여전하기 때문이다.

조금 더 극적인 설계를 생각해보면, 캐릭터의 체력을 계산하는 작업은 캐릭터 타입과 별개로 놓는 편이 맞다. "체력"이 캐릭터의 일부이지, "체력을 계산하는 방법"이 캐릭터의 일부는 아니다. 따라서, 각 캐릭터의 생성자에 체력 계산 함수 포인터를 받고, 이 함수를 호출하면서 캐릭터의 체력을 계산해보면 어떨까?

class CGameCharacter {
public:
	typedef int (*HealthCalcFunc) (const CGameCharacter &);

	explicit CGameCharacter(HealthCalcFunc hcf = defaultHealthCalc) : calcHealth(hcf) {}

	int helathValue() const {
		// 체력 계산 함수에 현재 객체를 전달
		return calcHealth(*this);
	}

private:
	// 체력 계산 함수
	HealthCalcFunc calcHealth;
};

 

위 방법은 전략(Strategy) 패턴의 단순한 예제이다. 클래스 내부에 직접 가상 함수를 심는 것과 비교해 특이한 융통성을 갖고 있다. 위와 같이 설계한다면, 같은 캐릭터 타입임에도 각각의 객체마다 서로 다른 체력 계산 함수를 가질 수 있다. 또한, 게임이 실행되는 도중에 특정 캐릭터에 대한 체력 계산 함수를 바꿀 수도 있다.

 

하지만, 이렇게 설계했을 때의 단점도 확실히 존재한다. 위와 같이 체력 계산 함수가 이제 캐릭터 클래스 계통의 멤버 함수가 아니기 때문에 private 데이터 멤버에는 접근할 수 없다는 것이다. 단순히 public 인터페이스로 얻은 정보만을 사용해서 체력을 계산해야 한다. 이는 클래스 내부 멤버 함수를 클래스 밖에서의 동등한 기능을 갖는 비멤버 비프렌드 함수로 만드는 과정에서 계속해서 고민되는 부분이다.

캡슐화를 완화해서라도 함수 포인터를 사용하는 방법을 선택해야 할지는 실제 설계를 하면서 상황에 맞게 스스로 판단해야 한다.

 

std::function(tr1::function)으로 구현한 전략 패턴

템플릿과 암시적 인터페이스에 대해 어색하지 않다면, 함수 포인터 기법이 뭔가 어색해 보일 수 있다. 체력 계산을 왜 함수가 해야 하며, 함수처럼 동작하는 다른 함수 객체를 쓰면 되지 않겠냐는 것이다. 이때 함수 객체(std::function)를 사용하면 문제를 해결할 수 있다. (std::function은 C++11부터 지원하며, 이전 버전은 tr1::function을 사용한다.)

함수 객체는 함수호출성 개체(callable entity)를 가질 수 있다. 이들 함수호출성 개체는 주어진 시점에서 예상되는 시그니처와 호환되는 시그니처를 갖고 있다.

class CGameCharacter {
public:
	// HealthCalcFunc는 함수호출성 개체
	// CGameCharacter와 호환되는 어떤 것이든 넘겨받아 호출 가능하다.
	// int와 호환되는 모든 타입의 객체를 반환한다.
	typedef std::function<int (const CGameCharacter &)> HealthCalcFunc;

	explicit CGameCharacter(HealthCalcFunc hcf = defaultHealthCalc) : calcHealth(hcf) {}

	int helathValue() const {
		// 체력 계산 함수에 현재 객체를 전달
		return calcHealth(*this);
	}

private:
	// 체력 계산 함수
	HealthCalcFunc calcHealth;
};

 

앞선 함수 포인터를 사용한 설계와 다른 것이 없다. 좀 더 일반화된 함수 포인터를 가질 수 있다는 것을 말고는 말이다. 이렇게 아주 조금 바꾼 덕택에 사용자 쪽에서는 체력 계산 함수를 지정하는 데 있어서 더 쉬워지고 융통성을 느낄 수 있다.

함수 시그니처에 대해 지정된 것이 아니라 입력값과 반환 값 모두 암시적 변환을 허용하기 때문이다. float을 반환해도 되며, CGameCharacter의 파생 클래스를 인자로 받는 계산 함수를 만들어 적용할 수도 있다.

 

고전적인 전략 패턴

C++을 파고드는 것보다 디자인 패턴에 더 깊게 공부를 했다면, 전통적인 방법으로 구현한 전략 패턴이 더 마음에 들 수 있다. 체력 계산 함수를 나타내는 클래스 계통을 아예 따로 만들고, 실제 체력 계산 함수는 이 클래스 계통의 가상 멤버 함수로 두는 것이다.

class CHealthCalcFunc {
public:
	virtual int calc(const CGameCharacter &gc) const;
};

class CGameCharacter {
public:
	explicit CGameCharacter(CHealthCalcFunc *phcf = &defaultHealthCalc) : pHealthCalc(phcf) {}

	int healthValue() const {
		return pHealthCalc->calc(*this);
	}
private:
	CHealthCalcFunc *pHealthCalc;
};

 

이 방법은 표준적인 전략 패턴 구현 방법으로, 빠르게 이해할 수 있다. 체력 클래스 계통에 파생 클래스를 추가함으로써 기존 체력 계산 알고리즘을 조정할 수 있다는 점도 플러스 요소다.

 

위의 4가지 방법만이 가상 함수를 대신할 수 있는 방법은 아니다. 하지만 이 정도를 가지고 나중에 실제로 적용할 때는 충분히 고민할 수 있을 것이다.

 

36. 상속받은 비가상 함수를 파생 클래스에서 재정의하는 것은 절대 금물!

public 상속을 통해 기본 클래스의 비가상 멤버 함수를 파생 클래스가 상속받았다고 가정하자. 이때, 하나의 파생 클래스의 객체를 기본 클래스의 포인터와 파생 클래스의 포인터로 받아서 하나의 상속됐던 해당 함수를 호출한다고 해보자. 이때 객체도 같은 객체고, 같은 함수니, 같은 동작을 할 것이라고 예상한다. 하지만, 그렇지 않을 수도 있다.

class B {
public:
	// 비가상 함수
	void mf();
};

class D : public B {
public:
	// 비가상 함수 재정의
	void mf(); // B::mf()의 이름을 가린다.
};

int main(void) {
	D x;
	B *pB = &x;
	D *pD = &x;

	pB->mf(); // B::mf() 호출
	pD->mf(); // D::mf() 호출

	return 0;
}

 

이렇게 동작하는 이유는, B와 D의 비가상 함수가 정적 바인딩(static binding, early binding)으로 묶였기 때문이다. pB는 B에 대한 포인터이기 때문에 항상 B 클래스에 정의되어 있는 비가상 함수를 호출한다는 것이다. pB가 실제로는 B의 파생 클래스 객체를 가리키고 있다고 해도 마찬가지다.

반면, 가상 함수를 사용할 경우에는 동적 바인딩(dynamically binding, late binding)으로 묶인다. 비가상 함수와 같이 동작하지 않는다. 기본 클래스에서 가상 함수로 호출하면 pB가 진짜로 가리키고 있는 타입의 함수가 호출된다.

 

파생 클래스에서 기본 클래스의 비가상 함수를 재정의해버리면, 위의 예제처럼 파생 클래스는 해당 함수에 대해 일관성 없는 동작을 보일 것이다. 기본 클래스의 함수를 호출할 것인지, 파생 클래스 본인의 함수를 호출할 것인지를 본인 객체를 가리키는 포인터 타입에 따라 결정하게 된다.

 

이론적으로 설명해보자면, public 상속은 "is-a" 관계를 형성하는 것으로, 모든 파생 클래스는 기본 클래스가 될 수 있다. 따라서, 모든 파생 클래스는 기본 클래스의 비가상 멤버 함수를 사용해야 하는 것이다. 그런데, 이를 재정의했으니 이론적으로 맞지 않는 구조가 되는 것이며, 모순이 생기는 것이다. 이런 경우에는 파생 클래스는 해당 기본 클래스를 public 상속을 받으면 안 된다. 만약 기본 클래스의 특정 함수를 파생 클래스에서 재정의해야 한다면 기본 클래스에서 가상 함수로 선언해두어야 한다. 파생 클래스마다 동작이 동일한 불변 동작이라면 비가상 함수로 정의하고, 재정의하는 일이 없도록 해야 한다.

 

37. 어떤 함수에 대해서도 상속받은 기본 매개변수 값은 절대로 재정의하지 말자

C++에서 상속받을 수 있는 함수의 종류는 두 가지, 가상 함수와 비가상 함수다. 비가상 함수는 언제라도 재정의해서는 안 되는 함수이기 때문에 기본 매개변수 값을 갖는 가상 함수를 상속하는 경우를 바탕으로 예제를 보이겠다.

class CShape {
public:
	enum SHAPE_COLOR_e {
		COLOR_RED,
		COLOR_GREEN,
		COLOR_BLUE
	};

	virtual void draw(SHAPE_COLOR_e color = COLOR_RED) const = 0;
};

class CRectangle : public CShape {
public:
	// 기본 매개변수 값이 달라졌다.
	virtual void draw(SHAPE_COLOR_e color = COLOR_GREEN) const;
};

class CCircle : public CShape {
public:
	// 기본 매개변수 값을 없앴다.
	virtual void draw(SHAPE_COLOR_e color) const;
};

int main(void) {
	CShape *ps;							// 정적 타입: CShape *
	CShape *pc = new CCircle;			// 정적 타입: CShape *. 동적 타입: CCircle *
	CShape *pr = new CRectangle;		// 정적 타입: CShape *. 동적 타입: CRectangle *

	return 0;
}

 

정적 타입은 모두 CShape에 대한 포인터다. 동적 타입은 진짜로 가리키는 대상을 나타내는 것으로, 각 객체가 어떻게 동작할 것인지를 가리키는 타입이다. *ps의 경우에는 아직 아무 객체도 참조하지 않고 있기 때문에 동적 타입이 없다. 즉, 동적 타입은 프로그램 실행 도중에 바뀔 수 있다.

 

가상 함수는 동적으로 바인딩된다. 즉, 객체의 동적 타입에 따라 어떤 함수가 호출될지 결정된다는 뜻이다. 그런데, 여기서 가상 함수의 기본 매개변수 값이 바뀌면 문제가 생긴다. 가상 함수는 동적으로 바인딩되어 있지만, 기본 매개변수는 정적으로 바인딩되어 있기 때문이다. 따라서, 파생 클래스에서 정의된 가상 함수를 호출하면서 기본 클래스에 정의된 기본 매개변수 값을 사용해버릴 수 있다는 이야기다.

	pr->draw(); // CRectangle::draw(CShape::COLOR_RED)를 호출한다.

 

기본적으로 draw는 가상 함수이지만, 결국 pr의 정적 타입은 CShape*다. 따라서, 기본 매개변수 값은 정적 타입의 클래스에서 가져오게 된다. 이로 인해 함수는 동적 타입의 것이 호출되지만, 기본 매개변수 값은 정적 타입의 것으로 호출되는 식으로 꼬이게 된다. 이는 C++의 런타임 효율성 때문이다.

 

그렇다면, 기본 클래스와 파생 클래스에서 모두 같은 기본 매개변수 값을 제공하면 해결될까? 이는 코드 중복이다. 게다가 의존성까지 걸려있다. 기본 클래스의 기본 매개변수 값이 변경되면 파생 클래스의 기본 매개변수 값도 그때마다 변경해주어야 한다.

원하는 대로 가상 함수가 동작하도록 만드는 것이 어렵다면, 다른 설계 방법을 찾는 것이 현명하다. 여기서는 항목 35에서 설명한 NVI 관용구를 사용했다.

class CShape {
public:
	enum SHAPE_COLOR_e {
		COLOR_RED,
		COLOR_GREEN,
		COLOR_BLUE
	};

	// 비가상 함수
	void draw(SHAPE_COLOR_e color = COLOR_RED) const {
		doDraw(color);
	}

private:
	// 순수 가상 함수(기본 매개변수 값이 없는)를 private으로 이동
	virtual void doDraw(SHAPE_COLOR_e color) const = 0;
};

class CRectangle : public CShape {
private:
	// 가상 함수 부분만 재정의
	virtual void draw(SHAPE_COLOR_e color) const;
};

 

38. "has-a" 혹은 "is-implemented-in-terms-of"를 모형화할 때는 객체 합성을 사용하자

합성(composition)이란, 어떤 타입의 객체들이 그와 다른 타입의 객체들을 포함하고 있는 경우 성립하는 타입들 사이의 관계다. 포함된 객체들을 모아서 이들을 포함한 다른 객체를 합성한다는 뜻이다. 레이어링(layering), 포함(containment), 통합(aggregation), 내장(embedding) 등으로도 불린다.

 

이러한 관계를 "has-a" 또는 "is-implemented-in-temrs-of"를 이용해 표현할 수 있다. 뜻이 두 개로 나뉜 이유는 대하는 영역(domain)이 두 가지이기 때문이다. 볼 수 있는 사물(사람, 이동수단 등)을 본뜬 것은 소프트웨어의 응용 영역에 속한다. 응용 영역에 속하지 않는 나머지는 시스템 구현만을 위한 인공물(버퍼, 뮤텍스, 트리 등)이며, 소프트웨어의 구현 영역이라고 한다. 응용 영역의 객체들 사이에서 일어나면 "has-a" 관계이며, 구현 영역에서 일어나면 "is-implemented-in-terms-of" 관계라고 한다.

 

39. private 상속은 심사숙고해서 구사하자

앞서 public 상속은 "is-a" 관계를 나타낸다고 했다. 이번 항목에서 보여줄 private 상속은 "is-implemented-in-terms-of" 관계를 의미한다. 

private 상속을 한 파생 클래스의 객체는 일반적으로 기본 클래스 객체로 변환(암시적 형변환)되지 않는다. private 상속을 통해 파생시킨 것은 기본 클래스에서 쓸 수 있는 기능 몇 개를 활용할 목적이지, 기본 클래스 타입과 파생 클래스 타입 사이에 개념적 관계가 있어서 한 행동은 아니라는 것이다.

 

앞서 객체 합성도 마찬가지로 "is-implemented-in-terms-of" 의미를 갖는다고 했다. 그러면 이 둘을 언제 어떻게 골라야 하는 것일까? 답은 간단하다. 할 수 있으면 객체 합성을 하고, 꼭 해야 한다면 private 상속을 하면 된다. 그렇다면, 꼭 해야 하는 경우는 언제일까? 기본 클래스의 private 멤버를 접근할 필요가 있을 때 또는 가상 함수를 재정의할 필요가 있을 때다.

 

private 상속을 해야 하는 경우도, 다른 방식으로 적용하는 방법도 있다. private 상속만 써서 만든 설계에 비해 복잡한 구조인데, public 상속에다가 객체 합성이 결합되어 있는 형태다. 실제로는 아래와 같은 형식이 더 유용한 경우가 많다.

class CTimer {
public:
	explicit CTimer(int tickFrequency);

	virtual void onTick() const; // 일정 시간이 경과할 때마다 자동으로 호출되는 함수
};

class CWidget {
private:
	// 내부 private 클래스(public 상속)
	class CWidgetTimer : public CTimer {
	public:
		// 가상 함수 재정의
		virtual void onTick() const;
	};

	// 내부 클래스를 합성
	CWidgetTimer time;
};

 

위와 같은 방식으로 적용하면 두 가지 장점이 있다.

  1. CWidget 클래스를 설계할 때, 파생은 가능하게 하되 파생 클래스에서 onTick을 재정의할 수 없도록 설계 차원에서 막았다.
    • 만약 CWidget을 CTimer로부터 상속시킨 구조라면 재정의를 막을 수 없다.
    • private 상속을 했더라도 재정의를 막을 수 없다.
    • CTimer를 상속한 CWidgetTimer 클래스가 CWidget 클래스의 내부 private 클래스이기 때문에 CWidget의 파생 클래스는 CWidgetTimer에 접근할 수 없는 것이다.
  2. CWidget의 컴파일 의존성을 최소화할 수 있다.
    • CWidget이 CTimer에서 파생되었다면, CWidget이 컴파일될 때 CTimer의 정의도 필요해 Timer.h 같은 헤더를 인클루드 해야 한다.
    • CWidgetTimer의 정의부를 CWidget으로부터 빼내고, CWidget이 CWidgetTimer 객체의 포인터만 갖도록 하면, CWidgetTimer 클래스를 간단히 선언하는 것만으로도 컴파일 의존성을 피할 수 있다.

 

이번 항목에서 말했듯이 private 상속보다는 다른 방식이 "is-implemented-in-terms-of" 관계를 구현하기에 낫다. 따라서 private 상속 자체가 필요한 경우가 거의 없다. private 상속이 적법한 설계 전략일 가능성이 높은 경우는, 아무리 봐도 "is-a"관계로 이어지지 않을 두 클래스를 사용해야 할 때다. 그런 경우도 public 상속과 객체 합성을 적절히 섞으면 대체할 수 있다. 그러니 private 상속은 모든 경우를 심사숙고하고 private 상속이 가장 좋은 상황일 때만 쓰자는 것이다.

 

40. 다중 상속은 심사숙고해서 사용하자

다중 상속(MI, Multiple Inheritance)은 크게 두 가지 진영으로 갈라진다. 단일 상속(SI, Single Inheritance)이 좋다면 다중 상속은 더 좋을 것이라는 의견과, 단일 상속은 좋지만 다중 상속은 걸칫거리라고 하는 의견이다.

 

다중 상속하면 둘 이상의 기본 클래스로부터 똑같은 이름을 물려받을 가능성이 생긴다는 점은 기본으로 떠올라야 한다. 즉, 다중 상속 때문에 모호성이 생길 수 있다는 것이다.

이러한 모호성을 없애기 위해서, 다중 상속에서는 호출할 기본 클래스의 함수를 직접 지정해주어야 한다.

class CBorrowableItem {
public:
	void checkOut();
};

class CElectronicGadget {
private:
	void checkOut();
};

class CMP3 : public CBorrowableItem, public CElectronicGadget {
};

int main(void) {
	CMP3 mp3;

	mp3.checkOut(); // Error. 모호성
	mp3.CBorrowableItem::checkOut(); // OK
	mp3.CElectronicGadget::checkOut(); // Error. private 접근

	return 0;
}

 

다중 상속은 단순히 둘 이상의 클래스로부터 상속을 받는 것이지만, 상위 단계의 기본 클래스를 여러 개 갖는 클래스 계통에서 심심치 않게 눈에 띈다. 이런 구조의 계통에서는 소위 "죽음의 MI 마름모꼴(deadly MI diamond)"라고 알려진 좋지 않은 모양이 나올 수 있다.

class CFile {};

class CInputFile : public CFile {};

class COutputFile : public CFile {};

// 결국에는 맨 위의 CFile 클래스를 상속하는 마름모꼴이 나옴
class CIOFile : public CInputFile, public COutputFile {};

 

위와 같은 예제 코드(죽음의 MI 마름모꼴)에서 발생할 수 있는 문제는 크다. CFile 클래스 안에 fileName이라는 데이터 멤버가 하나 있다고 해보자. CIOFile 클래스에서 이 필드가 몇 개가 있어야 할까? 바로 위의 기본 클래스가 2개이기 때문에 fileName도 두 개 있는 것이 맞다고 생각할 것이다. 또는 CIOFile 객체는 파일 이름이 하나만 있어야 하는 게 맞다고 생각할 것이다.

C++ 자체에서는 어떤 입장도 취하지 않는다. 두 가지를 모두 지원한다는 말이다. 기본적으로는 중복 생성하는 것이 맞고, 중복 생성을 원한 것이 아니었다면 해당 데이터 멤버를 가진 클래스를 가상 기본 클래스(virtual base class)로 만들면 해결된다. 즉, 가상 기본 클래스로 삼을 클래스에 직접 연결된 파생 클래스에서 가상 상속(virtual inheritance)을 하는 것이다.

// 가상 기본 클래스
class CFile {};

// 가상 상속
class CInputFile : virtual public CFile {};

// 가상 상속
class COutputFile : virtual public CFile {};

// 결국에는 맨 위의 CFile 클래스를 상속하는 마름모꼴이 나옴
class CIOFile : public CInputFile, public COutputFile {};

 

사실, C++ 표준 라이브러리가 이런 구조를 갖고 있다. 차례대로 basic_ios, basic_istream, basic_ostream, basic_iostream이다.

 

정확히는 public 상속은 항상 가상 상속이어야 한다. 하지만, 가상 상속을 사용하는 클래스로 만들어진 객체는 그렇지 않은 것보다 일반적으로 크기가 더 크다. 게다가 데이터 멤버에 접근하는 속도도 느리다. 또, 가상 기본 클래스의 초기화 규칙은 훨씬 복잡하고 직관성도 떨어진다. 

따라서 가상 기본 클래스에 대한 조언을 아래와 같이 준다.

  1. 구태여 쓸 필요가 없으면, 가상 기본 클래스를 쓰지 말 것
  2. 가상 기본 클래스를 정말 쓰지 않으면 안 될 상황이라면, 가상 기본 클래스에는 데이터를 넣지 말 것 (Java와 .NET의 인터페이스처럼 데이터를 갖지 말아야, 초기화에 골머리를 앓지 않아도 된다.)

 

MI가 유용한 경우가 있다. 특정 클래스를 구현할 때, 인터페이스 클래스를 public 상속(is-a)하고, 구현에 필요한 기능을 갖는 클래스를 private 상속(is-implemented-in-terms-of)을 하는 경우가 그렇다. 이 부분은 코드는 생략한다.

 

정리하자면, 다중 상속은 대단한 것이 아니다. 그냥 객체 지향 기법 중 하나로 봐야 한다. 단일 상속과 비교해 사용하기 좀 더 복잡하고 이해하기 좀더 복잡한 것은 사실이기 때문에, SI 설계로 충분히 MI와 동등한 효과를 줄 수 있는 경우에는 SI로 가야 한다. 그러니 심사숙고 하자는 것이다.

728x90