[Effective C++] Chapter 6. 상속, 그리고 객체 지향 설계
Table of Contents
이펙티브 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) 으로 묶이기 때문이다.
pB
는Base
에 대한 포인터 형으로 선언되었기 때문에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 관계이다.
- (1) has-a
- 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
템플릿을 만들고 싶다면?
- 표준 C++ 라이브러리에
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이 된다.
- C++은 크기가 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();
}
};