32. Make sure public inheritance models “is-a.”
C++ 객체 지향 프로그래밍에서 public 상속은 is-a 관계를 의미합니다.
- 자식 클래스로 만들어진 모든 객체는 부모 클래스에 해당되어야 합니다
- 부모 클래스는 자식 클래스보다 더 일반적인 개념에 해당합니다
- 자식 클래스 객체가 사용가능한 곳에는 부모 클래스 객체도 사용 가능합니다
- 모든 자식 클래스는 부모 클래스의 일종이지만, 부모 클래스는 자식 클래스의 일종이 아닙니다
개념과 실제 구현하는 것은 인식과 다르게 행해질 수도 있습니다. 예를 들면 새가 기본적으로 날 수 있기에 그런 함수를 구현 하였지만 펭귄은 새이지만 날 수 없습니다. 이러한 실제 인식과 다른 부분을 의식해가면 설계 해나가야합니다.
또한 다른 예시로 정사각형과 직사각형이 존재합니다. 직사각형은 당연하게도 정사각형의 부모클래스로 인식됩니다. 여기서 만약 직사각형에서 폭과 넓이를 늘려주는 함수가 존재한다고 합시다. 이를 똑같이 정사각형에서 수행하는 경우 한쪽 변만 늘어나기 때문에 정사각형의 범주를 벗어나게 됩니다. 따라서 이러한 문제에 대한 통찰력과 직관을 늘려가야만 합니다.
이것만은 잊지 말자!
- public 상속의 의미는 "is-a" 입니다. 기본 클래스에 적용되는 모든 것들이 파생 클래스에 그대로 적용되어야 합니다.
33. Avoid hiding inherited names
각 이름에는 유효범위(scope)가 존재합니다. 밑처럼 x에 값을 받아 넣는 경우, 전역 변수 x가 아닌 지역 변수 x에 대해 대입하게 됩니다. 이는 안 쪽에 해당하는 someFunc 함수 스택에 있는 이름이 바깥쪽에 해당하는 전역 변수보다 우선시 되어 발견되기 때문입니다. 국소적인 지역에서 탐색을 성공하면 그보다 더 넓은 범위에 대해서는 변수 형태에 신경쓰지 않고 더 찾지 않습니다.
int x; // 전역 변수
void someFunc()
{
double x; // 지역 변수
std::cin >> x;
}
이를 클래스의 범위로 들고 가봅시다.
class Base {
private:
int x;
public:
virtual void mf1() = 0;
virtual void mf1(int);
virtual void mf2();
void mf3();
void mf3(double);
...
};
class Derived : public Base {
public:
virtual void mf1();
void mf3();
void mf4() { mf2(); };
...
};
int main() {
Derived d;
int x;
d.mf1(); // Derived::mf1 호출
d.mf1(x); // Derived::mf1이 Base::mf1을 가립니다
d.mf2(); // Base::mf2 호출
d.mf3(); // Derived::mf3 호출
d.mf3(x); // Derived::mf3이 Base::mf3을 가립니다
}
위와 같이 Derived는 Base를 상속 받고, Derived 함수 mf4() 멤버 함수는 mf2() 함수를 찾고 있습니다. 이때 mf2는 어떠한 mf2가 시행될 것인가 주요 쟁점이 됩니다. 정답은 좀 더 작은 범위인 Derived에서 검색을 하고 Base를 찾게 되는데 밑에서는 mf2()를 찾지 못하기 때문에 Base::mf2()를 수행합니다. 밑의 main문을 보면 이에 대해서 조금 더 자세히 알 수 있습니다. 코드와 코딩 결과를 보면 모두 작은 범위에서 큰 범위를 찾고, 해당하는 함수의 인자 수와 타입이 같지 않더라도, 작은 지역 범위에서 찾은 이름에서 해결하는 것을 확인할 수 있습니다. 이렇게 가려진 이름의 경우는 using Base::mf1, using Base::mf3를 Derived 클래스 내에 선언해주는 것으로 모두 같은 유효 범위를 가지도록 만들면 됩니다.
class Base {
private:
int x;
public:
virtual void mf1() = 0;
virtual void mf1(int);
virtual void mf2();
void mf3();
void mf3(double);
...
};
class Derived : public Base {
public:
using Base::mf1;
using Base::mf3;
virtual void mf1();
void mf3();
void mf4() { mf2(); };
...
};
int main() {
Derived d;
int x;
d.mf1(); // Derived::mf1 호출
d.mf1(x); // Base::mf1 호출
d.mf2(); // Base::mf2 호출
d.mf3(); // Derived::mf3 호출
d.mf3(x); // Base::mf3 호출
}
또 다른 경우로는 private 상속의 경우 위에서 public으로 사용하던 함수가 private으로 변경되어 탐색이 되지 않을 가능성도 존재합니다.
이것만은 잊지 말자!
- 파생 클래스의 이름은 기본 클래스의 이름을 가립니다. public 상속에서는 이런 이름 가림 현상은 바람직하지 않습니다.
- 가려진 이름을 다시 볼 수 있게 하는 방법으로, using 선언 혹은 전달 함수를 쓸 수 있습니다.
34. Differentiate between inheritance of interface and inheritance of implementation
public 상속은 크게 인터페이스의 상속과 함수 구현의 상속으로 나눌 수 있습니다. 예를 들어 Shape라는 추상 클래스를 통해 기하학적 도형을 나타내는 클래스 계통 구조를 만들면 이를 상속하는 모든 함수들은 Shape가 가지고 있는 순수 가상 함수를 모두 오버라이딩해야만 합니다. 따라서 파생된 클래스에 인터페이스를 상속하도록 구조가 짜여져있습니다. 특히 순수 가상 함수는 구현이 아닌 인터페이스만을 물려주는 것이 그 목적입니다.
다음은 단순 가상 함수를 생각해봅시다. 이것이 의미하는 것은 인터페이스 및 그 함수의 기본 구현도 물려받게 합니다.
이것만은 잊지 말자!
- 인터 페이스 상속은 구현 상속과 다릅니다. public 상속에서, 파생 클래스는 항상 기본 클래스의 인터페이스를 모두 물려받습니다.
- 순수 가상 함수는 인터페이스 상속만을 허용합니다.
- 단순 가상 함수는 인터페이스 상속과 더불어 기본 구현의 상속도 가능하도록 지정합니다.
- 비가상 함수는 인터페이스 상속과 더불어 필수 구현의 상속도 가하도록 지정합니다.
35. Consider alternatives to virtual functions
[비가상 인터페이스 관용구(NVI)]
사용자로 하여금 public 비가상 멤버 함수를 통해 private 가상 함수를 간접적으로 호출하게 만드는 방법입니다. 아래와 같이 public 함수인 heathValue에서 private인 doHealthValue를 호출하는 것으로 이를 완성시켰다.
class GameCharacter {
public:
int healthValue() const
{
.... // 사전 동작
int retVal = doHealthValue(); // 실제 동작
... // 사후 동작
return retVal;
}
...
private:
virtual int doHealthValue() const
{
....
}
};
이러한 NVI 관용구의 이점은 코드에 주석문으로 써둔 사전 동작과 사후 동작이 존재하여 가상 함수가 호출되기 전에 어떤 상태를 구성하고 호출 후에는 그 상태를 없애느 작업을 한다는 등 여러 가지 작업을 수행할 수 있습니다. NVI 관용구의 경우는 파생 클래스에서 재정의되는 가상 함수가 기본 클래스의 대응 함수를 호출할 것을 예상하고 설계된 것도 있는데, 이런 경우에 적법한 함수 호출이 되려면 그 가상 함수는 private이 아니라 protected 멤버이어야만 합니다.
[함수 포인터로 구현한 전략 패턴]
클래스 안에서 일부를 구현하는 것은 다른 함수를 이용하여 처리하는 방법이다.
class GameCharacter; // 전방 선언
int defaultHealthCalc(const GameCharacter& gc); // 체력치 계산 구현
class GameCharacter {
public:
typedef int (*HealthCalcFunc) (const GameCharacter &);
explicit GameCharacter(HealthCalcFunc hcf = default HealthCalc)
: healthFunc(hcf)
{}
int healthValue() const
{ return healthFunc(*this) };
...
private:
HealthCalcFunc healthFunc;
};
다음과 같은 경우는 체력치 계산을 구현하는 함수를 포인터로 받아 저장하고 이를 사용하는 방법으로 구현하였다. 이 때의 장점은 같은 클래스의 객체더라도 함수를 다르게 받을 수 있다는 점이다. 또한 런타임 도중에도 함수를 교체하여 사용할 수 있단느 점이다.
하지만 단점으로는, 함수 포인터를 사용하기 때문에 public 멤버가 아닌 부분을 건드릴 수 없기에 접근성의 문제가 생긴다. 반대로 이를 해결하려고 하면 캡슐화의 정도가 낮아지게 된다.
[tr1::function으로 구현한 전략 패턴]
앞에서는 함수 객체를 사용하였지만 이번에는 std::tr1::function 계열의 객체를 써서 기존의 함수 포인터를 대신하게 만듭니다.
class GameCharacter;
int defaultHealthCalc(const GameCharacter& gc);
class GameCharacter {
public:
typedef std::tr1::function<int (const GameCharacter &)> HealthCalcFunc;
explicit GameCharacter(HealthCalcFunc hcf = default HealthCalc)
: healthFunc(hcf)
{}
int healthValue() const
{ return healthFunc(*this) };
...
private:
HealthCalcFunc healthFunc;
};
tr1::function을 인스턴스화하기 위해 매개변수로 쓰인 대상은 const GameCharacter에 대한 참조자를 받고 Int를 반환하는 함수인데, 이때 타입은 앞으로 대상 시그너처와 호환되는 함수호출성 개체를 어떤 것도 가질 수 있게 됩니다. 이떄 호환된다는 뜻은 리턴 값과 매개변수 값이 암시적 변환이 가능한 경우를 얘기합니다. std::bind를 통해 매개변수를 미리 몇개 넣어서 호환되도록 만들 수도 있습니다.
[고전적인 전략 패턴]
디자인 패턴을 이용한 전략 패턴을 소개하고 있고, 이는 디자인 패턴에서 보는 것이 좀더 좋아보입니다.
이것만은 잊지 말자!
- 가상 함수 대신에 쓸 수 있는 방법으로 NVI 관용구 및 전략 패턴을 들 수 있습니다. 이 중 NVI 관용구는 그 자체가 템플릿 메서드 패턴의 한 예입니다.
- 객체에 필요한 기능을 멤버 함수로부터 클래스 외부의 비멤버 함수로 옮기면, 그 비멤버 함수는 그 클래스의 public 멤버가 아닌 것들을 접근할 수 없다는 단점이 생깁니다.
- tr1::function 객체는 일반화된 함수 포인터처럼 동작합니다. 이 객체는 주어진 대상 시그너처와 호환되는 모든 함수호출성 개체를 지원합니다.
36. Never redefine an inherited non-virtual function
class B {
public:
void mf();
...
};
class D : public B {
public:
void mf(); // B::mf를 가려버립니다.
...
};
D x;
B *pB = &x; // x에 대한 포인터(클래스 B) 획득
pB->mf(); // B::mf 호출
D *pB = &x; // x에 대한 포인터(클래스 D) 획득
pB->mf(); // D::mf 호출
위와 같이 부모 클래스 B에서 mf함수가 비가상 함수로 선언되고 자식 클래스 D에서도 mf 함수가 선언된 경우, 어떤 값으로 구성되었던 간에 포인터 형식에 알맞게 함수를 호출하게 됩니다. 의도하지 않은 방향으로 프로그램이 실행되게 됩니다. 이 이유는 비가상 함수는 정적바인딩으로 묶여 B 클래스 포인터에 대해서는 항상 B 클래스가 정의되어 있을 것이라고 생각하지만, 가상 함수의 경우에는 동적 바인등으로 묶여 객체의 원본을 찾아가게 됩니다. 설계상으로도 문제가 됩니다.
- B 객체에 해당되는 모든 것들이 D 객체에 그대로 적용됩니다. 왜냐하면 모든 D 객체는 B 객체의 일종이기 때문입니다.
- B에서 파생된 클래스는 mf 함수의 인터페이스와 구현을 모두 물려받게 됩니다. mf는 B 클래스에서 비가상 멤버 함수이기 때문입니다.
- 하지만 D에서 mf를 재정의하면서 설계에 모순이 생기면서, 문제가 발생합니다.
이것만은 잊지 말자!
- 상속받은 비가상 함수를 재정의하는 일은 절대로 하지 맙시다.
37. Never redefine a function’s inherited default parameter value
기본 매개 변수 값을 가진 가상 함수를 상속하는 경우를 생각해봅시다.
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;
};
Shape *ps;
Shape *pc = new Circle;
Shape *pr = new Rectangle;
pc->draw(); // Circle::draw(Shape::Red) 호출
pr->draw(); // Rectangle::draw(Shape::Red) 호출
다음과 같이 가상 함수 draw가 존재하고 기본 매개변수로 Red가 들어간다고 하고 이를 나머지 두 곳에서 상속 받는데, 이때 받는 기본 매개변수를 바꾸어 보았습니다. Circle에서는 기본 매개변수가 존재하지 않고 Rectangle에서는 Green이 기본 매개변수로 들어가는 것을 기대하는데 실제로 들어가는 것은 Red가 들어가게 됩니다.
이는 런타임 효율이라는 요소가 깊게 들어가 있습니다. 포인터를 활용한 객체 부르기는 동적 타입으로 클래스를 찾아 이에 맞는 가상 함수를 찾습니다. 만약 함수의 기본 매개변수가 동적으로 바인딩된다면, 프로그램 실행 중에 가상 함수의 기본 매개변수 값을 결정할 방법을 컴파일러 쪽에서 마련해 주어야 합니다. 이 방법은 컴파일 과정에서 결정하는 현재의 매커니즘보다는 느리고 복잡할 것이 분명하기에, 효율 좋은 동작을 실행합니다.
이를 해결하기 위해서는 위에서 언급한 NVI 관용구를 활용해서 만들면 이를 해결할 수 있습니다.
이것만은 잊지 말자!
- 상속받은 기본 매개변수 값은 절대로 재정의해서는 안 됩니다. 왜냐하면 기본 매개변수 값은 정적으로 바인딩되는 반면, 가상 함수는 동적으로 바인딩 되기 때문입니다.
38. Model “has-a” or “is-implemented-in-terms-of” through composition
합성이란 어떤 타입의 객체들이 그와 다른 타입의 객체들을 포함하고 있을 경우에 성립하는 그 타입들 사이의 관계를 일컫습니다. public 상속의 의미가 "is-a"라고 생각되는 반면, 객체 합성의 경우는 "has-a"와 "is-implemented-of"라는 뜻을 가지고 있습니다. 객체 중 우리가 일상생활에서 볼 수 있는 사물을 본 뜻것 들은 소프트웨어의 응용 영역에 해당하며 이를 포함하는 경우는 has-a 관계이지만 구현만을 위한 인공물의 의미는 is-implemented-of 관계입니다.
예를 들면 set은 list와 같은 객체로 만들어질 수 있는데, 이는 is-implemented-of의 영역에 해당하며, Person객체에서의 std::string은 이름을 의미하기에 has-a 관계에 해당합니다.
이것만은 잊지 말자!
- 객체 합성의 의미는 public 상속이 가진 의미와 완전히 다릅니다.
- 응용 영역에서 객체 합성의 의미는 has-a 입니다. 구현 영역에서는 is-implemented-in-terms-of의 의미를 가집니다.
39. Use private inheritance judiciously
private 상속을 한다면 모든 함수를 자식 클래스에서 사용이 가능한 것이 아니기 때문에 is-a 관계가 아니다. 결국 private 상속의 의미는 is-implemented-in-terms-of의 의미를 가진다. 부모 클래스에서 private 상속으로 생긴 자식 크래스는 어떤 개념적 관계가 있어서 만들어진 것은 아니라는 것이다. 이와 같은 의미를 가지는 방법으로는 객체 합성이 존재하는데 이 두개는 사용법에서 조금 다르다.
비공개 멤버를 접근할 떄 혹은 가상 함수를 재정의할 경우는 private 상속을 이용하는 것이 좋다. 그렇지 않은 경우는 객체 합성을 사용하는 것이 좋다. private 상속으로 구현되는 방법은 객체 합성을 하되 필요한 객체를 다른 클래스의 public 상속을 받는 식으로도 가능합니다. 이는 두가지 관점에서 좋은데, 클래스 설계하는 데 있어서 파생은 가능하게 하되, 파생 클래스에서 필요한 함수를 재정의할 수 없도록 설계 차원에서 막고 싶을 때 유용합니다. 다른 이유로는 컴파일 의존성을 최소화할 수 있습니다.
그리고 private 상속을 하는 또 다른 경우에는 공간 최적화가 얽힌 경우가 존재합니다. C++에서 독립 구조의 객체는 반드시 크기가 0을 넘겨야 하는데 이를 private 상속을 통해 상속 시킨 클래스의 크기는 0으로 취급되기 됩니다. 이와 같은 것을 공백 기본 클래스 최적화라고 합니다.
이것만은 잊지 말자!
- private 상속의 의미는 is-implemented-in-terms-of입니다. 대개 객체 합성과 비교해서 쓰이는 분야가 많지는 않지만, 파생 클래스 쪽에서 기본 클래스의 protected 멤버에 접근해야 할 경우 혹은 상속받은 가상 함수를 재정의해야 할 경우에는 private 상속이 나름대로 의미가 있습니다.
40. Use multiple inheritance judiciously
다중 상속은 둘 이상의 기본 클래스로부터 똑같은 이름을 물려받을 가능성이 생겨버립니다. 이에 앞에서 말한 듯이 namespace를 점점 넓혀가며 이름을 찾아야하는데, 찾아가는데 최적으로 알맞는 것을 여러개 찾아버릴 수가 있어 최적 일치 함수가 결정되지 않습니다. 따라서 모호성을 해소하려면 확실하게 클래스를 지정해서 알려주어야합니다. 특히 죽음의 MI 마름모꼴이라 불리는 다이아몬드 상속이 나오게 됩니다.
class File { ... };
class InputFile: public File { ... };
class OutputFile: public File { ... };
class IOFile: public InputFile, public OutputFile { ... };
File 클래스에 filename이라는 데이터 멤버가 있다고 하면, IOFile 클래스는 이를 1개로 처리해야할 지 2개로 처리해야할 지를 몰라, 기본적으로는 데이터 멤버를 중복 생성을 합니다. 하지만 데이터 멤버를 가진 클래스를 가상 기본 클래스로 만들어 파생 클래스에서 갓아 상속을 하도록 만듭니다. 이렇게 데이터 멤버의 중복생성을 막기 위해 컴파일러가 꼼수를 부리는데 이는 가상 상속을 사용하는 클래스로 만들어진 객체는 가상 상속을 쓰지 않는 것보다 일반적으로 크기가 더 큽니다. 게다가, 가상 기본 클래스의 데이터 멤버에 접근하는 속도도 기본 클래스의 데이터 멤버에 접근하는 속도보다 느립니다. 즉 가상 상속은 상당히 비쌉니다.
게다가 가상 기본 클래스의 초기화에 관련된 규칙은 비가상 기본 클래스의 초기화 규칙보다 훨씬 복잡한데다가 직관성도 더 떨어집니다. 대부분의 경우, 가상 상속이 되어 있는 클래스 계통에서는 파생 클래스들로 인해 가상 기본 클래스 부분을 초기화할 일이 생기게 됩니다. 이때 초기화가 필요한 갓아 기본 클래스로부터 클래스가 파생됐다면 가상 기본 클래스의 존재를 염두에 두고 있어야 하며, 기존의 클래스 계통에 파생 클래스를 새로 추가할 때도 그 파생 클래스는 가상 기본 슬래스의 초기화를 떠맡아야 합니다.
따라서 가상 기본 클래스는 구태여 쓸 필요가 없다면 쓰지 말고, 최대한 가상 기본 클래스에는 초기화 규칙을 따르게 될 데이터를 넣지 않는 것이 좋습니다. 또한 다중 상속을 적법하게 쓰기 위해서 인터페이스 클래스로부터 public 상속을 받고, 구현에 도움이 되는 클래스로부터 private 상속을 받아 이를 해결 할 수 있습니다.
이것만은 잊지 말자!
- 다중 상속은 단일 상속보다 새로운 모호성 문제 및 가상 상속의 문제로 복잡해집니다.
- 가상 상속을 쓰면 크기 비용, 속도 비용 모두 늘어나며 초기화 및 대입 연산의 복잡도가 커집니다. 따라서 가상 기본 클래스에는 데이터를 두지 않는 것이 실용적입니다
- 다중 상속을 적법하게 쓸 수 있는 경우는 인터페이스 클래스로부터 public 상속을 시킴과 동시에 구현을 돕는 클래스로부터 private 상속을 시키는 것입니다.
'C++공부 > Effective C++' 카테고리의 다른 글
Chapter 8. Customizing new and delete (0) | 2022.05.21 |
---|---|
Chapter 7. Templates and Generic Programming (0) | 2022.05.21 |
Chapter 5. Implementations (0) | 2022.05.20 |
Chapter 4. Designs and Declarations (0) | 2022.05.19 |
Chapter 3. Resource Management (0) | 2022.05.19 |