6장. 상속, 그리고 객체 지향 설계🕶

안녕하세요! 두두코딩 입니다 ✋
오늘은 Effective C++ 항목 39에 대해 알아보겠습니다.

🖇 소스코드에 마우스를 올리고 copy 버튼을 누를 경우 더 쉽게 복사할 수 있습니다!

궁금한 점, 보안점 남겨주시면 성실히 답변하겠습니다. 😁
+ 감상평 댓글로 남겨주시면 힘이됩니다. 🙇

private 상속을 사용하면?

보통 우리는 상속할 때, public 상속을 많이 사용한다. 하지만, 간혹가다 private 상속을 볼 수 있는데, private라는 키워드를 활용했을 때 어떤 변화가 있는지 알아보자.

class Person { ... };

class Student : private Person { ... };

void eat (const Person& p);

void study (const Student& s);

Person p;
Student s;

eat(p);

// 컴파일 에러
eat(s);

위의 코드를 보자. 우리는 Person 클래스를 public 상속이 아닌 private 상속을 해서 Student 클래스를 만들었다. 이후 Person을 매개변수로 받는 eat() 함수에 Student 객체를 인자로 전달하였더니 컴파일 에러나는 것을 확인할 수 있다.

해당 결과를 통해 알 수 있는 부분은 private 상속을 할 경우 upcasting이 동작하지 않는 다는 점이다. 즉, 매개변수가 부모클래스 타입일 때, 전달되는 인자가 파생클래스 일 경우 암시적 형변환 하는 행위가 금지 된다.

또한, private을 상속받을 경우 멤버변수 혹은 함수들은 모두 private 형태로 상속받게 된다. protected 멤버이든, public 멤버이든 관계없이 모두 private형태로 상속받는다.

사실 생각해보면 이전포스팅 에서 배운 합성과 동일하다고 볼 수 있다. 부모클래스에서 파생 클래스에 private으로 상속받을 경우 기능들 몇 개를 활용할 목적으로 한 행동임으로, 결국 합성과 동일하게 사용가능하다.

그렇다면 합성과 private 상속 중 어떤걸 써야할까?

Widget profiling을 설계하면서 알아보도록 하자.

설계 요청
Widget 클래스에 함수 호출 횟수 및 시간을 기록해야함. C++ 내 존재하는 Timer 클래스를 활용해서 구현해야함. onTick 함수를 활용해 매 시간이 흐를 때 마다 기록하게 해주시오.

Private 상속을 활용한 Widget profiling

Private 상속을 활용하기 위해서는 아래와 같이 구현하면 된다.

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

위와 같이 구축하게 될 경우 Timer의 public 멤버를 Widget의 private 멤버로 활용할 수 있다. 즉, 외부 인터페이스를 열어두지 않고, 내부적으로 Tick이 올 때만 profiling을 할 수 있다. 생각해보면 흠잡을 것 없이, Private 상속을 통해 Widget을 생성하면 될 것 같다.

하지만, 위와 같이 Private 상속을 받을 경우 2가지 문제가 존재한다.

  1. Widget 클래스를 부모로 두고, 파생클래스를 만들 경우 onTick()를 재정의할 수 있게 됨. 우리는 Widget의 profiling 기법을 원한 것이다.

  2. 컴파일 의존성이 커짐. 직접적으로 Timer를 사용하기 때문에 time.h 라는 파일이 항상 따라다녀야한다.

이 문제를 합성으로 구현한다면 손쉽게 해결이 가능하다.

합성을 활용한 Widget profiling

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

		WidgetTimer timer;
		...
};

위와 같이, 내부적으로 합성을 하게 된다면, timer 포인터만 있어도 된다. 즉, 외부에서 접근이 불가능하고, 파생 클래스에서 재정의하는 문제도 사라진다.

또한, WidgetTimer 클래스를 다른 파일로 분리시킨다면 time.h 파일은 해당 클래스 파일에만 들어 있기 때문에 Widget을 컴파일하는 경우 timer까지 건들지 않고 할 수 있다.

그렇다면 정말 Private 상속을 사용하는 경우는 존재하지 않는 것인가?

그렇지 않다. 아래의 EBO(Empty Based Optimization) 같은 경우 private상속을 쓰는 것이 좋다.

EBO (Empty Based Optimization)

class Empty {};

class HoldAnInt {
private:
	int x;
	Empty e;
};

위와 같이, 아무것도 사용하지 않는 Empty 클래스가 있다고 가정해보자. 해당 클래스를 HoldAnInt에서 합성으로 사용하고 있다.

Empty 클래스 같은 경우 데이터가 아무것도 없기 때문에 결국 사이즈는 0일것이다. 그렇다면 아래의 결과는 어떻게 나오겠는가?

int main() {
	if (sizeof(HoldAnInt) == sizeof(int))
		cout << "same" << endl;
	else
		cout << "not same" << endl;

	// 결과는 not same!!!
}

HoldAnInt 클래스 같은 경우 사이즈가 0인 Empty와 사이즈가 4인 int타입을 가지고 있는데, 왜 다르다는 결과가 나온 것인가?

그 이유는 아래와 같다.

C++에서는 “독립 구조”의 객체는 반드시 크기가 0을 넣어야 한다는 규칙이 있다. 즉, 아무 연관도 없는 동떨어진 클래스가 사이즈가 0이라면 컴파일러는 슬그머니 char 타입을 하나 끼워넣는다. 하지만, Padding의 효과로 명령어 사이즈많큼 사이즈를 늘리기 떄문에 결국 int 타입의 사이즈와 동일해진 것이다.

이를 증명하기 위해 어셈블리로 분석을 해보았다.

      	push    rbp
        mov     rbp, rsp
# sizeof(HoldAnInt)
        mov     esi, 8
        mov     edi, OFFSET FLAT:_ZSt4cout
        call    std::basic_ostream<char, std::char_traits<char> >::operator<<(unsigned long)
        mov     esi, OFFSET FLAT:.LC0
        mov     rdi, rax
        call    std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)
# sizeof(int)
        mov     esi, 4

위의 어셈블리와 같이, sizeof(HoldAnInt)를 할 경우 Empty클래스의 패딩과 int로 인해 사이즈가 8로 나오는 것을 확인할 수 있다.

이를 막기위해서는 “독립 구조” 즉, 관계를 두도록 해 사이즈를 0으로 유지시킬 수 있다.

이때 바로 “private 상속” 을 활용한다.

class Empty {};

class HoldAnInt : private Empty {
private:
	int x;
};

위와 같이 Private 상속을 통할 경우 아래와 같이 어셈블리가 변경되게 된다.

독립구조가 아닌 연관된 구조가 되었기 떄문에 char 타입을 컴파일러가 끼워넣지 않는다 따라서, int 사이즈만 남게되고, 결국 sizeof(HoldAnInt) 와 sizeof(int)는 동일하게 이루어진다.

        push    rbp
        mov     rbp, rsp
# sizeof(HoldAnInt)
        mov     esi, 4
        mov     edi, OFFSET FLAT:_ZSt4cout
        call    std::basic_ostream<char, std::char_traits<char> >::operator<<(unsigned long)
        mov     esi, OFFSET FLAT:.LC0
        mov     rdi, rax
        call    std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)
# sizeof(int)
        mov     esi, 4

이런 케이스가 아니라면 Private 상속은 최대한 피하는 것이 좋다..