1. virtual 소멸자 - [메모리 누수 문제도 소멸자에 virtual, Base 클래스들은 반드시 소멸자를 virtual로 만들기]
2. (이전 내용에 대한 구현)레퍼런스를 통해 구현해도 문제 X
3. 가상함수의 구현 원리 - [가상 함수 테이블(vtable, 컴파일러 논리 구조]
4. 순수 가상 함수(pure virtual function)와 추상 클래스(abstract class) - [순수 가상 함수와 그 오해, 추상 클래스를 사용하는 이유]
5. 다중 상속(multiple inheritance) - [다중 상속 호출 순서]
6. 다중 상속 시 주의할 점 - [두 클래스에서 이름이 같은 멤버와 접근, 다이아몬드 상속 X, 가상 상속]
7. 다중 상속은 언제 사용해야 할까? - [다중 상속 뿐만 아닌 구현 방식(1) 브리지 패턴, 2) 중첩된 일반화, 3) 다중 상속)]
+) [관점] virtual과 override 키워드에 대한 코드 작성 스타일 - [자식 클래스의 override 하는 메서드에 virtual과 override를 함께 작성하는 방식 vs override 하는 메서드에 override 키워드만을 명시하는 방식(override 하는 메서드는 암묵적으로 virtual이기 때문이다.)]

 

→ 이전, “[C++ 설계] 8. 가상(virtual) 함수와 다형성, dynamic_cast, override”에서 했던 내용은 다음과 같다. Parent 클래스, Child 클래스에 모두 F()라는 가상함수가 정의되어 있고, Child 클래스가 Parent 클래스를 상속 받는다고 해본다고 해보자. 그리고, 동일한 Parent* 타입의 포인터들도 각각 Parent 클래스를 통해 만들어진 객체와 Child 클래스를 통해 만들어진 객체를 가리킨다고 생각해보자.

Parent* p = new Parent();
Parent* c = new Child();

→ 객체 p와 c 모두 Parent 객체를 가리키는(것으로 기대되는) 포인터들이다. 따라서,

p->F();
c->F();

→ 를 실행시키면 Parent의 F()가 호출되어야 한다. 하지만, 실제 F() 함수는 “가상함수”로 각각에서 호출되는 함수는 실제로 p, c가 가리키는 객체의 F() 함수가 호출된다.

 

1. virtual 소멸자

→ 클래스의 상속을 사용함으로써 중요하게 처리해야 하는 부분이 있다. 상속 될 때 소멸자를 가상함수로 만들어야 한다는 것이다.

→ 구현 시 아래와 같이 동작한다. 주석 참고.

#include <iostream>

class Parent {
 public:
  Parent() { std::cout << "Parent 생성자 호출" << std::endl; }
  ~Parent() { std::cout << "Parent 소멸자 호출" << std::endl; }
};
class Child : public Parent {
 public:
  Child() : Parent() { std::cout << "Child 생성자 호출" << std::endl; }
  ~Child() { std::cout << "Child 소멸자 호출" << std::endl; }
};
int main() {
  std::cout << "--- 평범한 Child 만들었을 때 ---" << std::endl;
// 스코프 블록이 끝나면 지역 객체는 자동으로 파괴된다.
  { Child c; }
  std::cout << "--- Parent 포인터로 Child 가리켰을 때 ---" << std::endl;
  {
    Parent *p = new Child();
    delete p;
  }
}

 

→ 결과: 기반 클래스의 객체를 가리키는 포인터로 Child 객체를 만들고 소멸할 시, 메모리 누수(memory leak) 문제가 발생할 수 있다.

--- 평범한 Child를 만들었을 때 ---
Parent 생성자 호출
Child 생성자 호출
Child 소멸자 호출
Parent 소멸자 호출
--- Parent 포인터로 Child를 가리켰을 때 ---
Parent 생성자 호출
Child 생성자 호출
Parent 소멸자 호출

→ (평범한 Child를 만들었을 때) Parent 생성자가 먼저 호출되고, Child 생성자, Child 소멸자, Parent 소멸자 순으로 호출된다. 이는 당연한데, 이유를, “객체를 만들고 소멸시키는 일”을 집을 짓고 철거하는 일로 비유할 수 있다.

→ (Parent 포인터로 Child를 가리켰을 때) “delete p”를 하더라도, p는 Parent 객체가 아닌 Child 객체를 가리키고 있다. 그래서 위에서 보통 Child 객체가 소멸되는 것과 같은 순서로 생성자와 소멸자가 호출되어야 할 것 같다. 그런데 실제로는 Child 소멸자가 호출되지 않는다. Child에 대한 소멸자가 호출되지 않아, Child 객체에서 메모리를 동적으로 할당하고 소멸자에서 해제하는데에 소멸자가 호출되지 않았다면 “메모리 누수(memory leak)” 문제가 생길 수 있다.

 

  • 이제는 쉬운 virtual 키워드

→ 하지만 우리는 해결 방법을 알고 있다. virtual 키워드를 활용하면 된다. 단순히 “Parent의 소멸자를 virtual로 만들어버리면 된다.”. 그렇게 p가 소멸자를 호출할 때, Child의 소멸자를 성공적으로 호출할 수 있게 된다.

class Parent {
 public:
  Parent() { std::cout << "Parent 생성자 호출" << std::endl; }
  virtual ~Parent() { std::cout << "Parent 소멸자 호출" << std::endl; }
};
class Child : public Parent {
 public:
  Child() : Parent() { std::cout << "Child 생성자 호출" << std::endl; }
  ~Child() { std::cout << "Child 소멸자 호출" << std::endl; }
};

 

→ 결과: 위와 같이 정말 가리키는 객체 값에 맞는 생성자/소멸자가 호출된다.

--- 평범한 Child 만들었을 때 ---
Parent 생성자 호출
Child 생성자 호출
Child 소멸자 호출
Parent 소멸자 호출
--- Parent 포인터로 Child 가리켰을 때 ---
Parent 생성자 호출
Child 생성자 호출
Child 소멸자 호출
Parent 소멸자 호출

→ 근데 여기서, Child 객체가 소멸되었는데, Parent 소멸자는 왜 호출될까? Child 소멸자를 호출하면서 Child 소멸자가 알아서 Parent의 소멸자도 호출해주기 때문이다. Child는 자신이 Parent를 상속받는다는 것을 알고 있다.

→ 반면에 Parent 소멸자를 먼저 호출한다면 Parent는 Child가 있는지 없는지를 모르므로, Child 소멸자를 호출해줄 수 없다. 따라서, 상속될 여지가 있는 Base 클래스들은(위 Parent 클래스) 반드시 소멸자를 virtual로 만들어주어야 나중에 문제가 발생할 여지가 없게 된다.

 

2. 레퍼런스도 된다.

→ 이전 글과 위에서 하던 방식은 다음과 같았다. 1) A, B 클래스를 만들고, 2) A 포인터 타입 객체 2개에 새로 만든(new) A, B 객체를 각각 넣는다. 그리고 3) 만들어둔 A 포인터 타입 객체를 통해 “[객체]->함수명();” 각각의 함수를 호출하는지 확인한다.

→ 이런 식으로 기반 클래스에서 파생 클래스 함수에 접근할 때 항상 기반 클래스의 포인터를 통해 접근했었는데, 사실 아래와 같이 기반 클래스의 레퍼런스를 통해 작성해도 문제 없이 작동한다.

#include <iostream>

class A {
 public:
  virtual void Show() { std::cout << "Parent !" << std::endl; }
};
class B : public A {
 public:
  void Show() override { std::cout << "Child!" << std::endl; }
};

void Test(A& a) { a.Show(); }
int main() {
  A a;
  B b;
  
  Test(a); // Parent !
  Test(b); // Child!

  return 0;
}

 

→ 결과: “Parent !” “Child!”

 

  • Test() 함수를 살펴보자.
void Test(A& a) { a.Show(); }

→ A 클래스의 레퍼런스를 받도록 되어있다. 그런데 “Test(b);”와 같이 B 클래스의 객체를 전달하였음에도 잘 작동하였다. B 클래스가 A 클래스를 상속 받고 있기 때문이다. 즉, 함수에 타입이 기반 클래스여도 그 파생 클래스는 타입 변환되어 전달할 수 있다.

→ 따라서 Test() 함수에서 Show()를 호출하였을 때 인자로 b를 전달하였다면, 비록 전달된 인자가 A의 객체라고 표현되어 있더라도 Show() 함수의 virtual 키워드 덕분에 알아서 B의 Show() 함수를 찾아내서 호출한다.

 

3. 가상함수의 구현 원리

→ 이전까지 학습한 virtual 키워드의 능력을 생각해보면, ‘그냥 모든 함수들을 virtual(가상 함수)로 만들어버리면 안되나?’라는 생각이 든다.

→ 문제될 것은 전혀 없다. 모든 함수들을 가상 함수로 만들어 언제나 동적 바인딩이 제대로 동작하게 만들 수 있다. 실제로 Java는 모든(non-static) 함수들이 디폴트로 virtual 함수로 선언된다.

→ C++에서는 왜 사용자가 직접 virtual로 가상함수를 선언하도록 되어있을까? 그 이유는 보통의 함수를 호출하는 것보다 가상 함수를 호출하는데 걸리는 시간이 조금 더 오래 걸리기 때문이다.

→ 이를 제대로 이해해보기 위해서는 가상 함수가 어떻게 구현되는지, 동적 바인딩이 어떻게 구현되는지를 살펴보면(아래) 좋을 것이다.

 

  • 가상 함수 예시: 간단한 두 개의 클래스를 생각해보자.
class Parent {
 public:
  virtual void Func1();
  virtual void Func2();
};
class Child : public Parent {
 public:
  virtual void Func1();
  void Func3();
};

→ C++ 컴파일러는 가상 함수가 하나라도 존재하는 클래스에 대해, “가상 함수 테이블(virtual function table: vtable)”을 만든다. 이는 “전화번호부” 정도로 생각해볼 수 있다.

→ 함수의 이름(전화번호부 가게명)과 실제로 어떤 함수(해당 가게의 전화번호)가 대응되는지가 테이블로 저장되고 있는 것이다. 그 결과는 아래와 같다.

→ 이미지와 같이 Child의 Func3()(이미지 상 func3())은 일반함수(비가상 함수)로, 특별한 단계를 거치지 않고 단순하게 직접 실행된다.

→ 가상 함수를 호출하였을 때에는 가상 함수 테이블을 한 단계 더 걸쳐서 실제로 어떤 함수를 고를지 결정하게 된다.

 

위 예시에 대한 컴파일러 논리 구조)

Parent* p = Parent();
p->Func1();

→ 코드를 실행시키면 컴파일러는 다음과 같이 돌아간다. 1) p가 Parent를 가리키는 포인터이니까, Func1()의 정의를 Parent 클래스에서 찾아봐야 겠다. 2) Func1()이 가상 함수네? 그렇다면 3) Func1()을 직접 실행하는 것이 아니라, 가상 함수 테이블에서 Func1()에 해당하는 함수를 실행해야겠다.

→ 그리고, 실제로 이미지와 같이 가상 함수 테이블에서 Func1()에 해당하는 함수(Parent::Func1())를 호출하게 된다.

 

Parent* c = Child();
c->Func1();

→ 이미지와 같이 가상 함수 테이블에서 Func1()에 해당하는 함수를 호출하게 된다. 이번에는 c가 실제로 Child 객체를 가리키므로, Child 객체의 가상 함수 테이블을 참조하여 Child::Func1()를 호출하게 된다. 성공적으로 Parent::Func1()을 오버라이드 한 것이다.

 

→ 위와 같이 두 단계에 걸쳐 함수를 호출하여, 소프트웨어적으로 동적 바인딩을 구현할 수 있게 된다. 이런 이유로 가상 함수를 호출하는 경우, 일반적인 함수보다 약간 더 시간이 오래 걸리게 된다. 이 차이는 극히 미미하지만, 최적화가 매우 중요한 분야에서는 이를 감안할 필요가 있다.

 

4. 순수 가상 함수(pure virtual function)와 추상 클래스(abstract class)

순수 가상 함수(pure virtual function): 반드시 오버라이딩 되어야만 하는 함수

#include <iostream>

class Animal {
 public:
  Animal() {}
  virtual ~Animal() {}
  virtual void Speak() = 0;
};

class Dog : public Animal {
 public:
  Dog() : Animal() {}
  void Speak() override { std::cout << "왈왈" << std::endl; }
};

class Cat : public Animal {
 public:
  Cat() : Animal() {}
  void Speak() override { std::cout << "야옹야옹" << std::endl; }
};

int main() {
  Animal* dog = new Dog();
  Animal* cat = new Cat();

  dog->Speak();
  cat->Speak();
}

→ 결과: “왈왈”, “야옹야옹”

 

  • ★위 소스코드에서의 Speak() 함수 그리고…★

→ 위 코드에서 특이한 점은 “Speak() 함수의 몸통 부분”일 것이다. 다른 함수들과 달리 함수의 몸통이 정의되어 있지 않고, 단순하게 “=0;”으로 처리되어 있는 가상 함수이기 때문이다.

→ 이 함수는 “무엇을 하는지 정의되어 있지 않은 함수”로, 다시 말하자면 반드시 오버라이딩 되어야만 함수가 된다. 이런 식으로 가상 함수에 “=0;”을 붙여 반드시 오버라이딩 되도록 만든 함수를 완전한 가상함수인 “순수 가상 함수(pure virtual function)”이라고 부른다.

→ 당연하게도 순수 가상 함수는 본체가 없기 때문에, 이 함수를 호출하는 것은 불가능하다. 그래서 Animal 객체를 생성하는 것 또한 불가능하다.

Animal a;
a.Speak();

→ 위와 같이 코드를 작성하면 안되기 때문이다. 물론 함수 호출 자체를 컴파일러 상에서 금지하면 되지 않냐고 물을 수 있지만, C++ 개발자들은 이런 방법 대신 아예 Animal 객체 생성을 금지시키는 것으로 택했다. 쉽게 말해 Animal의 인스턴스(클래스를 통해 생성된 객체 하나하나)를 생성할 수 없는 것이다.

→ Animal처럼 순수 가상 함수를 최소 한 개 이상 포함하고 있는 클래스는 객체를 생성할 수 없으며, 인스턴스화 시키기 위해서는 이 클래스를 상속 받는 클래스를 만들어서 모든 순수 가상 함수를 오버라이딩 해주어야 한다.

→ 이렇게 순수 가상 함수를 최소 1개 포함하고 있는, 즉, 반드시 상속되어야 하는 클래스를 가리켜 **“추상 클래스(abstract class)”**라고 부른다.

 

  • 순수 가상 함수(pure virtual function)의 오해와 알아두면 좋을 것

→ 참고로, 순수 가상 함수는 무조건 public 이어야 하는 것이 아니다. private 안에 순수 가상 함수를 정의하여도 문제될 것은 없다. private에 정의되어 있어도 오버라이드는 된다. 자식 클래스에서 부모 클래스의 해당 함수를 호출을 못할 뿐이다.

→ 부모 클래스에서의 private 형태의 순수 가상 함수를 자식 클래스에서 오버라이드할 때, 접근 지정자(public 등)를 자유롭게 바꿀 수 있다.

→ private 안에 일반 가상 함수는 오버라이드 할 수 없다. 부모 클래스에서 “private: virtual void Hidden()”와 같이 정의되어 있다면 상속 받는 자식 클래스에서 “void Hidden() override {}”와 같이 작성할 수 없다는 것이다.

→ 즉, 아래에서 Animal의 Speak() 함수가 prviate인 순수 가상 함수이어야 override가 된다는 것이다.

class Dog : public Animal {
 public:
  Dog() : Animal() {}
  void speak() override { std::cout << "왈왈" << std::endl; }
};

 

  • 추상 클래스를 도대체 왜 사용할까?: 추상 클래스 자체로는 인스턴스화를 시킬 수도 없고, 사용하기 위해서는 반드시 다른 누군가가 상속을 해줘야 하는데 말이다.

→ 추상 클래스는 “설계도”라고 생각하면 된다. 즉, 이 클래스를 상속받아 사용하는 사람에게 “이 기능은 일반적인 상황에서 만들기는 힘드니 너가 직접 특수화 되는 클래스에 맞추어서 만들어 써라.”라고 알려주는 것이다.

 

ex) Animal 클래스: 동물마다 어떤 소리를 내는지는 동물마다 달라서 Speack()를 가상 함수로 만들기는 불가능하다. 여기서 Speak()를 순수 가상 함수로 만들게 되면 모든 Animal들은 Speak() 한다는 의미 전달과 함께 사용자가 Animal 클래스를 상속 받아서 Speak()를 상황에 맞게 구현하면 된다.

 

+) 추상 클래스는 비록 객체는 생성할 수 없지만, 추상 클래스를 가리키는 포인터는 문제 없이 만들 수 있다. 그런 방식으로 아래와 같이 dog와 cat의 Speak()를 호출해볼 수 있다.

Animal* dog = new Dog();
Animal* cat = new Cat();

dog->Speak();
cat->Speak();

 

5. 다중 상속(multiple inheritance)

→ C++에서의 상속의 특징 중 하나인 다중 상속에 대해 알아보자. C++에서는 한 클래스가 다른 여러 개의 클래스들을 상속 받는 것을 허용한다. 이를 가리켜 다중 상속(multiple inheritance)라 부른다.

 

ex) 클래스 A, B를 상속받는 클래스 C

class A {
 public:
  int a;
};

class B {
 public:
  int b;
};

class C : public A, public B {
 public:
  int c;
};

→ 위 코드를 그림으로 표현해보면 아래와 같다. 단순히 그냥 A, B의 내용이 모두 C에 들어간다고 생각하면 된다.

→ 그렇게 아래와 같은 것이 가능하다.

C c;
c.a = 3;
c.b = 2;
c.c = 4;

→ 근데 다중 상속에서 생성자들의 호출 순서는 어떨까?

 

  • 다중 상속에서 생성자의 호출 순서
#include <iostream>

class A {
 public:
  int a;

  A() { std::cout << "A 생성자 호출" << std::endl; }
};

class B {
 public:
  int b;

  B() { std::cout << "B 생성자 호출" << std::endl; }
};

class C : public A, public B {
 public:
  int c;

  C() : A(), B() { std::cout << "C 생성자 호출" << std::endl; }
};
int main() { C c; }

→ 결과: “A 생성자 호출 B 생성자 호출 C 생성자 호출”, A → B → C 순으로 호출된다.

 

ex) 상속 순서를 변경해보자.

// 위 class C : public A, public B와 달리,
class C : public B, public A

→ 결과: “B 생성자 호출, A 생성자 호출, C 생성자 호출”, B → A → C 순으로 호출된다.

 

→ 결론은 다른 것들에 좌우되지 않고 오직 상속하는 순서에만 좌우된다.

 

6. 다중 상속 시 주의할 점

→ C++에서 다중 상속은 많이 사용되는데, 다중 상속을 올바르게 사용하기 위해서는 주의해야 할 점이 있다.

 

ex) 두 클래스에서 이름이 같은 멤버 변수나 함수가 있는 경우 그리고 그것에 대한 접근

class A {
 public:
  int a;
};

class B {
 public:
  int a;
};

class C : public B, public A {
 public:
  int c;
};

int main() {
  C c;
  c.a = 3;
}

→ B의 a인지, A의 a인지 구분이 불가능하다고 오류가 뜬다. 마찬가지로 클래스 A와 B에 같은 이름의 함수가 있다면 똑같이 어떤 함수를 호출해야 될지 구분이 불가능하다.

 

ex) 다이아몬드 상속(diamond inheritance), 공포의 다이아몬드 상속(dreadful diamond of derivation)

class Human {
  // ...
};
class HandsomeHuman : public Human {
  // ...
};
class SmartHuman : public Human {
  // ...
};
class Me : public HandsomeHuman, public SmartHuman {
  // ...
};

→ 베이스 클래스로 Human 클래스가 있고, HandsomeHuman, SmartHuman 클래스는 Human 클래스를 모두 상속 받는다. 이를 그림으로 표현하면 아래와 같이 표현된다.

→ 상속이 되는 두 개의 클래스가 공통의 베이스 클래스를 포함하고 있는 형태를 가리켜 다이아몬드 상속이라고 부른다.

→ 문제점은 직관적으로도 있을 것이다. 만일, Human에 name이라는 멤버 변수가 있다면, HandsomeHuman과 SmartHuman은 모두 Human을 상속받고 있으므로, 여기에도 name이라는 변수가 들어가게 된다. 그런데 Me가 이 두 개의 클래스를 상속 받으니 Me에서는 name이라는 변수가 겹치게 된다. 결과적으로 HandsomeHuman과 SmartHuman을 아무리 안겹치게 만든다고 해도, Huamn의 모든 내용이 각각에 상속되어 중복되는 문제가 발생하게 된다.

→ 이를 해결하는 방법은 아래와 같다.

class Human {
 public:
  // ...
};
class HandsomeHuman : public virtual Human {
  // ...
};
class SmartHuman : public virtual Human {
  // ...
};
class Me : public HandsomeHuman, public SmartHuman {
  // ...
};

→ 이런 형태로 Human을 virtual로 상속받는다면, Me에서 다중 상속 시에도 컴파일러가 언제나 Human을 한 번만 포함하도록 지정할 수 있게 된다.

 

+) 참고로, 가상 상속 시, Me의 생성자에서 HandsomeHuman과 SmartHuman의 생성자를 호출함은 당연하고, Human의 생성자 또한 호출해주어야 한다.

class Human {
 public:
  Human(const std::string& name) { std::cout << "Human 생성: " << name << '\\n'; }
};

// "virtual"로 인해 Human을 직접 만들지 않고,
// 포인터처럼 공유만 할 수 있게 된다.
//
class HandsomeHuman : public virtual Human {
 public:
  HandsomeHuman() : Human("HANDOME") {}
};

class SmartHuman : public virtual Human {
 public:
  SmartHuman() : Human("SMART") {}
};

class Me : public HandsomeHuman, public SmartHuman {
 public:
  Me() : Human("ME"), HandsomeHuman(), SmartHuman() {}
};

→ Human()은 Handsome 클래스나 Smart 클래스에서 호출하면 무시된다.

 

7. 다중 상속은 언제 사용해야 할까?

→ 절대적인 관점은 없겠지만, C++ 공식 웹사이트 FAQ에 따르면 가이드라인을 정리해보면 아래와 같다.

하나의 객체가 여러 역할 기반 클래스의 기능을 동시에 수행하고, 각 역할을 독립적인 레퍼런스를 통해 다형적(override한 함수가 이용되는 등)으로 조작해야 하는 경우, 다중 상속은 그 요구를 만족시킬 수 있는 수단이 된다.

 

  • 예시를 들어서 확인해보자(슈도 코드 사용, 소멸자 등 요소 제외하여 작성).

ex) Vehicle에 관련한 클래스를 생성한다고 생각해보자. 1) 차량(이동수단)의 종류로는 땅에서 다니는 차, 물에서 다니는 차, 하늘에서 다니는차, 우주에서 다니는 차 등이 있을 것이다. 또, 이 차량들의 2) 동력원은 휘발유, 풍력, 원자력, 페달 등 다양할 것이다. 이런 차량들을 클래스로 나타내기 위해 다중 상속을 활용할 수 있지만, 그 전에 아래 질문에 대한 대답을 해보는게 적절하다.

Vehicle&(레퍼런스)가 실제로는 LandVehicle 객체를 가리킬 때, Vehicle의 함수를 호출하면 LandVehicle에서 override한 함수가 호출되기를 원하는가?(LandVehicle을 가리키는 Vehicle& 레퍼런스를 필요로 할까?)

Vehicle&(레퍼런스)가 실제로는 GasPoweredVehicle을 가리킬 때, Vehicle&의 멤버함수를 호출한다면, GasPoweredVehicle의 멤버함수가 override 돼서 동작이 호출되기를 원하는가?

→ 두 질문에 대한 대답이 모두 “예”라면 다중 상속을 사용하는 것이 적절할 것이다. 그 전에 고려해야 할 점이 있다. 이 차량이 작동하는 환경이 N개(땅, 물, 하늘, 우주 등) 있고, 동력원(휘발유, 풍력 등)의 종류가 M개 있다고 해보자.

→ 이를 위해 크게 3가지 방법으로 클래스를 디자인할 수 있다. “1) 브리지 패턴(bridge pattern)”, “2) 중첩된 일반화 방식(nested generalization)”, “3) 다중 상속”이다. 각각의 방식에는 장단점이 존재한다.

 

- 1) 브리지 패턴 알아보기

→ 브리지 패턴: 차량을 나타내는 한 가지 카테고리(역할 기반 클래스)를 아예 멤버 포인터로 만들어버리는 식으로 구현한다. Vehicle 클래스의 파생 클래스로, LandVehicle, SpaceVehicle 클래스들이 있을 것이다. 그리고 Vehicle 클래스의 멤버 변수로 어떤 엔진을 사용하는지 가리키는 Engine*(혹은 Engine&(레퍼런스) 멤버 변수를 만든다. Engine은 GasPowered, NuclearPowered와 같은 Engine의 파생 클래스들의 객체들을 가리키게 될 것이다. 런타임 시 사용자가 Engine을 적절히 설정해주는 식으로 구현한다. 이 경우에는 동력원이나 환경을 하나 추가하더라도 클래스를 1개만 더 만들면 된다. 즉, (N+M) 개의 클래스만 생성하면 된다.

 

ex) 브리지 패턴

[1] 동력 클래스 계층
Engine
  + virtual power() = 0  // 순수 가상 함수

GasEngine : Engine
  + power() override → "휘발유로 움직입니다"

WindEngine : Engine
  + power() override → "바람으로 움직입니다"

[2] 운송수단 클래스 계층
Vehicle
  - Engine& engine        // 동력원을 레퍼런스로 소유
  + virtual move() = 0    // 순수 가상 함수

LandVehicle : Vehicle
  + move() override → "지상 주행 " + engine.power()

WaterVehicle : Vehicle
  + move() override → "수상 주행 " + engine.power()

[3] 사용 예
main:
  GasEngine gas
  WindEngine wind

  LandVehicle car(gas)
  WaterVehicle boat(wind)

  car.move()  → 출력: "지상 주행 휘발유로 움직입니다"
  boat.move() → 출력: "수상 주행 바람으로 움직입니다"

→ 오버라이딩 되는 클래스의 가지 수가 (N+M)개 뿐이다. 그렇게 “LandVehicle에서 휘발유를 쓸 때만 이런 기능을 추가하자.”와 같은 조합 전용 알고리즘은 일반적인 브리지 패턴에서는 만들 수가 없다.

→ 그리고, 컴파일타임 타입 체크를 적절히 활용할 수 없다. 예를 들어 엔진이 페달이고 작동환경이 우주라면 해당 객체를 애초에 생성할 수 없어야 할 텐데, 이를 컴파일 타임에서 강제할 방법이 없고, 런타임(프로그램을 실행해서야 말이 안되는 것을 확인할 수 있다.)에서나 확인할 수 있게 된다.

 

- 2) 중첩된 일반화 알아보기

→ 중첩된 일반화: 한 가지 계층을 먼저 골라 파생 클래스들을 생성한다. Vehicle 클래스의 파생 클래스들로 LandVehicle, WaterVehicle 등이 있을 것이다. 이후, 각각의 클래스에 대해 다른 계층에 해당하는 파생 클래스들을 더 생성한다. 예컨대, LandVehicle의 경우 동력원으로 휘발유를 사용한다면 GasPoweredLandVehicle, 원자력을 사용한다면 NuclearPoweredLandVehicle 클래스를 생성할 수 있을 것이다.

 

ex) 중첩된 일반화

[1] 최상위 추상 클래스
Vehicle
  + virtual move() = 0  // 순수 가상 함수

[2] 환경별로 일반화
LandVehicle : virtual Vehicle
  + virtual move() → "지상에서 주행!"

WaterVehicle : virtual Vehicle
  + virtual move() → "물 위를 주행!"

[3] 동력원을 기준으로 환경별 세분화 (조합별 파생 클래스)
GasPoweredLandVehicle : LandVehicle
  + move() override → "지상 + 휘발유로 주행"

NuclearPoweredLandVehicle : LandVehicle
  + move() override → "지상 + 원자력으로 주행"

ElectricWaterVehicle : WaterVehicle
  + move() override → "수상 + 전기로 주행"

[4] 사용 예
main:
  // 객체 생성
  GasPoweredLandVehicle obj1
  ElectricWaterVehicle obj2

  // 레퍼런스로 참조
  GasPoweredLandVehicle& car = obj1
  ElectricWaterVehicle& boat = obj2

  // 동작 확인
  car.move() → "지상 + 휘발유로 주행"
  boat.move() → "수상 + 전기로 주행"

  // 다형성 사용
  Vehicle& v1 = obj1
  Vehicle& v2 = obj2

  for each v in [v1, v2]:
    v.move() → Vehicle 인터페이스 기준으로 다형적 호출

→ 따라서 최대 (N*M)개의 파생 클래스들을 생성할 수 있게 된다. 브리지 패턴에 비해 좀 더 섬세한 제어가 가능하다. 하지만, 동력원을 하나 더 추가하게 된다면 최대 N개의 파생 클래스를 더 만들어야 한다.

→ 또, 브리지 패턴의 문제와 마찬가지로, 휘발유를 사용하는 모든 차량을 가리킬 수 있는 기반 클래스를 만들 수 없다. 따라서 만약에 휘발유를 사용하는 차량들에서 공통적으로 사용되는 코드가 있다면 매번 새로 작성해줘야 한다.

 

- 3) 다중 상속 알아보기

→ 다중 상속: Vehicle&(레퍼런스 or 포인터)이 실제로는 각각의 역할 기반 클래스의 객체를 가리킬 때, Vehicle의 함수를 호출하면, 해당하는 역할 기반 클래스에서 override한 함수가 호출되는 것 같은 형태이다.

→ 브리지 패턴처럼 각 카테고리에 해당하는 파생 클래스들을 만들게 되지만, 브리지 패턴과는 달리 Engine&(레퍼런스)나 Engine* 같은 멤버 변수를 없애고 동력원과 환경에 해당하는 클래스를 상속받는 파생 클래스들을 최대 (N*M)개 만들게 된다.

→ 역할 기반 클래스를 조합해 기능을 구성한 다중 상속 활용 구조를 작성해보자.

 

ex) 다중 상속(주석 참고)

// [0] 공통 최상위 인터페이스
Vehicle
  + virtual move() = 0  // 순수 가상 함수

// [1] 환경 역할 클래스들
LandVehicle : virtual Vehicle
  + virtual terrain() → "지상 주행 기능"

WaterVehicle : virtual Vehicle
  + virtual terrain() → "수상 주행 기능"

// [2] 동력 역할 클래스들
GasPoweredEngine
  + virtual refuel() → "휘발유 충전"

ElectricEngine
  + virtual recharge() → "전기 충전"

// [3] 조합 클래스들 (다중 상속, 실제 인스턴스)
GasLandVehicle : LandVehicle, GasPoweredEngine
  + move() override → "지상에서 휘발유로 주행"
  + refuel() override → GasPoweredEngine::refuel()
  + terrain() override → LandVehicle::terrain()

ElectricWaterVehicle : WaterVehicle, ElectricEngine
  + move() override → "수상에서 전기로 주행"
  + recharge() override → ElectricEngine::recharge()
  + terrain() override → WaterVehicle::terrain()

ElectricLandVehicle : LandVehicle, ElectricEngine
  + move() override → "지상에서 전기로 주행"
  + recharge() override → ElectricEngine::recharge()
  + terrain() override → LandVehicle::terrain()

main:
  // 객체 생성
  GasLandVehicle obj1
  ElectricWaterVehicle obj2
  ElectricLandVehicle obj3

  // 레퍼런스로 참조
  GasLandVehicle& car = obj1
  ElectricWaterVehicle& boat = obj2
  ElectricLandVehicle& tesla = obj3

  // 레퍼런스를 통한 기본 기능 사용
  car.move()         // "지상에서 휘발유로 주행"
  car.refuel()       // "휘발유 충전"
  car.terrain()      // "지상 주행 기능"

  boat.move()        // "수상에서 전기로 주행"
  boat.recharge()    // "전기 충전"
  boat.terrain()     // "수상 주행 기능"

  tesla.move()       // "지상에서 전기로 주행"
  tesla.recharge()   // "전기 충전"
  tesla.terrain()    // "지상 주행 기능"

// 아래와 같이 묶어서 관리하거나 다형성을 활용해
// 프로그램을 간단히 동작시킬 수 있다.
// 다형성 사용 (공통 인터페이스 기반)
  Vehicle& v1 = obj1
  Vehicle& v2 = obj2
  Vehicle& v3 = obj3

  for each v in [v1, v2, v3]:
    v.move()   // Vehicle 인터페이스 기준으로 다형적 호출

→ 예를 들어 휘발유를 사용하며 지상에서 다니는 차량을 나타내는 GasPoweredLandVehicle 클래스의 경우,  GasPoweredEngine 과 LandVehicle 두 개의 클래스를 상속 받게 된다.

→ 따라서 이 방식을 통해서 브리지 패턴에서 불가능 하였던 섬세한 제어를 수행할 수 있을 뿐더러, 말도 안되는 조합을 (예컨대 PedalPoweredSpaceVehicle) 컴파일 타입에서 확인할 수 있다(애초에 정의 자체를 안하면 되기 때문이다.). 또한 이전에 두 방식에서 발생하였던 휘발유를 사용하는 모든 차량을 가리킬 수 없다 문제를 해결할 수 있습니다. 왜냐하면 이제 GasPoweredEngine 을 통해서 휘발유를 사용하는 모든 차량을 가리킬 수 있기 때문이다.

 

  • 우월한 방식?

→ 절대적으로 우월한 방식은 없고, 상황에 맞게 최선의 방식을 골라 사용하는 것이 좋다. 다중 상속이 만능 툴은 아니다. 실제로 다중 상속을 이용해 해결해야 될 것 같은 문제도 알고보면 단일 상속을 통해 해결할 수 있는 경우들이 있다. 하지만 적절한 사오항에 다중 상속을 이용한다면 위력적인 도구가 될 것이다.

 

  • +) [관점] 씹어먹는 C++로 학습 중, virtual, override 키워드에 대한 코드 작성 스타일에 대한 내용

 

→ 1) 자식 클래스의 override 하는 메서드에 virtual과 override를 함께 작성하는 방식

class Base {
 public:
  virtual void sayHello() const {
    std::cout << "Hello from Base" << std::endl;
  }
};

class Derived : public Base {
 public:
  virtual void sayHello() const override { 
    std::cout << "Hello from Derived" << std::endl;
  }
};

 

→ 2) override 하는 메서드에 override 키워드만을 명시하는 방식(override 하는 메서드는 암묵적으로 virtual이기 때문이다.)

class Base {
 public:
  virtual void sayHello() const {
    std::cout << "Hello from Base" << std::endl;
  }
};

class Derived : public Base {
 public:
  void sayHello() const override {  // ← `virtual` 생략, `override`만 있음
    std::cout << "Hello from Derived" << std::endl;
  }
};

 

 


→ 고봉밥이다 고봉밥...