[Effective C++ 3판] Chapter 6. 상속, 그리고 객체 지향 설계 (항목 32~40)
Chapter 6. 상속, 그리고 객체 지향 설계 (항목 32~40)
항목 32. public 상속 모형은 반드시 "is-a(...는 ...의 일종이다)"를 따르도록 만들자.
상속 관계를 설계할 때 자연어 혹은 직관을 따르는 경우가 많다. 하지만 가끔 그것이 엄밀하지 못해서 문제가 생길 때가 있다. 따라서 public 상속을 할 때 is-a관계가 성립하는 지 철저히 따져봐야한다. 그리고 개인적으로 클래스 설계에 있어서 직관과 논리가 일치할 수 있도록 설계하는 게 정말 중요하다고 생각한다.[이것만은 잊지 말자]
- public 상속의 의미는 is-a입니다. 기본 클래스에 적용되는 모든 것들이 파생 클래스에 그대로 적용되어야 합니다. 왜냐하면 모든 파생 클래스 객체는 기본 클래스 객체의 일종이기 때문입니다.
항목 33. 상속된 이름을 숨기는 일은 피하자.
// using 선언을 이용한 방법.
// public 상속시, 가려진 이름을 볼 수 있게 하기 위해 사용.
namespace using_declaration {
class Base {
public:
void f() {}
void f(int x) {}
};
class Derived : public Base {
public:
using Base::f; // this statement must be public.
void f(int x, int y) {}
};
}
// forwarding function을 이용한 방법.
// private 상속시, 가려진 이름을 가진 것들중 일부분만을 볼 수 있게 하기 위해 사용.
namespace forwarding_function {
class Base {
public:
void f() {}
void f(int x) {}
};
class Derived : private Base {
public:
void f() {
Base::f();
}
void f(int x, int y) {}
};
}
int main() {
using_declaration::Derived obj;
obj.f();
obj.f(1);
obj.f(1, 2);
forwarding_function::Derived obj2;
obj2.f();
obj2.f(1); // compile error.
obj2.f(1, 2);
}
[이것만은 잊지 말자]
- 파생 클래스의 이름은 기본 클래스의 이름을 가립니다. public 상속에서는 이런 이름 가림 현상은 바람직하지 않습니다.
- 가져진 이름을 다시 볼 수 있게 하는 방법으로, using 선언 혹은 전달 함수를 쓸 수 있습니다.
항목 34. 인터페이스 상속과 구현 상속의 차이를 제대로 파악하고 구별하자.
// 파생된 클래스에서 가상함수를 재정의하는 걸 개발자가 깜박할 경우,
// 의도하지 않은 동작이 발생할 수 있다.
namespace issue {
class Base {
public:
virtual void func() { /* default working */ }
};
class Derived : public Base {
// no declaration of func.
};
void situation() {
Derived obj;
obj.func(); // no error.
}
}
// 해결책 1
// 순수가상함수로 선언을 하고, default함수를 비가상함수로 제공하는 방법.
// 파생 클래스에서 무조건 가상함수를 재정의해줘야함으로 성가진 단점이 있다.
namespace solution_1 {
class Base {
public:
virtual void func() = 0;
protected:
void defaultFunc() { /* default working */ }
};
class Derived : public Base {
public:
// must declare func.
virtual void func() { defaultFunc(); }
};
void situation() {
Derived obj;
obj.func();
}
}
// 해결책 2
// 순수가상함수로 선언을 하고 정의를 제공해버린다(?).
// default 함수를 또 선언해야하는 점이 거슬릴 경우 이용할 수 있는 방법.
namespace solution_2 {
class Base {
public:
virtual void func() = 0;
};
class Derived : public Base {
public:
// must declare func.
virtual void func() { Base::func(); }
};
void situation() {
Derived obj;
obj.func();
}
void Base::func() { /* default working */ }
}
위 코드는 개발자가 파생 클래스를 작성할 때, 재정의해야만 하는 함수를 깜박할 실수를 방지하기 위한 해결책에 대한 코드이다. 물론 대신에 파생 클래스를 작성할 때, 일일히 가상함수를 재정의해줘야 하는 성가심이 존재한다. 정리하자면, "인터페이스와 기본 구현을 둘 다 물려 주고" 싶은 데 추가적으로 "파생 클래스에서 '명시적으로' 기본 구현을 선택하도록 강제" 하게끔 하고싶은 경우에 사용하는 것이다. 즉, issue의 경우에는 암시적으로 기본 구현이 선택되는 거고, solution 1,2의 경우에는 명시적으로 기본 구현을 선택해야 하는 것이다. 구체적으로 어떤 경우에 암시/명시중 방법을 가려져야 할 지는 그때그때 판단해야 할 것 같다. 내 개인적으로 생각으로는 기본적으로는 '암시'의 방법을 사용하되, 반드시 재정의해야하는 가상함수인데 기본 구현 또한 제공하고 싶을 때 '명시'를 사용하는게 옳다고 생각된다.
해결책 1,2 중에선 개인적으로 "해결책 1"이 더 낫다고 생각한다.
[정리]
- 순수 가상 함수를 선언하는 목적은 파생 클래스에게 함수의 인터페이스만을 물려주려는 것입니다.
- 단순 가상 함수를 선언하는 목적은 파생 클래스로 하여금 함수의 인터페이스뿐만 아니라 그 함수의 기본 구현도 물려받게 하자는 것입니다. (만약 기본 구현을 쓰겠다는 것을 '명시'하도록 강제하고 싶은 경우 상단에 나온 해결책 1,2를 사용)
- 비가상 함수를 선언하는 목적은 파생 클래스가 함수 인터페이스와 더불어 그 함수의 필수적인 구현을 물려받게 하는 것입니다. (클래스 파생에 상관없는 불변동작)
[이것만은 잊지 말자]
- 인터페이스 상속은 구현 상속과 다릅니다. public 상속에서, 파생 클래스는 항상 기본 클래스의 인터페이스를 모두 물려받습니다.
- 순수 가상 함수는 인터페이스 상속만을 허용합니다.
- 단순(비순수) 가상 함수는 인터페이스 상속과 더불어 기본 구현의 상속도 가능하도록 지정합니다.
- 비가상 함수는 인터페이스 상속과 더불어 필수 구현의 상속도 가하도록 지정합니다.
항목 35. 가상 함수 대신 쓸 것들도 생각해 두는 자세를 시시때때로 길러 두자.
[가상 함수 대신 쓸 수 있는 것들]- 비가상 인터페이스 관용구(NVI관용구)를 통한 템플릿 메서드 패턴
- 함수 포인터로 구현한 전략(strategy) 패턴
- std::function로 구현한 전략(strategy) 패턴
- 고전적인 전략(strategy) 패턴
[비가상 인터페이스 관용구를 통한 템플릿 메서드 패턴]
- 가상함수를 private로 놓고, public interface의 비가상함수에서 가상함수를 호출하는 구조. (가상 함수가 파생클래스에서 명시적 호출되게 하고싶을 경우 protected로 선언)
- 가상함수 앞뒤로 사전 동작, 사후 동작를 집어 넣을 수 있다.
[함수 포인터로 구현한 전략 패턴]
- 가상함수대신 함수 포인터를 사용한다.
- 융통성이 높다. 런타임에 실행 동작을 바꿀 수 있고, 같은 클래스여도 객체마다 다른 동작을 할 수 있다.
- 캡슐화 정도가 떨어진다. (비멤버 함수에게 non-public 변수/함수에 대한 접근권한을 줘야할 경우, friend선언등의 방법을 사용해야하므로)
[std::function으로 구현한 전략 패턴]
- 가상 함수대신 std::function (함수객체)를 사용한다.
- 융통성이 허벌나게 높다. 함수 포인터를 이용한 방법의 장점은 기본이다. 반환 타입, 매개변수가 달라도 호환되면(=암시적 형변환이 가능하면) 함수를 품을 수 있다. std::bind를 이용한 매개변수 binding이 가능하다. (함수객체의 반환값 <- 품은 함수의 반환값 <- 품은 함수 실행 <- 품은 함수의 매개변수 <- 함수객체의 매개변수 의 흐름을 띤다. 즉, 위 흐름이 암시적으로 가능한 선에서 함수를 품을 수 있다. 예시, std::function<Base(Derived)> = 반환형이 Derived이고, 매개변수형이 Base인 함수)
- 함수 포인터를 이용한 방법처럼 캡슐화 정도가 떨어진다.
[고전적인 전략 패턴]
- 한쪽 클래스 계통에 속해 있는 가상 함수를 다른 쪽 계통에 속해 있는 가상 함수로 대체한다.
- 함수 포인터를 이용한 방법과 비교하면, 함수 포인터를 이용한 방법은 단순히 가상 함수를 포인터로 대체했을 뿐이고, 고전적인 전략 패턴은 가상 함수 체계를 다른 클래스 계통으로 빼는 방법이다.
[이것만은 잊지 말자]
- 가상 함수 대신에 쓸 수 있는 다른 방법으로 NVI 관용구 및 전략 패턴을 들 수 있습니다. 이 중 NVI 관용구는 그 자체가 템플릿 메서드 패턴의 한 예입니다.
- 객체에 필요한 기능을 멤버 함수로부터 클래스 외부의 비멤버 함수로 옮기면, 그 비멤버 함수는 그 클래스의 public 멤버가 아닌 것들을 접근할 수 없다는 단점이 생깁니다.
- std::function 객체는 일반화된 함수 포인터처럼 동작합니다. 이 객체는 주어진 목적 시그니처와 호환되는 모든 함수호출성 개체를 지원합니다.
항목 36. 상속받은 비가상 함수를 파생 클래스에서 재정의하는 것은 절대 금물!
class Base {
public:
virtual void bar() final {}
virtual void foo() {}
};
class Derived : public Base {
public:
//virtual void bar(); // compile error.
};
int main() {
Derived d;
Derived &obj = d;
obj.bar();
obj.foo();
}
C++11 부터는 final 키워드가 추가되었다. 그러나 가상 함수에만 적용할 수 있다...ㅠㅜ
그렇지만 왠지 가상 함수여도 final 키워드가 붙어있으면 비가상함수처럼 compile-time에 실행될 함수가 결정될 것 같다. 디스어셈블해보았다.
역시나 final 키워드가 붙어있으면 runtime시의 overload가 없다. 그러나 final키워드가 붙어있지 않으면 파생 클래스에서 override하지 않더라도 runtime시의 overload가 존재한다. (근데 이 부분은 충분히 컴파일러 단에서 최적화해 줄 수 있을 거 같은데...ㅠ)
어쨌든 C++11에서부터는 비가상 함수를 모조리 final 키워드와 함께 가상 함수로 선언함으로서 항목36을 효율적으로 지킬 수 있을 것 같다.
[이것만은 잊지 말자]
- 상속받은 비가상 함수를 재정의하는 일은 절대로 하지 맙시다.
항목 37. 어떤 함수에 대해서도 상속받은 기본 매개변수 값은 절대로 재정의하지 말자.
가상 함수는 동적으로 바인딩되지만 그것의 매개변수는 정적으로 바인딩되는 점이 문제를 발생시킨다. 따라서 파생 클래스에서 상속받은 기본 매개변수 값을 재정의 하지 말아야한다. 근데 이는 곧 기본 클래스의 기본 매개변수 값이 변경되면 파생 클래스도 변경을 해야하는 단점이 생긴다. 따라서 이를 깔끔하게 해결하려면 NVI 관용구를 사용하면 된다. 너무나도 좋은 NVI 관용구.... 이쯤되면 "interface로서 가상 함수는 사용하지 말고, 대신에 NVI관용구를 사용하라." 라는 항목이 하나 더 추가되도 되지 않을까??? 문제는 코드 양이 증가한다는 것...[이것만은 잊지 말자]
- 상속받은 기본 매개변수 값은 절대로 재정의해서는 안 됩니다. 왜냐하면 기본 매개변수 값은 정적으로 바인딩되는 반면, 가상 함수(여러분이 오버라이드할 수 있는 유일한 함수이죠)는 동적으로 바인딩되기 때문입니다.
항목 38. "has-a(...는...를 가짐)"혹은 "is-implemented-in-terms-of(...는...를 써서 구현됨)"를 모형화할 때는 객체 합성을 사용하자.
[이것만은 잊지 말자]- 객체 합성의 의미는 public 상속이 가진 의미와 완전히 다릅니다.
- 응용 영역에서 객체 합성의 의미는 has-a입니다. 구현 영역에서는 is-implemented-in-terms-of의 의미를 갖습니다.
항목 39. private 상속은 심사숙고해서 구사하자.
[private 상속의 의미]- private 상속은 is-implemented-in-terms-of 의 의미를 가진다.
- private 상속은 소프트웨어 설계 도중에는 아무런 의미도 갖지 않으며, 단지 소프트웨어 구현 중에만 의미를 가질 뿐이다.
[private 상속을 써야하는 경우]
- 기본 클래스의 가상 함수를 재정의 해야 할 때 (기본 클래스를 public 상속하는 새로운 클래스 정의 + 객체 합성 방식으로 대체가능. 사실상 이 방법이 더 좋다. 성가셔서 그렇지...)
- protected 영역의 함수/변수에 접근해야 할 때 (이것도 위에 방법 쓰는게 낫다.)
- 공백 클래스를 합성해야 하는 경우 (private 상속을 이용하면 EBO최적화 효과를 누릴 수 있다.)
* 가능하면 객체 합성을 사용하고, 꼭 해야 하는 경우에만 private 상속을 사용하자!
[이것만은 잊지 말자]
- private 상속의 의미는 is-implemented-in-terms-of입니다. 대개 객체 합성과 비교해서 쓰이는 분야가 많지는 않지만, 파생 클래스 쪽에서 기본 클래스의 protected 멤버에 접근해야 할 경우 혹은 상속받은 가상 함수를 재정의해야 할 경우에는 private 상속이 나름대로 의미가 있습니다.
- 객체 합성과 달리, private 상속은 공백 기본 클래스 최적화(EBO)를 활성화시킬 수 있습니다. 이 점은 객체 크기를 가지고 고민하는 라이브러리 개발자에게 꽤 매력적인 특징이 되기도 합니다.
항목 40. 다중 상속은 심사숙고해서 사용하자.
[다중 상속의 문제점]- 똑같은 이름을 물려받아서 모호성이 생길 수 있다.
- 특정 기본 클래스의 데이터 멤버가 중복 생성될 수 있다. (ex, deadly MI diamond. 해결책 : 가상 상속)
[가상 상속의 문제점]
- 가상 상속을 사용하는 클래스 객체의 크기가 커진다.
- 가상 기본 클래스의 데이터 멤버에 접근하는 속도가 느려진다.
- 가상 기본 클래스의 초기화 규칙이 복잡하고 직관성이 떨어진다.
[가상 상속에 대한 조언]
- 굳이 쓸 필요가 없으면 가상 상속을 사용하지 말자.
- 굳이 써야한다면 가상 기본 클래스에는 데이터를 넣지 않는 쪽으로 신경을 써라.
[다중 상속에 대한 조언]
- 왠만하면 SI를 써라.
- 아무래도 MI를 써야할 것 같다면 SI를 쓰는 방향으로 머리를 쥐어 짜라.
- 정말 MI가 가장 적합한 방법이라고 확신이 들 경우에만 MI를 사용해라.
[이것만은 잊지 말자]
- 다중 상속은 단일 상속보다 확실히 복잡합니다. 새로운 모호성 문제를 일으킬 뿐만 아니라 가상 상속이 필요해질 수도 있습니다.
- 가상 상속을 쓰면 크기 비용, 속도 비용이 늘어나며, 초기화 및 대입 연산의 복잡도가 커집니다. 따라서 가상 기본 클래스에는 데이터를 두지 않는 것이 현실적으로 가장 실용적입니다.
- 가상 상속을 적법하게 쓸 수 있는 경우가 있습니다. 여러 시나리오 중 하나는, 인터페이스 클래스로부터 public 상속을 시킴과 동시에 구현을 돕는 클래스로부터 private 상속을 시키는 것입니다.
후... 포스팅 너무 오래걸려....ㅠㅜ 모레에 또 포스팅할께요! (이번엔 진짜로!!)
댓글
댓글 쓰기