Search

CPP Module 03

Created
2021/10/09
tag
42서울
42Seoul
CPP Module
All of C++98
Class
Inheritance
Diamond Inheritance
virtual
override

Subjects

1. ex00 (Aaaaand... OPEN!)

Base Class

전체적인 내용을 쭉 훑어봤다면 알겠지만, 이번 Module 03은 상속에 대한 구현이다. 클래스의 상속은 대체적으로 기반 클래스에서 할 일들이 꽤 많다. 파생 클래스의 고려가 빠지게 되면, 기반 클래스를 고치는데 많은 시간을 쏟게 된다. 다행스럽게도 문제에서 주어진 기반 클래스ClapTrap 클래스는 그나마 내용이 많지는 않다.
다른 멤버 변수나 멤버 함수에 대해서는 문제에서 제시된 대로 하면 되고, 고민해볼 사항은 각 클래스의 속성을 유지할 방법이다. 당장 Repair 기능 때문에 HP의 상한치를 설정할 필요가 있고, 추후에 파생 클래스가 별도의 Overriding 없이 ClapTrapRepair를 이용한다면 파생 클래스 만의 HP, EP, AD의 속성을 결정지을 필요가 있다. 만일 이와 같은 설정이 생략된다면, 파생 클래스 임에도 ClapTrap 클래스의 속성 값으로 함수 호출이 될 수 있다.
가능한 방법은 여럿 있겠지만, 클래스마다 고유한 속성 값을 부여하기 위해서 static const 값으로 주는 것도 가능하고, 단순히 멤버 변수로 두어 파생 클래스에서는 그 값을 덮어 써서 사용하는 것도 가능하다.
두 방법 모두 속성 값을 상속 받을 때 파생 클래스 내부에 위치시켜 이용하는 점은 동일하지만 약간의 차이가 있다. static const의 경우 파생 클래스를 만들 때마다 static const 값을 정의하고 해당 변수를 위한 getter, setter를 정의해야 하지만, 각 클래스마다 그 값을 유지하기 때문에 Scope 연산자인 ::로 필요한 속성에 쉽게 접근할 수 있다는 장점이 있다. 후자의 경우에는 기반 클래스에서 정의한 getter, setter를 이용하므로 파생 클래스에서 일일이 이를 정의하지 않아도 되지만, 파생 클래스를 만들 때마다 속성 값을 덮어 씌우므로 특정 값이 필요한 경우에 Scope 연산자로 접근이 불가능하다.
내 경우에는 getter, setter를 클래스마다 정의하는 것이 코드를 너무 길게 작성해야 해서 후자의 방법을 택하였다. 이 때 파생 클래스로 갈 때마다 속성 값을 덮어 씌워지면서 특정 속성 값이 필요할 때는 #define으로 정의된 값을 이용하는 것으로 문제를 해결했다.

2. ex01 - ex02

Protected on Base Class

이전 항목에서 기반 클래스가 정의되었다면, 이를 상속하여 ScavTrap 클래스를 정의해야 한다. ScavTrap 클래스에서 기반 클래스에서 정의된 멤버 변수들을 이용할 수 있게 하려면 기반 클래스private에 정의한 멤버 변수들을 모두 protected로 옮겨줘야 한다.

Constructor on Derived Class

내 경우에 파생 클래스의 속성 값은 기반 클래스의 속성 값을 그대로 유지하도록 만들었으므로, #define으로 파생 클래스의 속성 값을 두고 파생 클래스생성자에서 기존 속성 값을 갱신하도록 만들어야 한다.
#define으로 파생 클래스의 속성 값을 정의하지 않고 생성자에서 값을 직접 명시해도 되는데, 이렇게 되면 속성 값을 변경하고 싶을 때 일괄적인 변경이 되지 않아 코드의 적응성이 떨어진다.

Virtual for Up Casting

멤버 변수를 protected로 옮긴 뒤에, 상속된 파생 클래스에서 Overriding하려는 함수들을 virtual로 선언할 필요가 있다. virtual로 선언하지 않았다고 해서 기반 클래스에 존재하는 함수를 파생 클래스에서 Overriding 하지 못하는 것은 아니다. 그럼에도 해당 함수들을 기반 클래스에서 virtual 키워드로 선언하는 것이 필요한 이유는 업 캐스팅 때문이다.
클래스를 상속하여 파생 클래스를 만들었을 때, 기반 클래스들과 파생 클래스들은 기반 클래스포인터로 묶어서 관리하는 것이 가능하다. 이를 업 캐스팅이라 한다. 이 때 파생 클래스업 캐스팅 하여 기반 클래스 포인터로 파생 클래스Overriding한 함수를 호출하려 하면 파생 클래스의 함수가 아니라 기반 클래스의 함수가 호출되는 것을 확인할 수 있다. 이는 호출 시 기반 클래스포인터를 이용하여 기반 클래스로 타입을 인식하면서 발생하는 것인데, 이를 극복하여 파생 클래스 타입을 정상적으로 인식시키기 위해선 런 타임에 자신이 파생 클래스 라는 것을 알게할 수 있어야 한다. 이를 가능하게 하는 것이 virtual 키워드이다.
일반적으로 기반 클래스파생 클래스의 구조를 찾아보면, 기반 클래스파생 클래스 위에 그려진 모습을 많이 볼 수 있다. 이 때 파생 클래스가 그 위에 있는 기반 클래스포인터로 캐스팅 되므로 업 캐스팅이라 한다. 반대로 기반 클래스파생 클래스포인터로 캐스팅하는 다운 캐스팅도 있는데, 이는 여기서 다루지 않으므로 직접 찾아보길 바란다. 다운 캐스팅dynamic_cast<T>와 관련이 있다.
멤버 함수들은 클래스 내에 귀속되는 엔트리를 갖는데, virtual 키워드로 선언된 함수들은 클래스 내에 엔트리를 갖지 않는다. virtual 키워드로 선언된 함수들은 vTable (Virtual Table)에 자신의 타입과 묶어서 별도의 엔트리를 유지한다. 따라서 런 타임 도중에 해당 함수를 호출하려 했을 때 클래스 내에 엔트리가 없는 것이 인식되면, vTable에서 기반 클래스포인터가 실제로 어떤 타입인지 확인한 뒤에 타입에 맞는 함수를 호출할 수 있게 된다.
기반 클래스포인터로 일반화하여 각 상속 객체들을 운용하는 방법은 굉장히 유용한데, 이를 이용할 때 예기치 못한 결과를 얻고 싶지 않다면 Overriding하려는 함수들을 기반 클래스에서 반드시 virtual로 선언할 수 있도록 하자.

Overriding?

현재 진행하고 있는 서브젝트는 C++98이라 해당되지 않지만, C++11부터는 override라는 키워드가 추가되었다. 해당 키워드를 명시한 함수는 기반 클래스에서 이미 정의된 함수와 다른 시그니처로 선언된 경우 컴파일러를 통해 오류 문구를 받으면서 사전에 문제를 방지할 수 있다. 즉, override 키워드의 역할은 기반 클래스의 특정 함수를 Overriding 했다는 것을 명시적으로 알리는데 있다.
#include <iostream> class A { public: virtual void scream(void) { std::cout << "hi" << std::endl; } }; class B : public A { public: void scream(void) const { std::cout << "hello" << std::endl; } }; int main(void) { B b; A *p = &b; b.scream(); p->scream(); return (0); }
C++
복사
예를 들어 위 코드와 같이 AA 클래스와 이를 상속 받은 BB 클래스가 있다고 해보자. 두 클래스의 scream 함수는 동일하게 정의되어 Overriding 된 것처럼 보이나, 멤버 함수의 const 유무에 차이가 있다. main 함수를 보면 BB 클래스 객체 그대로 scream을 호출한 경우와 AA 클래스로 업 캐스팅 후에 scream을 호출한 것을 볼 수 있는데, 정상적으로 Overriding가 되었다면 두 함수 모두 hello를 출력해야 한다. 하지만 실행 결과는 hellohi가 출력된다.
물론 서브젝트에서 명시된 것처럼 -Wall -Wextra -Werror를 이용하면 위 그림과 같이 경고를 내주긴 하지만, 해당 클래스 내에는 scream 함수 외에도 의도적으로 위처럼 구분지은 경우가 있을 수 있기 때문에 명확하게 Overriding을 한 것인지 아닌지 override 키워드를 통해 구분지을 필요가 있다.
#include <iostream> class A { public: virtual void scream(void) { std::cout << "hi" << std::endl; } }; class B : public A { public: void scream(void) const override { std::cout << "hello" << std::endl; } }; int main(void) { B b; A *p = &b; b.scream(); p->scream(); return (0); }
C++
복사
override 키워드를 사용하여 함수를 선언하게 되면, 컴파일 시에 위와 같이 명확하게 에러를 발생시키는 것을 볼 수 있다. 이와 같은 명시는 컴파일러 뿐만 아니라 프로그래머가 만드는 실수를 인지할 수 있게 많은 도움을 준다. 따라서 C++11 이상을 사용할 일이 있다면, override 키워드를 적극적으로 이용하자.

Virtual Destructor?

Overriding 하려는 함수들을 virtual로 선언하는 것 외에도 중요한 습관이 하나 더 있다. 소멸자virtual 키워드를 붙여 선언하는 것이다. 이 역시도 업 캐스팅과 관련이 있다.
이전 항목에서 설명한 기반 클래스포인터로 상속 객체들을 직접 생성하여 실행해보면 Overriding 함수들을 정상적으로 호출하지만, 상속 객체의 소멸자 내에서 출력문을 두어 확인했을 때 기반 클래스소멸자만 호출 되는 것을 확인할 수 있다.
예를 들어 기반 클래스 AA파생 클래스 BB가 있다고 해보자. 정상적인 생성 및 소멸은 AA 생성 → BB 생성 → B 소멸 → AA 소멸인데, 업 캐스팅하여 이용하는 객체가 소멸자virtual로 두지 않았을 때는 AA 생성 → BB 생성 → AA 소멸의 결과만 얻게 된다. 자칫하면 BB로 생성한 객체가 소멸되지 않아 누수로 이어질 수 있다. 따라서 소멸자virtual로 선언함으로써, 런 타임에 기반 클래스포인터가 참조하는 값이 실제로 어떤 타입인지 확인하고 타입에 해당되는 소멸자를 올바르게 호출할 수 있게 만들 수 있다.

3. ex03 (Now it’s weird!)

Diamond Inheritance

다이아몬드 상속은 위와 같은 구조로 나타난다. Module 03의 가장 핵심적인 부분이라고 할 수 있다. 이전 항목까지 구현된 ClapTrap 클래스, FragTrap 클래스, ScavTrap 클래스를 이용하여 DiamondTrap 클래스를 만드는 것이 ex03의 목표인데, DiamondTrap 클래스를 구현할 때 FragTrap 클래스ScavTrap 클래스를 그냥 바로 상속해버리면 위 그림과 같은 다이아몬드 상속이 나타나지 않는다.
Class DiamiondTrap : public FragTrap, public ScavTrap { /* If there's no revision on FragTrap & ScavTrap ** A A ** | | ** B C ** \-------------/ ** | ** | ** D */ };
C++
복사
DiamondTrap 클래스FragTrap 클래스ScavTrap 클래스를 각각 상속 받은 다중 상속 객체인데, DiamondTrap 클래스의 두 기반 클래스의 코드를 별도로 수정하지 않고 위와 같이 상속을 받으면 최상위에 존재하는 ClapTrap 클래스의 객체가 2번 생성되는 것을 볼 수 있다. 따라서 최초에 제시된 그림처럼 다이아몬드 상속이 나타나는 것이 아니라, 주석에 보이는 것과 같은 구조가 나오게 된다.
주석과 같은 구조가 문제가 되는 이유는 최상위 기반 클래스ClapTrap 클래스의 멤버 변수를 접근하려 할 때 해당 멤버 변수가 중복으로 존재하므로 어느 멤버 변수로 접근할 지 Ambiguity (모호성)이 발생하기 때문이다. 따라서 이번 서브젝트에서 DiamondTrap 클래스에게 원하는 사항 중 하나는 ClapTrap 클래스생성자가 한 번만 호출되도록 만드는 것이다. 이를 만족하기 위해 FragTrap 클래스 혹은 ScavTrap 클래스 하나만 상속 받기에는 상황이 여의치 않다. 어떻게 이를 해결할 수 있을까?

Virtual Inheritance

위에서 제시된 사뭇 다른 구조는 virtual 키워드를 이용하여 해결할 수 있다. 이전에 소개한 virtual 키워드와 동일하지만 기능은 사뭇 다르다. 파생 클래스에서 Overriding 하려는 함수를 기반 클래스에서 virtual로 선언하면 업 캐스팅 시에 자신의 타입에 맞는 함수를 호출할 수 있었는데, 클래스를 정의할 때 virtual 키워드를 사용하여 중첩될 수 있는 기반 클래스를 명시하면 다중 상속 객체를 만들 때 해당 클래스를 중복으로 생성하지 않도록 만들 수 있다.
함수의 Overriding에서 virtual 함수들은 vTable에 존재하여 vptr (Virtual Pointer)를 통해 접근할 수 있었다. 다중 상속 객체에 대한 virtual 키워드들 역시 vptr을 이용하여 vTable에 접근하면서 virtual로 명시한 클래스를 이용할 수 있다. 즉, 두 virtual 키워드의 기능이 다르게 보이지만 그 동작은 vptrvTable을 공통적으로 이용한다.
참고로 vTable은 구현된 클래스마다 정적인 형태로 각각 존재한다.
이에 대해서 조금 더 언급하자면, virtual이 명시된 기반 클래스를 상속 받은 클래스의 경우 Invariant RegionShared Region으로 나뉜다. Invariant Region은 컴파일러에 의해 고정된 위치 값을 받아 직접적으로 접근이 가능한 공간을 의미하며, 이번 서브젝트에서는 DiamondTrap 클래스, FragTrap 클래스, ClapTrap 클래스가 해당된다. 반면 Shared Region의 경우 virtual로 명시된 기반 클래스로써 ClapTrap 클래스가 해당되며, 이 공간은 파생 클래스들에 의존적으로 유동성을 갖기 때문에 직접적인 접근이 되지 않는 공간이다. 두 공간은 클래스를 객체로 만들었을 때 메모리 레이아웃에서 명확하게 확인할 수 있는데, 대체적으로 Invariant Region은 객체 시작 주소 쪽에 위치하고 Shared Region은 그 끝 쪽 주소에 위치하게 된다.
자세한 내용은 아래 글들을 참고해보면 많은 도움이 된다.
class FragTrap : virtual public ClapTrap { // ... implementation }; class ScavTrap : virtual public ClapTrap { // ... implementation };
C++
복사
서론이 길었는데, 결과적으로 기반 클래스가 중첩되지 않기 위해선 FragTrap 클래스ScavTrap 클래스를 정의할 때 위와 같이 ClapTrap 클래스virtual로 상속한다고 명시하면 된다.

Constructor Calling Order of Base Class

ClapTrap 클래스를 상속할 때 virtual 키워드를 이용했다면 거의 끝난 것과 다름 없다. 서브젝트에서는 DiamondTrap 클래스를 정의할 때 기반 클래스의 상속 순서를 FragTrap 클래스 이 후 ScavTrap 클래스로 제시했는데, 이 상속 순서는 DiamondTrap 클래스생성자를 정의하고 기반 클래스생성자를 호출하는 순서에 큰 영향을 끼친다.
class A { // ... implementation }; class B : virtual public A { // ... implementation }; class C : virtual public A { // ... implementation }; class D : public C, public B { // ... implementation };
C++
복사
다중 상속 객체를 구성하는 기반 클래스생성자가 여럿 존재할 때 이들을 어떤 생성자로 호출할지는 크게 중요하지 않지만, 기본적으로 이를 호출하는 순서는 중요하다. 위와 같은 코드로 주어져 있다면, 생성자 호출 순서는 AABBCC 순서를 지켜야 한다. 이 때 특정 생성자를 생략하여 AACC, BBCC, AACC로 호출했을 때는 생략된 클래스의 기본 생성자를 호출하므로 큰 문제가 되지 않는다.
// Error 1 DiamondTrap::DiamondTrap(void) : FragTrap(), ScavTrap(), ClapTrap() // Error 2 DiamondTrap::DiamondTrap(void) : ScavTrap(), FragTrap()
C++
복사
따라서 DiamondTrap 클래스생성자를 정의할 때 FragTrap 클래스ScavTrap 클래스의 순서를 바꾼다거나, ClapTrap 클래스생성자를 제일 끝으로 미는 등 위와 같은 코드로 작성하면 컴파일 에러를 볼 수 있으니 주의해야 한다.
이 외의 주의할 점 및 상속의 기본 개념은 아래 링크에서 더 자세히 알아볼 수 있다.

Using Specific Scope

virtual 키워드로 최상위 기반 클래스의 Ambiguity를 해결 했지만, ex03과 같이 기반 클래스와 동일한 이름의 멤버 변수를 사용하게 되면 여전히 Ambiguity가 남게 된다. 예를 들어 _name이라는 멤버 변수는 ClapTrap 클래스에도 존재하고 DiamondTrap 클래스에도 존재한다. 최근 이용하는 컴파일러는 똑똑해서 DiamondTrap 내에서 _name을 이용하면 DiamondTrap의 멤버 변수로 인식하는데, 그렇다면 ClapTrap_name을 이용할 때는 어떻게 해야할까? 해결법은 Scope 연산자를 이용하여 ClapTrap::_name으로 작성하면 된다.
업 캐스팅을 소개하면서 virtual 함수가 자기 자신에게 맞는 함수를 호출하는 얘기를 들었을 때, 변수도 가능할까라고 의문을 가졌을 수도 있다. 변수의 경우에는 Scope 연산자를 이용하여 직접 원하는 Scope의 변수를 명시해야 한다.
그리고 ex03DiamondTrap 클래스는 멤버 변수와 멤버 함수를 특정 기반 클래스의 것으로 이용하라는 명시가 되어 있는데, 이를 Scope 연산자로 어떻게 해결하는지 하나 하나 알아보자.
// Solution 1 class DiamondTrap : public FragTrap, public ScavTrap { private: // ... implementation public: using ScavTrap::attack; // ... implementation }; // Solution 2 void DiamondTrap::attack(void) { ScavTrap::attack(); }
C++
복사
멤버 함수의 경우 Scope 연산자를 이용하면 되는데, Scope 연산자를 이용하는 방법이 2가지가 있다. 첫 째는 클래스 정의 시 using 키워드를 이용하여 특정 Scope의 함수를 이용한다고 명시하는 방법이 있고, 둘 째는 멤버 함수를 직접 정의하여 그 안에 Scope의 함수를 호출하는 방법이 있다.
// Solution 1 // Attributes of Base Class implemented by static const class DiamondTrap : public FragTrap, public ScavTrap { private: // ... implementation public: using ScavTrap::attack; using FragTrap::_hp; using ScavTrap::_ep; using FragTrap::_ad; // ... implementation }; // Solution 2 // Attributes of Base Class implemented by Member Variables DiamondTrap::DiamondTrap(...) : ... { _hp = F_HP; _ep = S_EP; _ad = F_AD; }
C++
복사
DiamondTrap 클래스의 멤버 변수는 기존에 작성한 기반 클래스의 형태에 따라서 설정 방법이 달라진다. 기반 클래스에서 속성 값을 static const로 유지한 경우에는 멤버 함수에서 이용한 using 키워드를 명시하여 특정 Scope의 속성 값을 멤버 변수로 이용하는 것이 가능하다. 기반 클래스에서 속성 값을 멤버 변수로 유지한 경우에는 다중 상속 객체를 생성하면서 FragTrap 클래스ScavTrap 클래스생성자 호출에 따라 그 값이 갱신 되기 때문에 특정 Scope의 값을 이용하라고 명시해도 이미 그 값이 갱신되어 있다. 따라서 결과적으로 가장 마지막에 호출한 ScavTrap의 속성 값으로 이용하게 된다. 이 때는 DiamondTrap 클래스생성자 내에서 멤버 변수로 나타난 속성 값을 직접 설정해줘야 한다. 기존에 각 클래스를 정의한 헤더에서 #define을 이용하여 속성 값을 정의했으므로 이를 이용하면 일괄적으로 특정 Scope의 값을 쓸 수 있게 된다.