C++: 객체를 생성해낸다는 것

많은 분들 — 특히 고수준의 스크립트 언어 사용자거나, 고수준/저수준 추상화가 부족한 C, Java 사용자들[1] — 이 흔히 C++에서 하는 삽질 중의 하나는, C++의 객체 생성이 어떻게 이루어지는지 오해하는 것. IRC에서 과 후배들에게 좀 물어보니 명확히 아는 사람은 드물더라(정확한 동작을 아는 사람이 없었다).

C++에서 객체를 생성한다는 것은 크게 2 단계의 작업이 필요하다.[2]

  • 객체를 저장하기 위한 메모리 공간을 확보하는 것
  • 객체 자체가 “초기화” 되는 것

이렇게 두 가지이고, C++에서는 이 두 가지를 구분할 수 있는 문법적인 도구를 제공한다.

예제로 사용하기 위해서, 이런 클래스가 하나 있다고 하자.

class Base
{
protected:
    int x;
public:
    Base() { x = 1; }
    virtual ~Base() { }
    virtual void DoSomething() { cout << "Base:" << x << endl; }
};

메모리 할당 + 초기화를 동시에

우선 두 단계가 “동시에” 일어나는 코드를 하나 보자.

Base* x = new Base();

new가 메모리를 할당해주고, Base() 함수(?)가 데이터를 초기화한다.

메모리 할당과 초기화를 분리

  • 전역 new 함수를 호출해서 메모리를 할당하고
  • 할당된 메모리에 초기화를 수행하는[3]

예제를 하나 들어보면 이런 것.

Base* y = new (static_cast<Base*>( operator new(sizeof(Base)) )) Base();
// or equivalent allocation/initialzation
Base* p = static_cast<Base*>( operator new( sizeof(Base) ) ); // 생성
new (p) Base(); // 초기화(생성자호출)

참고로 처음 한 줄 / 나중 2줄이 같은 의미다. operator new( 크기 ) 로 호출한 코드가 전역 new를 써서 메모리 할당[4] + 그러고 나서 new (메모리 위치) 생성자() 호출로 초기화를 수행한다.

다만 이런 동작을 하고나면 소멸자도 직접 호출해줘야하고, 메모리 해제도 따로 해야한다. 즉, 이런 코드를 사용해야 한다.((대조적으로 동시에 할당/초기화 했던 코드는 delete x로 끝이지))

p->~Base();
delete static_cast<void*>(p);

이걸 알면 뭐에 써먹을 수 있냐고? 메모리 할당과 객체 생성이 분리되었다는 사실을 알면,

  • intel TBB의 scalable malloc 같은 함수를 호출해서 메모리를 할당하고 / 객체를 만들어낼 수 있고[5]
  • 임시 객체를 (소멸자가 필요없다면 더 좋고) 다형성을 쓸 수 있는 형태로 잠시 스택에 할당하고 써버릴 수도 있고[6]
  • 메모리 풀링 / 객체 생성 프레임웍을 작성할 수도 있다[7]

뭐 이런 것들이 가능하다.[8] 반면에 이렇게 사실 상 두 단계인 객체생성을 이해 하지 못하면 다음과 같은 경우를 볼 수 있다.

  1. malloc()으로 할당하고 — 당연히 생성자는 호출 안되고 — 객체를 사용하려고 시도.
  2. memcpy 혹은 유사한 형태로 객체의 데이터만 가지고 있는(초기화된)상태에서 가상함수를 호출. [9] 이 경우엔 참 재미있는 에러메시지를 볼 수 있다.

1의 경우는 학부생 시절에 프로젝트 수업에서 같은 팀원의 코드 — C 프로그래머고 C++은 잘 모르는 — 에서 발견했다. 생성자가 없는 C 의 부족한 추상화에 당한 경우라고 생각한다.

2의 경우는 사실 이 토픽과는 약간 거리가 있긴하지만, 놀랍게도 회사원 생활 중에 발견. C++의 추상화가 애매한 수준이라 발생한 문제인 것 같다. OS/Compiler에 따라선 virtual function table pointer를 잘 조작해서(…) 실제로 객체가 생성된 것처럼 동작하게 만들 수 있지만, “확신이 서도” 왠간하면 하지 말 짓이겠지. 근데 저게 왜 안되는지 이해하지 못하고 있는건 좀 보고있기 괴로웠다(…).

여튼 한 줄 요약.

C++의 객체 생성은 할당/초기화 2 단계고, 이걸 잘 이용하면 성능을 올리거나 편의성을 증대할 수 있다.

ps. 뭔가 요즘 주석이 주저리주저리 달린 글을 자주 쓰게되는데 뭔가 주석이 필요없거나 / 주석이 잘 이해되게 글을 쓰는 좋은 방법이 없을까;

  1. 이 포스팅 전체에서도 말하고 있는 내용이지만, C의 경우엔 객체를 초기화한다는 개념이 제대로 추상화되어 있지 않다. 반면에 Java는 객체를 만들어낸다는 개념은 통짜로 존재하지만, 실제로 이 과정을 제어할 수 있는 수단 혹은 추상화는 제공되지 않는다. []
  2. 그렇지만 편의상 이 두 가지 연산이 하나인 것처럼 표현하는 문법이 있는 것도 분명한 사실 []
  3. 일명 displacement-new operator; 위치지정 new 연산자라고도 한다. []
  4. C에서 malloc()이나 그런 류의 함수를 써본 사람들은 익숙할 듯 []
  5. 상대적으로 cache 효율이 좋거나, 코어 수가 많을 때도 효율적인 할당자를 선택적으로 쓸 수 있다. []
  6. 스택 객체로 다형성 쓰는거 참 골치아픈일인데 쉽게 된다. 다만 소멸자가 필요하면 직접 호출해야하는 것은 괴롭다. []
  7. 메모리를 프로그램 시작 시에 정적 혹은 동적으로 왕창 할당받고, 여기에다 대고 displacement-new를 호출해서 객체를 만들고, 소멸자 호출해서 없애는 식으로 사용하기 쉬워진다. 그리고 이런데는 domain-specific-knowledge를 쓰면 성능 올리기 좋을 걸? []
  8. 아마 이게 C++의 저레벨 접근과 추상화의 힘? 혹자는 이런것 때문에 너무 복잡하다고 말하기도 하겠지만 — 어떤 의미에서 이미 C++은 전문가/Guru?의 언어가 되어가고 있으니 []
  9. 비가상함수들은 잘 호출될 수도 있지만 []

Author: rein

나는 ...

12 thoughts on “C++: 객체를 생성해낸다는 것”

  1. 굳이 1과 2 사이에 하나쯤 끼워 넣자면

    1. 메모리를 할당한다.
    1.1. 상속을 통해 만들어진 클래스의 경우, 함수 테이블 포인터를 갱신함으로써 “정체성” 문제를 해결한다. 이 포인터는 클래스 초반의 4바이트에 저장되는 것으로 기억.
    2. 생성자 호출. 변수 초기화.

    정도 되겠네요.

    가상 포인터 문제는 성능 등 이런저런 데서 자주 다루어지는 데다가 c++과 c#의 차이점을 논할 때도 자주 이야기되는 것인지라 생각나는 게 이 정도네요. 그런데, 평소에 잘만 쓰다가 갑자기 위 조건들을 딱딱 끊어서 이야기하는 것은 좀 당황스러워서라도 말이 잘 안나올 듯.(그래도 memcpy는 좀 심하지만요. 클래스 정체성에 대해 잘 모른다는 얘기?)

  2. 굳이 끼워넣는다기보단 vftbl을 초기화해주는 작업은 생성자에서 무조건 일어난다. 그것도 상속단계 따라 각 생성자마다 무려 각각 자기가 아는 vftbl의 주소로 새로 덧씌우는 작업이 일어나지. (그래서 생성자 안에서 가상함수를 부르면 쳐죽일놈이 될 수 있다(…))

  3. 아, 그 작업을 생성자가 해주는 것이었군요. 생성자 안에서 가상함수 불렀다가 피보는 이야기는 Effective c++에서 본 듯. c++에서는 안되지만 c#에서는 되는 경우의 대표적인 예인 듯.

  4. 그렇지 생성자에서 해주는 것이지.

    가상함수가 생성자의 현재까지 생성된 지점에 의존적(…)으로 가상함수를 호출하기 때문에, 추상 기반 클래스에서 하위 클래스의 가상함수를 호출했다간
    “Pure Virtual Function Call” 같은 아스트랄한 에러를 볼 수 있음;

  5. stack alloc (C alloca() 함수) -> displacment new

    소멸자가 필요없다면 stack rewind때 알아서 사라져주시기까지함(…)

Leave a Reply