다른 언어를 쓰면서 희미해졌던 기억을 되살리기 위한 글.
이번 포스트에서는 c++에서의 상속관계 및 virtual 키워드에 대해 자세히 알아보도록 하겠다.
상속
c++은 java와 c#등과 같은 다른 객체지향 언어와 달리 상속 시 접근 제한자를 지정할 수 있다.
1
2
|
// syntax
clsss Derived : <access_specifier> Parent
|
이는 부모클래스를 상속할 때, 부모클래스의 원소를 어떻게 받을 것인지를 나타낸다.
access_specifier
에 명시된 유형보다 느슨하게 지정된 원소들을 해당 유형으로 바꿔 상속받는다 생각하면 된다.
아래의 클래스를 상속하는 3가지 유형의 클래스를 살펴보며 각각 어떤 특징을 갖는지 살펴볼 것이다.
1
2
3
4
5
6
7
8
9
|
class Base
{
public:
int pubInt = 1;
private:
int privInt = 2;
protected:
int protInt = 3;
};
|
public
is-a 관계를 나타낼 때 사용. 부모의 접근지정자를 그대로 따른다.
1
2
3
4
5
6
7
8
9
10
|
class Pub : public Base // is-a
{
public:
Pub()
{
pubInt = 1; // public
//privInt = 2; // 부모클래스의 private이기 때문에 접근할 수 없음
protInt = 3; // protected
}
};
|
따라서 외부에서는 부모클래스에서 public으로 지정된 pubInt에만 접근할 수 있다.
1
2
3
4
|
Pub* pub = new Pub();
cout << pub->pubInt << endl;
//cout << pub->privInt << endl; //error: ‘int Base::privInt’ is private within this context
//cout << pub->protInt << endl; //error: ‘int Base::protInt’ is protected within this context
|
private
is-implemented-in-terms-of 관계를 나타낼 때 사용(default in cpp). 부모 클래스의 원소 중
private보다 느슨하게 접근할 수 있는 원소(protected와 public으로 지정된 원소)를 private으로 상속한다.
1
2
3
4
5
6
7
8
9
10
|
class Priv : private Base // is-implemented-in-terms-of(cover this later)
{
public:
Priv()
{
pubInt = 1; // public -> private
//privInt = 2; // 부모클래스의 private이기 때문에 접근할 수 없음
protInt = 3; // protected- > private
}
};
|
따라서 외부에서는 부모클래스의 어떤 원소에도 접근할 수 없다.
1
2
3
4
|
Priv* priv = new Priv();
//cout << priv->pubInt << endl; //error: ‘int Base::pubInt’ is inaccessible within this context
//cout << priv->privInt << endl; //error: ‘int Base::privInt’ is private within this context
//cout << priv->protInt << endl; //error: ‘int Base::protInt’ is protected within this context
|
또한 이 클래스를 상속받는 새로운 클래스를 만들어도 조부모클래스의 어떤 원소에도 접근할 수 없다.
1
2
3
4
5
6
7
8
|
class GrandChild : Priv
{
public:
GrandChild()
{
// Base의 어떤 원소에도 접근 불가능
}
};
|
protected
private 지정자와 마찬가지로 is-implemented-in-terms-of 관계를 나타낼 때 사용.
부모클래스의 public원소를 protected로 변경하여 상속한다.
1
2
3
4
5
6
7
8
9
10
|
class Prot : protected Base // is-implemented-in-terms-of
{
public:
Prot()
{
pubInt = 1; // public -> protected
//privInt = 2; // error: ‘int Base::privInt’ is private within this context
protInt = 3;// protected
}
};
|
따라서 외부에서는 Base클래스의 어떤 원소에도 접근할 수 없다.
1
2
3
4
|
Prot* prot = new Prot();
//cout << prot->pubInt << endl; //error: ‘int Base::pubInt’ is inaccessible within this context
//cout << prot->privInt << endl; //error: ‘int Base::privInt’ is private within this context
//cout << prot->protInt << endl; // error: ‘int Base::protInt’ is protected within this context
|
또한 private과 달리 이를 상속받는 클래스를 작성했을 때에는 조부모클래스의 원소에 접근할 수 있다.
1
2
3
4
5
6
7
8
9
|
class GrandChild2 : Prot
{
public:
GrandChild2()
{
pubInt = 1; // because it is specified protected
protInt = 3; // because it is specified protected
}
};
|
다만 위 코드에서는 default(private)으로 상속하였기 때문에, 외부에서는 GrandChild2의 pubInt와 protInt에 접근할 수 없다.
casting
각각의 경우에 대한 캐스팅 지원 여부는 아래와 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
int main()
{
// upcasting check
Base *ptr1, *ptr2, *ptr3;
ptr1 = new Pub();
//ptr2 = new Priv(); // error: ‘Base’ is an inaccessible base of ‘Priv’
//ptr3 = new Prot(); // error: ‘Base’ is an inaccessible base of ‘Prot’
// downcasting check
Base* base = new Base();
Pub* pub_down = static_cast<Pub*>(base); // compile-time에 검사. 다형성 여부 신경 쓰지 않음
// Pub* pub_down = dynamic_cast<Pub*>(base); // 가상함수가 없기 때문에 (source type is not polymorphic) 오류
// Priv* priv_down = static_cast<Priv*>(base); // error: ‘Base’ is an inaccessible base of ‘Priv’
// Prot* prot_down = static_cast<Prot*>(base); // error: ‘Base’ is an inaccessible base of ‘Prot’
delete ptr1, ptr2, ptr3, pub_down, base;
return 0;
}
|
한편,
다른 언어에 익숙한 사람이거나 개발하면서 상속에서의 접근지정자에 대해 크게 고민해본 적이 없는 사람이라면 도대체
is-implemented-in-terms-of이 뭐길래 non-public 상속으로 이를 나타내는지 궁금해할 것이다.
is-implemented-in-terms-of
일반적인 프로그래밍 언어에서는 is-a관계는 상속(cpp의 경우 public 상속)을, has-a관계는 composition(;containment, aggregation)을 이용해 객체 간의 관계를 나타낸다.
cpp은 위 두 개념과 더불어 is-implemented-in-terms-of라는 용어를 사용하여 객체 관의 관계를 나타낸다.
이름에서도 알 수 있듯, is-implemented-in-terms-of는 어떤 객체가 다른 객체를 사용하여 동작할 때를 의미한다.
따라서 is-implemented-in-terms-of 역시 cpp에서는 composition을 이용해 나타낼 수 있다.
그렇다면 has-a와 is-implemented-in-terms-of의 차이가 무엇일까? 두 관계를 나누는 기준은 바로 domain이다.
has-a관계는 우리가 일상속에서 쉽게 인지할 수 있는 영역을 프로그래밍으로 모델링할 때 사용된다.
예를 들어 사람, 통신수단, 운송수단과 같은 어플리케이션 도메인을 has-a 관계로 모델링할 수 있다.
반면 is-implemented-in-terms-of는 온전히 소프트웨어 구현의 영역을 나타낼 때를 지칭힌다. 버퍼, 뮤텍스, 탐색트리 등을 예시로 들 수 있다.
cpp에서의 has-a 관계는 다른 언어와 마찬가지로 컴포지션으로 구현할 수 있다.
한편 is-implemented-in-terms-of 관계는 has-a와 같은 방식으로 구현하거나, non-public 상속을 통해 구현할 수 있다.
그렇다면 어떨 때 non-public 상속으로 구현하고, 어떨 때 composition으로 구현하는 게 적절할까?
두 방식으로 구현된 클래스를 보며 어떤 방식으로 구현하는 것이 좋을지 살펴보도록 하자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
template <class T>
class MyList {
public:
bool Insert( const T&, size_t index );
T Access( size_t index ) const;
size_t Size() const;
private:
T* buf_;
size_t bufsize_;
};
template <class T>
class MySet1 : private MyList<T> {
public:
bool Add( const T& ); // calls Insert()
T Get( size_t index ) const; // calls Access()
using MyList<T>::Size;
//...
};
template <class T>
class MySet2 {
public:
bool Add( const T& ); // calls impl_.Insert()
T Get( size_t index ) const; // calls impl_.Access()
size_t Size() const; // calls impl_.Size();
//...
private:
MyList<T> impl_;
};
|
상속으로 구현 vs 컴포지션으로 구현
앞선 예시 코드를 통해서 확인할 수 있듯, 단일 컴포지션으로 할 수 있는 것은 모두 상속으로도 구현이 가능하다. 그렇다면 왜 is-implemented-in-terms-of와 has-a를 cpp은 구분하는 것일까?
바로 non public 상속으로는 구현할 수 있는 것을 단일 컴포지션으로는 구현할 수 없는 경우가 있기 때문이다.
아래 5가지 항목은 각각의 경우를 말해준다(대략 자주 발생하는 경우부터 나열되었다).
- protected 멤버에 접근할 필요가 있는 경우. 보통 protected 메서드(혹은 생성자)를 호출할 필요가 있는 경우를 뜻한다.
- 가상함수를 오버라이딩할 필요가 있는 경우. base 클래스에 pure virtual function이 있는 경우에는 컴포지션을 사용할 수 없다.
- 구현체를 또다른 구현체의 생성 이전에 생성(혹은 파괴 이후 파괴)해야 하는 경우. 여러 구현체가 서로 종속되어 있어, 특정 구현체의 생애가 조금 더 길어야하는 경우를 말한다.
구현체에 critical section이나 data transaction과 같이 lock이 필요한 경우엔 다른 구현체의 lifetime을 전부 다 cover할 수 있어야 한다.
- 가상 기본 클래스(virtual base class)를 공유하거나, 가상 기본 클래스 생성에 대한 변경이 필요한 경우.
마지막으로, is-implemented-in-terms-of과는 거리가 있지만, 컴포지션으로는 구현할 수 없는 non public 상속만의 특징이 하나 더 있다.
- ‘제한된 다형성’이 필요한 경우; 일부 코드에 대해서만 리스코프 치환이 필요한 경우. public 상속은 ‘항상’ 리스코프 치환 가능하다.
반면 non public 상속은 ‘제한된’ IS-A관계를 나타낼 수 있다. 물론 클래스 외부에서는 non public 상속으로는 전혀 다형적이지 않게 느껴지지만
(Derived 클래스는 Base클래스가 아니지만; D is not a B), 멤버함수 내부에서 혹은 freind 클래스에서는 다형성이 필요한 경우가 있을 수 있다.
다시 MySet과 MyList 코드를 살펴보도록 하자. 이 경우에는
- MyList는 protected 멤버가 없다.
- MyList는 추상 클래스가 아니다.
- MySet은 MyList 이외에 다른 클래스를 상속받지 않는다.
- MyList는 MySet이 필요로하거나 생성을 재정의할 가상 기본 클래스를 상속하지 않는다.
- MySet은 MyList가 아니다; MySet is-not-a MyList
다섯가지 상황에 모두 해당되지 않으므로 상속보다는 컴포지션으로 구현하는 것이 좋다.
상속의 경우 쓸데없는 정보까지 자식클래스에서 확인할 수 있기도 하고, 필요 이상의 종속성이 생기기 때문이다.
정리하면서 느낀 건데, c++은 다른 언어에 비해 객체 관계를 구현하는 방법이 참 많은 것 같다.
동시에 이해하기도 쉽지 않을 뿐더러, 객체지향의 원칙에 위배되기에 함부로 남발하면 안되는 부분도 참 많은 것 같다.
오죽하면 언리얼에서도 public 상속을 강제할까
다형성
다형성이란 같은 이름의 연산자 혹은 메서드가 다른 역할을 수행할 수 있는 것을 말한다. cpp에서의 다형성은 연산자/메서드 오버로딩과, 메서드 오버라이딩을 통해 구현할 수 있다.
이 중 메서드 오버라이딩은 virtual 키워드와 함께 사용된다.
이번 포스트에서는 메서드 오버라이딩과 함께 사용되는 virtual키워드가 어떻게 사용되는지 알아보겠다.

virtual
cpp에서 virtual 키워드는 가상함수를 선언할 때와, 가상 기반 클래스(virtual base class)를 상속할 때 사용된다.
1
2
3
4
5
|
// syntax
virtual [type-specifiers] member_function_declarator
class Class_Name : virtual [access-specifier] Base_Class_Name
class Class_Name : [access-specifier] virtual Base_Class_Name
|
virtual function
가상함수는 다형성을 위해 자식 클래스에서 override하기 위한 함수를 선언할 때 사용된다.
가상함수로 선언된 함수는 컴파일러에 의해 클래스마다 생성된 vTable(virtual table)에 등록되며,
런타임에 이에 접근(vPtr)하여 어떤 함수를 호출할지 결정한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
|
// C++ program to show the working of vtable and vptr
#include <iostream>
using namespace std;
// base class
class Base {
public:
virtual void function1()
{
cout << "Base function1()" << endl;
}
virtual void function2()
{
cout << "Base function2()" << endl;
}
virtual void function3()
{
cout << "Base function3()" << endl;
}
};
// class derived from Base
class Derived1 : public Base {
public:
// overriding function1()
void function1()
{
cout << "Derived1 function1()" << endl;
}
// not overriding function2() and function3()
};
// class derived from Derived1
class Derived2 : public Derived1 {
public:
// again overriding function2()
void function2()
{
cout << "Derived2 function2()" << endl;
}
// not overriding function1() and function3()
};
// driver code
int main()
{
// defining base class pointers
Base* ptr1 = new Base();
Base* ptr2 = new Derived1();
Base* ptr3 = new Derived2();
// calling all functions
ptr1->function1();
ptr1->function2();
ptr1->function3();
ptr2->function1();
ptr2->function2();
ptr2->function3();
ptr3->function1();
ptr3->function2();
ptr3->function3();
// deleting objects
delete ptr1;
delete ptr2;
delete ptr3;
return 0;
}
// Console Output
Base function1()
Base function2()
Base function3()
Derived1 function1()
Base function2()
Base function3()
Derived1 function1()
Derived2 function2()
Base function3()
|
virtual base class
cpp은 다중상속을 허용한다. 한편 인스턴스 생성 시 주소공간에 부모가 먼저 생성된 후, 자식의 생성자가 호출된다.
그렇다면 어떤 클래스가 다른 부모, 같은 조부모를 두었다고 했을 때는 어떤 일이 벌어질까?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
#include <iostream>
using namespace std;
class A {
public:
void show()
{
cout << "Hello form A \n";
}
};
class B : public A {
};
class C : public A {
};
class D : public B, public C {
};
int main()
{
D object;
object.show();
}
|
위와 같은 상황에서는 메모리에 A 영역이 2번 올라가려고 할 것이다. 다행이도 컴파일러는 이를 검출해내어 오류를 방출한다.
사실 위와 같은 상속구조는 데스 다이아몬드 문제를 야기할 수 있기 때문에, 권장되는 상속구조는 아니다.
그럼에도 불구하고 A의 show()를 호출하고 싶을 때는 B와 C가 A를 가상 기본으로 상속받게하면 된다.
1
2
3
4
5
6
|
class B : virtual public A {
};
class C : public virtual A {
};
// 둘 다 가능한 문법
|
References