개요
이론 정리 - < 가리키기와 옮기기 > 마지막에 다음은 virtual, override, 가상 소멸자, 상속과 다형성 쪽이라고 적어뒀다. 그 다음 편이다.
상속은 예전에 C# - <2>에서 한 번 정리했고, 지난주에는 유니티 프로젝트에서 SpawnerBase 상속 계층을 직접 세워봤다. 그래서 이번 편은 아는 걸 C++ 문법으로 옮기기만 하면 되는 회차라고 생각하고 들어갔다.
결과부터 적으면, 옮겨진 건 절반이었다. 나머지 절반은 답은 맞는데 이유가 틀린 것들이었다.
-
결과는 맞고 이유가 틀린 것 —
virtual없는 호출에서 부모 함수가 불린다는 건 맞췄는데, 그 이유를 “자식이 오버라이드를 안 했으니까”라고 설명했다 -
같은 덩어리로 묶여 있던 것 —
virtual과override를 둘 다 “덮어쓰기 계열”로 봤다 - 증상은 짚었는데 급이 틀린 것 — 가상 소멸자가 없을 때를 Memory Leak이라고만 봤다. C++ 규칙상으로는 그보다 위다
- 아예 반대로 잡고 있던 것 — 순수 가상 함수를 “나중에 자식이 쓸 수도 있다는 표시” 정도로 읽었다
그리고 뒤쪽 설계 이야기(합성, 결합도, 의존성 주입)에서는 반대 현상이 나왔다. 유니티에서 이미 손으로 해봤던 판단들이 여기서 이름표를 달고 나왔다. 지난주에 감으로 고른 게 아니었다는 걸 뒤늦게 확인한 셈이다.
부모 타입 하나로 자식들을 안전하게 다룰 수 있는가? 이번 편은 이 질문 하나로 계속 돌아온다.
( 이번에도 눈으로 읽고 결과를 추론하는 방식으로 진행했고, 직접 컴파일해서 돌려보지는 않았다. 아래 코드는 전부 개념 예시다 )
1. 상속은 타입 관계, 다형성은 선택되는 동작
먼저 용어부터 갈랐다. 이 둘을 붙여서 쓰다 보니 “상속하면 다형성이 생긴다”는 감각이 있었다.
-
상속(Inheritance) — 클래스 사이의 타입 관계다.
Dragon is-a Monster - 다형성(Polymorphism) — 공통 인터페이스로 호출해도 실제 객체 종류에 따라 다른 구현이 실행되는 성질이다
Monster* m1 = &slime;
Monster* m2 = &goblin;
m1->Attack();
m2->Attack();
두 줄 다 타입은 Monster* 하나인데, Attack이 virtual이면 각각 Slime::Attack()과 Goblin::Attack()이 실행된다. 상속은 이 그림이 성립하게 해주는 전제고, 다형성은 그 위에서 일어나는 일이다. 상속만 해놓고 virtual을 안 붙이면 이 그림은 안 나온다.
정적 타입과 동적 타입
Monster* m = &slime;
이 한 줄에 타입이 두 개 있다.
| 무엇 | 언제 정해지나 | |
|---|---|---|
| 정적 타입(static type) | Monster* |
컴파일 시점. 컴파일러가 소스만 보고 안다 |
| 동적 타입(dynamic type) | Slime |
실행 시점. 그 주소에 실제로 뭐가 있는지 |
virtual 함수 호출은 동적 타입을 기준으로 구현을 고르고, non-virtual 함수 호출은 정적 타입을 기준으로 컴파일 시점에 정해진다.
그래서 내 설명은 왜 틀렸나
virtual이 없는 상태에서 Monster*로 Attack()을 부르면 뭐가 나오냐는 물음에, 나는 Monster::Attack()이라고 답했다. 결과는 맞다. 그런데 이유를 “Slime이 Monster의 Attack을 오버라이드하지 않았으니까” 라고 설명했다.
여기서 갈린다. Slime이 같은 시그니처의 Attack()을 가지고 있어도 결과는 똑같이 Monster::Attack()이다. non-virtual 호출은 자식이 뭘 가졌는지 보지 않고 손에 든 포인터의 정적 타입만 본다.
내 설명대로라면 “자식에 있으면 자식 게 불린다”가 되는데, 실제로는 virtual이 없으면 자식에 있어도 안 불린다. 결과가 같아서 그냥 넘어갈 뻔했던 지점이다..
non-virtual 호출은 자식을 안 쳐다본다. 손에 든 타입이 전부다.
2. virtual과 override는 같은 일을 하지 않는다
처음 직관은 이랬다.
- 부모가
virtual로 가상 함수를 만들어 둔다 - 자식이 원하면
override로 덮어쓴다
방향은 맞다. 그런데 이 문장 안에서 두 키워드가 같은 축에 놓여 있다. 실제로는 작동하는 시점이 아예 다르다.
| 하는 일 | 시점 | |
|---|---|---|
virtual |
동적 타입 기준으로 함수를 고르게 한다 | 실행 시점 |
override |
부모의 virtual 함수를 진짜로 재정의하는 게 맞는지 검사한다 | 컴파일 시점 |
override는 실행 중에 자식 함수한테 우선권을 주는 키워드가 아니다. 붙이든 안 붙이든 실행 결과는 같고, 대신 오타나 시그니처 불일치로 재정의가 아니라 새 함수를 만들어버린 경우를 컴파일러가 잡아준다.
C# 때는 왜 안 걸렸나
C# - <2>에서 이걸 정리하면서 이렇게 적어뒀었다.
이 경우 Animal의 Eat()이 우선순위로 사용되나, virtual 에 의해 자식의 메서드가 사용된다!
지금 보면 “우선순위”라는 말이 정확히 이 지점을 뭉개고 있다. 우선순위 경쟁에서 이기는 게 아니라, 애초에 무엇을 기준으로 고르느냐가 바뀌는 것이다.
C#에서 안 걸린 이유도 있다. C#은 재정의할 때 override를 문법적으로 요구한다. 그러니 virtual과 override가 항상 세트로 붙어 다니고, 둘의 역할을 나눠 생각할 일이 없었다. C++은 override가 선택이라 이 둘이 분리되어 보인다.
virtual은 고르는 방식,override는 잘 골랐는지 검사. 하나는 실행 중, 하나는 컴파일 중이다.
3. 순수 가상 함수는 표시가 아니라 계약이다
virtual void Attack() = 0;
이걸 “선언만 해두고 부모에서는 실질적으로 안 쓰며, 상속 클래스에서 쓸 가능성을 명시한다” 는 느낌으로 읽었다. “쓸 수도 있다”는 쪽에 무게를 뒀다.
정반대다. = 0은 가능성 표시가 아니라 요구다.
-
Monster라는 추상은Attack()능력을 반드시 요구한다 - 실제 구현은 자식이 제공해야 한다
- 안 채워진 순수 가상 함수가 하나라도 남아 있으면 그 클래스는 추상 클래스이고, 직접 인스턴스를 못 만든다
지난주에 유니티에서 세운 기준이 여기 있었다
유니티 < 5 >에서 abstract와 virtual을 가르려고 문장 하나를 정해뒀었다.
안 채우면 반드시 버그인가. 그렇다면
abstract, 아니면virtual.
C++로 옮기면 그대로 맞는다. abstract 자리에 순수 가상 함수(= 0), virtual 자리에 기본 구현이 있는 virtual 함수가 온다.
| C# | C++ | |
|---|---|---|
| 자식이 반드시 채워야 함 |
abstract 메서드 |
순수 가상 함수 = 0
|
| 기본 구현이 있고 바꿔도 됨 |
virtual 메서드 |
본문이 있는 virtual 함수 |
| 재정의 표시 |
override 필수
|
override 권장(선택) |
그때는 “빈 override가 남으면 강제가 비용만 남긴다”까지 갔었는데, 정작 그 강제가 언어 수준에서 무슨 계약인지는 안 보고 있었다. 감으로 고른 게 아니라 이유가 있던 기준이었다는 걸 이제야 확인했다..
4. 자식도 추상 클래스가 될 수 있다
class PeacefulSlime : public Monster
{
};
Monster가 Attack()을 순수 가상으로 두고 있는데 PeacefulSlime이 그걸 구현하지 않았다. 이때 PeacefulSlime은 인스턴스를 만들 수 있을까.
나는 “자식이니까 추상 클래스는 아니고, 이론상 생성이 가능할 수도 있다” 고 봤다. 몸통에 = 0이 안 적혀 있으니 추상이 아니라고 읽은 것이다.
추상 여부는 그 클래스 몸통에 = 0이 적혀 있는지로 판단하지 않는다. 상속 사슬 전체에서 아직 안 채워진 순수 가상 함수가 남아 있는지로 판단한다. PeacefulSlime은 Attack()을 물려받았고 안 채웠으니, 그대로 추상 클래스다.
말이 되는 규칙이다. 순수 가상 함수가 계약이라면, 계약을 안 지킨 클래스로 객체를 만들 수 있게 두면 계약이 아니게 된다. 부르면 실행할 게 없는 Attack()을 가진 객체가 생겨버린다.
추상은 상속받으면 풀리는 표시가 아니다. 채워야 풀린다.
5. 가상 소멸자 — 새는 것보다 급이 위다
Monster* m = new Slime();
delete m;
~Monster가 virtual이 아닐 때 무슨 일이 나는가. 나는 이렇게 추론했다. ~Monster만 실행되고 ~Slime은 안 불리니, Slime이 따로 들고 있던 자원이 안 치워져서 Memory Leak이 난다고.
대표적인 결과로는 맞다. 그런데 언어 규칙상 핵심은 그게 아니다. 정적 타입과 동적 타입이 다른 객체를 delete하는데 정적 타입 쪽 소멸자가 virtual이 아니면, 그 자체로 Undefined Behavior다. 자원이 새느냐 마느냐 이전에 이미 정의되지 않은 동작이다.
이 구분이 왜 중요하냐면, “Slime이 따로 들고 있는 자원이 없으면 괜찮은가” 라는 질문의 답이 갈리기 때문이다. Memory Leak으로만 이해하면 “샐 게 없으면 괜찮다”가 나오는데, UB라면 그런 조건부 면제가 없다.
앞 편 10번의 Rule of Zero와 이어진다. 거기선 자원을 직접 들고 있으면 특수 멤버 함수를 세트로 생각해야 한다였는데, 여기선 부모 포인터로 지울 가능성이 있으면 소멸자를 virtual로 열어야 한다가 붙는다.
class Monster
{
public:
virtual void Attack() = 0;
virtual ~Monster() = default; // 이 줄이 없으면 위가 UB
};
정상적으로 파괴될 때는 동적 타입을 확인해서 ~Slime() → ~Monster() 순으로 내려간다. 앞 편에서 정리한 생성의 역순으로 파괴된다가 상속 계층에도 그대로 걸린다.
C++ Core Guidelines도 이걸 규칙으로 못박아 뒀다. 기반 클래스 소멸자는 public이면서 virtual이거나, protected이면서 non-virtual이어야 한다. 뒤쪽은 아예 부모 포인터로 못 지우게 막는 선택지다.
6. Object Slicing — 가리키면 안 잘리고, 담으면 잘린다
Slime slime;
Monster* p = &slime; // 안 잘린다
Monster& r = slime; // 안 잘린다
Monster v = slime; // 잘린다
세 줄의 차이는 상속 관계가 아니라 이 줄에서 새 객체를 만드느냐다.
-
p,r— 원래slime객체를 그대로 가리킨다. 이름만 하나 더 생겼다 -
v— 이 줄에서Monster타입 객체가 새로 만들어진다.Monster가 아는 부분만 복사되고, Slime 고유 부분은 복사할 자리가 없다
앞 편에서 계속 돌아왔던 질문이 그대로 쓰인다. 이름이 하나 더 생겼을 뿐인가, 객체가 하나 더 생겼는가. 슬라이싱은 후자에서만 일어난다.
그리고 잘려나가는 건 데이터만이 아니다. v는 진짜 Monster 객체이므로 v.Attack()은 Monster::Attack()이 된다. 다형성이 여기서 끊긴다. Core Guidelines가 다형적 클래스의 public 복사/이동을 막으라고 하는 이유가 이것이다.
스마트 포인터 변환은 슬라이싱이 아니다
std::unique_ptr<Monster> p = std::make_unique<Dragon>();
이건 헷갈렸던 자리다. Dragon이 Monster로 들어가니 뭔가 잘리는 것 아닌가 싶었다.
안 잘린다. 여기서 변환되는 건 소유권을 표현하는 포인터 타입이지 객체가 아니다. 힙에 만들어진 Dragon 객체는 그대로 Dragon이고, 그걸 Monster 타입 포인터로 들고 있을 뿐이다. Monster 값 객체를 새로 만드는 Monster v = slime;과는 다른 줄이다.
7. 소유권과 다형성이 만나는 자리
std::vector<Monster*> monsters;
이 벡터가 원소들의 수명을 책임지는가. 나는 “주소만 저장하니 소유권은 없고, 따로 안 지우면 Undefined Behavior” 라고 봤다.
앞부분은 맞다. Monster*라는 타입 자체에 소유권 정보가 없다는 게 < 소유권과 수명 >에서 raw pointer에 여섯 개를 물어보기로 한 이유였다. 벡터는 주소값을 담아둘 뿐이라 소멸될 때 원소를 delete하지 않는다.
뒷부분은 급을 다시 나눴다. new로 만든 걸 아무도 delete하지 않는 상황의 직접적인 결과는 Memory Leak이다. UB는 이미 파괴된 객체를 dangling pointer로 건드릴 때 나온다. 둘 다 나쁘지만 원인이 다르고 고치는 방법도 다르다.
| 상황 | 결과 |
|---|---|
new 한 걸 아무도 delete 안 함 |
Memory Leak |
| 이미 파괴된 객체를 포인터/레퍼런스로 접근 | Undefined Behavior |
non-virtual 소멸자 + 부모 포인터로 delete
|
Undefined Behavior |
그래서 이렇게 바꾸면 소유권이 타입에 적힌다.
std::vector<std::unique_ptr<Monster>> monsters;
monsters.push_back(std::make_unique<Slime>());
monsters.push_back(std::make_unique<Goblin>());
- 벡터가 단독 소유자다
- 객체는 실제 파생 타입을 그대로 유지한다 (6번의 이유로 슬라이싱 없음)
- 벡터가 정리될 때
Monster의 가상 소멸자를 통해 각자 자기 타입으로 파괴된다
앞 편들에서 따로 정리했던 것들이 이 세 줄에서 한꺼번에 걸린다. 소유권, 다형성, 소멸 순서가 별개 지식이 아니라 하나의 수명 사슬이었다.
순환 참조는 앞 편에서 이미 걸렀다
GameWorld와 Monster가 서로 shared_ptr을 들면 참조 카운트가 0이 안 되어 둘 다 안 죽는다 — 이건 < 소유권과 수명 > 4번에서 이미 정리했고, 그때도 유니티에서 좀비-스포너 순환을 콜백으로 끊었던 것과 같은 모양이라고 적어뒀다. 오늘 다시 나왔을 때는 막히지 않고 넘어갔다.
여기에 하나 더 얹었다. shared_ptr은 더 안전한 포인터가 아니라 객체 수명을 연장할 권한이다. BattleSystem이 몬스터를 잠깐 보기만 하는데 shared_ptr로 받으면, 보는 쪽이 수명을 붙잡는다. 순서는 항상 이쪽이다.
먼저: 누가 이 객체의 수명을 책임지는가?
그 다음: 그 계약을 가장 정확히 표현하는 타입은 무엇인가?
weak_ptr도 마찬가지로 만능이 아니다. shared_ptr의 제어 블록을 관찰하는 도구라서, 스택 지역 객체나 일반 레퍼런스를 weak_ptr로 감시할 수는 없다. 수명이 구조적으로 보장되는 자리라면 raw pointer나 레퍼런스가 더 단순하고 정확하다.
8. 상속으로 물려받을 것인가, 멤버로 조립할 것인가
Dragon is-a Monster -> 상속
Dragon has-a FireBreath -> 합성(Composition)
is-a / has-a로 가르는 건 어렵지 않았다. 실제로 헷갈리는 건 기능을 재사용하고 싶을 때다. 부모에 넣어두면 자식들이 다 쓸 수 있으니 상속이 편해 보인다.
그런데 그렇게 늘린 계층은 나중에 “이 자식만 그 기능이 없다” 는 순간에 무너진다. 기능 재사용이 목적이면 행동을 멤버로 조립하는 합성이 대체로 자연스럽다. Core Guidelines도 클래스 계층은 원래부터 계층 구조인 개념을 표현할 때만 쓰라고 한다.
결합도는 무조건 낮은 게 좋은가
- 응집도(Cohesion) — 한 클래스 안의 책임이 하나의 목적에 얼마나 모여 있는가
- 결합도(Coupling) — 클래스나 시스템끼리 얼마나 강하게 의존하는가
보통은 응집도 높게, 결합도 낮게가 맞다. 그런데 여기서 한 번 걸러야 할 게 있다. 결합도를 낮추는 것도 공짜가 아니다.
이벤트로 전부 떼어놓으면 이런 게 생긴다.
- 호출 흐름을 눈으로 따라가기 어렵다
- 그래서 디버깅이 어렵다
- 이벤트 순서와 중복 발생을 따로 설계해야 한다
- 구조 자체의 복잡도가 올라간다
지난주에 이 계산을 실제로 해봤었다. 유니티 < 4 >에서 좀비가 죽을 때 스포너에게 회수를 알리는 방법으로 네 개를 놓고 골랐는데, IDespawner 인터페이스로 좁히는 안을 이렇게 적어놓고 떨어뜨렸다.
의존이 얇아지긴 하지만 여전히 “회수해주는 무언가”를 알아야 한다
결국 Action<GameObject> 콜백 주입으로 갔다. 받는 쪽이 상대 타입을 아예 모르게 만드는 쪽이었다. 그때는 그냥 제일 얇아 보여서 골랐는데, 오늘 문장으로 정리하면 변할 가능성이 높은 의존을 안정된 경계 뒤로 숨기되, 단순한 관계에는 직접 의존이 낫다가 된다. 인터페이스를 하나 더 만드는 것도 비용 쪽에 들어간다는 걸 그때 손으로 계산했던 셈이다.
9. 의존성 주입과 의존성 역전은 다른 질문이다
이 둘은 이름이 비슷해서 한 덩어리로 묶기 쉽다. 나는 각각 이렇게 갈랐다.
- 의존성 주입(Dependency Injection) — 내가 만들지 않은 의존을 외부에서 주입받는 것
- 의존성 역전(Dependency Inversion) — 받는 대상을 추상화해서 구체 구현을 몰라도 되게 하는 설계
여기까지는 맞다. 그런데 예제를 보면서 의존성 역전을 만족하려면 RealSoundSystem 자체가 추상 클래스여야 한다고 말한 부분이 있었다. 이건 화살표를 반대로 본 것이다.
Dragon(RealSoundSystem& sound); // DI는 맞다. DIP는 아니다
Dragon(ISoundPlayer& sound); // DI + DIP
두 줄 다 주입은 받고 있다. Dragon이 내부에서 직접 만들지 않으니 DI다. 갈리는 건 무슨 타입에 의존하느냐다.
Dragon ------> ISoundPlayer <------ RealSoundSystem
RealSoundSystem은 구체 클래스 그대로여도 된다. 오히려 실제로 소리를 내야 하니 구체적이어야 한다. 중요한 건 Dragon이 그 구체 클래스를 모르는 것이고, 양쪽이 가운데 추상을 향해 화살표를 그리는 것이다.
| 묻는 것 | |
|---|---|
| 의존성 주입 | 누가 의존을 만들어서 연결하는가 |
| 의존성 역전 | 어떤 타입에 의존하는가 |
C# - <3>에서 인터페이스의 이점으로 “느슨한 결합”을 적어뒀었는데, 그게 여기 아래쪽 절반이었다. 위쪽 절반인 누가 넣어주는가는 그때 따로 안 봤다.
10. 레퍼런스 멤버 — 넣는 게 아니라 처음부터 붙는 것
class Dragon : public Monster
{
public:
Dragon(ISoundPlayer& sound)
: sound_(sound)
{
}
private:
ISoundPlayer& sound_;
};
레퍼런스 멤버를 빈 레퍼런스를 먼저 만들고 나중에 값을 넣는 구조로 생각했다. 앞 편에서 이미 한 번 걸렀던 습관이 멤버 자리에서 또 나왔다.
sound_는 생성되는 순간 참조 대상에 바인딩된다. 초기화 리스트가 있어야 하는 이유가 이것이다. 그리고 앞 편 2번에서 확인한 대로 한 번 붙으면 다른 객체로 갈아타지 않는다.
int a = 10;
int b = 20;
int& r = a;
r = b; // r이 b를 참조하게 되는 게 아니다
결과는 r은 여전히 a를 가리키고, a가 20이 된다. 레퍼런스 대입은 참조 대상의 값을 바꾼다. 앞 편에서 Player& ref로 걸렸던 것과 같은 규칙인데, 멤버로 들어가니까 새 문제처럼 보였다.
그래서 이 코드가 위험하다
Dragon* CreateDragon()
{
RealSoundSystem sound;
return new Dragon(sound);
}
나는 여기서 sound의 소유자가 없어서 Memory Leak이 날 수 있다고 봤다. 아니다. sound는 지역 객체라 함수가 끝나면 자동으로 파괴된다. 새는 게 아니라 사라진다.
문제는 반환된 Dragon이 들고 있는 ISoundPlayer&가 이미 파괴된 객체를 참조한다는 것이다. dangling reference다. 7번 표의 두 번째 줄, Undefined Behavior 쪽이다.
레퍼런스로 의존을 받는다는 건 이런 계약이다.
ISoundPlayer& |
ISoundPlayer* |
|
|---|---|---|
| 없을 수 있나 | 없다 |
nullptr 가능 |
| 나중에 갈아타나 | 안 된다 | 된다 |
| 소유하나 | 안 한다 | (raw면) 안 한다 |
| 대상 수명 | 바깥에서 보장해야 한다 | 마찬가지 |
레퍼런스는 “이건 반드시 있고, 안 바뀐다” 를 강하게 말하는 대신, 수명 보장 책임을 바깥으로 넘긴다. 선택이 아니거나 나중에 바꿔 끼워야 하면 포인터가 낫다.
멤버 선언 순서가 그 보장을 만든다
그럼 그 바깥의 보장은 어떻게 만드나. 여기서 마지막 조각이 붙었다.
class GameWorld
{
private:
RealSoundSystem sound_;
std::vector<std::unique_ptr<Monster>> monsters_;
};
멤버는 초기화 리스트에 적은 순서가 아니라 선언 순서대로 생성되고, 파괴는 그 역순이다.
생성 : sound_ -> monsters_
파괴 : monsters_ -> sound_
그래서 monsters_ 안의 Dragon들이 sound_를 레퍼런스로 들고 있어도 안전하다. 몬스터가 전부 먼저 파괴되고 sound_가 마지막에 파괴되니, sound_의 수명이 Dragon의 수명을 항상 덮는다.
두 줄 순서를 바꾸면 이 보장이 뒤집힌다. 스타일 문제로 보이던 게 실제로는 수명 계약을 만드는 자리였다.
멤버 선언 순서는 취향이 아니다. 누가 누구보다 오래 사는지를 정하는 줄이다.
정리
기억이 확실했던 것
-
is-a와has-a로 상속과 합성을 가르는 것. 여기는 흔들리지 않았다 -
shared_ptr순환 참조가 Memory Leak을 만든다. 앞 편에서 정리한 게 그대로 나왔다 - object slicing의 결과 자체는 맞게 추론했다
- 잘못 만든 이동 생성자가 double delete를 만드는 이유도 앞 편에서 이어졌다
결과는 맞았는데 이유가 틀렸던 것
- non-virtual 호출은 자식이 오버라이드했는지를 보지 않는다. 손에 든 포인터의 정적 타입으로 정해진다
- 가상 소멸자가 없을 때는 Memory Leak 이전에 Undefined Behavior다. 샐 게 없어도 면제가 아니다
-
raw pointer를
delete안 하는 건 Memory Leak이고, 파괴된 객체 접근이 UB다. 섞어 쓰고 있었다 - 지역 객체를 레퍼런스로 주입하면 Memory Leak이 아니라 dangling reference다
반대로 잡고 있던 것
- 순수 가상 함수는 “쓸 수도 있다”는 표시가 아니라 요구다
-
자식도 추상 클래스가 될 수 있다. 몸통에
= 0이 없어도 안 채웠으면 그대로다 - 의존성 역전을 만족하려고 구체 클래스를 추상으로 만들 필요는 없다. 의존하는 쪽이 추상을 보면 된다
- 레퍼런스 멤버는 나중에 값을 넣는 게 아니라 생성 시 붙는다
이번에 새로 얹은 것
- 정적 타입과 동적 타입. virtual이면 동적, 아니면 정적 기준이다
-
virtual은 실행 시점 dispatch,override는 컴파일 시점 검사. 같은 축이 아니다 -
unique_ptr<Monster>로 담아도 슬라이싱은 없다. 변환되는 건 포인터 타입이지 객체가 아니다 shared_ptr은 수명을 연장할 권한이지 안전한 포인터가 아니다- 결합도를 낮추는 것도 비용이다. 이벤트 간접성과 디버깅 난이도가 그 값이다
- 멤버 선언 순서가 수명 계약을 만든다
지난주 유니티에서 abstract와 virtual을 가르려고 세웠던 문장, 좀비-스포너 순환을 콜백으로 끊었던 선택이 오늘 전부 이름을 달고 나왔다. 그때는 손에 잡히는 대로 판단했는데 왜 그게 맞았는지는 몰랐다. 순서가 거꾸로였던 셈인데, 그래도 먼저 해봤던 게 이해를 훨씬 빠르게 만들었다.
다음은 SOLID 쪽이다. 특히 문법적으로 상속이 되는 것과 올바른 서브타입인 것은 다르다는 지점 — Penguin is-a Bird는 맞는데 Bird::Fly() 계약이 왜 문제가 되는지를 볼 차례다. 오늘 4번에서 “계약을 안 지킨 클래스로 객체를 못 만든다”까지 왔으니, 다음은 계약을 지킨 척하는 구현을 보는 자리가 될 것 같다.
참고 자료
- cppreference - virtual function specifier
- cppreference - abstract class
- cppreference - Destructors
- cppreference - Derived classes
- cppreference - Constructors and member initializer lists
- C++ Core Guidelines - C.35 기반 클래스 소멸자
- C++ Core Guidelines - C.67 다형적 클래스의 복사/이동
- C++ Core Guidelines - C.120 클래스 계층은 언제 쓰는가
- C++ Core Guidelines - C.121 인터페이스로 쓸 기반 클래스는 순수 추상으로
- 이론 정리 - < 가리키기와 옮기기 > — 이 글의 앞 편
- 이론 정리 - < 소유권과 수명 > — 소유권, 순환 참조, 핸들
- C# - <2>, C# - <3> — 같은 개념을 C#으로 정리했던 글
-
유니티 학습 프로젝트 - < 5 > —
abstract/virtual판단 기준
한줄 평
- 오늘은 새 용어를 외운 날이라기보단, 붙어 있던 단어들을 하나씩 떼어낸 날이였다.. 답은 맞았는데 이유가 틀린 게 이렇게 많을 줄은 몰랐다. 그래도 지난주 유니티에서 감으로 골랐던 것들이 왜 그랬는지 뒤늦게 설명이 붙는 게 재밌어서, 진도가 느린 것치곤 꽤 남는 게 많은 시간이였다.