관리 메뉴

KoreanFoodie's Study

Effective C++ 정리 6-1 : 상속, 그리고 객체 지향 설계 본문

Tutorials/C++ : Advanced

Effective C++ 정리 6-1 : 상속, 그리고 객체 지향 설계

머니덕 2022. 9. 17. 11:29

C++ 프로그래머의 필독서이자 바이블인, 스콧 마이어스의 Effective C++ 를 읽고 기억할 내용을 요약하고 있습니다. 꼭 읽어보시길 추천드립니다!


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

핵심 :

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

 

어떤 Base 클래스로부터 Derived 클래스가 public 상속을 받았다면, Derived 클래스는 Base 클래스보다 더 특수한 개념을 나타내게 된다. 사용처가 제한된다고 봐도 좋다.

public 상속이 신기한 이유는, 우리가 일반적으로 생각하고 있는 논리적인 개념과 충돌할 수 있기 때문이다. 예를 들어, 수학적으로는 직사각형이 정사각형을 포함하는 경우이므로 정사각형 클래스를 직사각형 클래스로부터 public 상속을 받게 사용할 수 있을 거라고 가정할 수 있다. 다음 코드를 보자.

class Rectangle
{
public:
	virtual void setHeight(int newHeight);
	virtual void setWidth(int newWidth);
	virtual void height() const;
	virtual void width() const;
	...
};

// r 의 넓이를 늘리는 함수
void makeBigger(Rectangle& r)
{
	int oldHeight = r.height();
	r.setWidth(r.newWidth() + 10);
	assert(r.height() == oldHeight);
}

class Square : public Rectangle
{
	...
};

Square s;
// 높이와 너비가 달라서 단언문이 거짓!
makeBigger(s);

위의 코드처럼, Rectangle 에서 통하는 코드가 Squre 에서는 에러를 낼 수도 있다. 따라서 public 상속을 할 때는 객체 지향의 관점에서 설계를 제대로 할 필요가 있다!

 

 

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

핵심 :

1. 파생 클래스의 이름은 기본 클래스의 이름을 가린다. public 상속에서는 이런 이름 가림 현상은 바람직하지 않다.
2. 가려진 이름을 다시 볼 수 있게 하는 방법으로, using 선언 혹은 전달 함수를 쓸 수 있다.

 

다음 예시 코드를 보자.

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();
	void mf3();
	void mf4();
};

...

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 을 가림

해당 함수가 어떤 인자를 받는지, 가상 함수인지 아닌지와 상관없이, 파생 클래스에서 이름이 같은 함수는 기반 클래스의 함수를 가리게 된다. 이는 멀리 떨어져 있는 기본 클래스로부터 오버로드 버전을 상속시키는 경우를 막기 위한 의도이다. Derived 클래스에 다음 두 줄을 추가하면, 위의 코드의 에러를 해결할 수 있다.

class Derived: public Base
{
public:
	// Base 에 있는 것들 중 mf1 과 mf3 를 이름으로 가진
	// 것들을 Derived 의 유효범위에서 볼 수 있도록 만듦
	using Base::mf1;
	using Base::mf3;

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

...

Derived d;
int x;

d.mf1(x); // Base::mf1 호출
d.mf3(x); // Base::mf3 호출

 

그렇다면 이제 한 걸음 더 나아가보자. int 인자를 받는 mf1 함수와 인자를 받지 않는 mf1 함수가 Base 클래스에 정의되어 있고, Derived 클래스는 mf1 을 찾을 때 인자를 받지 않는 mf1 함수만 탐색하게 만들고 싶다고 하자. 이 경우, 전달 함수(forwarding function) 을 만들어 주면 이 문제를 간단히 해결할 수 있다.

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

class Derived: private Base
{
public:
	// 암시적으로 인라인 함수가 됨
	virtual void mf1()
	{
		Base::mf1();
	}
};

...

Derived d;
int x;

d.mf1(); // Derived::mf1 호출 (매개변수 없는 버전)
d.mf1(x); // 에러! Base::mf1() 은 가려져 있음

 

 

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

핵심 :

1. 인터페이스 상속은 구현 상속과 다르다. public 상속에서, 파생 클래스는 항상 기본 클래스의 인터페이스를 모두 물려받는다.
2. 순수 가상 함수는 인터페이스 상속만을 허용한다.
3. 단순(비순수) 가상 함수는 인터페이스 상속과 더불어 기본 구현의 상속도 가능하도록 지정한다.
4. 비가상 함수는 인터페이스 상속과 더불어 필수 구현의 상속도 가하도록 지정한다.

 

클래스 설계자의 입장에서, 멤버 함수의 인터페이스(선언)만을 파생 클래스에 상속받고 싶을 때가 있다. 어쩔 때는 구현을 오버라이드 가능하게 만들고 싶을 때도, 오버라이드 할 수 없도록 막고 싶을 때도 있다. 다음 Shape 클래스 예제 코드를 보며 여러 경우들을 살펴보자.

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 { ... };

먼저, 순수 가상 함수를 선언하는 목적은 파생 클래스에게 함수의 인터페이스만을 물려주려는 것이다. 왜냐하면 순수 가상 함수는 파생 클래스에서 새롭게 정의해야 하기 때문이다.

물론 순수 가상 함수에도 정의를 제공할 수 있다. 다만, 반드시 클래스 이름을 한정자로 붙여 주어야 한다.

#include <iostream>

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

void Shape::draw() const
{
	std::cout << "Draw Shape" << std::endl;
}

class Rectangle: public Shape 
{
public:
	void draw() const override
	{
		std::cout << "Draw Rectangle" << std::endl;
	} 
};
class Ellipse: public Shape 
{
	public:
	void draw() const override
	{
		
	} 
};



int main()
{
	Shape *ps1 = new Rectangle;
	ps1->Shape::draw(); // Draw Shape

	Rectangle *ps2 = new Rectangle;
	ps2->draw(); // Draw Rectangle
}

 

다음으로, error 함수를 보자. 단순 가상 함수를 선언하는 목적은 파생 클래스로 하여금 함수의 인터페이스뿐만 아니라 그 함수의 기본 구현도 물려받게 하자는 것이다. 즉, "error 함수는 당신이 직접 구현해야 하지만, 굳이 새로 만들 생각이 없다면 Shape 클래스에 있는 기본 버전을 써라" 라는 뜻이다.

만약 fly 함수를 지원하는 Airplane 클래스로부터, Model A, Model B, Model C 를 만든다고 해보자. 이때, A 와 B는 Airplane 의 기본 fly 동작을 그대로 따르지만, C 에서의 fly는 다른 방식으로 동작해야 한다고 가정한다. 그럼 어떤 방식으로 구현해야 할까?

class Airport {};

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 
{
	public:
	virtual void fly(const Airport& destination)
	{
		// Do something else...
	}
};

위와 같이 defaultFly 함수를 추가로 정의할 수도 있고, fly 를 순수 가상 함수로 만들 수도 있다.

class Airport {};

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

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

class ModelA: public Airplane 
{
public:
	virtual void fly(const Airport& destination)
	{
		Airplane::fly(destination);
	}
};
class ModelB: public Airplane 
{
	public:
	virtual void fly(const Airport& destination)
	{
		Airplane::fly(destination);
	}
};
class ModelC: public Airplane 
{
	public:
	virtual void fly(const Airport& destination)
	{
		// Do something else...
	}
};

위처럼 순수 가상 함수를 사용하면, 함수 양쪽에 각기 다른 보호 수준을 부여할 수 있는 융통은 날아가게 된다.

 

이제 비가상함수인 objectID 를 보자.

멤버 함수가 비가상 함수로 되어 있다는 것은, 클래스 파생에 상관없이 변하지 않는 동작을 지정하는 데 쓰인다. 즉, 비가상 함수를 선언하는 목적은 파생 클래스가 함수 인터페이스와 더불어 그 함수의 필수적인 구현(mandatory implementation)을 물려받게 하는 것이다.

위에서 설명한 점들을 주의하며, 클래스를 설계할 때는 '모든 함수를 가상함수로 만들거나', '모든 함수를 비가상 함수로 만드는' 실수를 하지 않도록 하자!

 

 

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

핵심 :

1. 가상 함수 대신에 쓸 수 있는 다른 방법으로 NVI 관용구 및 전략 패턴을 들 수 있다. 이 중 NVI 관용구는 그 자체가 템플릿 메서드 패턴의 한 예이다.
2. 객체에 필요한 기능을 멤버 함수로부터 클래스 외부의 비멤버 함수로 옮기면, 그 비멤버 함수는 그 클래스의 public 멤버가 아닌 것들을 접근할 수 없다는 단점이 생긴다.
3. std::function 객체는 일반화된 함수 포인터처럼 동작한다. 이 객체는 주어진 대상 시그니처와 호환되는 모든 함수 호출성 개체를 지원한다.

 

체력을 계산하는 함수를 다음과 같이 만든다고 해 보자.

class GameCharacter
{
public:
    // 파생 클래스는 이제 이 함수를 재정의할 수 없다
	int healthValue() const
	{
		// 사전 동작 수행
		int retVal = doHealthValue();
		// 사후 동작 수행
		return retVal;
	}
private:
    // 파생 클래스는 이 함수를 재정의할 수 있다
	virtual int doHealthValue() const
	{
		// 캐릭터 체력치 계산을 위한 기본 알고리즘 구현
	}
};

 

 

위와 같은 방식을 비가상 함수 인터페이스(non-virtual interface: NVI) 관용구라고 한다. 사실 이 관용구는 템플릿 메서드(Template Method) 라 불리는 고전 디자인 패턴을 C++ 식으로 구현한 것이다.

 

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 loseHealthSlowly(const GameCharacter&);

EvilBadGuy ebg1(loseHealthSlowly);
EvilBadGuy ebg2(loseHealthQuickly);

물론 이와 같은 방식은, 체력치 계산을 위해 public 함수보다 더 보호 수준이 높은 함수를 활용할 수 없다는 단점을 가지고 있다.

 

세 번째로, std::function 으로 구현한 전략 패턴을 살펴보자.

// typedef int (*HealthCalcFunc)(const GameCharacter&);
typedef std::function<int (const GameCharacter&)> HealthCalcFunc;

GameCharacter 의 함수 포인터 정의에서 위의 한 줄만 바꿔주면 된다! 위 함수 객체는 'const GameCharacter 에 대한 참조자를 받고 int 를 반환하는 함수' 가 된다. 이 객체는 앞으로 대상 시그니처와 '호환되는' 함수 호출성 개체를 가질 수 있으며, 이는 매개변수 타입이 'const GameCharacter&' 이거나 암시적으로 'const GameCharacter&' 로 변환이 가능한 타입이어도 된다는 뜻이다. 반환 타입도 암시적으로 int 로 변환되면 사용할 수 있다. 예시를 보자.

// 반환 타입이 short 인 함수도 사용할 수 있다.
short calcHealth(const GameCharacter&);

// 체력치 계산용 함수 객체를 만들기 위한 클래스
struct HealthCalculator
{
	int operator()(const GameCharacter&) const {}
};

class GameLevel
{
public:
	// 체력치 계산에 쓰일 멤버 함수
	// 반환 타입이 int 가 아닌 float 이다
	float health(const GameCharacter&) const;
};

class EvilBadGuy: public GameCharacter { ... };
class EyeCandyCharacter: public GameCharacter { ... };

EvilBadGuy ebg1(calcHealth);
EyeCandyCharacter ecc1(HealthCalculator());

GameLevel currentLevel;
EvilBadGuy ebg2(
	std::bind(
		&GameLevel::health,
		currentLevel,
		_1)
);

bind 문에 대해 설명을 덧붙이자면, ebg2 의 체력치를 계산하기 위해 GameLevel 클래스의 health 멤버 함수를 써야 하는데, 실제로는 GameLevel 객체 하나를 암시적으로 받으므로 매개 변수를 두 개 받는다. 이 객체는 this 포인터가 가리키는 것인데, 이 매개변수 두개(GameCharacter 및 GameLevel) 를 받는 함수를 매개변수 한 개(GameCharacter)만 받는 함수로 바꾸기 위해, GameLevel::health 함수가 호출될 때마다 currentLevel 이 사용되도록 묶어 주고 있는 것이다. "_1" 은 "ebg2 에 대해 currentLevel 과 묶인 GameLevel::health 함수를 호출할 때 넘기는 첫 번째 자리의 매개변수" 를 뜻한다!

 

마지막으로, "고전적인" 전략 패턴에 대해 알아보자. 체력치 계산 함수를 나타내는 클래스 계통을 아예 따로 만드는 방식이다.

// 전방 선언
class GameCharacter;

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

class GameCharacter
{
public:

	explicit GameCharacter(HealthCalcFunc *phcf = &defaultHealthCalc)
	: pHealthFunc(phcf)
	{}

	int healthValue() const
	{
		return pHealthFunc->calc(*this);
	}

private:
	HealthCalcFunc *pHealthFunc;
};

 

어쨌든, 가상 함수를 대신하는 설계 4가지를 잘 고려해 보자!

  1. 비가상 인터페이스 관용구(NVI 관용구)
  2. 가상 함수를 함수 포인터 데이터 멤버로 대체
  3. 가상 함수를 std::function 데이터 멤버로 대체
  4. 전통적인 전략 패턴 사용(다른 쪽 계통에 속해 있는 가상함수로 대체)

 

 
0 Comments
댓글쓰기 폼