본문 바로가기

Effective C++/6. 상속, 그리고 객체 지향 설계

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

우리는 지금 비디오 게임 개발팀에서 일을 하고 있다. 맡은 일은 게임에 등장하는 각종 캐릭터를 클래스로 설계하는 직업이다. 근데 게임이 치고받고 싸우는 게임이라, 체력바가 남아나질 않는다. 그래서 healthValue() 라는 맴버 함수를 제공하기로 한다. 체력이 어떻게 계산되는지는 캐릭마다 다르기 때문에, 함수를 가상 함수로 선언하기로 한다.

 

 

healthValue() 순수가상함수는 아니기 때문에, 체력치를 계산하는 기본 알고리즘(기본 구현) 제공된다는 사실을 있다. (항목 34 참고)

 

너무나 당연한 설계라 오히려 맥이 빠진다. 이것 말고 다른 설계도 있지 않을까?


  • 비가상 인터페이스 관용구를 통한 템플릿 메서드 패턴

 

이야기는 "가상 함수는 반드시 private 맴버로 두어야 한다" 주장하는 소위 "가상 함수 은폐론" 시작하고자 한다. 이론에 따르면, healthValue public 비가상 함수 선언하고, 내부적으로는 실제 동작을 맡은 private 가상 함수 호출하는 식으로 만들어야 한다.

 

 

지금 함수 본문을 클래스 내부에 넣어서 암시적으로 인라인 요청을 하고 있는데, 이는 아무 상관이 없다. 인라인 함수를 말하고자 하는게 아니다.

 

지금 보고 있는 코드가 기본 설계이다. 사용자는 public 비가상 함수만을 사용할 있고, 함수 내부에서 private 가상 함수를 호출한다.

 

=> 비가상 함수 인터페이스 관용구(non-virtual interface : NVI)

=> 템플릿 메서드라 불리는 C언어의 고전적 디자인 패턴을 C++ 스타일로 구현한 것이다. 필자는 관용구에 쓰이는 비가상 함수(healthValue) 가상 함수의 랩퍼(wrapper)라고 부른다.

 

 

관용구의 이점은, 코드에 주석문으로 써둔 "사전 동작" "사후 동작" 전부 들어있다. 둘이 핵심이다.

가상 함수(실제 함수) 호출되기 전에 어떤 상태를 구성하고, 가상 함수가 호출된 후에 상태를 없애는 작업이 랩퍼를 통해 가능해진다는 것이다.

 

이와 관련된 예는 상당히 많다.

  • 뮤텍스 잠금을 걸고 푼다.
  • 로그 정보를 갱신한다.
  • 클래스의 불변속성(invariant) 함수의 사전조건(사후조건) 만족되었나를 검증,

 

많은 행동들을 사전, 사후 동작에 수행 가능하다.

 

그러고 보니, NVI 관욕구를 사용하면 private 가상 함수를 파생 클래스에서 재정의하게 된다. 근데 함수는 재정의 해놓고 호출할 수도 없는데 모순 아니냐? 라고 생각할 수도 있는데,

  • 가상 함수를 재정의하는 일은 어떻게 구현할 것인가를 정하는
  • 가상 함수를 호출하는 일은 동작이 수행될 시점을 정하는

파생 클래스의 가상 함수 재정의는 허용하되, 어짜피 함수를 언제 호출할지 결정하는 것은 기본 클래스의 wrapper 이다. wrapper 기본 클래스의 비가상 맴버 함수이다.

 

따져보면, NVI 관용구에서 굳이 가상함수가 무조건 private 필요는 없다. 어떤 클래스 계통의 경우엔, 파생 클래스에서 재정의되는 가상 함수가 기본 클래스의 대응 함수(, 같은 이름의 가상 함수)를 호출할 것을 예상하고 설계된 것도 있는데, 이런 경우 적법한 함수 호출이 되려면 가상 함수가 private 아니라 protected 맴버이어야 한다.

 

 

예를 들어 항목 27 코드를 보면, SpecialWindow onResize() 보면, 이를 NVI 관용구로 고치면 Windows::onResize() 실제 함수가 되는데, Windows::onResize() private 되는 순간 파생 클래스에서 함수를 호출할 수가 없다. 그래서 Windows::onResize() 최소 protected 되어야 한다.

 

심지어 가상 함수를 public으로 해야 때도 있다.(다형성 기본 클래스의 소멸자가 예이다. 항목 7 참고) 여기까지 오면 사실상 NVI 관용구는 의미가 없어지긴 한다.


  • 함수 포인터로 구현한 전략 패턴

 

NVI 관용구는 public 가상 함수 => public 비가상 함수로 대신할 있는 참신한 방법이지만, 클래스 설계의 관점에서 보면 눈속임이나 다름이 없다. 어쨌든 가상함수를 사용하기 때문이다. 조금 극단적으로 가상함수를 사용하지 않는 설계로 가보면, 캐릭터의 체력치를 계산하는 작업을 캐릭터의 타입과 별개의 타입으로 만들어 놓는 편이 맞을 것이다. 다시 말해, 체력치 계산을 구태어 어떤 캐릭터의 일부로 만들 필요가 없다는 것이다.

예시로, 캐릭터의 생성자에 체력치 계산용 함수의 포인터를 넘기게 만들고, 함수를 호출해서 실제 계산을 수행하게 하면 되지 않을까?

 

 

방법은 많이 쓰이는 디자인 패턴인 전락(Strategy) 패턴 단순한 응용이다.

 

  • 같은 캐릭터 타입으로 만들어진 객체(인스턴스)들도 체력치 계산 함수를 각각 다르게 가질 있다. 이런게 가능하다.

 

 

 

  • 게임이 실행되는 도중에 특정 캐릭터에 대한 체력치 계산 함수를 바꿀 있다. 예를 들어 GameCharacter 클래스에서 setHealthCalculator()라는 맴버 함수를 제공하고 있다면, 이를 통해 현재 쓰이는 체력치 계산 함수의 교체가 가능해지는 것이다.

 

 

하지만, 패턴에도 단점이 존재한다. 체력치 계산 함수가 이제 GameCharacter 클래스 계통의 맴버 함수가 아니라는 점은, 체력치가 계산되는 대상 객체의 private 데이터는 함수로 접근할 수가 없다는 뜻도 된다.

예를 들어 defaultHealthCalc() EvilBadGuy public 맴버가 아닌 부분을 건들 없다. 만약 캐릭터의 public 인터페이스만 가지고 체력을 계산할 있다면 상관이 없지만, 아니라면 문제가 발생한다.

 

사실 이러한 문제는 클래스 내부 기능 -> 클래스 외부 기능으로 대체할려고 하면 항상 생기는 문제이다. 이를 해결하는 유일한 방법은 클래스의 캡슐화를 약화시키는 방법밖에 없다.

 

예를 들어, 비맴버 함수를 프렌드로 선언한다던지, 숨겨놓은 세부 구현사항에 대한 접근자 함수를 public으로 제공한다던지 이러한 행동들은 전부 클래스의 캡슐화를 약화시킨다.

 

함수 포인터로 얻는 이점(객체별로 체력치 계산 함수를 있다는 , 이런 함수들을 런타임 도중 바꿀 있다는 )  vs GameCharacter 클래스의 캡슐화를 떨어트려서얻는 불이익

 

둘을 적당히 저울질 해서 설계에 맞게 판단하는 것이 좋겠다.


  • t1::function으로 구현한 전략 패턴

 

템플릿, 암시적 인터페이스(항목 41 참고) 대해 어색하지 않은 독자라면 함수 포인터 기반 전략 패턴이 뭔가 막혀 보일 것이다.

  • "체력치 계산을 함수가 해야 ?, 그냥 함수처럼 동작하는 다른 (함수 객체) 쓰면 안돼?"
  • "반드시 함수이어야 한다면 맴버 함수이면 안돼?"
  • "반환타입이 int여야 ? int 바꿀 있는 임의의 타입이면 충분한데?"

이런 질문들이 쏟아져 나올 같다.

 

=> tr1::function 타입의 객체를 써서 기존의 함수 포인터를 대신하게 만드는 순간 모든 것들이 시원하게 해결된다. 항목 54 보면, tr1::function 계열의 객체는 함수호출성 개체(callable entity, 풀어서 말하면 함수 포인터, 함수 객체 혹은 맴버 함수 포인터) 가질 있고, 이들 개체는 주어진 시점에서 예상되는 시그니처 호환되는 시그니처를 갖고 있다고 한다… 말인지 모르겠다. 그냥 예제를 통해 알아보자.

 

 

함수 포인터 => tr1::function 으로 바뀐 말고 달라진게 딱히 없다. 부분을 주목해보자.

 

 

tr1::function 템플릿이다. 템플릿을 인스턴스화 하기 위해 매개변수로 쓰인 "대상 시그니처" int(const GameCharacter&) 이다. 대상 시그니처는 함수 타입이다. 매개변수로 const GameCharacter 참조자를 받고, int 반환하는 함수 타입이다. 함수 타입을 대상 시그니처로 지정한 function 타입의 객체는 쉽게 말하면 int(const GameCharacter&) 확장판이라고 수있다.

 

매개변수에는 const GameCharacter& 호환되는(암시적 타입변환이 가능한) 모든 객체가 있고, 리턴 타입도 int 호환되는 모든 타입들이 있다는 것이다. 

 

 

 

 

ebg2 정의문을 보자. ebg2 체력치를 계산하기 위해 GameLevel::health() 써야 하는데, 맴버함수를 인스턴스를 거치지 않고 호출하기 위해서는 매개변수를 2개를 넣어줘야 한다. 바로 this 이다. 근데 실질적으로 함수에 필요한 매개변수는 GameCharacter이기 때문에, this 해당하는 인스턴스는 currentLevel 고정시키고 GameLevel::health() GameCharacter 받는 함수로 바꾸는 역할을 bind 한다. GameLevel::health() 호출될 마다 currentLevel 사용되도록 "묶어" 것이다.

 

"_1" 의미하는 바는 "ebg2 대해 currentLevel 묶인 GameLevel::health() 호출할 넘기는 번째 자리의 매개변수" 뜻한다는 것이다. currentLevel 항상 GameLevel::health() 첫번째 매개변수로 고정되는 것이다.

 

어쨌든 우리는 함수 포인터 대신 tr1::function 사용함으로써, 사용자가 게임 캐릭터의 체력치를 계산할 , 시그니처가 호환되는 함수호출성 개체 어떤 것도 원하는 대로 구사할 있도록 융통성을 열여 주었다는 것이다.


  • "고전적인" 전략 패턴

 

C++만으로 파고드는 방법보다, 조금 디자인 패턴을 활용하여 구현한 전통적인 전략 패턴을 소개하겠다.

 

체력치 계산 함수를 나타내는 클래스 계통을 아예 따로 만들고, 실제 체력치 계산 함수는 클래스 계통의 가상 맴버 함수로 만드는 것이다.

 

이해가 안될태니 그림으로 보자.

 

 

클래스 계통을 UML 방식으로 표기했다고 하는데, 그냥 봐도 이해될 수준의 그림이다.

 

GameCharacter : 상속 계통 최상위 클래스

EvilBadGuy,  EyeCandyCharacter : GameCharacter 파생 클래스

HealthCalcFunc : SlowHealthLoser FastHealthLoser 최상위 클래스

 

GameCharacter 계통의 모든 클래스는 HealthCalcFunc 타입의 객체에 대한 포인터를 포함해야 한다. (검은색 마름모꼴 화살표)

 

그림에 대응되는 코드 골격을 꾸며 보면 다음과 같다.

 

 

방법은 "표준적인" 전략 패턴 구현 방법에 친숙한 경우 빠르게 이해할 있다는 점이 장점이다.

게다가 HealthCalcFunc 클래스 계통에 파생 클래스를 추가함으로써 기존의 체력치 계산 알고리즘을 조정/개조할 있는 가능성을 열어 놓았다는 점도 장점이다.

 


지금까지 공부한 것들에 대한 요약

 

핵심은 '어떤 문제를 해결하기 위한 설계를 찾을 , 가상 함수를 대신하는 방법들도 고려해 보자' 이다.

 

  • 비가상 인터페이스 관용구(NVI 관용구) : 공개되지 않은 가상 함수를 비가상 public 맴버 함수로 감싸서 호출하는, 템플릿 메서드 패턴의 형태이다.
  • 가상 함수를 함수 포인터 데이터 맴버 대체 : 군더더기 없는 전략 패턴의 핵심만을 보여주는 형태이다.
  • 가상 함수를 tr1::function 데이터 맴버 대체하여, 호횐되는 시그니처를 가진 함수호출성 개체를 사용할 수 있도록 만듬 : 역시 전략 패턴의 형태
  • 한쪽 클래스 계통에 속해 있는 가상 함수를 다른 계통에 속해 있는 가상 함수 대체 : 전략 패턴의 전통적인 구현 형태

기상 함수 대신 있는 방법이 4가지가 전부는 아니지만, 대안이 얼마든지 있다는 점이 핵심이다. 그리고 방법의 장단점을 고려해서, 나중에 가상 함수를 대체해야할 일이 생겼을 , 충분히 고려할 있어야 한다.


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