Skip to main content

[Effective C++] Chapter 6. 상속, 그리고 객체 지향 설계

이펙티브 C++ 책을 읽고 공부한 노트입니다.




Item 32: public 상속 모형은 반드시 “is-a"를 따르도록 하자 #

반드시 기억하자! public 상속은 “is-a"를 의미한다!

  • 이것은 기본 클래스는 더 일반적인 개념을 나타내며 파생 클래스는 더 특수한 개념을 나타낸다는 의미이다.
  • public 상속은 Base 클래스가 가진 모든 것들이 파생 클래스 객체에도 그대로 적용된다고 단정하는 상속이다.



Item 33: 상속된 이름을 숨기는 일은 피하자 #

  • 이름을 숨긴다는 것은 유효범위(scope) 와 관계가 있다.
int x; // 전역 변수 

void someFunc()
{
  double x; // 지역 변수
  // someFunc의 유효범위 안쪽에 있으므로 
  // int x를 가린다
  
  std::cout << 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(); // Base의 mf1을 다 가려버린다
  
  void mf3(); // Base의 mf3을 다 가려버린다
  
  void mf4();
};

// 클래스 유효범위에 대한 예시
void Derived::mf4()
{
  mf2();
  // 지역 유효범위(mf4)에 mf2를 찾는다.
  // 없다면 Derived 클래스 유효범위에서...
  // 없다면 Base 클래스 유효범위에서...
  // 없다면 Base를 둘러싼 네임스페이스를...
  // ...
}

int main()
{
  Derived d;
  int x;
  
  d.mf1();  // Derived::mf1 호출
  d.mf1(x); // 에러! 
  
  d.mf2();  // Base::mf2 호출
  
  d.mf3();  // Derived::mf3 호출
  d.mf3(x); // 에러!
}



가려진 이름을 다시 볼 수 있게 하는 방법 #

  • (1) using 선언
class Derived : public Base
{
public:
  // using 선언으로 
  // Base에 있는 것들 중 mf1, mf3 이름을 가진 것들을 
  // Derived에서 볼 수 있도록 (public 멤버로) 만든다. 
  using Base::mf1;
  using Base::mf3;
  
  virtual void mf1();
  void mf3();
  void mf4();
};

int main()
{
  Derived d;
  int x;
  
  // 모두 다 잘 된다. 
  d.mf1();
  d.mf1(x); // Base::mf1 호출
  
  d.mf2();
  
  d.mf3();
  d.mf3(x); // Base::mf3 호출
}



  • (2) 전달 함수
    • private 상속이라면, 그리고 mf1는 매개변수가 없는 버전만 상속하고 싶다면?
class Derived : private Base
{
public:
  // 전달 함수
  virtual void mf1() { Base::mf1(); }
};

int main()
{
  Derived d;
  int x;
  
  d.mf1();  // Derived::mf1 호출
  d.mf1(x); // 에러! 
}



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

  • 인터페이스 상속과 구현 상속은 다르다. 예시를 통해 살펴보자.
// 추상 클래스 
class Shape
{
public:
  // 순수 가상 함수 
  virtual void draw() = 0;
  // 비순수 가상 함수 
  virtual void error(const std::string& msg);
  // 비가상 함수 
  int objectID() const;
};

class Rectangle : public Shape {};
class Ellipse : public Shape {};

  • public 상속이다.
    • 인터페이스는 항상 상속하게 되어 있다.



순수 가상 함수 #

  • 물려받은 구체 클래스가 순수 가상 함수를 다시 선언해야 한다.
  • 전형적으로 추상 클래스 안에서 정의를 갖지 않는다.

함수의 인터페이스만을 물려준다.


  • 순수 가상 함수를 추상 클래스 안에서 정의할 수도 있다.
  • 단, 호출하려면 반드시 클래스 이름을 한정자로 붙여 주어야만 한다.
Shape *ps = new Shape;
Shape *ps1 = new Rectangle;
Shape *ps2 = new Ellipse;

// Shape::draw를 호출한다. 
ps1->Shape::draw();
ps2->Shape::draw();



비순수 가상 함수 #

  • “이 함수는 여러분이 지원해야 합니다. 하지만 굳이 새로 만들 생각이 없다면 기본 버전을 그냥 써도 무방합니다.”

함수의 인터페이스뿐만 아니라 그 함수의 기본 구현도 물려준다.


  • 때로는 함수 인터페이스와 기본 구현을 한꺼번에 지정하는 것은 위험할 수도 있다.
class Airplane
{
public:
  virtual void fly(const Airport& destination);
};

void Airplane::fly(const Airport& destination)
{
  // 기본 날기 동작
}

class ModelA : public Airplane {};
class ModelB : public Airplane {};

class ModelC : public Airplane {};
// fly와 다른 날기 동작을 해야 하는 ModelC가 추가되었는데,
// 까먹고 fly를 재정의 하지 않았다면?

int main()
{
  Airport PDX;
  
  Airplane *pa = new ModelC;
  pa->fly(PDX);  // Airplane::fly가 호출되고 말았다!
}

  • 해결 방법
    • 가상 함수의 인터페이스와 그것의 기본 구현을 분리한다.
class Airplane
{
public:
  virtual void fly(const Airport& destination) = 0;

protected:
  void defaultFly(const Airport & destination);
  // 파생 클래스에서 재정의하면 안되므로 비가상 함수이다. 
  // protected로 비행기류만 알고 있도록 한다. 
};

void Airplane::defaultFly(const Airport & destination)
{
  // 기본 날기 동작 
}

class ModelA : public Airplane
{
public:
  virtual void fly(const Airport& destination)
  {
    defaultFly(desination); // 기본 날기 동작
  }
};
// ModelB도 동일


class ModelC : public Airplane
{
public:
  virtual void fly(const Airport& destination)
  {
    // 새로운 날기 동작 
  }
};



비가상 함수 #

  • 클래스 파생에 상관없이 변하지 않는 동작을 지정하는 데 쓰인다.

함수 인터페이스와 더불어 그 함수의 필수적인 구현(mandatory implementation)을 물려받게 한다.



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

  • 가상 함수 대신 쓸 수 있는 방법들은 없을까?

비가상 함수 인터페이스 관용구 사용하기 #

  • 비가상 함수 인터페이스(non-virtual interface: NVI) 관용구
    • public 비가상 멤버 함수를 만들어서 private 가상 함수를 간접적으로 호출하게 만드는 방법이다.
    • 템플릿 메서드(Template Method) 패턴이다.
    • public 비가상 함수를 랩퍼(Wrapper)라고 부른다.
    • 사전 작업과 사후 작업을 앞 뒤로 삽입할 수 있는 좋은 방법이다.
class GameCharacter
{
private:
  // 파생 클래스가 이 가상 함수를 재정의 할 수 있다.
  virtual int doHealthValue() const;
  
public:
  // 비가상 함수 인터페이스 
  int healthValue() const
  {
    // ... 사전 작업
    int retVal = doHealthValue();
    // ... 사후 작업
    return retVal;
  }
};



함수 포인터 사용하기 #

  • 전략(Strategy) 패턴이다.
  • 실행 도중에 계산 함수를 바꿀 수 있으며, 객체 마다 다른 함수를 실행하도록 할 수 있다.
  • 계산 함수가 클래스 외부에 있기 때문에 클래스의 public 부분만을 사용해서 계산할 수 있다.
    • 따라서 public이 아닌 데이터에도 접근해야만 계산할 수 있게 된다면, 프렌드로 하던지 public으로 접근하게 할 수 밖엔 없다.
class GameCharacter; // 전방선언

// 계산 함수 
int defaultHealthCalc(const GameCharacter& gc);

class GameCharacter
{
private:
  // 계산 함수를 포인터로 가지고 있다. 
  HealthCalcFunc healthFunc;

public:
  // HealthCalcFunc은
  // 반환타입이 int, 매개변수는 const GameCharacter&형인 함수에 대한 포인터를 나타낸다. 
  typedef int (*HealthCalcFunc)(const GameCharacter&);
  
  explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc)
    : healthFunc(hcf)
  {}
  
  int healthValue() const
  {
    return healthFunc(*this);
  }
};

class EvilBadGuy : public GameCharacter {};

// 다른 계산 함수들 
int loseHealthQuickly(const GameCharacter&);
int loseHealthSlowly(const GameCharacter&);

int main()
{
  // 객체 마다 다르게 함수를 지정할 수 있다. 
  // 런다임에 함수를 바꿀 수 있다. 
  EvilBadGuy ebg1(loseHealthQuickly);
  EvilBadGuy ebg1(loseHealthSlowly);
}



tr1::function 사용하기 #

  • 함수 포인터 대신 tr1::function을 사용하면
    • 함수호출성 개체(callable entity: 함수 포인터, 함수 객체, 멤버 함수 포인터)를 모두 다룰 수 있다. 즉, 좀 더 일반화된 함수 포인터를 물게 되는 셈이다.
class GameCharacter
{
public:
  // HealthCalcFunc은
  // 반환타입이 int, 매개변수는 const GameCharacter&형인 함수에 대한 tr1::function 을 나타낸다. 
  typedef std::tr1::function<int (const GameCharacter&)> HealthCalcFunc;
};

// (1) 반환타입이 short인 함수이다. 
short calcHeatlh(const GameCharacter&);

// (2) 함수 객체를 만들기 위한 클래스이다. 
stuct HealthCalculator
{
  int operator()(const GameCharacter&) const {}
};

class GameLevel
{
public: 
  // (3) 반환타입이 float인 멤버 함수이다. 
  float health(const GameCharacter&) const;
}

int main()
{
  // (1)
  EvilBadGuy ebg1(calcHealth); 
  
  // (2)
  EvilBadGuy ebg2(HealthCalculator());
  
  // (3)
  GameLevel currentLevel;
  EvilBadGuy ebg3(std::tr1::bind(&GameLevel::health, currentLevel, _1));
  // GameLevel::health 함수를 사용하는데, 
  // GameLevel::health는 암시적으로 GameLevel 객체가 있어야 한다. 
  // 하지만 GameLevel::health의 매개변수는 GameCharacter 하나 뿐이다. 
  // 그래서 currentLevel를 계산할 때 함께 사용하도록 묶어주고 있다. 
  // _1은 첫 번째 자리의 매개변수가 자유 매개변수(free parameter)임을 나타내는 것이다. 
}



다른 클래스의 가상 함수로 만들기 #

  • 고전적인 전략 패턴이다.
class GameCharacter; // 전방 선언

// 계산하는 함수를 나타내는 클래스를 따로 둔다. 
class HealthCalcFunc
{
public: 
  // 그 안에 가상 함수로 계산을 한다. 
  virtual int calc(const GameCharacter& gc) const {}
};

HealthCalcFunc defaultHealthCalc;

class GameCharacter
{
private:
  // 계산하는 클래스에 대한 포인터를 가지고 있다. 
  HealthCalcFunc *pHealthCalc;
  
public:
  explicit GameCharcter(HealthCalcFunc *phcf = &defaultHealthCalc)
    : pHealthCalc(phcf)
  {}
  
  int healthValue() const
  {
    return pHealthCalc->calc(*this);
  }
};



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

  • 상속받은 비가상 함수를 파생 클래스에서 재정의 하면 이상한 일이 발생한다.
class Base 
{
public: 
  // 비가상 함수 
  void mf();
};

class Derived : public Base 
{
public:
  // 기본 클래스의 비가상 함수를 재정의 해버렸다. 
  // Base::mf를 가려버린다. 
  void mf();
};

int main()
{
  Derived d;
  
  // 포인터 형만 다를 뿐
  // 똑같이 d를 가리킨다. 
  Base *pB = &d;
  Derived *pD = &d;
  
  pB->mf(); // Base::mf 호출
  pD->mf(); // Derived::mf 호출
  // 일관성 없는 이상한 동작이다!
}

  • 이렇게 되는 이유는 비가상 함수는 정적 바인딩(static binding) 으로 묶이기 때문이다.
    • pBBase에 대한 포인터 형으로 선언되었기 때문에 pB를 통해 호출되는 비가상 함수는 항상 Base 클래스에 정의되어 있을 것이라고 결정해 버린다.
  • 반면 가상 함수는 동적 바인딩(dynamically binding) 으로 묶인다.
    • pB가 진짜로 가리키는 대상이 Derived 이므로 Derived::mf가 호출된다.



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

  • 가상 함수로부터 상속받은 기본 매개변수는 절대 재정의 하면 안 된다.
    • 가상 함수는 동적으로 바인딩되어 있지만
    • 매개변수는 정적으로 바인딩되기 때문에 아래와 같은 일이 생긴다.
class Shape 
{
public:
  enum ShapeColor { Red, Green, Blue };
  
  // 기본 클래스의 가상 함수. 
  // 기본 매개변수가 있으며 Red이다. 
  virtual void draw(ShapeColor color = Red) const = 0;
};

class Rectangle : public Shape
{
public:
  // 기본 매개변수가 Green이다. 
  virtual void draw(ShapeColor color = Green) const;
};

int main()
{
  Shape * pr = new Rectangle;
  pr->draw(); // Rectangle::draw(Shape::Red) 를 호출해 버린다! 
}

  • 따라서 기본 클래스와 파생 클래스의 기본 매개변수는 같아야 한다.
  • 그렇다고 기본 매개변수를 똑같이 설정해 버리면?
class Shape 
{
public:
  virtual void draw(ShapeColor color = Red) const = 0;
};

class Rectangle : public Shape
{
public:
  virtual void draw(ShapeColor color = Red) const;
  // 코드 중복이다.
  // 게다가 Shape 클래스의 기본 매개변수가 변하면
  // 파생 클래스에서 일일히 다 바꿔줘야 한다. 
};

  • 이럴 때는 비가상 인터페이스 관용구(NVI 관용구) 를 쓰면 된다.
class Shape
{
private:
  virtual void doDraw(ShapeColor color) const = 0;
  
public: 
  // 비가상 함수
  void draw(ShapeColor color = Red) const
  {
    doDraw(color);
  }
};

class Rectangle : public Shape
{
private:
  virtual void doDraw(ShapeColor color) const;
}



Item 38: “has-a” 혹은 “is-implemented-in-terms-of"를 모형화할 때는 객체 합성을 사용하자 #

  • 합성(composition)
    • layering, containment, aggregation, embedding 등으로도 불린다.
    • 어떤 객체가 그와 다른 타입의 객체들을 포함함으로써 생기는 관계를 일컫는 말이다.

  • 객체 합성의 두 가지 뜻
    • (1) has-a
      • 사물을 본 뜬 객체들은 소프트웨어의 응용 영역(application domain)에 속한다.
      • 객체 합성이 응용 영역의 객체들 사이에서 일어나면 has-a 관계이다.
    • (2) is-implemented-in-terms-of
      • 응용 영역에 속하지 않는, 순수하게 시스템 구현만을 위한 객체들은 소프트웨어의 구현 영역(implementation domain)에 속한다.
      • 객체 합성이 구현 영역의 객체들 사이에서 일어나면 is-implemented-in-terms-of 관계이다.

  • has-a 예제
class Person
{
private:
  // Person과 has-a 관계이다. 
  std::string name;
  Address address;
  PhoneNumber voiceNumber;
  PhoneNumber faxNumber;
};

  • is-implemented-in-terms-of 예제
    • 표준 C++ 라이브러리에 list 템플릿을 가지고 새로운 Set 템플릿을 만들고 싶다면?
template<class T>
class Set
{
private:
  // Set은 list 객체를 써서 구현된다.(is implemented in terms of) 
  std::list<T> rep;
  
public:
  bool member(const T& item) const;
  void insert(const T& item);
  void remove(const T& item);
  std::size_t size() const;
};



Item 39: private 상속은 삼사숙고해서 구사하자 #

private 상속의 특징 #

  • is-implemented-in-terms-of 관계이다.
  • 컴파일러는 파생 클래스 객체를 기본 클래스 객체로 변환하지 않는다.
  • 기본 클래스로부터 물려받은 멤버는 모조리 private 멤버가 된다.
    • 즉, 기본 클래스는 단지 구현 세부사항일 뿐이라는 것이다.
    • 구현만 물려받을 수 있고, 인터페이스는 국물도 없다!
class Person {};

class Student : private Person {};

void eat(const Person& p);

int main()
{
  Person p;
  Student s;
    
  eat(p);
  eat(s); // 에러! Stduent는 Person이 아니다. 
}

  • 그럼 객체 합성을 해야할까 private 상속을 해야할까?
    • private 상속은 반드시 필요할 때만 하자.
    • (1) 비공개 멤버에 접근해야 할 때
    • (2) 가상 함수를 재정의 해야할 때
class Timer
{
public:
  explicit Timer(int tickFrequency);
  
  // 이것을 재정의 해서 Widget 전용 타이머로 사용하고 싶다. 
  virtual void onTick() const;
};

class Widget : private Timer // private 상속 
{
private:
  // Widget 전용 기능들을 추가해서 재정의한다. 
  virtual void onTick() const;
};



public 상속 + 객체 합성 #

  • private 상속 대신에 public 상속 + 객체 합성 조합으로 만들 수도 있다.
  • 장점
    • 파생 클래스에서 onTick을 재정의 할 수 없도록 막을 수 있다.
    • 컴파일 의존성을 최소화할 수 있다. Widget 입장에서는 Timer와 관련된 어느 것도 include 할 필요가 없다. (아래 코드에서 WidgetTimer 정의를 밖으로 빼낸다면)
class Widget
{
private:
  class WidgetTimer : public Timer
  {
  public:
    virtual void onTick() const;
  };
  
  WidgetTimer timer;
};



private 상속은 EBO가 적용된다 #

  • 반면, 객체 합성과 달리 private 상속은 EBO를 활성화 시킬 수 있다.
  • 공백 기본 클래스 최적화(empty base optimization: EBO)
    • C++은 크기가 0인 독립 구조의 객체가 생기는 것을 금지한다. 그래서 비정적 데이터 멤버가 없더라도 컴파일러가 슬그머니 char 한 개를 끼워넣는다.
    • 하지만 private 상속을 하면 단일 상속인 경우에만에 한해서 크기가 0이 된다.
class Empty {};

class HoldsAnInt
{
private:
  int n;
  Empty e; // 크기가 0이 아니다.
};

class HoldsAnInt : private Empty
// 크기가 0이다. 
{
private:
  int n;
};



Item 40: 다중 상속은 심사숙고해서 사용하자 #

모호한 다중 상속 #

  • 다중 상속(multiple inheritance: MI) 은 모호하다.
class BorrowableItem 
{
public: 
  void checkOut();
};

class ElectronicGadget 
{
private: 
  void checkOut();
};

class MP3Player : public BorrowableItem, public ElectronicGadget {};

int main()
{
  MP3Player mp;
  
  mp.checkOut(); // 에러!
  // BorrowableItem의 함수인가?
  // ElectronicGadget의 함수인가?
  // ElectronicGadget::checkOut 은 private이므로 
  // BorrowableItem::checkOut 이 불릴 것 같지만 아니다. 
  // 컴파일러는 최적 일치 함수를 찾은 후에 비로소 함수의 접근 가능성을 점검한다. 
  // 그래서 이 경우에는 먼저 최적 일치 함수를 결정할 수 없어서 에러가 난다. 
  
  // 이 모호성을 피하려면 아래와 같이 함수를 손수 지정해 주어야한다.
  mp.BorrowableItem::checkOut();
  // 하지만 private 멤버에 접근하려고 한다는 에러가 나올 뿐이다. 
  // 근본적으로 막을 수가 없다. 
}

  • 죽음의 다이아몬드(deadly MI diamond) 문제
class File 
{
private:
  std::string fileName;
};

class InputFile : public File {};
class OutputFile : public File {};

class IOFile : public InputFile, public OutputFile {};
// IOFile 클래스는 fileName이 두 개인가? 하나인가?



가상 상속 #

  • File가상 기본 클래스(virtual base class) 로 만들면, 중복이 없어진다.
class File {};

class InputFile : virtual public File {};
class OutputFile : virtual public File {};

class IOFile : public InputFile, public OutputFile {};
  • 하지만 단점이 있다.
    • 가상 상속 때문에 객체의 크기가 커진다.
    • 그리고 데이터 멤버에 접근하는 속도도 느려진다.
    • 또한 파생 클래스로 하여금 기본 클래스를 초기화시키도록 한다.
  • 따라서 가상 클래스는 되도록 사용하지 말고, 혹시라도 꼭 사용해야 한다면 기본 클래스에는 데이터를 넣지 않는 쪽으로 하자.
    • (자바와 닷넷에는 데이터를 못가지는 Interface라는 개념이 있다. )



다중 상속을 잘 사용하는 법 #

  • 인터페이스 클래스를 public으로 상속하고, 구현을 돕는 클래스를 private 상속하는 것이다.
// 인터페이스를 갖고 있다. 
class IPerson
{
public:
  virtual ~IPerson();
  
  virtual std::string name() const = 0;
  virtual std::string birthDate() const = 0;
};

// 사용하면 유용한 함수들이 있다. 
class PersonInfo
{
public:
  explicit PersonInfo(DatabaseID pid);
  virtual ~PersonInfo();
  
  virtual const char * theName() const;
  virtual const char * theBirthDate() const;
  virtual const char * valueDelimOpen() const;
  virtual const char * valueDelimClose() const;
};

// 다중 상속을 한다. 
class CPerson : public IPerson, private PersonInfo
{
private:
  const char * valueDelimOpen() const { return ""; }
  const char * valueDelimClose() const { return ""; }
  
public:
  explicit CPerson(DatabaseID pid) : PersonInfo(pid) {}
  
  virtual std::string name() const
  {
    return PersonInfo::theName();
  }
  
  virtual std::string brithDate() const
  {
    return PersonInfo::theBirthDate();
  }
};