본문 바로가기

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

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

(public)상속 이라는 개념은 사실 2가지로 나뉜다.

 

인터페이스 상속 vs (함수)구현 상속

 

둘의 차이는 함수 선언과 함수 정의의 차이와 맥을 같이한다고 보면 된다.

 

클래스 설계자의 입장에서

  • 맴버 함수의 인터페이스(선언)만을 파생클래스에 상속받고 싶은 경우
  • 함수의 인터페이스 구현을 모두 상속받고 싶은 경우
    • 상속받은 구현이 오버라이드가 가능하게 만들고 싶은 경우
    • 어떤 것도 오버라이드할 없도록 막고 싶은 경우

 

이러저러한 선택사항이 나올 있는데, 이러한 선택사항들 사이의 차이점을 명확하게 몸으로 느끼는 것이 중요하다.

그래서 예를 들어보면, 그래픽 응용프로그램에 쓰이는 기하학적 도형을 나타내는 클래스 계통구조를 놓고 한번 생각해보자.

 

 

Shape 추상 클래스이다. 왜냐하면 맴버 함수인 draw() 순수 가상 함수이기 때문이다. 추상 클래스이기 때문에 Shape 인스턴스를 만들 없고, 클래스의 파생 클래스에서 인스턴스화해야 한다. 이렇게 파생 클래스에 종속적일 같은 Shape는 사실 역으로 파생 클래스에 막대한 영향을 준다. ?

 

  • 맴버 함수 인터페이스는 항상 상속되게 되어 있기 때문이다. 항목 32에서 설명했듯이, public = is-a  이므로 기본 클래스에 있는 모든 것들이 파생클래스에도 해당되어야 한다. Shape에서 동작하는 모든 함수는 파생 클래스에서도 동작해야 한다.

 

Shape에는 개의 함수가 선언되어 있고, 각각 선언된 형태가 다르다. 이게 무슨 의미를 지니는 걸까?

 

일단 순수 가상함수인 draw()부터 보자.

 

순수 가상 함수의 가장 특징은 2가지이다.

  1. 순수 가상 함수를 물려밭은 concrete 클래스가 해당 순수 가상 함수를 다시 선언해야 한다.
  2. 순수 가상 함수는 전형적으로 추상 클래스 안에서 정의를 갖지 않는다.

이걸 종합하면

 

  • 순수 가상 함수를 선언하는 목적은 파생 클래스에게 함수의 인터페이스(선언)만을 물려주려는

 

이건 Shape::draw() 목적에도 맞는 이야기인게, "Shape 계통을 따르는 모든 객체는 그리기가 가능해야한다", 하지만 객체마다 그리는 방식이 다를 것이기 때문에 따로 기본 구현은 제공하지 않는다. 인터페이스만 물려준다.

 

=> "draw 함수는 사용자가 직접 제공하도록 하고, 어떻게 구현할지에 대해선 알아서 해라"

라고 말하는 것과 똑같다.

 

참고로, 순수 가상 함수에도 정의(구현) 제공할 있긴 하다. , 구현이 붙은 순수 가상 함수를 호출할려면 반드 클래스 이름을 한정자로 붙여 주어야 한다.(추상 클래스는 인스턴스화가 안되니 당연한 소리이다.)


다음은 단순(비순수) 가상 함수이다. 순수 가상 함수와 다른 점은 파생 클래스로 하여금 함수의 인터페이스를 상속하게 한다는 점은 똑같지만, 파생 클래스쪽에서 오버라이드(재정의) 있는 함수 구현부도 같이 제공한다는 점이다.

 

순수 가상함수 : 인터페이스 제공

단순 가상함수 : 인터페이스 제공, 기본 구현 제공

 

  • 단순 가상함수를 선언하는 목적은 파생 클래스로 하여금 함수의 인터페이스 + 함수의 기본 구현도 물려받게 하는 것이다,

 

 

Shape::error() 보면, 실행 중에 에러와 마주쳤을 자동으로 호출될 함수를 제공하는 것은 모든 클래스가 해야할 일이지만, 그렇다고 클래스마다 그때그때 맞는 방법으로 에러를 처리해야할 필요는 없다는 것이다. 에러가 생겨도 특별한 일을 안해줘도 되는 클래스이면, 그냥 기본으로 제공하는 error() 써도 된다는 것이다.

 

=> "error 함수는 사용자가 직접 제공하도록 하고, 굳이 새로 만들 생각이 없으면 Shape::error() 써라"

 


 

알고보면, 단순 가상함수에서 함수 인터페이스와 기본 구현을 한꺼번에 지정하도록 내버려 두는 것이 위험할 때도 있다. 예로 설명하면, XYZ라는 항공사에서 비행기 A 모델과 B 모델을 운용하는데, 모델은 비행 방식이 똑같다. 그래서 다음과 같은 클래스 계통으로 설계했ㄷ

 

 

 

Airplane::fly() 단순 가상함수로 설계되어 있다. 여기서 모든 비행기는 fly() 지원해야 한다는 점도 드러난다. 모델이 다른 비행기는 원칙상 fly() 대한 구현을 다르게 필요가 있으니 가상함수로 설계한 까진 이해가 된다. 하지만 ModelA ModelB 같은 방식으로 비행을 한다. -> 코드 중복을 피하기 위해 기본적인 비행 원리를 Airplane::fly() 본문(구현)으로 제공함으로써 이것을 ModelA ModelB 물려받을 있게 히였다.

 

지금 상황만 보면 정말 괜찮은 객체 지향 설계이다. 클래스가 하나의 공통 특징(fly 구현 방법) 공유하고 있으므로, 공통 특징을 기본 클래스로 올려 보낸 클래스가 특징을 물려 받는 형태로 설계된 것이다.

=> 코드 중복을 피하고, 기능 개선의 통로도 열려 있고, 장기적인 유지보수도 간단해진다.

 

이윽고 XYZ사가 C 모델을 도입한다. C 모델은 기존의 비행방식과 완진히 다른 비행 방식을 가지고 있다. XYZ사는 C 모델을 서둘러 서비스에 투입할려고 하다, 그만 fly() 재정의하는 것을 잊어버리고 말았다.

 

 

 

이게 문제가 되냐면, ModelC Airplane::fly() 원하지 않았는데도(명시적으로 밝히지 않았는데도) 동작을 물려받는데 아무런 걸림돌(에러) 없었다는 점이다. 프로그래머의 실수를 막을 일말의 기회조차 없었다.

 

=> 파생 클래스에서 요구하지 않으면 이러한 동작을 주지 않는 방법

 

: 가상 함수의 인터페이스와 가상 함수의 기본 구현을 잇는 연결 관계를 끊어 버리는 것이다.

 

 

Airplane::fly() 순수 가상 함수로 만듬으로써 fly() 기본 구현을 없애고, 기본 구현 부분을 비가상 함수인 defaultFly() 제공하는 형태로 바뀌었다.

 

이렇게 하면, 기본 구현을 사용하고 싶은 클래스는 fly() 내부에서 defaultFly() 호출하기만 하면 된다.

 

 

이제 ModelC 자신과 맞지 않는 기본 구현을 물려받을 걱정은 없어졌다. Airplane::fly() 순수 가상함수로 선언되어있어서, ModelC 자신만의 버전을 스스로 제공하지 않으면 되는 상황이기 때문이다.

 

그리고 defaultFly() protected 선언되었냐에 대한 건데, protected 맴버 = Airplane 클래스 계통 내에서만 사용하는 구현 세부사항이기 때문에, 비행기를 실제로 사용하는 사용자는 '비행기가 있다' 알면 되지, '비행 동작이 어떻게 구현되는가' 신경을 써도 된다는 것이다.

 

다른 중요사항은 Airplane::defaultFly() 비가상 함수라 것이다.

 

=> 이유는 파생 클래스 쪽에서 함수를 재정의해선 안되기 때문이다. 만약 함수가 가상 함수로 선언되있으면, 어떤 파생 클래스에서 defaultFly() 재정의하는 것을 잊어버렸을 곤란해지는 그런 꼬리에 꼬리를 무는 문제가 일어날 있다.


어떤 사람들 중에는 fly() defaultFly() 별도의 함수로 만들어 놓는 아이디어를 별로 안좋아 하는 사람도 있다. 굳이 이렇게 서로 얽히게 함수를 짜냐는 건데, 이러면 네임스페이스도 더러워지고 어쩌고..

 

근데 핵심은 인터페이스와 기본 구현은 분리되어야 한다는 점이다. fly() 하나로 점을 어떻게 지킬까?

아까 보았던, 순수 가상 함수의 구현을 외부에서 정의하는 기능을 이용하는 것이다.

 

 

그냥 defaultFly() 자리에 fly() 본문이 들어 것이다. 다만 fly() 순수 가상 함수라 모든 파생 클래스에서 재정의를 해줘야 하기 때문에 단순 가상 함수때와 달리 프로그래머의 실수를 막을 있다.


아까의 Shape 돌아가서, 비가상함수인 objectID() 대해 마자막으로 설명하자면

 

맴버 함수가 비가상 함수로 되어 있다는 것은, 함수는 파생 클래스에서 다른 행동이 일어날 것으로 가정하지 않았다는 것이다. , 클래스 파생에 관련 없이 일관된 동작을 지정하는데 쓰인다.

 

  • 비가상 함수를 선언하는 목적은 파생 클래스가 함수 인터페이스 + 함수의 필수 구현(mandatory implementation) 물려받게 하는

 

 

필수 구현 vs 기본 구현

 

기본 구현은 재정의(override) 있고, 필수 구현은 재정의(override) 없다!

 

그러니까 Shape::objectID() 선언은 다음과 같은 의미를 지닌다.

"draw 함수는 사용자가 직접 제공하도록 하고, 어떻게 구현할지에 대해선 알아서 해라"

라고 말하는 것과 똑같다.

 

=>"ojbectID 함수는 Shape 이것에서 파생되는 모든 객체에서 항상 똑같이 작동하고, 실제 계산 방법은 Shape::ojbectID() 정의에서 결정되고, 파생 클래스는 이걸 바꿀 없다"

 

계속 같은 반복임


순수 가상 함수, 단순 가상 함수, 비가상 함수의 선언문이 이러저러한 차이점을 가지기 때문에, 우리는 파생 클래스가 물려받았으면 하는 것을 정교하게 컨트롤 있다. 각각의 선언문 형식만큼 뜻하는 바도 제각각이기 때문에 이들 하나를 골라 목적에 맞게 선택해야 한다. 다음은 클래스 설계에 있어 사용자가 많이 하는 실수 2가지 이다.

 

 

  1. 모든 맴버 함수를 비가상 함수로 선언하는

 

이렇게 하면 파생 클래스를 만들더라도 기본 클래스의 동작을 특별하게 만들 만한 여지가 없어지게 된다. 특히 기본 클래스에 비가상 소멸자가 있으면 문제가 있다.(항목 7 참고) 만약 클래스 파생을 처음부터 염두에 두지 않은 클래스를 설계하는 경우라면, 비가상 함수들만 모아두는게 맞긴 하지만, 가상 함수와 비가상 함수의 차이를 알고 써야 한다. 그리고 애초에 기본 클래스로 쓰이는 클래스는 십중팔구 가상 함수를 갖고 있다.

 

가상 함수는 비용이 많이 든다! 라고 생각하는 사람에게는 80-20 법칙을 말하고 싶다. 실제 프로그램 성능의 80% 결정하는 것은 전체 코드의 20%라는 건데, 가상 함수에 들어가는 자그마한 비용(가상 함수 테이블)보다 20% 중요한 코드에 집중을 해보자는 것이다.

 

 

  1. 모든 맴버 함수를 가상 함수로 선언하는

 

물론 맞는 경우도 있다. 항목 31 인터페이스 클래스가 예시이다. 하지만 어떻게 보면 클래스를 설계한 사람은 수동적인 사람일 있다. 만약 클래스 파생에 상관없는 불변동작(필수 구현) 갖고 있어야 한다면 당당하게 비가상 함수로 박아버리는 것도 좋은 선택이 있다. 분명히 파생 클래스에서 재정의가 되면 안되는 함수가 있을 것이다.

 


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