안녕하세요. 저에요.

저번에 다중 상속에서의 캐스팅 주의점에 대해 알아봤는데요.

오늘은 어떻게 그런 식으로 캐스팅이 동작하는지 알아볼 거예요. (대충 지루하고 현학적인 이야기다요)

C++스타일의 cast에는 여러 종류가 있는데요.

오늘은 그중에서 가장 많이 쓰이는 cast은 static_cast와 dynamic_cast에 대해 알아봅시다.


● static_cast

static_cast에 대해 많은 오해를 하고 있는 부분이 있어요.

그건 바로 static_cast는 컴파일 타임에 에러를 검출해 낸다는 것인데요.

근데 써보신 분들은 이런 경험을 겪으신 적이 있을 겁니다.

난 분명 static_cast를 썼고, 컴파일도 제대로 되었는데, 런타임 중에 static_cast에서 터졌어!!

 

분명 컴파일 타임에 에러를 검출해 준다 했는데... 컴파일이 되었는데 터졌어.... 뭐지... 뭔가 일어나고 있음...

 

이런 경우를 겪으신 분 계신가요? (전 없긴함)

제대로 알고 써야 합니다!!

static_cast는 컴파일 타임에서 A와 B에 대한 상속 관계에 대한 합리성만 판단합니다.

무슨 말인지 예시를 들어보자면, (대충 지루하고 현학적인 예시)

영희는 철수에게 생일선물을 주려고 한다.
영희는 철수가 닌텐도를 가지고 싶다는 이야기를 들었다..
하지만 철수가 무슨 기종을 가지고 싶은지 명확히 모른다.....
그래서 영희는 최신 기종인 닌텐도 스위치를 선물하기로 했다!
아뿔싸! 철수가 가지고 싶었던 기종은 닌텐도 DS였다!!!!

이런 상황인 것이에요.

런타임 중에 static_cast가 터졌다는 것은,

닌텐도 DS든, 닌텐도 Wii든, 닌텐도 스위치든 전부 닌텐도라 할 수 있는데요.

영희는 이런 상황에 닌텐도 스위치가 닌텐도라는 합리성만 판단하고 위험하게 닌텐도 스위치를 선물한 겁니다.

 

코드로 표현하자면 이런 거죠.

class Nintendo {};
class Nintendo_Switch : public Nintendo {};
class Nintendo_DS : public Nintendo {};

// 1. 철수: 아 닌텐도가 가지고 싶다!
Nintendo_DS* ds = new Nintendo_DS(); 

// 2. 영희: 음... 철수가 닌텐도가 가지고 싶다 했지...
Nintendo* nt = ds; // Nintendo로 업 캐스팅

// 3. 영희: 그럼 최신 기종인 닌텐도 스위치를 선물해야겠다!
Nintendo_Switch* sw = static_cast<Nintendo_Switch*>(nt); // 컴파일러는 허용, 그러나 런타임에서는 잘못된 다운캐스팅

컴파일러 입장에선 Nintendo_Switch* ⇐ Nintendo* 관계가 성립하므로 캐스트 자체는 허용되지만, 실제로 ds는 Nintendo_Switch의 주소를 가리키는 게 아니기 때문에 알 수 없는 동작을 하게 됩니다.(보통은 터짐)

 

그러면 이런 상황에서 안전하게 확인하는 방법이 뭘까요?

그건 바로 dynamic_cast입니다!


● dynamic_cast

dynamic_cast는 말 그대로 안전한 캐스팅입니다.

RTTI를 통해 하나하나 유효성 검사를 실시하기 때문인데요.

RTTI란?
Run-Time Type Information의 약자로, 런타임에서의 객체들의 정보를 뜻합니다.

때문에 dynamic_cast는 캐스팅 시, 해당 객체에 대한 타입과 캐스팅 대상 타입을 상속 트리를 통해 하나하나 검사합니다.

실패할 시 터지지 않고 반환 값을 nullptr을 반환해 실패 여부도 확인할 수 있죠.

그럼 무조건 dynamic_cast 써야겠네? 무적이네?

절!!! 대!!! 아닙니다!!!!

하나하나 검사하는데 빠를 리가 없겠죠?

그래서 dynamic_cast는 속도가 느립니다. 여러분 생각보다 훨씬 느려요.

그저 맞냐 or 아니냐를 따지는 과정이 말로는 간단하지만, 여러 상황에 대한 예외 처리 때문에 생각보다 복잡한 과정이 있다고 하더라구요.

심지어 상속 관계가 복잡하고 깊어질수록 더 느려지겠죠?

 

그래서 자체 엔진 제작할 때 일화를 들려드리자면,

GetComponent<>() 함수 같은 것들에 dynamic_cast를 썼었는데....

테스트해 보니까 속도가 좀 느리더라구요....

그래서 다음에 만들 엔진엔 타입별 ID를 해싱해서 부여하고, 해시 맵으로 성능향상을 노려볼까 합니다.


 마치며

이런 과정을 통해 cast함수들은 오프셋을 받아와 주소 재보정을 거치게 됩니다.

그래서 저희는 캐스팅 후에 주소 값이 달라진 걸 저번 포스팅을 통해 확인했구요.

 

이상 저였습니다. 감사합니다.

안녕하세요 접니다.
게임 개발을 하다 보면 어떻게든 몬스터든, NPC든 AI를 도입해야 할 때가 오는데요.
예를 들어 보스몬스터의 행동 패턴, NPC의 이동 패턴 등이 있습니다.
그럼 당신은 이 패턴들을 하나하나 하드코딩할 것인가?

그러지 말고 행동 트리를 사용하도록 하자.
 
앞서, 메이플스토리 월드 Document에 행동 트리 문서를 통해 공부했습니다.
행동 트리를 활용한 AI 만들기: MapleStory Worlds Creator Center

MapleStory Worlds Creator Center

메이플스토리 월드의 크리에이터 센터입니다.

maplestoryworlds-creators.nexon.com

(여담으로, 메이플스토리 월드가 Documents를 진짜 정성 들여 작성해 놨습니다. 굳이 메이플스토리 월드를 사용하지 않더라도, 엔진 개발 관련 좋은 정보가 많으니 가끔 찾아보는 것도 좋을 것 같습니다.)


●행동 트리(Behavior Tree)란?

행동 트리는 노드 + 트리 구조의 행동 제어 모델입니다.

출처: MapleStory Worlds Creator Center

"행동 제어 모델이 뭔데? 로봇임?" 싶지만 사실 자료구조 중 하나일 뿐입니다.
행동 트리는 Root노드를 시작으로, 순서에 맞게 각 노드를 처리하게 됩니다.
처리할 노드는 일반적으로 3가지 상태를 가지게 되는데요.

상태설명
Success노드의 작업이 의도에 맞게 완수(처리)된 상태.
Failure 노드의 작업이 의도와 맞지 않게 완수(처리)된 상태.
Running 노드의 작업이 처리 중인 상태.

 
 
노드의 종류는 크게 2가지가 있습니다.
바로 Action Node와 Composite Node가 있는데요.


Action(Leaf) Node

: 객체의 실질적인 행동을 처리하는 노드입니다.
하위(자식) 노드를 가지지 않으므로, Leaf Node라고도 불립니다.
 

Composite Node

: 하위(자식) 노드의 흐름을 제어하는 노드입니다.
Composite Node는 그 안에서도 일반적으로 3가지의 종류를 가지는데요.
1. Sequence Node: 자식 노드가 Failure를 반환하기 전까지 순서대로 Node를 실행합니다. 자식 노드가 Failure를 반환하는 경우 그 즉시 실행을 멈추고 Failure를 반환합니다. 모든 자식노드가 Success를 반환한 경우 Success를 반환합니다.
2. Selector Node자식 노드가 Success를 반환하기 전까지 순서대로 Node를 실행합니다. 자식 노드가 Success를 반환하는 경우 그 즉시 실행을 멈추고 Success를 반환합니다. 모든 자식노드가 Failure를 반환한 경우 Failure를 반환합니다.
3. Parallel Node: 자식 노드를 조건 없이 동시에(혹은 순서대로) 실행합니다.
성공/실패를 판단하는 기준은 설정된 정책(예: 성공 기준 수, 실패 기준 수 등)에 따라 달라집니다.
예를 들어, 자식 노드 중 일정 수 이상이 Success를 반환하면 전체를 Success로 간주할 수 있고, 반대로 일정 수 이상이 Failure를 반환하면 전체를 Failure로 판단할 수 있습니다.
이 노드는 복수의 행동을 병렬적으로 처리해야 할 때 사용됩니다.



●행동트리의 단점?

행동 트리는 그러면 무조건 좋은걸까요?
그건 또 아닌데용
행동 트리도 명확한 단점이 있는데요.



복잡한 트리 관리: 트리위 규모가 커지면 디버깅이 복잡해질 수 있습니다.

상태 전이의 가시성이 떨어짐: 상태 간 전이 흐름이 FSM보다 가시성이 떨어집니다.

런타임 비용 증가: 매 프레임마다 트리를 순회하며 조건 검사를 하는데 이는 성능 면에서 영향을 끼칩니다.



●마치며

사실 행동 트리와 FSM 중 뭘 써야한다는 정답은 명확하지 읺은 것 같습니다.
프로젝트를 하며 느낀건데, 행동 트리 로직을 짜는 것이 FSM 생각보다 접근성이 높은 것 같습니다.
또한 FSM만큼 상태에 대한 전이가 시각적으로 명확하게 보이지도 않은 것 같구요.
그래서 나중에 자체엔진을 만들 땐 FSM과 행동 트리를 섞어 쓰는 하이브리드 전략도 기용해볼 것 같습니다.
이상 저였습니다. 감사합니다.

오늘 C++에 대한 빨간약을 또 하나 먹었다...

 

안녕하세요. 저에요.

오늘도 슬프게 코딩을 하던 도중 C++ 빨간약을 먹었는데요?

다중 상속에 대한 것인데, 매우 간과하고 사용하고 있었어요.

C++의 세계란 알아도 끝이 없네요.

 

함께 알아가보자.


상속의 구조

상속은 컴파일러가 어떤 식으로 처리할까요?

바로 상속 객체 단위로 메모리 레이아웃을 배치하게 되는데요. (알고계셨다구요? 그럼 바로 아래 파트로 넘어가시죠.)

그게 무슨 소리임? 하실까봐 예시 코드를 하나 가져왔습니다.

class A 
{ 
public: 
    int a_member; 
};
class B 
{
public: 
    int b_member; 
};
class C : public A, public B 
{
public: 
    int c_member; 
};

 

A와 B를 상속받은 클래스 C가 있다고 칩시다.
그럼 메모리 레이아웃은 어떻게 형성될까요?

바로 C를 만들면서 상속한 순서대로 레이아웃이 형성됩니다.

 

바로 아래처럼 형성되는데요.

저 같은 놈 말은 안믿는다고요?

 

그럼 비주얼 스튜디오 메모리 레이아웃을 당장 켜보세요.

그럼 제 말대로 나옵니다. ㅇㅅㅇ

 

그럼 검증해볼까요?

int main()
{
	C* c = new C;
	std::cout << "a_member : " << &c->a_member << std::endl;
	std::cout << "b_member : " << &c->b_member << std::endl;
	std::cout << "c_member : " << &c->c_member << std::endl;
	return 0;
}

위 예시 클래스를 이용해 main함수를 작성해 보았습니다.

결과는 어떻게 나올까요?

더보기

결과:

4byte 단위 오프셋이 제대로 적용된 모습입니다.

여담으로, C의 상속 순서를 뒤집으면 어떻게 될까요?

더보기

이렇게 상속하면...

이렇게 뜬답니다?
와~~~ 신기하다~~~

 

지금까지 상속의 구조를 알아봤는데요.

"이게 업캐스팅이랑 뭔 상관임? 내 시간 돌려놓으셈!!!"

 

 

다음 파트에서 설명해보겠습니다...


다중 상속에서의 업캐스팅? 알고 쓰자!

위 설명에 사용한 예제에 이어 다음과 같은 코드를 추가해봤습니다.

c를 할당하고, c를 A객체로 업캐스팅한 a와 B객체로 업캐스팅한 b가 있습니다.

class A { public: int a_member; };
class B { public: int b_member; };
class C : public B, public A { public: int c_member; };

int main()
{
	C* c = new C;
	A* a = c;
	B* b = c;
	void* aPtr = static_cast<void*>(a);
	void* bPtr = static_cast<void*>(b);
	void* cPtr = static_cast<void*>(c);
	std::cout << "a: " << aPtr << std::endl;
	std::cout << "b: " << bPtr << std::endl;
	std::cout << "c: " << cPtr << std::endl;
	return 0;
}

 

결과는 어떨까요?

똑같지 않을까요?

더보기

결과:

c == a, c != b

충격적이게도 c와 c를 업캐스팅한 a는 같지만 c를 업캐스팅한 b는 다르다...

결과를 보셨나요?

 

왜 이런 결과가 나왔을까요.

사실 앞서 한 상속의 구조 설명을 잘 읽어보면 답이 있습니다. (괜히 설명한게 아니라고)

c는 사실 사실 가장 첫 상속 객체의 시작 주소를 가리키는데요.

C 클래스는 A->B순서로 상속받았으니 사실상 C에 속한 A 상속 객체의 시작 주소를 가리키고 있는겁니다.


그래서 결과적으로,

c(생성한 원본 C 객체) >> (C 클래스는 C::A의 시작 주소다.)

a(C객체에서 업캐스팅한 A객체) >> (C::A의 시작 주소) 

이므로 c == a가 되는 것이고,

b(C객체에서 업캐스팅한 B객체) >> (C::B의 시작 주소 (C의 시작 주소(A)에서 A객체의 메모리 크기만큼 건너 뛴 주소)

이므로 c != a가 되는 것입니다.

따라서 b는 c혹은a의 + 4byte(A객체의 크기)만큼의 주소를 가지게 되는거죠.


사실 이렇게 보면 당연한 소린데, 왜 이런 생각을 안했는지 모르겠네요. 쩝


마치며,

근데 뭔가 이상하지 않나요?

제가 아는 캐스팅은 주소는 안바뀌고, 해당 자료형을 바꿔서 사용하는 줄 알았는데?

c의 주소를 b로 캐스팅하니까 주소가 바뀐거 아닙니까?

그렇다는 것은 캐스팅 메소드가 알아서 주소를 C::B의 오프셋을 적용해서 반환해줬다는 건데.... 이거 괜찮은거 맞음...?

그것에 대해선 다음 포스팅에 다뤄보겠습니다. (이거때문에 (1)이라 적은 것 << 퍽퍽)

 

어쨋든!

이래서 RAII 설계가 중요하다고 하는 것 같습니다.

객체의 생성을 담당한 주체가 소멸도 담당해야한다는 것이, 이런 문제가 발생할 수도 있으니까 그런 것 같습니다.

 

이상 저였습니다. 감사합니다.

+ Recent posts