[C++] 항목 35: 가상 함수 대신 쓸 것들도 생각해 두는 자세를 시시때때로 길러 두자
카테고리: Cpp
태그: Cpp
이 글은 아래의 책을 자세히 정리한 후, 정리한 글을 GPT에게 요약을 요청하여 작성되었습니다.
이펙티브 C++ 제3판, 스콧 마이어스 저자, 곽용재 번역
📦 6. 상속, 그리고 객체 지향 설계
👉🏻 항목 35: 가상 함수 대신 쓸 것들도 생각해 두는 자세를 시시때때로 길러 두자
✅ 기본적인 가상 함수 접근법
게임 캐릭터의 체력을 계산하는 함수를 다음과 같이 구현할 수 있다:
class GameCharacter {
public:
// 캐릭터의 체력을 반환하는 단순 가상 함수
virtual int healthValue() const;
...
};
// 체력을 계산하는 기본 알고리즘 제공
int GameCharacter::healthValue() const { ... }
하지만 가상 함수가 항상 최선의 선택은 아니다. 상황에 따라 더 나은 대안들이 있다.
✅ 방법 1: 비가상 인터페이스(NVI) 관용구를 통한 템플릿 메서드 패턴
class GameCharacter {
public:
// 일반 함수이므로 재정의 불가
int healthValue() const {
... // 사전 동작 수행
int retVal = doHealthValue(); // 실제 동작 수행
... // 사후 동작 수행
return retVal;
}
...
private:
// 가상 함수이므로, 파생 클래스는 재정의 가능
// 체력을 계산하는 기본 알고리즘 제공
virtual int doHealthValue() const { ... }
};
핵심 아이디어:
healthValue()는 이제 일반 함수이다.- private 가상 함수인
doHealthValue()가 간접적으로 호출된다. - 이를 비가상 함수 인터페이스(NVI) 관용구라고 부르며, 템플릿 메서드 디자인 패턴의 한 형태이다.
장점:
healthValue()는 가상 함수doHealthValue()의 랩퍼(wrapper) 역할을 한다.- 사전/사후 동작을 통해
doHealthValue()가 안전하게 실행된다.- 예: 뮤텍스 잠금, 로그 정보 기록, 클래스의 불변속성 검증, 함수의 사전/사후 조건 확인 등
설계 특징:
doHealthValue()는 파생 클래스에서 재정의는 가능하지만, 직접 호출은 불가능하다.- 함수를 언제 호출하는지는 기본 클래스의 권한이다.
- 반드시 private일 필요는 없다:
- protected 멤버여야 할 경우도 있고 (항목 27의 Window::onResize())
- public 멤버여야 할 경우도 있다 (항목 7의 다형성 기본 클래스의 소멸자)
✅ 방법 2: 함수 포인터로 구현한 전략 패턴
class GameCharacter;
// 체력을 계산하는 기본 알고리즘
int defaultHealthCalc(const GameCharacter& gc);
class GameCharacter {
public:
// 함수 포인터
// HealthCalcFunc == int (*)(const GameCharacter&);
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&);
int main() {
// 같은 타입이지만, 체력 계산이 다르게 됨
EvilBadGuy ebg1(loseHealthQuickly);
EvilBadGuy ebg2(loseHealthSlowly);
}
장점:
- 전략 패턴을 사용하여 체력 계산 로직을 분리했다.
- 각 객체마다 다른 체력 계산 함수를 가질 수 있다.
- 게임 실행 도중, 체력 계산 함수를 동적으로 변경할 수 있다.
단점:
- 체력 계산 함수 내에서
GameCharacter계통 객체의 public 멤버가 아닌 데이터에 접근할 수 없다. - 접근 가능하게 하려면 클래스의 캡슐화를 약화시켜야 한다.
✅ 방법 3: function으로 구현한 전략 패턴
함수 포인터 대신 함수 객체를 사용하는 방법이다.
class GameCharacter;
int defaultHealthCalc(const GameCharacter& gc);
class GameCharacter {
public:
// 함수호출성 개체
// GameCharacter와 호환되는 모든 것을 받을 수 있고,
// int와 호환되는 모든 타입의 객체를 반환한다
typedef function<int (const GameCharacter&)> HealthCalcFunc;
explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc)
: healthFunc(hcf) {}
int healthValue() const { return healthFunc(*this); }
...
private:
HealthCalcFunc healthFunc;
};
핵심:
function<int (const GameCharacter&)>는int (*)(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 { ... };
int main() {
// 일반 함수 포인터 사용
EvilBadGuy ebg1(calcHealth);
// 함수 객체 사용
EyeCandyCharacter ecc1(HealthCalculator());
GameLevel currentLevel;
...
// 멤버 함수 포인터 사용 (bind로 감싸야 함)
EvilBadGuy ebg2(
bind(&GameLevel::health, currentLevel, _1)
);
}
function이 받을 수 있는 것들:
- 일반 함수 포인터:
int (*)(const GameCharacter&) - 람다:
[](const GameCharacter&) { ... } - 함수 객체:
struct Foo { int operator()(const GameCharacter&) const; }; - 멤버 함수 포인터:
std::bind로 감싸서 사용
bind 사용법:
EvilBadGuy ebg2(bind(&GameLevel::health, currentLevel, _1));
&GameLevel::health→ 멤버 함수 포인터currentLevel→ 어떤 GameLevel 객체를 쓸지 지정_1→ 플레이스홀더로서, EvilBadGuy의 체력 계산 함수 호출 시 전달될 GameCharacter 자리
✅ 방법 4: “고전적인” 전략 패턴
class GameCharacter;
class HealthCalcFunc {
public:
...
virtual int calc(const GameCharacter& gc) const { ... }
...
};
HealthCalcFunc defaultHealthCalc;
class GameCharacter {
public:
explicit GameCharacter(HealthCalcFunc *phcf = &defaultHealthCalc)
: pHealthCalc(phcf)
{}
int healthValue() const { return pHealthCalc->calc(*this); }
...
private:
HealthCalcFunc* pHealthCalc;
};
특징:
HealthCalcFunc클래스 계통을 확장에 열려있도록 만들어두었다.- 전략 패턴의 전통적인 구현 형태이다.
클래스 구조:
GameCharacter 계통 <- (Has-a) HealthCalcFunc 계통
├── EvilBadGuy ├── SlowHealthLoser
└── EyeCandyCharacter └── FastHealthLoser
- GameCharacter는 HealthCalcFunc 객체를 포함(Composition) 한다.
- 한쪽 클래스 계통에 속해 있는 가상 함수를 다른 쪽 계통에 있는 가상 함수로 대체한 형태이다.
✅ 네 가지 방법 요약
- 비가상 인터페이스(NVI) 관용구 사용
- private 영역에 있는 가상 함수를 일반 public 멤버 함수로 감싸서 호출한다.
- 템플릿 메서드 패턴의 한 형태이다.
- 가상 함수를 함수 포인터 데이터 멤버로 대체
- 전략 패턴의 핵심이다.
- 가상 함수를 function 데이터 멤버로 대체
- 호환되는 시그니처를 가진 함수호출성 개체를 사용할 수 있도록 한다.
- 전략 패턴의 한 형태이다.
- 한쪽 클래스 계통에 속해 있는 가상 함수를 다른 쪽 계통에 있는 가상 함수로 대체
- 전략 패턴의 전통적인 구현 형태이다.
🧐 정리
- 가상 함수 대신에 쓸 수 있는 다른 방법으로 NVI 관용구 및 전략 패턴이 있다.
이 중 NVI 관용구는 템플릿 메서드 패턴의 한 예이다. - 객체에 필요한 기능을 멤버 함수에서 클래스 외부의 비멤버 함수로 옮기면,
그 클래스의 public 멤버가 아닌 것들을 접근할 수 없어진다. - function 객체는 일반화된 함수 포인터처럼 동작한다.
이 객체는 주어진 대상 시그니처와 호환되는 모든 함수호출성 개체를 지원한다.
댓글남기기