내일배움캠프/TIL

TIL260526 - CPP

옆집히드라 2026. 5. 26. 12:23

1. 핵심 개념


  • sort에서는 comp가 부등호 순으로 오름차순, 내림차순(<면 오름차순)이고, priority_queue에서는 <면 최대힙, >면 최소힙임(a가 b보다 우선순위가 낮나요?)
  • nullptr의 this
  • cpp에서 virtual은 내부적으로 어떻게 구성되었을까?(vtable) 그리고 메모리 레이아웃은 어떻게 생겼는가? 패딩? 버츄어 테이블?

2. 상세 내용


cpp에서의 다형성(virtual)

컴파일러는 virtual 메서드를 선언하지 않은 메서드를 컴파일 시간에 결정된 타입의 코드로 교체한다.(static binding or early binding)
만약 메서드에 virtual 키워드가 달린 경우 vtable이라고 부르는 특수 메모리 영역을 활용해서 가장 적합한 구현 메서드를 호출한다. 그리고 생성된 객체들은 내부에 vtable에 대한 포인터를 가지게 된다.

대부분의 경우 virtual 구현으로 인한 성능 손해는 없으나 virtual 메서드를 가진 클래스의 객체를 수십억 개 생성한다면 각 객체가 가지는 포인터 공간의 크기나 메모리 오버헤드는 무시할 수 없게 된다.

#include <iostream>

using namespace std;

class Base {
public:
    virtual void func1();
    virtual void func2();
    void nonVirtualFunc();
};

class Derived : public Base {
public:    
    virtual void func2() override;
    void nonVirtualFunc();
};

int main()
{
    Base myBase;
    Derived myDerived;

    return 0;
}

 

부모 타입에 자식 생성

부모 타입에 자식을 생성할 때 스택 영역에서 자식을 생성하는 경우 다형성 정보가 잘려나갈 수 있다.

1. 객체 슬라이싱 (값 대입)

  • 문법: Base base = Derived();
  • 동작: 컴파일러는 base 변수를 위해 스택에 Base 클래스 크기만큼의 메모리만 할당합니다. 자식 클래스인 Derived 객체를 대입하려고 하면, 공간이 부족하여 자식 고유의 데이터와 다형성 정보(vtable)가 잘려나가는(Slicing) 현상이 발생합니다.
  • 결과: 정적 바인딩(Static Binding)이 일어나 무조건 부모의 함수가 호출됩니다. 다형성이 동작하지 않습니다.

2. 포인터를 통한 다형성 (힙 할당)

  • 문법: Base* base = new Derived();
  • 동작: 자식 객체를 힙(Heap) 영역에 온전히 생성하고, 부모 타입의 포인터로 그 주소를 가리킵니다. 메모리 잘림이 발생하지 않습니다.
  • 결과: 동적 바인딩(Dynamic Binding)이 일어나 런타임에 실제 가리키는 객체(Derived)를 확인하고 자식의 함수를 호출합니다.

3. 참조를 통한 다형성 (스택 유지)

  • 문법: Base& base = derived_obj;
  • 동작: 동적 할당(new)을 피하면서도 다형성을 유지하고 싶을 때 사용합니다. 스택에 자식 객체를 온전히 생성한 뒤, 부모 타입의 참조자(&)로 이를 참조합니다.
  • 결과: 포인터와 동일하게 동적 바인딩이 일어나 자식의 함수가 호출됩니다.

nullptr의 this 호출

아래 예제에서 다음의 사실을 알 수 있다.

  • 멤버 함수는 객체 내부에 존재하지 않고, Code Segment에서 단 하나만 만들어진다.
  • this는 Implicit Parameter이다.
  • nullptr의 this 포인터는 내부적으로 멤버 변수를 참조하지 않는 이상 크래시가 발생하지 않는다.
#include <iostream>

using namespace std;

class Something {
public:
    void func() {
        cout << "Hello, World!" << endl;
    }
};

int main()
{
    Something* s = nullptr;

    s->func();

    return 0;
}

위와 같은 레이아웃에서 s->func()가 정상적으로 작동하는 것을 볼 수 있다.

이는 this가 클래스에 내재된 변수가 아니라 s->func(&this)와 같이 컴파일러가 치환하는 포인터임을 알 수 있다. 그 결과 s는 nullptr임에도 컴파일 타임에 만들어진 Something의 메모리 레이아웃을 참조해 크래시 없이 func를 호출한 것을 알 수 있다.(단, 이는 c++ 표준상 UB(미정의 동작)로 컴파일러에 따라 부작용이 발생할 수 있다.)

단, 함수 내에서 this를 사용하는 경우 에러가 발생한다.(this는 nullptr이므로)

--- C:\NDH\NBC\CPP\cppPractice.cpp ---------------------------------------------
#include <iostream>

using namespace std;

class Test {
public:
    int a = 10;

    virtual void func()
    {
00007FF6565324E0  mov         qword ptr [rsp+8],rcx  
00007FF6565324E5  push        rbp  
00007FF6565324E6  push        rdi  
00007FF6565324E7  sub         rsp,0F8h  
00007FF6565324EE  lea         rbp,[rsp+20h]  
00007FF6565324F3  lea         rcx,[__DAC5EE27_cppPractice@cpp (07FF65654C078h)]  
00007FF6565324FA  call        __CheckForDebuggerJustMyCode (07FF6565316B8h)  
        cout << "Hello, Virtual World!" << endl;
00007FF6565324FF  lea         rdx,[string "Hello, Virtual World!" (07FF656540CC0h)]  
00007FF656532506  mov         rcx,qword ptr [__imp_std::cout (07FF65654A210h)]  
00007FF65653250D  call        std::operator<<<std::char_traits<char> > (07FF6565310D2h)  
00007FF656532512  mov         qword ptr [rbp+0C0h],rax  
00007FF656532519  lea         rdx,[std::endl<char,std::char_traits<char> > (07FF656531050h)]  
00007FF656532520  mov         rcx,qword ptr [rbp+0C0h]  
00007FF656532527  call        qword ptr [__imp_std::basic_ostream<char,std::char_traits<char> >::operator<< (07FF65654A1F8h)]  

        cout << this->a << endl;
00007FF65653252D  mov         rax,qword ptr [this]  
00007FF656532534  mov         edx,dword ptr [rax+8]  
00007FF656532537  mov         rcx,qword ptr [__imp_std::cout (07FF65654A210h)]  
00007FF65653253E  call        qword ptr [__imp_std::basic_ostream<char,std::char_traits<char> >::operator<< (07FF65654A248h)]  
00007FF656532544  mov         qword ptr [rbp+0C0h],rax  
00007FF65653254B  lea         rdx,[std::endl<char,std::char_traits<char> > (07FF656531050h)]  
00007FF656532552  mov         rcx,qword ptr [rbp+0C0h]  
00007FF656532559  call        qword ptr [__imp_std::basic_ostream<char,std::char_traits<char> >::operator<< (07FF65654A1F8h)]  
    }
00007FF65653255F  lea         rsp,[rbp+0D8h]  
00007FF656532566  pop         rdi  
00007FF656532567  pop         rbp  
00007FF656532568  ret  

디셈블리한 결과를 보면 this 포인터가 implicit parameter인 것을 명확하게 알 수 있다. 컴파일러는 함수의 첫번째 매개변수로 객체 자신의 포인터를 넘기는데(00007FF6565324E0) 디버거에서는 이를 this로 표기한다.

this 포인터는 프로그래머가 명시적으로 넘기지 않아도 컴파일러 차원에서 this 포인터를 넘겨서 사용한다.(00007FF65653252D) 그리고 이렇게 받은 this 포인터에서 8byte 이동해서(00007FF656532534) 객체의 멤버 변수인 a를 가져오고 operator<<에 사용한 결과를 알 수 있다.

추가적으로 위 예제에서 클래스의 메모리 레이아웃을 직접 포인터로 접근해볼 수 있지 않을까 해서 아래와 같이 직접 접근 해보았다.

#include <iostream>

using namespace std;

class Test {
public:
    virtual void func()
    {
        cout << "Hello, Virtual World!" << endl;
    }
};

// 클래스 멤버 함수는 보이지 않는 this 포인터를 첫 번째 인자로 받으므로, 
// 매개변수로 Test* 를 받는 함수 포인터 타입을 정의합니다.
typedef void (*FuncType)(Test*);

int main()
{
    Test t;

    // 1. 객체의 시작 주소(&t)에는 vptr이 있습니다. 
    // vptr은 포인터들의 배열(vtable)을 가리키는 포인터이므로, void*** 로 캐스팅합니다.
    void*** vptr_address = reinterpret_cast<void***>(&t);

    // 2. 한 번 역참조하면 가상 함수 테이블(vtable)의 실제 메모리 주소가 나옵니다.
    void** vtable = *vptr_address;

    // 3. vtable 배열의 0번째 인덱스에 첫 번째 가상 함수인 func()의 주소가 들어있습니다.
    void* func_address = vtable[0];

    // 4. 추출한 메모리 주소를 우리가 정의한 함수 포인터 타입으로 캐스팅합니다.
    FuncType manual_func = reinterpret_cast<FuncType>(func_address);

    // 5. 함수를 호출합니다. 이때 객체의 주소(&t)를 넘겨주어 this 포인터 역할을 하게 합니다.
    manual_func(&t);

    return 0;
}

3. 질문 및 해결 (Q&A)


* 메모리 릭 체크

int DbgFlag = _CrtSetDbgFlag(_CRTDBG_REPORT_FLAG); DbgFlag |= _CRTDBG_ALLOC_MEM_DF; DbgFlag |= _CRTDBG_LEAK_CHECK_DF; _CrtSetDbgFlag(DbgFlag);

4. 관련 문서 (Links)


'내일배움캠프 > TIL' 카테고리의 다른 글

TIL260529  (0) 2026.05.29
TIL260528 - 챕터2 프로젝트 - 2  (0) 2026.05.28
TIL260522 - Unreal  (0) 2026.05.22
TIL260521 - Unreal  (0) 2026.05.21
TIL260518 - VFX  (0) 2026.05.18