관리 메뉴

KoreanFoodie's Study

Effective C++ 정리 6-2 : 상속, 그리고 객체 지향 설계 본문

Tutorials/C++ : Advanced

Effective C++ 정리 6-2 : 상속, 그리고 객체 지향 설계

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

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

핵심 :

상속받은 비가상 함수를 재정의하는 일은 절대로 하지 말자


사실 이 항목은 당연하다면 당연한데, 비가상 함수를 상속받는다는 것은 기반 클래스의 인터페이스와 구현을 모두 물려받는다는 뜻이므로 파생클래스에서 재정의한다는게 이론적으로도 모순되게 된다. 해당 기반 클래스를 물려받은 클래스는 해당 비가상 클래스를 호출할 때 기반 클래스에서 의도한 비가상 함수의 동작을 그대로 재현해야 하기 때문이다!
예시를 하나 보자.

class B
{
public:
	void mf() {}
};

class D: public B 
{
public:
	// B::mf 를 가린다
	void mf() {}
};


int main()
{
	D x;

	B *pB = &x;
	pB->mf();

	D *pD = &x;
	pD->mf();
}

위와 같이 되면, pD 에서의 mf 함수 호출은 D::mf 를 호출한다. 이는 비가상 함수는 정적 바인팅(static binding) 으로 묶여 있어서 포인터 타입에 맞는 함수가 호출된다. 반면, 가상 함수는 동적 바인딩(dynamically binding) 으로 묶여 pB 및 pD 가 진짜로 가리키는 D 타입 객체의 함수가 호출된다.
참고로, 정적 바인딩은 빌드 중에 타입이 정해지고, 동적 바인딩은 런타임에 정해진다. 하지만 C++ 의 가상 함수의 바인딩은 문서상으로는 동적 바인딩이지만, 구현상으로는 런타임 성능을 높이기 위해 정적 바인딩을 쓰고 있다. 즉, 컴파일 중에 아예 가상 함수 테이블을 파생 클래스에 맞게 바꿈으로써, 겉보이게은 파생 클래스 타입에서 오버라이드한 가상 함수를 호출하는 것처럼 보이게 만든다.

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

핵심 :

상속받은 기본 매개변수 값은 절대로 재정의해서는 안 된다. 왜냐하면 기본 매개변수 값은 정적으로 바인딩되는 반면, 가상 함수(오버라이드 가능한 유일한 함수)는 동적으로 바인딩되기 때문이다.


아래 예시를 보자.

class Shape
{
public:
	enum ShapeColor { Red, Green, Blue };

	// 모든 도형은 자기 자신을 그리는 함수를 제공해야 함
	virtual void draw(ShapeColor color = Red) const = 0;
};

class Rectangle: public Shape
{
public:
	// 기본 매개변수 값이 달라짐!
	virtual void draw(ShapeColor color = Green) const;
};

class Circle: public Shape
{
public:
	virtual void draw(ShapeColor color) const;
};


int main()
{
	Shape *ps;
	Shape *pc = new Circle;
	Shape *pr = new Rectangle;

	// Rectangle::draw(Shape::Red) 를 호출!
	// Rectangle::draw(Shape::Green) 이 아니다!
	pr->draw();
}

위에서, pr->draw() 가 우리가 기대한 대로 동작하지 않는 원인이 뭘까? 호출되는 가상 함수는 Rectangle 것이 맞지만, pr 의 정적 타입은 Shape* 이기 때문에, 가상 함수에 쓰이는 기본 매개변수 값은 Shape 클래스에서 가져온다! 만약 상속받은 가상 함수의 기본 매개변수 값을 재정의하면 위처럼 기상천외한 동작이 발생하는 것이다.
이는, C++ 가 런타임 효율을 위해 기본 매개변수 값을 정적으로 바인딩하기 때문이다.

그렇다면 파생 클래스에서 가상 함수의 기본 매개변수 값을 어떻게 하면 깔끔하게 받게 만들 수 있을까? 여러 방법이 있지만, 비가상 인터페이스(non-virtual interface) 관용구를 사용하는 것을 추천한다. 예시를 보자.

class Shape
{
public:
	enum ShapeColor { Red, Green, Blue };
	void draw(ShapeColor color = Red) const
	{
		doDraw(color);
	}

private:
	// 진짜 작업은 이 함수에서 이루어짐
	virtual void doDraw(ShapeColor color) const = 0;
};

class Rectangle: public Shape
{
public:
	...
private:
	// 기본 매개변수 값이 없음
	virtual void doDraw(ShapeColor color) const { ... }
};


항목 38 : "has-a(...는...를 가짐)" 혹은 "is-implemented-in-terms-of(...는...를 써서 구현됨)"를 모형화할 때는 객체 합성을 사용하자

핵심 :

1. 객체 함성(composition)의 의미는 public 상속이 가진 의미와 완전히 다르다.
2. 응용 영역에서 객체 합성의 의미는 has-a(...는...를 가짐)이다. 구현 영역에서는 is-implemented-in-terms-of(...는 ...를 써서 구현됨)의 의미를 갖는다.


합성(composition) 이란, 어떤 타입의 객체들이 그와 다른 타입의 객체들을 포함하고 있을 경우에 성립하는 타입들 사이의 관계를 일컫는다. 예시를 보자.

class Address { ... };
class PhoneNumber { ... };

class Person
{
public:
	...
private:
	std::string name;
	Address address;
	PhoneNumber phoneNumber;
}

위의 Person 클래스는 Address 와 PhoneNumber 클래스를 가지고 있고, 구현 영역에서는 is-implemented-in-terms-of 의 의미를 갖는다.

항목 39 : private 상속은 심사숙고해서 구사하자

핵심 :

1. private 상속의 의미는 is-implmented-in-terms-of(...는 ...를 써서 구현됨) 이다. 대개 객체 합성과 비교해서 쓰이는 분야가 많지는 않지만, 파생 클래스 쪽에서 기본 클래스의 protected 멤버에 접근해야 할 경우 혹은 상속받은 가상 함수를 재정의해야 할 경우에는 private 상속이 나름대로 의미가 있다.
2. 객체 합성과 달리, private 상속은 공백 기본 클래스 최적화(EBO) 를 활성화시킬수 있다. 이는 객체 크기를 가지고 고민하는 라이브러리 개발자에게 꽤 매력적인 특징이 되기도 한다.


public 상속을 private 상속으로 고친 예시를 보자.

class Person {};
class Student: private Person {};

void eat(const Person& p) {}
void study(const Student& s) {}

int main()
{
	Person p;
	Student s;

	eat(p);
	// 에러! Student 는 Person 의 일종이 아니다
	// Student 가 public 상속을 하면 정상 동작한다
	eat(s);
}

public 상속과 달리, 상속 관계가 private 이면 컴파일러는 파생 클래스 객체를 기본 클래스 객체로 변환하지 않는다. private 상속의 의미는 '구현은 물려받을 수 있다. 인터페이스는 국물도 없다' 는 뜻이다. private 상속은 소프트웨어 설계 도중에는 아무런 의미도 갖지 않으며, 단지 소프트웨어 구현(implementation) 중에만 의미를 가진다.
private 상속의 의미는 is-implemented-in-terms-of 인데, 언제 객체 합성을 쓰고 언제 private 상속을 써야 할까? 답은, 되도록이면 객체 합성을 사용하고, 꼭 해야 하는 경우 private 상속을 하는 것이 좋다. 꼭 해야 하는 경우는, 비공개 멤버를 접근하거나 가상 함수를 재정의할 경우가 주로 이에 속한다.
이런 상황을 가정해 보자. Widget 클래스가 있고, 해당 Widget 클래스가 얼마나 사용되었는지를 추적하고 싶다고 가정하겠다. 이때, 우리가 미리 만들어 놓은 Timer 클래스를 재활용하면, 다음과 같이 사용할 수 있다.

class Timer
{
public:
	explicit Timer(int tickFrequency);
	// 일정 시간이 경과할 때마다 자동으로 호출
	virtual void onTick() const;
};

// Widget 은 Timer 와 is-a 관계가 아니므로,
// public 상속이 아니라 private 상속을 사용함!
class Widget: private Timer
{
private:
	// Widget 사용 자료 등을 수집
	virtual void onTick() const;
};


물론 객체 합성을 이용할 수 도 있다. 다음과 같이 말이다.

class Widget
{
private:
	class WidgetTimer: public Timer
	{
	public:
		virtual void onTick() const;
	};

	WidgetTimer timer;
};

위와 같은 방식은, 첫째, Widget 클래스를 설계하는 데 있어 파생은 가능하게 하되, 파생 클래스에서 onTick 을 재정의할 수 없도록 설계 차원에서 막고 싶을 때 유용하다. 또한, Widget 의 컴파일 의존성을 최소화 할 수 있다. 위의 WidgetTimer 클래스의 정의를 Widget 에서 빼내고 Widget 이 WidgetTimer 객체에 대한 포인터만 갖도록 만들어 놓으면, Timer 에 관련된 어떤 것도 #include 할 필요가 없어진다.

객체 합성 보다 private 상속을 선호할 수밖에 없는, 소위 공간 최적화가 얽힌 '만약의 경우'를 한 번 보자.
C++ 는 "독립 구조(freestanding)의 객체는 반드시 크기가 0을 넘어야 한다" 라는 금기사항이 있다. 예시 코드를 보자.

// 정의된 데이터가 없으므로, 객체는 메모리를 사용하지 않아야 한다
// 하지만 원칙상 최소 1 바이트를 할당해야 한다
// 실제로는 바이트 정렬(alignment) 때문에 padding 을 끼워 넣는다
// 그래서 4바이트 크기 정도로 늘어난다 
class Empty {};

// sizeof(HoldsAnInt) > sizeof(int)
class HoldsAnInt
{
private:
	int x;
	Empty e;
};

그런데 위의 금기사항은 "상속을 한 객체" 에 대해서는 적용되지 않는다. 즉, 다음과 같이 쓰면 sizeof(HoldsAnInt) 가 sizeof(int) 와 같아지게 된다!

// sizeof(HoldsAnInt) == sizeof(int)
class HoldsAnInt : private Empty
{
private:
	int x;
};

이 공간 절약 기법은 공백 기본 클래스 최적화(empty base optimization: EBO) 라고 알려져 있으며, 대부분의 컴파일러에서 구현하고 있다. EBO 는 일반적으로 단일 상속하에서만 적용되어, 기본 클래스를 두 개 이상 갖는 파생 클래스에는 EBO 가 적용될 수 없다.
결론적으로 "private 상속을 심사숙고해서 구사하자" 라는 말은 섣불리 이것을 쓸 필요가 없다는 생각을 갖고 주어진 상황에서 두 클래스 사이의 관계를 나타낼 가장 좋은 방법이 private 상속이라는 결론이 나면 쓰라는 뜻이다!

항목 40 : 다중 상속은 심사숙고해서 사용하자

핵심 :

1. 다중 상속은 단일 상속보다 복잡하다. 새로운 모호성 문제를 일으킬 뿐 아니라 가상 상속이 필요해질 수도 있다.
2. 가상 상속을 쓰면 크기 비용, 속도 비용이 늘어나며 초기화 및 대입 연산의 복잡도가 커진다. 따라서 가상 기본 클래스에는 데이터를 두지 않는 것이 현실적으로 가장 실용적이다.
3. 다중 상속을 적법하게 쓸 수 있는 경우가 있다. 여러 시나리오 중 하나는, 인터페이스 클래스로부터 public 상속을 시킴과 동시에 구현을 돕는 클래스로부터 private 상속을 시키는 것이다.


다중 상속으로 인해 생기는 모호성 문제의 예시 코드를 한 번 보자.

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

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

class MP3: public Item, public Gadget {};

int main()
{
	MP3 mp;
	// 에러! 어떤 함수인지 모호함(ambiguous)
	mp.checkOut();

	// Item 의 checkOut 
	mp.Item::checkOut();

	// 에러! private 임
	mp.Gadget::checkOut();
}

접근 지정자가 다르더라도, 최적 일치 함수를 찾는 것이 먼저이므로, 모호성 에러가 발생한다.

"죽음의 MI(multiple inheritance) 마름모꼴" 문제도 있다.

class File { ... };
class InputFile: public File { ... };
class OutputFile: public File { ... };

class IOFile: public InputFile, public OutputFile { ... };

이렇게 되면, IOFile 클래스는 File 클래스의 경로 갯수 만큼의 File 객체를 가지게 된다. 가상 상속(virtual inheritance) 를 사용하면, 위와 같은 데이터 멤버의 중복 생성을 막을 수 있다.

class File { ... };
class InputFile: virtual public File { ... };
class OutputFile: virtual public File { ... };

class IOFile: public InputFile, public OutputFile { ... };

하지만 가상 상속은 느리고 비싸다. 그러므로 웬만하면 피하는 것이 좋다.

MI 도 마찬가지로 단점이 많다고 알려진 방식이지만, 다음 예시를 보면서 MI 를 사용했을 때 이득을 볼 수 있는 시나리오를 하나 익혀두도록 하자. 인터페이스 클래스로부터 public 상속을 시킴과 동시에 구현을 돕는 클래스로부터 private 상속을 시키는 케이스를 볼 것이다.

class IPerson
{
public:
	virtual ~IPerson();

	virtual std::string name() const = 0;
	virtual std::string birthDate() const = 0;
};
class DatabaseID { ... };

class PersonInfo
{
public:
	explicit PersonInfo(DatabaseID pid);
	virtual ~PersonInfo();

	virtual const char* theName() const;
	virtual const char* theBirthDate() const;
	
	virtual const char* valueDelimOpen() const;
	virtual const char* valueDelimClose() const;
};

// 다중 상속 사용
class CPerson: public IPerson, private PersonInfo
{
public:
	explicit CPerson(DatabaseID pid) : PersonInfo(pid) {}

	virtual std::string name() const
	{
		return PersonInfo::theName();
	}
	virtual std::string birthDate() const
	{
		return PersonInfo::theBirthDate();
	}

private:
	// 가상 함수들에 대한 재정의 버전을 만듦 (private 상속의 이점)
	// 객체 합성의 경우, 가상 함수의 재정의를 못하는 설계\
	const char* valueDelimOpen() const { return ""; }
	const char* valueDelimClose() const { return ""; }
};

0 Comments
댓글쓰기 폼