알림!
이 글은 국방망 내 대한민국 해군 1함대 지휘통신대대 자유게시판에 게시한 내용을 베껴 적은 뒤 오타 및 비문만 수정한 글입니다. 실제 작성일은 2022년 3월 9일입니다.
필승! 불철주야 당직근무로 고생하시는 전국의 수병님들, 오늘도 안전항해를 기원합니다.
휴가 가서 MP3에 분명 노래를 잔뜩 넣어 왔을 텐데 어째서인지 홀로라이브 오리지널 음반의 80%가 재생 불능이 되어있었습니다. 거기에 아틔시가 너무 좋아서 정신이 나갈 것 같은 노래도 포함돼 있어서 매우 슬픕니다. 코러스 같이 넣어주실 수뱀을 찾습니다. (\아쿠아!/ \아쿠아!/ \아쿠아!/ \아쿠아!/)
아무튼, 저번에 썼던 프롤로그 퀴즈의 정답을 공개합니다. 아직 퀴즈 내용을 보지 못하셨다면 아래 링크에서 한번 풀어봐 주십시오.
이 멋진 흑마법에 축복을! 프롤로그 - C++의 기묘한 모험
사실 이 문제는 그렇게까지 어려운 문제는 아니지만, C++ 기반 지식과 ABI에 관한 사전 지식이 없다면 풀기 곤란한 문제입니다. 평소에 확인할 필요가 없는 내용을 만나게 되니 마음을 다잡고 열린 생각으로 접근해주시면 감사하겠습니다.
스읍, 후우…
심호흡 하고…
그러면 시작합니다.
하나. 시작이 반이다.
문제의 코드는 아래와 같습니다.
((void(__thiscall*)(Entity*, unsigned int))(*(void***)this)[N])(this, index);
음, 보기만 해도 구역질이 ㄴ…. 쿨럭
아무튼 잘 모르겠을 때는 DIVIDE AND CONQUER! 나폴레옹이 된 것처럼 문제를 조각조각 내서 야금야금 해치워 줍시다.
일단 위 코드를 쌈싸먹기 전에 조각부터 내는 게 좋겠습니다.
using mystery_t = void(__thiscall*)(Entity*, unsigned int);
auto mystery_val = (*(void***)this)[N];
((mystery_t) mystery_val)(this, index);
이걸로 문제의 코드는 mystery_val을 mystery_t 타입으로 캐스팅한 뒤, this, index를 매개변수로 해서 호출하는 물건이라는 점을 알 수 있습니다.
여기까지 하면 본 문제의 반 이상은 정답을 맞힌 것과 다름없습니다! 왜냐하면 이제 남은 건 mystery_t의 의미와 mystery_val의 정체를 밝혀내기만 하면 되기 때문입니다!
함수 포인터 뽀개기
뭐가 됐든 이건 함수 호출이니 mystery_t가 뭔지부터 알아보는 게 좋을 것 같습니다.
using mystery_t = void(__thiscall*)(Entity*, unsigned int);
일단 타입 시그니처만 보면 리턴 타입, 애스터리스크, 매개변수 타입으로 이루어져 있는 것이 보입니다. 이 친구는 함수 포인터 타입이군요!
함수 포인터가 무엇이냐! 말 그대로 어떤 함수를 가리키는 포인터라고 생각하시면 되니다. 이 포인터는 일반 함수처럼 호출할 수도 있죠! 함수 포인터는 보통 콜백 함수가 필요한 경우 애용합니다.
자, 다시 가운데 애스터리스크 부분부터 시작해서 찬찬히 함수 포인터를 낱낱이 분해해 보겠습니다.
(__thiscall*)
보통 함수 포인터라면 * 하나만 붙어 있을 텐데 어째서인지 __thiscall이라는 키워드가 붙어 있습니다. 이게 뭘까요?
컴파일러의 세상에는 여러 가지 고려할 점이 많지만, 특히 함수 호출에서 가장 중요하게 생각해야 하는 것은 호출 규약(calling convention)입니다. 호출 규약은 매개변수의 위치, 스택의 할당 및 뒷정리, 리턴값의 위치 등을 어떻게 할 것인지 미리 약속한 것이라고 할 수 있습니다. 호출 규약이 맞지 않다면 매개변수의 위치가 뒤바뀐다거나, 스택을 사용하려는데 스택이 할당되지 않았거나, 스택 뒷정리를 하지 않아서 메모리가 줄줄 샌다거나 하는 일이 발생하기 때문이죠.
보통 Windows 환경에서 C언어는 cdecl(기본값), stdcall, fastcall 이상 3개의 호출 규약을 지원합니다. Visual C++에는 C에서 지원하는 3개의 호출 규약에다가 하나를 더 추가하는데, 바로 그게 thiscall입니다. thiscall은 이름에서도 볼 수 있듯이, this 포인터를 포함하는 호출 규약으로, 클래스의 메서드는 자동으로 모두 thiscall 규약으로 호출됩니다.
위 타입에서 애스터리스크 옆에 __thiscall 키워드가 붙어 있는 걸 보면 이 함수 포인터는 thiscall 규약을 사용해서 호출해야 한다는 걸 알 수 있습니다.
즉, 여기까지 분석했을 때 mystery_t는 thiscall 호출 규약을 사용하는 함수로의 포인터입니다.
void(__thiscall*)
다음은 애스터리스크 왼쪽의 void인데, 이 친구는 간단합니다. 이 포인터는 아무것도 반환하지 않는 함수의 포인터라는 점이죠.
그러면 mystery_t는 thiscall 호출 규약을 사용하고 아무것도 반환하지 않는 함수로의 포인터가 됩니다.
void(__thiscall*)(Entity*, unsigned int)
이제 마지막, 매개변수 정보입니다. 이 함수는 Entity 인스턴스로의 포인터랑 unsigned int 하나를 받는 함수인가 봅니다.
그런데 잠깐! 위에서 클래스의 메서드는 모두 thiscall 호출 규약을 사용해서 호출한다고 설명드렸습니다. thiscall의 특징은 파이썬의 self처럼 첫 매개변수로 대상 객체로의 포인터를 넘긴다는 것인데, 이 포인터가 바로 메서드 안에서 쓰이는 this의 정체입니다. (그래서 이름이 thiscall이죠!)
그럼 이 정보를 바탕으로 이 함수 포인터가 무엇인지 다시 정리해 보겠습니다.
이 함수의 시그니처에서 첫 번째 매개변수는 Entity 인스턴스 포인터입니다. 즉, 여기서 this는 Entity 인스턴스가 되죠. 이건 무슨 의미냐?! 바로 이 함수 포인터는 사실 "함수"가 아니라 "Entity 클래스의 메서드" 포인터라는 것입니다!
그러면 마지막으로 mystery_t는 아래와 같이 정리할 수 있겠습니다.
"mystery_t는 unsigned int 하나를 매개변수로 받아 아무것도 반환하지 않는 Entity 클래스의 멤버 메서드로의 포인터다."
이러면 mystery_val의 정체도 대출 알 수 있겠죠?
셋. "구현체에 맡긴다"니 직무유기도 정도껏 해야지
먼저 mystery_val의 코드부터 보겠습니다.
auto mystery_val = (*(void***)this)[N];
이 친구도 안에서부터 하나하나 분석하는 게 좋을 것 같습니다. N이야 컴파일 타임 상수라고 하니 무시하고, 실제로 의미 있는 값을 갖는 친구는 this 뿐이니 저기부터 시작해 볼까요?
this
힌트에서 알려준 대로, 모든 코드는 Mystery::do_something 메서드에서 발췌했기 때문에 여기서 this는 Mystery 인스턴스를 가리키는 포인터일 겁니다. 즉, 타입은 Mystery*라고 할 수 있죠. 이 친구를 다른 타입으로 캐스팅할 모양입니다.
(void***)this
…? 캐스팅할 타입의 모양이 좀 이상합니다. void의 포인터의 포인터의 포인터로 캐스팅한다니, 이게 무슨 돌고래 보고 앵카 박히는 소리입니까?
여기서는 한 번 건너뛰어서 다음 단계를 보는 게 더 좋을 것 같습니다.
*(void***)this
this의 타입이 Mystery*였다는 점을 생각하면, 이건 this를 Mystery의 인스턴스 포인터가 아니라, void**의 포인터로 생각하기로 했다고 짐작할 수 있습니다.
하지만, 과연 그럴까요?
아시다시피, C/C++에서 배열과 포인터는 사실 차이가 없습니다. int *var1과 int var2[] 모두 실제 변수 안에 들어가는 값은 메모리의 주소이기 때문입니다. 예를 들어, 배열 arr의 n번째 값을 가져오려면 보통 arr[n]이라고 씁니다. 하지만 arr[n]은 실제로 어셈블리로 컴파일됐을 때 *(arr + n)이랑 완벽하게 일치하게 바뀝니다. C의 배열 인덱스가 1이 아니라 0에서부터 시작하는 이유도 바로 여기에 있죠!
이 사실을 염두에 두고 다음 단계로 가보겠습니다.
(*(void***)this)[N]
void** 타입으로 마개조당한 *this의 N번째 항목에 접근하고 있습니다. 그러면 조금 전 우리의 예상이 완벽하지는 않았다는 뜻입니다. this 포인터가 가리키는 객체는 void의 포인터의 포인터가 아니라 void*의 배열인 것이죠!
그러면 void*는 무엇인가! void는 C/C++에서 "아무것도 없다"를 나타내는 타입이잖습니까? 아무것도 없는 것의 포인터가 존재할 수 있는 걸까요?
정확히 말하면, void*에서 void는 "타입 정보가 없다"를 나타냅니다. 즉, void*는 "아무튼 포인터인데 무슨 타입의 포인터인지는 모르는 포인터"라고 할 수 있습니다. 그렇기에 이 포인터를 따라가기 위해서는 먼저 캐스팅을 이용해서 int*처럼 어떤 타입을 가지고 있다고 명시해 줘야 합니다.
여기까지 코드를 읽었을 때 위 코드는 다음과 같은 의미라고 할 수 있습니다.
"mystery_val은 this가 가리키는 값을 void 포인터의 배열으로 해석했을 때 그 배열의 N번째 값이다."
그런데 의아한 점이 하나 있습니다. 애초에 Mystery의 인스턴스를 왜 void*의 배열으로 해석하는 걸까요?
여기서 프로그래밍 언어의 최대 가불기가 등장합니다.
"아, 자세한 건 구현하는 사람에게 맡길게요."
사실 제가 이 문제를 낼 때 운영체제, 컴파일러, 런타임 버전까지 명시한 이유가 있는데…
컴파일러마다 같은 기능을 구현하는 방식이 천차만별이기 때문입니다. 왜 이런 이야기를 하냐면, 지금부터 이야기할 내용은 C++ 표준에 전-혀 명시되어있지 않은 것이기 때문입니다.
자, 제가 문제에 드렸던 힌트 중 하나로 잠시 돌아가 보겠습니다. 문제에는 제가 앞뒤 문맥만 알려준다고 했지만 사실 여기에 엄청 큰 힌트가 숨겨져 있지 말입니다…
5.
Entity는 가상 클래스이며,Mystery클래스와 "어떤 관계"를 맺고 있습니다.
Entity는 가상 클래스이며,
가상 클래스
이게 바로 결정적인 힌트였습니다. 가상 클래스의 가장 큰 특징이 뭘까요? 바로 vtable의 존재입니다.
vtable을 이해하려면 C++에서 상속을 어떻게 구현하는지부터 살펴볼 필요가 있습니다!
만약 자식 클래스가 부모 클래스의 메서드를 재정의한다면 컴파일러는 어떤 인스턴스가 부모 클래스인지 자식 클래스인지 어떻게 구분할까요?
가상 함수 테이블(virtual function table), 줄여서 vtable을 사용하는 방식입니다!
어떤 클래스의 인스턴스를 만들 때마다 메서드 코드를 복사하는 건 공간적으로 효율적이지 않습니다. 그래서 C++ 컴파일러는 클래스의 메서드를 하나씩만 만들어 두고, 인스턴스의 포인터만 바꾸면서 같은 코드를 재사용하는 방법을 택했습니다.
클래스의 메서드가 가상 함수가 아니라면 일은 간단합니다. 어차피 자식 클래스에서 재정의할 수 없으니 컴파일러는 간단하게 링킹만 잘 해주면 됩니다. 그러나 부모 클래스의 타입으로 자식 인스턴스를 저장한다면, 어떻게 자식 클래스에 맞는 메서드를 호출할 수 있겠습니까?
여기서 발상의 전환을 한번 해보겠습니다. 전지적 시점에서 컴파일하는 게 불가능하다면, 각 인스턴스가 자신이 호출해야 할 메서드를 알면 되는 것 아닙니까?
즉, 각 인스턴스마다 정해진 위치에 자신이 호출할 수 있는 가상 메서드를 어떤 자료구조(vtable)에 저장해 두고, 필요할 때 가져다가 호출하면 되는 것 아니겠습니까!! 여기서 "정해진 위치"가 포인트입니다. 만약 vtable이 오만군데에 흩어져 있다면 어디서 어떻게 함수를 찾을지 알 수 없지 않겠습니까?
참고로, Windows 환경에서 VC++2012로 컴파일하면 vtable을 항상 인스턴스의 처음에 메서드 포인터의 배열으로 저장됩니다.
에, 잠깐…
인스턴스의 처음에…
포인터의 배열…?
어디서 많이 들어본 조합 아닙니까?
바로 mystery_val의 의미 그 자체 아닙니까!!!!!!!
자, 이렇게 우리는 mystery_val의 진정한 의미를 알아낼 수 있게 되었습니다.
"mystery_val은 this의 vtable로부터 N번째에 위치하는 메서드로의 포인터이다."
마지막. 퍼즐 맞추기
자, 이제 지금까지 모은 퍼즐을 다 짜 맞춰서 말로 바꿔 쓰면 골든 정답일 것 같습니다. 문제의 코드는
this의 vtable로부터N번째에 위치하는 메서드의 주소를unsigned int하나를 매개변수로 받아 아무것도 반환하지 않는Entity클래스의 멤버 메서드의 포인터로 바꿔서this와index를 매개변수로 하여 호출하는 코드입니다.
와아아!! 짝짝짝짝~
드디어 정답을 알아냈습니다!!!!!!!! 문제 풀기를 시도한 모든 분들 수고 많으셨습니다!
……근데 보너스 포인트의 정답은 뭘까요?
보너스 스테이지. 수 읽기 싸움
여기서부터는 출제자의 의도를 읽는 문제입니다.
아까 고찰해본 thiscall의 특성을 고려하면 매개변수로 등장하는 this는 Entity* 타입이어야 할 겁니다.
……이 코드가 Mystery::do_something 메서드에서 발췌됐다는 점만 빼면 말입니다.
즉, this는 Mystery* 타입인데 Entity* 타입으로도 호출할 수 있다…
그것참 객체지향 프로그래밍의 3대 특징 중 하나인 다형성(polymorphism)처럼 들리지 않습니까?!?!?!?
C++에서 다형성은 상속을 기반으로 하니 Mystery는 Entity의 자식 클래스라는 점을 알 수 있습니다!!!!
그런데 가장 큰 의문이 남습니다. "대체 굳이 왜?"
위 코드는 멤버 메서드를 호출하는 코드라는 점에서 좀 많이 이상합니다. 아무리 부모 클래스에서 선언된 메서드라지만, 자식 클래스이기 때문에 그냥 사용해도 문제가 없는데 굳이 이렇게 접근해야 했겠습니까? 그런데 굳이 이렇게까지 했다…?
부모 클래스에서 선언된 메서드지만 자식 클래스가 사용하면 문제가 될 만한 것이 있다…?
부모 클래스에서 선언됐지만 자식 클래스에서 사용할 수 없다…?
그러면 위 코드는 Mystery가 Entity의 private 메서드에 접근할 수 있도록 하기 위해 생 난리를 친 결과물이 아닐까?!
딩동댕동! 골든 정답 하나마루 도장 꾹입니다!
저 코드는 사실 모 인디게임팀이 개발한 게임에서 발견된 코드로, 스프라이트 렌더링에 관한 코드였습니다! 애니메이션에 제한받지 않고 스프라이트 자체만을 변경하는 코드가 부모 클래스에 private로 선언되어 있었기 때문에 어떻게 할 방법이 없어 무지성으로 부모 클래스의 메서드를 호출해버리기로 마음먹은 정신나간 코드입니다…
여기까지 요상한 C++ 코드를 분석해보는 시간을 가졌습니다. 즐거우셨나요?
원래는 프롤로그에 딸린 글이 될 예정이었던 글이지만 쓰다보니 내용이 너무나도 길어서 그냥 본편 1편이라고 하고 날로 먹기로 했습니다. 감사합니다.
사실 예시도 더 많고 엄청 내용이 풍부했는데 너무 사이즈가 커서 그런지 업로드가 안 되길래 포기했습니다…
다른 글도 분량이 최소 이거의 1.5배는 될 텐데 정말 고민이지 말입니다… 좋은 방법을 알고 계신 수병님은 댓글로 좀 알려주시면 감사하겟습니다…
아무튼 저는 조만간 또 다른 흑마법과 함께 찾아뵙겠습니다.
그러면 모두 몸조심하십쇼! 필승!
다음편 예고
"전설적인 개발자의 전설적인 알고리즘"
TO BE CONTINUED…