C / C++/Effective C

Chapter 6. 상속, 객체지향 설계

부레두 2024. 10. 8. 01:59

32. public 상속 모형은 반드시 "is-a"를 따르도록 만들자

public 상속은 '기본 클래스 객체가 가진 모든 내용들이 파생 클래스 객체에도 그대로 적용된다는 사실을 명시'한 것.

 

예시로 "새" 라는 기본 클래스와 "비둘기"와 "펭귄" 이라는 파생 클래스를 생성했을때,

기본 클래스에 "날다 = Fly"라는 기능을 정의 했을 경우, 파생 클래스인 "비둘기"와 "펭귄" 모두 Fly 라는 기능을 상속받는다.

하지만, 실제로 펭귄은 날 수 없는 새의 한 종류로 날다라는 기능이 실행되면 안되는 파생 클래스다.

 

때문에 상속 관계의 클래스를 설계할 때는 가상함수를 정의해 파생 클래스들이 상속받는 함수들의 동작을 다르게 만들 수 있는 여지를 남겨두는 것이 설계에 영향을 끼칠 수 있다. 


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

스코프(유효 범위) 내에서 생성되는 겹치는 동일 이름으로 선언된 변수들의 판정에 대해 얘기한 항목.

 

단순하게 상속관계를 포함한 거의 모든 상황에서 '좁은 범위의 변수가 넓은 범위의 변수를 가리게 된다.' 

 

이를 해결하기 위한 방법으로 using 선언을 이용해 어떤 범위의 변수를 사용할지 직접할지 지정하는 방법.

ex) using Base::typeX;

 

상속된 함수의 경우도 함수의 이름이 동일 이름의 함수가 선언되었을 경우 동일한 문제가 발생한다.

마찬가지로 using을 이용한 선언을 이용하거나 '특별히 기본 클래스 함수를 전부 상속하고 싶지 않을 때' 해결 방법이 있다.

class 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:
	// using 키워드를 이용해 상속받은 몇개의 함수만 재정의 하고 싶은 경우를 지정
    using Base::mf1;
    using Base::mf3;
    
	virtual void mf1();
    void mf3();
    void mf4();
    ...
};

 

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


// 특별히 상속받고 싶지 않은 경우가 생길 경우 
// Private 상속... 과 임의 호출을 통한 전달로 특정 함수만 실행한다.
class Derived : private Base
{
public:
	virtual void mf1()
    { Base::mf1(); }
    ...
};

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

기본 클래스에 정의된 내용은 파생 클래스에 모두 내려간다는 것이 항목 32의 핵심이였다.

때문에 어떤 방식, 내용들을 파생 클래스에 전달할지가 중요한데 이번 항목에서 인터페이스 상속, 구현 상속이라는 분류로 설명한다.

 

두 상속의 차이는

순수 가상 함수를 이용해 어떤 함수가 존재하는지 '명세', 즉 설계만 상속하는 것이 '인터페이스 상속'

가상, 비가상 함수를 사용해 설계와 내용이 모두 존재하는 것이 '구현 상속'으로

각각의 클래스가 가지는 기능의 목적을 정밀하게 조절할 수 있다.

 

그럼 모두 가상함수로 만들고 파생 클래스에서 지정하는것이 완전한 설계의 방법이 아닌가?

하는 의문이 들었을 때 고민해보면 가상함수를 선언하는것도 '가상 함수 테이블'이 생성되며 발생하는 비용이 또한 문제고

반대로 모든 함수를 각자 만들자니 당연하게도 보수성 문제와 보기 싫은 코드가 탄생하니

구별하며 사용하는 것이 포인트다.


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

항목 34에서 전달한 비가상, 가상 함수를 구별하며 사용하자는 내용을 보강하는 내용으로 가상 함수 대신 사용할 방법으로

NVI(비가상 인터페이스 관용구) , 즉 private에 선언한 함수를 public에서 임의의 전달 함수를 통해 사용하는 

'템플릿 메서드 패턴'을 예시로 전달하고 있다.

class GameCharacter
{
public:
	// private 함수를 사용한 전달 함수 NVI 관용구
	int healthValue() const
    {
    ...
    int retVal = doHealthValue();
    ...
    return retVal;
    }
    
	...

private:
	virtual int doHealthValue() const;
    {
    	...
    }
}:

 

또 다른 예시로 '함수 포인터로 구현한 전략 패턴' 이 있다.

전략 패턴이라는 용어를 처음 듣기에 이해가 어려웠지만, 게임을 빗대어 설명 해줬기 때문에 이런 구현도 있다는 점을 볼 수 있었다.

class GameCharacter;

int defaultHealthCalc(const GameCharacter& gc);

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

// 같은 타입의 객체도 서로 다른 계산 함수를 적용 할 수 있다.
class EvilBadGuy : public GameCharacter
{
public:
	explicit EvilBadGuy(HealthCalcFunc hcf = defaultHealthCalc)
    : GameCharacter(hcf)
    { ... }
};

int loseHealthQuickly(loseHealthQuickly);
int loseHealthSlowly(loseHealthSlowly);

 

추가로 tr1::function으로 구현한 전략 패턴이 있지만 해당 구문을 처음 보기 때문에 이해가 어려워 다음을 기약하며 넘겼다. 


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

제목의 내용으로 정리가 가능한 항목으로

public 상속을 받은 비가상 함수를 재정의 하게 되면 상속이 되긴 하지만 객제 지향 설계에 구멍이 생기는 것과 마찬가지고

파생 클래스를 부모로 상속을 하게 되면 원본 부모의 함수 내용이 변경되게 되는 사고가 발생한다. 


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

가상 함수는 '동적 바인딩', 기본 매개변수는 '정적 바인딩'이기 때문에 발생하는 차이.

// 객체의 동적 타입
Shape* ps;						// 정적 타입 = Shape*	// 동적 타입은 없음
Shape* pc = new Circle;					// 정적 타입 = Shape*	// 동적 타입 Circle*
Shape* pr = new Rectangle;				// 정적 타입 = Shape*	// 동적 타입 Rectangle*

ps = pc;							// ps의 동적 타입은 이제 Circle*
ps = pr;							// ps의 동적 타입은 이제 Circle*

 

위 코드는 객체가 실행 중에 타입이 바뀔 수 있음을 보여준다.

 

pc->draw(Shape::Red);		// Circle::draw(Shape::Red)
pr->draw(Shape::Red);		// Rectangle::draw(Shape::Red)

// 아무거도 전하지 않고 호출 했을 때
pr->draw();					// Rectangle::draw(Shape::Red) 호출!

하지만 가상함수는 매개변수를 비워두고 호출하면

이미 정해져있는 매개변수의 값이나 원본 함수의 기능을 호출하기 때문에

이를 해결하고자 억지로 매개변수의 값까지 같게 한다면 함수가 가려지는 문제까지 이어질 수 있다.

 

해결법의 한 종류로 책에선 NVI를 이용한 코드를 보여준다.

class Shape
{
public:
	// 매개 변수를 Enum 선언
	enum ShapeColor { Red, Green, Blue };
    
    // NVI 사용
    void draw(ShapeColor color = Red) const
    {
    	doDraw(color);
    }
    ...
private:
	virtual void doDraw(ShapeColor color) const = 0;
};

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

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

'객체 합성'은 "has-a"와 "is-implemented-interms-of (를 써서 구현된)"을 모두 뜻한다.

 

사람은 이름을 가지고 있고 전화번호를 가지고 있는 등의 "has-a"관계,

명확한 포함관계는 아니지만 교집합이 존재하는 "is-implemented-in-terms-of" 관계,

 

예시로 list를 사용해 set 구조를 만드는 예시를 보여줬지만 명확한 이해가 어려워 나름의 정리를 하자면

public과 객체 합성의 조합은 '합성은 가능하지만 파생 클래스에서 기본 클래스의 멤버 함수를 재정의 할 수 없게 막으려 할때 사용한다."


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

private 상속을 실제로는 한번도 본 적이 없기 때문에 이해가 어려운 항목;


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

이론으로만 알고 있는 내용으로 다중 상속은 다이아몬드 구조를 만들 위험이 있다.

이외에도 기본 클래스가 중복 생성되며, 파생 클래스가 호출하는 함수에 모호성이 생긴다.

 

이런 문제는 virtual 키워드를 추가한 상속으로 해결 할 수 있으나 일반적으로 크기가 더 크고, 접근 속도도 떨어지고, 초기화 규칙도 복잡해지기 때문에 다중 상속을 사용할 경우에는 세심한 주의가 필요하다.