public 상속은 반드시 "is-a" 관계를 뜻해야 한다.

가상함수: 인터페이스가 상속되어야 한다.

비가상함수: 인터페이스와 구현이 둘 다 상속되어야 한다.

 

32. public 상속 모형은 반드시 "is-a(...는 ...의 일종이다)"를 따르도록 만들자

public 상속과 is-a 관계는 똑같은 뜻이다.

파생 클래스의 타입이 기본 클래스의 타입이 될 수는 있지만 반대는 성립할 수 없다.

public 상속을 받을수록 일반적인 개념에서 특수한 개념으로 점점 좁혀진다.

 

public 상속은 기본 클래스가 가진 모든 것들이 파생 클래스에도 그대로 적용된다고 단정하는 것이다.

 

◾ public 상속의 의미는 "is-a(...는 ...의 일종)"입니다. 기본 클래스에 적용되는 모든 것들이 파생 클래스에 그대로 적용되어야 합니다. 왜냐하면 모든 파생 클래스 객체는 기본 클래스 객체의 일종이기 떄문입니다.

 

 

33. 상속된 이름을 숨기는 일은 피하자

유효범위에 관련된 내용이다.

 

class Base {
private:
    int x;
public:
    virtual void mf1() = 0;
    virtual void mf2();
    void mf3();
};

class Derived: public Base {
public:
    virtual void mf1();
    void mf4();
}

 

위와 같은 코드의 경우 데이터 멤버와 함수의 유효 범위는 그림과 같다.

만약 Derived의 mf4에서 mf2를 호출하게 된다면 컴파일러는 mf2 함수의 선언문이 들어있는 유효 범위를 탐색한다.

바로 Base::mf2에 접근하는 것이 아니라, 안에서부터 바깥으로 탐색을 넓혀나간다.

 

탐색 순서

함수 오버로딩의 경우에도 이름이 가려진다.

 

class Base {
public:
    virtual void mf1() = 0;
    virtual void mf1(int);
    void mf3();
    void mf3(double);
};

class Derived: public Base {
public:
    virtual void mf1();
    void mf3();
    void mf4();
};

Derived d;
int x;

d.mf1(); // Derived::mf1 호출
d.mf1(x); // error. Derived::mf1에 가려짐

d.mf3(); // Derived::mf3 호출
d.mf3(x); // error. Derived::mf3에 가려짐

 

가상 함수와 비가상 함수 여부나 매개변수 타입이 달라도 전혀 관련이 없이 이름이 가려진다.

애초에 public 상속을 쓰면서 오버로딩을 모두 상속받지 않으면 is-a 관계가 위반된다.

 

가려진 이름은 using 선언으로 꺼낼 수도 있다.

 

class Derived: public Base {
public:
    using Base::mf1;
    using Base::mf3;
};

 

파생 클래스 내부에서 가려지는 오버로드된 함수를 using 선언을 통해 Derived의 유효범위 안에서 볼 수 있게 만들어준다.

 

결과적으로 기본 클래스를 상속 받을 때, 오버로드된 함수의 일부만 재정의 하고 싶다면 using 선언을 붙여주어야 한다.

 

 

class Base
{
public:
    virtual void mf1() { std::cout << "mf1()" << '\n'; }
    virtual void mf1(int x) { std::cout << "mf1(x)" << '\n'; }
};

class Derived1 : private Base
{
public:
    using Base::mf1;
};

class Derived2 : public Derived1 {};

int main(void)
{
    Derived2 d;
    d.mf1(10); // Derived1이 private 상속임에도 접근이 가능해진다
    return 0;
}

 

만약 파생 클래스가 private 상속을 받고 일부만 재정의 한 뒤에 또 파생이 된다면 using 선언을 사용해선 안된다. 해당되는 이름이 모두 파생 클래스로 내려가 버리기 때문이다.

 

 

class Derived1 : private Base
{
public:
    virtual void mf1() { Base::mf1(); }
};

class Derived2 : public Derived1 {};

int main(void)
{
    Derived2 d;
    d.mf1(); // ok
    d.mf1(10); // error
    return 0;
}

 

이 때는 using 선언 대신 기본 클래스의 함수를 호출하는 전달 함수를 사용하면 된다.

 

◾ 파생 클래스의 이름은 기본 클래스의 이름을 가립니다. public 상속에서는 이런 이름 가림 현상은 바람직하지 않습니다.

◾ 가려진 이름을 다시 볼 수 있게 하는 방법으로, using 선언 혹은 전달 함수를 쓸 수 있습니다.

 

 

34. 인터페이스 상속과 구현 상속의 차이를 제대로 파악하고 구별하자

public 상속의 개념은 인터페이스 상속과 함수 구현 상속 두가지로 나뉜다.

 

class Shape {
public:
    virtual void draw() const = 0;
    virtual void error(const std::string& msg);
    int objectID() const;
};

 

순수 가상 함수, 단순 가상 함수, 비가상 함수의 목적은 각기 다르다.

 

◾ 순수 가상 함수를 선언하는 목적은 파생 클래스에게 함수의 인터페이스만을 물려주려는 것이다.

인터페이스만 물려받았기 때문에 파생 클래스에서는 반드시 구현을 해야한다.

 

◾ 단순 가상 함수를 선언하는 목적은 파생 클래스에게 함수의 인터페이스뿐만 아니라 기본 구현도 물려주려는 것이다.

둘 다 물려받았기 때문에 필요에 따라 재정의를 해도 되고 안해도 된다. 다만 재정의를 해야 할 상황에서 잊어버리고 재정의를 하지 않았을 때, 의도치 않은 동작이 이루어지는 것을 주의해야한다.

 

class Airplane {
public:
    virtual void fly(const Airport& destination);
};

class ModelA: public Airplane {
public:
    virtual void fly(const Airport& destination);
};

class ModelB: public Airplane {
public:
    virtual void fly(const Airport& destination);
};

class ModelC: public Airplane {}; // 재정의를 깜빡함

Airport PDX();
Airplane* pa = new ModelC;
pa->fly(PDX); // 하지만 동작함

 

이런 문제는 가상 함수의 인터페이스와 기본 구현을 잇는 연결 관계를 끊으면 조금은 해소할 수 있다.

 

 

class Airplane {
public:
    virtual void fly(const Airport& destination) = 0;
protected:
    void defaultFly(const Airport& destination); // 비가상함수. 재정의불가
};

class ModelA: public Airplane {
public:
    virtual void fly(const Airport& destination) { defaultFly(destination); }
};

class ModelB: public Airplane {
public:
    virtual void fly(const Airport& destination)  { defaultFly(destination); }
};

class ModelC: public Airplane {}; // 재정의를 깜빡함

Airport PDX();
Airplane* pa = new ModelC;
pa->fly(PDX); // error

 

복붙같은 문제를 해결할 수는 없지만 인터페이스와 구현을 분리함으로써 조금은 나아진다.

혹은 기본 클래스의 순수 가상 함수를 구현함으로써 외견을 깔끔하게 할 수 있다.

defaultFly를 구현하고 호출하는 대신 Airplane::fly를 호출하면 된다. 설계는 거의 똑같다.

 

◾ 비가상 함수를 선언하는 목적은 파생 클래스에게 함수의 인터페이스뿐만 아니라 필수적인 구현도 물려주는 것이다.

가상 함수와는 다르게 재정의가 불가능하기 때문에 모든 파생 클래스에서 똑같이 작동한다.

 

◾ 인터페이스 상속은 구현 상속과 다릅니다. public 상속에서, 파생 클래스는 항상 기본 클래스의 인터페이스를 모두 물려받습니다.

◾ 순수 가상 함수는 인터페이스 상속만을 허용합니다.

◾ 단순(비순수) 가상 함수는 인터페이스 상속과 더불어 기본 구현의 상속도 가능하도록 지정합니다.

◾ 비가상 함수는 인터페이스 상속과 더불어 필수 구현의 상속도 가하도록 지정합니다.

 

35. 가상 함수 대신 쓸 것들도 생각해 두는 자세를 시시때때로 길러 두자

◾ 비가상 인터페이스 관용구를 통한 템플릿 메서드 패턴

public 비가상 함수를 호출하면 내부 동작은 private 가상 함수에 의해 일부분이 다르게 동작하게 설계한다.

 

class GameCharacter {
public:
    int healthValue() const
    {
        ...
        int retVal = doHealthValue();
        ...
        return retVal;
    }
private:
    virtual int doHealthValue() const { ... } // 파생 클래스 재정의 가능
};

 

가상 함수가 private으로 선언되어 있어도 재정의는 가능하다. 단, 자기 자신의 가상 함수만 접근할 수 있다.

파생 클래스는 구현만 할 뿐이고 실제 호출은 기본 클래스의 비가상 함수에서 이루어지기 때문에 재정의에 아무런 문제가 없다.

위와 같은 설계를 비가상 함수 인터페이스(NVI) 관용구라고 부른다. 템플릿 메서드라고도 한다.

 

◾ 함수 포인터로 구현한 전략 패턴

체력을 계산하는 작업이 굳이 캐릭터의 일부일 필요가 없어서 각 생성자에 체력을 계산하는 함수의 포인터를 넘겨주는 방식을 택할 수 있다.

 

class GameCharacter; // 전방 선언

int defaultHealthCalc(const GameCharacter& gc); // 체력 계산 기본 구현 함수

class GameCharacter {
public:
    typedef int (*HealthCalcFunc)(const GameCharacter&);
    explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc) : healthFunc(hcf) {}
    int healthValue() const { return healthFunc(*this); }
private:
    HealthCalcFunc healthFunc;
};

class EvilBadGuy: public GameCharacter {
public:
    explicit EvilBadGuy(HealthCalcFunc hcf = defaultHealthCalc) : GameCharacter(hcf) {}
};

int loseHealthQuickly(const GameCharacter&);
int lostHealthSlowly(const GameCharacter&);

EvilBadGuy ebg1(loseHealthQuickly);
EvilBadGuy ebg2(loseHealthSlowly); // 동일한 객체이지만 함수를 다르게 설정 가능

 

가상 함수 대신 함수 포인터로 대체되었다.

템플릿 메서드 패턴과 크게 다른 점이 있는데, 템플릿 메서드는 타입이 같은 객체라면 체력 계산 알고리즘이 동일하지만 함수 포인터는 타입이 같은 객체라도 다른 함수를 사용하거나 타입이 다른 객체라도 같은 함수를 사용할 수 있게 된다.

그대신 클래스 외부로 빠져나왔기 때문에 public 멤버를 제외한 데이터에 접근이 불가능해진다.

 

위와 같은 개념을 디자인 패턴화 시킨 것이 전략(Strategy)패턴이다.

추후 체력 계산 알고리즘이 변경되어도 클래스에서 변하는 것은 아무것도 없다.

 

◾ tr1::function으로 구현한 전략 패턴

함수 대신 함수 객체를 사용한다. (참고: VC에서는 tr1 이름 공간이 더이상 사용되지 않음. 그냥 std::function이다.)

함수 포인터와 다른점은 대상 시그니처와 호환되는 함수호출성 개체를 어떤 것도 가질 수 있게된다. (암시적 변환)

특정 객체의 멤버 함수를 사용할 수도 있다. (std::bind 이용 시)

 

using GameCharacter;
typedef std::function<int(const GameCharacter&)> HealthCalcFunc;
// using HealthCalcFunc = std::function<int(const GameCharacter&)>; 둘 다 똑같다

class GameLevel {
public:
    float health(const GameCharacter&) const;
};
...
EvilBadGuy ebg1(calcHealth); // 함수 사용
EyeCandycharacter ecc1(HealthCalculator()); // 함수 객체 사용
GameLevel currentLevel;

EvilBadGuy ebg2(std::bind(&GameLevel::health, currentLevel, _1)); // 멤버 함수 사용

 

_1은 std::placeholder이다. 매개 변수를 자유롭게 받겠다는 의미이다.

 

◾ "고전적인" 전략 패턴

함수 포인터나 함수 객체가 아니라 체력 계산 함수를 나타내는 클래스를 따로 만들고 실제 계산하는 함수를 가상 함수로 만드는 것이다.

 

class GameCharacter;
class HealthCalcFunc {
public:
    virtual int calc(const GameCharacter& gc) const { ... }
};

HealthCalcFunc defaultHealthCalc;

class GameCharacter {
public:
    explicit GameCharacter(HealthCalcFunc *phcf = &defaultHealthCalc) : pHealthCalc(phcf) {}
    int healthValue() const { return pHealthCalc->calc(*this); }
private:
    HealthCalcFunc* pHealthCalc;
};

 

기존의 함수 포인터나 함수 객체와 구조가 크게 다르지는 않다. 그저 표준적인 전략 패턴일 뿐이다.

 

요약

 

◾ 비가상 인터페이스 관용구(NVI 관용구)를 사용한다

공개되지 않은 가상 함수를 비가상 public 멤버 함수로 감싸서 호출하는, 템플릿 메서드 패턴의 한 형태이다.

 

◾ 가상 함수를 함수 포인터 데이터 멤버로 대체한다

전력 패턴의 핵심만을 보여주는 형태이다.

 

◾ 가상 함수를 함수 객체 데이터 멤버로 대체한다

호환성을 높인다. 전략 패턴의 한 형태이다.

 

◾ 한쪽 클래스 계통에 속해 있는 가상 함수를 다른 쪽 계통에 속해 있는 가상 함수로 대체한다

전략 패턴의 전통적인 구현 형태이다.

 

◾ 가상 함수 대신에 쓸 수 있는 다른 방법으로 NVI 관용구 및 전략 패턴을 들 수 있습니다. 이 중 NVI 관용구는 그 자체가 템플릿 메서드 패턴의 한 예입니다.

◾ 객체에 필요한 기능을 멤버 함수로부터 클래스 외부의 비멤버 함수로 옮기면, 그 비멤버 함수는 그 클래스의 public 멤버가 아닌 것들을 접근할 수 없다는 단점이 생깁니다.

◾ tr1::function 객체는 일반화된 함수 포인터처럼 동작합니다. 이 객체는 주어진 대상 시그니처와 호환되는 모든 함수호출성 개체를 지원합니다.

 

 

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

가상 함수는 동적 바인딩으로 묶이지만 비가상 함수는 정적 바인딩으로 묶이기 때문에 재정의를 한다면 의도치 않은 동작을 하게된다.

그것보다는 앞서 언급했듯이 비가상 함수를 상속받으면 불변성이 보장되어야 하는데 재정의를 하는 순간 불변성이 깨져버린다.

 

◾ 상속받은 비가상 함수를 재정의하는 일은 절대로 하지 맙시다.

 

 

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

가상 함수가 동적으로 바인딩 된다 하더라도 기본 매개변수 값은 정적으로 바인딩 된다.

 

◾ 상속받은 기본 매개변수 값은 절대로 재정의해서는 안 됩니다. 왜냐하면 기본 매개변수 값은 정적으로 바인딩되는 반면, 가상 함수는 동적으로 바인딩되기 때문입니다.

 

 

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

컴포지션은 객체 안에 객체가 포함되는 경우를 얘기한다.

is-a 관계와 has-a 관계는 헷갈일 일이 거의 없지만 is-a 관계와 is-implemented-in-terms-of 관계는 헷갈릴 수 있다.

예를 들어서 list를 이용해 set를 구현한다고 했을 때, is-a 관계는 기본 클래스에서 참인 것들이 파생 클래스에서도 모두 참이어야 하지만 list는 중복 원소를 가질 수 있으므로 is-a 관계가 성립하지 않는다.

대신 list 객체를 사용해서 구현하는 형태(is-implemented-in-terms-of)를 가질 수 있다.

 

template<typename T>
class Set: public std::list<T> { ... }; // is-a 관계가 성립하지 않음

template<class T>
class Set {
public:
    bool member(const T& item) const;
    ...
private:
    std::list<T> rep;
}

template<typename T>
bool Set<T>::member(const T& item) const
{
    return std::find(rep.begin(), rep.end(), item) != rep.end(); // list를 사용한 구현
}

 

◾ 객체 합성의 의미는 public 상속이 가진 의미와 완전히 다릅니다.

◾ 응용 영역에서 객체 합성의 의미는 has-a(...는...를 가짐)입니다. 구현 영역에서는 is-implemented-in-terms-of(...는 ...를 써서 구현됨)의 의미를 갖습니다.

 

 

39. private 상속은 심사숙고해서 구사하자

클래스 사이의 상속 관계가 private이라면 파생 클래스 객체를 기본 클래스 객체로 변환하지 않는다. (업캐스팅X)

 

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

void eat(const Person& p) {}

Person p;
Student s;

eat(p);
eat(s); // error

 

또한 상속이 private으로 이루어지면 기본 클래스에서 멤버들의 접근 지정자가 어떻게 되었던 간에 파생 클래스에서 전부 private 멤버가 되어버린다.

 

결론적으로 private 상속의 의미는 is-implemented-in-terms-of이다. private 상속 자체로 구현 기법 중 하나가 된다.

기본 클래스를 private으로 상속하는것은 기능 몇 개를 활용할 목적이 있는것이지 어떤 개념적 관계가 있어서 하는 행동이 아니다. 구현만 물려받을 뿐 인터페이스는 일절 제공하지 않는다.

 

앞서 컴포지션은 has-a 또는 is-implemented-in-terms-of 둘 중 하나라고 언급했었다.

컴포지션이 이미 존재하는데 private 상속은 언제 사용해야 하는걸까?

답은 가능하면 컴포지션을 사용하고 객체의 비공개 멤버에 접근하거나 가상 함수를 재정의 할 필요가 있을때 private 상속을 사용하면 된다.

 

다만 파생은 가능하게 하되 파생 클래스에서 재정의를 막거나 컴파일 의존성을 최소화하고 싶을 때 컴포지션이 더 좋기때문에 컴포지션이 더 자주 쓰인다.

 

◾ private 상속의 의미는 is-implemented-in-terms-of입니다. 대개 객체 합성과 비교해서 쓰이는 분야가 많지는 않지만, 파생 클래스 쪽에서 기본 클래스의 protected 멤버에 접근해야 할 경우 혹은 상속받은 가상 함수를 재정의해야 할 경우에는 private 상속이 나름대로 의미가 있습니다.

◾ 객체 합성과 달리, private 상속은 공백 기본 클래스 최적화(EBO)를 활성화시킬 수 있습니다. 이 점은 객체 크기를 가지고 고민하는 라이브러리 개발자에게 꽤 매력적인 특징이 되기도 합니다.

 

 

40. 다중 상속은 심사숙고해서 사용하자

다중 상속은 똑같은 이름을 물려받을 가능성이 생긴다.

만약 다중 상속으로 인해 기본 클래스와 파생 클래스 사이의 경로가 두 개 이상이 된다면 데이터 멤버를 중복 생성하거나 가상 상속을 통해 해결해야 한다.

 

정확한 동작의 관점에서 보면 public 상속은 항상 가상 상속이어야 맞지만 성능상의 이유로 무조건 사용할 수는 없다.

때문에 쓸 필요가 없다면 가상 기본 클래스를 쓰지 말아야 한다. 만약 반드시 써야할 상황이라면 가상 기본 클래스에는 데이터를 넣지 않도록 각별히 신경써야 한다. 초기화 규칙에서 자유로워지기 때문이다.

 

가능하면 단일 상속을 사용하려고 하되, 정말 필요하다고 확신이 들면 다중 상속을 사용하도록 한다.

 

◾ 다중 상속은 단일 상속보다 확실히 복잡합니다. 새로운 모호성 문제를 일으킬 뿐만 아니라 가상 상속이 필요해질 수도 있습니다.

◾ 가상 상속을 쓰면 크기 비용, 속도 비용이 늘어나며, 초기화 및 대입 연산의 복잡도가 커집니다. 따라서 가상 기본 클래스에는 데이터를 두지 않는 것이 현실적으로 가장 실용적입니다.

◾ 다중 상속을 적법하게 쓸 수 있는 경우가 있습니다. 여러 시나리오 중 하나는, 인터페이스 클래스로부터 public 상속을 시킴과 동시에 구현을 돕는 클래스로부터 private 상속을 시키는 것입니다.

'도서 > Effective C++' 카테고리의 다른 글

8. new와 delete를 내 맘대로  (0) 2022.12.07
7. 템플릿과 일반화 프로그래밍  (0) 2022.12.05
5. 구현  (0) 2022.12.03
4. 설계 및 선언  (0) 2022.12.01
3. 자원 관리  (0) 2022.11.30

+ Recent posts