본문 바로가기

C, C++/Effective C++

6장 상속과 객체 지향 디자인

Item32: public 상속이 "is-a" 관계를 모델링하도록 하라.

class Person { ... };

class Student : public Person { ... };

위와 같은 상황일 때 모든 사람은 학생이 아니지만, 모든 학생은 사람이다.

Student is a Person 인 관계를 is-a 관계라고 한다.

 

void eat(const Person& p);   // 모든 사람은 먹을 수 있다.

void study(const Student& s);  // 학생만 공부한다.

Person p;    // p는 Person 객체
Student s;   // s는 Student 객체

eat(p);   // 가능, p는 Person 객체이므로
eat(s);   // 가능, s는 Student 객체이며 Student는 Person이므로

study(s);   // 가능
study(p);   // 오류 p는 Student가 아님

 

기반 클래스에 적용되는 모든 것은 파생 클래스에도 적용되어야 한다.

모든 파생 클래스 객체는 기반 클래스 객체이다.

 

펭귄은 새를 public으로 상속받아야 할까? (펭귄은 새이지만 날 수 없다)

정사각형은 직사각형을 public으로 상속받아야 할까? (수학적으로는 맞지만, 코드로 생각하면 아님)

 


Item33: 상속된 이름을 숨기지 말라.

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();          // Derived에서 새롭게 정의된 함수
    ...
};
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을 숨김

상위 클래스의 mf3은 하위 클래스에서 만든 mf3을 통해 완전히 가려진다. (파라미터에 관계없이)

따라서 파라미터가 없는 mf3만 가려진다고 생각되지만, name lookup 관점에 따라, mf1과 mf3은 Derived에 의해 상속되지 않는다.

 

기대하지 않은 오버로드된 함수가 상속되는 것을 막기 위해서 이러한 규칙이 있다.

 

class Derived : public Base {
public:
    using Base::mf1;  // Base에서 정의된 mf1을 모두 상속
    using Base::mf3;  // Base에서 정의된 mf3을 모두 상속

    virtual void mf1();
    void mf3();
    void mf4();
    ...
};

using을 사용하면 d.mf1(x)와 d.mf3(x)은 정상적으로 Base의 함수가 호출된다.

 

private 상속을 받는다면 포워딩 함수를 통해서 호출하자.


Item34: 인터페이스 상속과 구현 상속을 구분하라.

public 상속은 인터페이스의 상속과 구현의 상속으로 구성되어있다.

이 두 가지 상속 방식의 차이는 함수 선언(function declarations)과 함수 정의(function definitions)의 차이와 정확히 일치한다.

 

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

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

 

순수 가상 함수

오직 인터페이스만 상속

virtual 함수에 =0이 있는 것(draw)은,  순수 가상 함수이다.

순수 가상 함수를 포함하고 있기 때문에 Shape 클래스는 직접 생성할 수 없고, 이를 상속받은 클래스에서 해당 함수를 구현하고 생성해야 한다.

 

(일반)가상 함수

인터페이스 + 기본 구현 제공

파생 클래스는 일반 가상 함수(error)를 재정의(override) 할 수도 있고, 그대로 사용할 수도 있다.

 

비가상 함수

인터페이스 + 필수 구현 강제(변경 불가)

 

인터페이스 상속과 구현 분리

만약 기반 클래스를 상속받는 클래스들 중 대부분은 기본적인 동작을 따르고, 특정 클래스는 아니라면, 인터페이스 상속과 구현을 분리할 필요가 있다.

 

책에서는 Model이 다른 비행기(fly, defaultFly)를 예시로 듬

fly는 순수가상함수로 만들어 상속받게 하고, defaultFly는 비가상함수로 만들어 파생클래스에서 필요한 경우 fly에 구현부로 활용하도록 만들었다.

 

모던 C++에서는 override, final, default 등의 키워드를 통해 프로그래머의 명확한 의도를 코드에 나타낼 수 있다.


Item35: 가상 함수의 대안을 고려하라.

너무 명확한 설계(virtual function)는 다른 방법을 고려하지 않을 수도 있게한다.

다형성을 구현하는 다른 방법들에 대해서 알아보자.

 

템플릿 메서드 패턴을 이용한 비가상 인터페이스(NVI-Non Virtual Interface) 

class GameCharacter {
public:
    int healthValue() const  // 비가상 함수 (파생 클래스가 재정의할 수 없음)
    {
        ...
        int retVal = doHealthValue();  // 비공개 가상 함수 호출
        ...
        return retVal;
    }

private:
    virtual int doHealthValue() const  // 파생 클래스가 재정의할 가상 함수
    {
        ...
    }
};

클라이언트 코드가 가상 함수를 직접 호출하지 않고, 비가상 멤버 함수를 통해 간접적으로 호출하도록 하는 방식이다.

 

NVI는 do before stuff와 do after stuff가 가능하다.

가상 함수 전과 후에 원하는 기능을 넣을 수 있다.

 

전략 패턴을 이용한 함수 포인터 (The Strategy Pattern via Function Pointers)

class GameCharacter;  // 전방 선언(forward declaration)

// 기본 건강 상태 계산 알고리즘 함수
int defaultHealthCalc(const GameCharacter& gc);

// GameCharacter 클래스 정의
class GameCharacter {
public:
    typedef int (*HealthCalcFunc)(const GameCharacter&);  // 함수 포인터 타입 정의

    explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc)  // 생성자에서 함수 설정
        : healthFunc(hcf)
    {}

    int healthValue() const
    { return healthFunc(*this); }  // 설정된 건강 상태 계산 함수 호출

private:
    HealthCalcFunc healthFunc;  // 건강 상태 계산 함수 저장 변수
};

같은 타입의 캐릭터라도 서로 다른 건강 상태 계산 방식을 가질 수 있다.

 

하지만 함수 포인터로 연산해야 하는 정보가 비공개 정보라면 적절하지 않을 수 있다.(외부 함수에 의존하기 때문)

 

전략 패턴을 활용한 람다 함수(std::function)

 

클래식한 전략 패턴(Strategy Pattern)


Item36: 상속된 비가상 함수를 재정의하지 마라.

클래스 D가 클래스 B로부터 public 상속되었고, B에는 mf가 정의되어 있을 때 

class B {
public:
    void mf();  
    ...
};

class D : public B { ... };

D x;

B *pB = &x; 
pB->mf();    

D *pD = &x;  
pD->mf();

 

만약에 mf가 비 virtual이고 D가 자체적으로 mf를 정의했다면 non-virtual 함수이기 때문에, 정적 바인딩이 적용된다.

 

만약 가상함수 였다면, 동적 바인딩이 적용된다.

 

상속된 비가상 함수를 재정의하면 발생하는 문제

클래스 D에서 상속받은 비가상 함수를 재정의하면 일관되지 않은 행동을 하게 될것이다.

같은 D 객체라도, B처럼 행동할 수도 있고, D처럼 행동할 수도 있다.

어떤 mf가 호출될지는 객체 자체가 아닌, 포인터의 타입에 의해 결정된다.

 

또한 기본적으로 public 상속을 받았다면, is-a관계여야 하는데, 상속된 비가상 함수를 재정의 하는 것은 논리적으로도 오류가 있다.

 

결론적으로 절대로 상속된 비가상 함수를 재정의하면 안된다.


Item37: 상속된 함수의 기본 매개변수 값을 재정의하지 마라.

상속될 수 있는 함수는 1. 가상 함수, 2. 비가상 함수 가 있다.

비가상 함수는 위에서 언급했듯이 항상 잘못된 선택이다.

 

가상함수는 동적 바인딩을 따른다. 따라서, 실제 호출될 함수는 객체의 동적 타입에 의해 결정된다.

기본 매개변수 값은 정적 바인딩을 따른다. 따라서, 객체의 정적 타입에 의해 결정된다.

 

 

  • 정적 바인딩(static binding) → "초기 바인딩(early binding)"이라고도 함.
  • 동적 바인딩(dynamic binding) → "지연 바인딩(late binding)"이라고도 함.

 

// 기하학적 도형을 나타내는 기본 클래스
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 *pr = new Rectangle;  로 선언해서 pr->draw()를 해도 매개변수 값은 Shape 클래스에서 정의된 Red 값이 사용된다.

기본 매개변수 값이 정적 바인딩을 따르기 때문이다.

 

이는 헷갈릴 수 있으므로 

그냥 기본 매개변수 값을 재정의 하지 말자.

 

꼭 하고 싶다면?

NVI패턴을 통해 Color를 따로 받아서 사용하면 될 것이다.


Item38: "has-a" 또는 "is-implemented-in-terms-of"를 구성(composition)으로 모델링하라

Composition은 한 타입의 객체가 다른 타입의 객체를 포함할 때 발생하는 관계이다.

class Address { ... };            // 누군가가 사는 장소
class PhoneNumber { ... };

class Person {
public:
    ...
private:
    std::string name;             // 구성된 객체
    Address address;              // 위와 동일
    PhoneNumber voiceNumber;      // 위와 동일
    PhoneNumber faxNumber;        // 위와 동일
};

composition은 has-a 또는 is implemented in terms of를 의미한다.

 

application domain(응용 도메인): 객체들이 현실 세계의 사물을 모델링 하는 경우

implementation domain(구현 도메인): 버퍼, 뮤텍스 등 단순 구현의 산물인 경우 

 

구성이 응용 도메인 내에서 객체들 간에 발생하면, has-a 관계,

구성이 구현 도메인 내에서 발생하면 is-implementation-in-terms-of에 속한다. 

 

Person은 name, address, voidNumber, faxNumber를 가지기 때문에 has a관계이다. 

is-a와 has-a는 구별하기 쉽지만 is implemented in terms of는 까다로울 수 있다.

 

예를 들어 set과 list 는 is a관계라고 하기에는 적합하지 않지만, set객체는 list객체 기반으로 구현되기 때문에 is-implementation-in-terms-of 관계라고 할 수 있다.


Item39: private 상속을 신중하게 사용하라.

private 상속의 경우 is-implemented-in-terms-of 을 의미한다.

D가 B를 private 상속한다면, D는 B의 일부 기능을 이용하고 싶어서 상속한 것이지, B와 D사이에 개념적 관계가 있는 것은 아니다.

 

private 상속은 구현 기술과 같다.

private 상속은 구현부만 상속되어야 하고, 인터페이스는 무시되어야 한다.

 

private상속과, composition이 같은 의미라고 생각될 수 있기 때문에 가능하면 composition을 사용하고, 반드시 필요한 경우에만 private 상속을 사용하자.

 

그 경우는 protected멤버나 가상함수가 관련된 경우이다.

class Timer {
public:
    explicit Timer(int tickFrequency);
    virtual void onTick() const; // 주기적으로 호출됨
    ...
};
class Widget: private Timer {
private:
    virtual void onTick() const;   // Widget 사용 데이터 보기 등
};
class Widget {
private:
    class WidgetTimer: public Timer {
    public:
        virtual void onTick() const;
    };
    WidgetTimer timer;
};

 

위는 private 상속과, composition + public 상속의 차이를 보여준다.

 

class Empty {         // 크기가 0인 클래스
};

class HoldsAnInt {
private:
    int x;
    Empty e;
};
class HoldsAnInt: private Empty {
private:
    int x;
};

composition은 정렬을 위해 Empty에 char하나를 삽입해 sizeof(Empty)가 1이 되지만, 상속을 받는 경우 0이된다.

 

이를 empty base optimization(EBO)라고 한다.

 

기억할 것(Things to Remember)

  •  Private 상속은 is-implemented-in-terms-of를 의미한다. 일반적으로 composition에 비해 열등하지만(inferior), 파생 클래스가 기본 클래스의 protected 멤버나 virtual 함수 재정의 접근이 필요한 경우라면 의미가 있다
  •  Composition과 달리, private 상속은 Empty Base Optimization(EBO, 빈 베이스 최적화)을 지원한다. 이는 객체 크기 최소화가 중요한 라이브러리 개발자에게 유용할 수 있다.

Item40: 다중 상속을 신중하게 사용하라.

class BorrowableItem {
  public:
    void checkOut(); // 라이브러리에서 항목을 대출
  …
};

class ElectronicGadget {
  private:
    bool checkOut() const; // 자체 테스트 수행, 성공 시 true 반환
  …
};

class MP3Player: 
  public BorrowableItem,  // 일부 라이브러리는 MP3 플레이어를 대출
  public ElectronicGadget 
{ … };

MP3Player mp;
mp.checkOut(); // 모호함! 어떤 checkOut()인가?
mp.BorrowableItem::checkOut();

이렇게 모호한 경우, 명시적으로 어느 곳에서 호출할 것인지 알려줘야 한다.

 

class File { … };
class InputFile: public File { … };
class OutputFile: public File { … };
class IOFile: public InputFile,
              public OutputFile 
{ … };

이 경우 다이아몬드 상속문제를 가지게 된다.

 

IOFile은 File을 간접적으로 두 번 상속받게 된다.

따라서 File내의 멤버가 있다면 멤버가 

 

class File { … };
class InputFile: virtual public File { … };
class OutputFile: virtual public File { … };
class IOFile: public InputFile,
              public OutputFile 
{ … };

virtual을 붙이면 문제가 해결된다.

 

다만 virtual 상속을 받는 클래스는 일반적으로 크기가 크며, 느리다.

따라서 기본적으로는 virtual 클래스 상속을 지양하고, 반드시 사용해야 한다면, 기반 클래스에 멤버를 사용해서는 안된다.

이 개념은 interface와 비슷하다. 

 

다중 상속이 유용한 경우

인터페이스와 구현 클래스를 각각 상속 받는 경우 다중 상속이 유용하게 사용될 수 있다.

 

 

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

[Effective C++] 1장 C++의 규칙 따르자  (0) 2026.02.04
7장 템플릿과 제네릭 프로그래밍  (0) 2025.04.02
5장 구현  (0) 2025.03.25
4장 설계와 선언  (0) 2025.03.21
3장 리소스 관리  (0) 2025.03.21