private 상속은 쓰지 않을래요

JellyPower·2023년 4월 26일
0

나만 몰랐던 C++

목록 보기
6/11
post-thumbnail
💡 feat. [Effective C++) 39. private 상속은 심사숙고해서 구사하자]

private 상속의 작동방식

#include<cstdio>

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

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

int main() {
	Person p;
	Student s;

	eat(p);

	study(s);
//	eat(s); // 에러!
}
  • private 상속public 상속과 대조적으로 파생 객체(자식객체)가 기본 클래스 객체(부모객체)로의 변환이 불가능하다.
  • 대신, 내부 멤버끼리는 private상속을 받은 객체의 기능을 사용할 수 있다.
  • 그렇기에 private상속is-a관계가 아닌 is-implemented-in-terms-of관계로 보는것이 맞다.
    • private 상속의 의미는 “구현만 물려받을 뿐 동일한 인터페이스는 아니다”라고 생각하면 된다.
    • 그렇기에 클래스의 설계에 관련됐다기보단 중복된 구현을 막는(즉, 복붙을 막는)다는 의미가 강하다고 보면 된다.

private 상속을 사용하는 상황의 예시

  • 프로파일링을 위해 내가 사용하는 Widget클래스의 호출 횟수, 시간에 따른 호출 빈도를 추적하고 싶다고 가정해보자.

  • 마침, Widget에서 필요로하는 시간 관련 기능이 Timer클래스에 미리 구현돼있다면?

class Timer {
public:
	explicit Timer(int tickFrequency) {}
	virtual void onTick() const;

};
  • 다음과 같이 Timer클래스를 상속받아 기능을 활용하면 된다.
  • 내부 Timer 대한 기능들에 접근이 가능하고 내가 원하는대로 동작하게 하기 위한 오버라이딩도 가능하다.
class Widget : private Timer {
private:
	virtual void onTick() const override;
};

private 상속 대신 composition pattern을 쓰자

  • 그런데 한 번 생각해보자, 굳이 Timer의 기능을 사용하기 위해 상속을 쓸 이유가 있을까?
    • 컴포지션을 사용하는게 has-a관계를 표현하는데 더 적합하지 않나?
    • 때에 따라선 다중상속의 위협이 있고, 캡슐화를 어기면서까지 private상속을 써야할까?

상속보다는 컴포지션을 사용하자

  • 다음과 같이 컴포지션을 활용하면 이러한 문제를 해결할 수 있다.
class Widget{
private:

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

	WidgetTimer timer;
};
  • 이러한 방법이 조금은 복잡해 보일 수 있지만,
    1. Widget을 상속받는 파생 클래스에서 onTick()함수를 오버라이딩 하는 등의 상황을 막을 수 있다. (C++은 C#처럼 sealed 키워드를 지원하지 않기 때문 ->사실 이부분도 C++11에서 override키워드와 함께 final 키워드를 지원하기 시작함)
    2. Timer를 상속받으려면 선언부에서 헤더파일을 include해야 하기에 컴파일 의존성이 생기지만 컴포지션 패턴을 사용하면 전방선언을 활용하고 선언부가 아닌 구현부에서 include해도 되기에 컴파일 의존성을 줄일 수 있다.

저자가 말하는 private 상속을 써야만 하는 상황

💡 저자도 웬만하면 private상속을 사용하지 말라고 말한다
  • 그러나, 크게 세가지 상황의 예를 들며 “꼭 필요하다면” 사용하는 것을 허용한다.
    1. 비공개 멤버에 접근하는 상황
    2. 가상함수를 재정의 하는 상황
    3. 공간 문제가 얽히는 상황

그런데, private 상속 쓰지 않을래요.

  1. 비공개 멤버에 접근하는 상황
    • 접근하고자 하는 비공개 멤버가 protected로 선언돼있다면 위에서 했던 것처럼 Timer클래스를 WidgetTimer로 상속받은 이후 필요한 정보들을 노출시킬 수 있는 함수를 두면 된다.
    • 접근하고자 하는 비공개 멤버가 private이라면 어차피 Timer클래스를 Widget 클래스에 private상속해도 어차피 private멤버에 대한 접근은 불가능하다.
    • 마지막으로, 비공개 멤버에 접근해야 한다는 것부터 기존 클래스 설계에 부족한 부분이 있지 않은지 고려해봐야 한다.
  2. 가상함수를 재정의 하는 상황
    • 위에 나왔던 것처럼 Timer클래스를 WidgetTimer로 상속받고 재정의 하면 된다.

3. 공간 문제가 얽히는 상황

class Empty {};

class HoldsAnInt {
	int x;
	Empty e;
};

int main() {
	printf("%d byte\n",sizeof(HoldsAnInt));
	return 0;
};
  • 위와 같은 상황에서 출력값은 얼마가 나올까?
    • 정확한 정답은 컴파일러마다 다르겠지만 msvc기준 8 byte가 나온다. 4 byte가 아니다.

독립구조(freestanding)의 객체는 반드시 크기가 0을 넘어야 한다.

  • 위처럼 실제 객체의 내부 크기가 0인 클래스를 사용한다고 하더라도, Empty e; 를 통해 인스턴스를 독립적으로 요구한다면, 이러한 “공백” 객체에 컴파일러는 char한 개(1 byte)를 슬그머니 끼어넣는다.
  • 게다가 HoldsAnInt클래스를 4 byte에 맞춰 byte alignment까지 하면 총 5 byte8 byte 라는 결과가 나오게 된다.
  • 그러나 상속의 경우, 독립구조 객체가 아니기 때문에 1 byte의 공간을 할당할 필요가 없는 것이다.
class Empty {};

class HoldsAnInt : private Empty {
	int x;
};

int main() {
	printf("%d byte\n",sizeof(HoldsAnInt));
};
  • 그래서 위와 같이 구현하면 총 4 byte가 나오게 된다.

  • 위 예제에서는 Empty클래스가 비어있어서 아무 의미도 없어 보이지만, 사실 공백클래스는 진짜 비어있는 클래스는 아니다.

    • 비정적 데이터 멤버는 없지만 typedef, enum, 정적 데이터 멤버, 비가상 함수 등의 메모리 할당이 필요하지 않는 개념만 필요로 되는 클래스 설계에선 Empty클래스를 사용 할 수 있는 것이다.
    • 공백 클래스를 사용하는 경우에 위처럼 메모리를 아끼는 방법을 보통 공백 기본 클래스 최적화(empty base optimization: EBO)라고 부른다.
    • 이러한 공백 클래스는 실제로 STL에서 대부분 typedef에 많이 쓰인다고 한다.

3. 공간 문제가 얽히는 상황에선 그럼 써도 되는가?

  • 그런데, 저자는 대부분의 경우에 아무것도 없는 클래스를 사용하는 경우는 정말 드물다고 한다.
  • 즉, 위처럼 객체 크기를 예민하게 고려해야만 하는 특수한 상황 하나만으로 private 상속을 정당화 할 수 없다는 것이다.
  • 그리고 (객체 크기를 민감하게 고려해야하는 상황일 확률) * (공백 클래스를 사용하는 것이 좋을 확률) 이 얼마나 될까? 차라리 애초에 고려하지 않는 것이 좋아보인다.
  • 애초에 C#, Java등의 객체지향 언어에서 private 상속이 구현되지 않은것만 봐도 답이 나온다.
profile
게임엔진코드싸개(진)

0개의 댓글