동적 객체 생성

Kang Chang Hwan·2024년 5월 27일

Computer Science

목록 보기
3/5
post-thumbnail

"객체는 언제 살았다가 언제 죽지?"

이번 포스팅에서는 객체의 생성에 대해 살펴보려고 한다.

Thinking in C++에 의하면 일반적인 프로그래밍 문제를 해결하려면 "runtime에 객체를 생성하고 파괴할 수 있는 것은 필수!"라고 한다.

객체의 생성은 3가지 저장소에 메모리가 할당되고 해제되는데,

  1. Static Stotrage
  • 전역, 정적 변수가 속하는 메모리 영역으로
  • 프로그램이 실행될 때부터 종료될 때까지 메모리가 해제되지 않는다.
  1. Stack
  • 함수 호출 시 함수 내 지역 변수가 생성됐다가 함수 호출 종료와 함께 사라진다.
  1. Heap(called free-storage)
  • 런타임 때 생성되는 공간으로 프로그래머가 객체를 생성하고 소멸시킬 수 있다.
  • 그런 만큼 객체가 사용되지 않을 때 소멸하는 것을 잊으면 메모리가 누수될 수 있다.

항상 궁금했던 거였지만 "이 공간들은 어떻게 생겼을까?" 였는데, 책에 의하면 해당 3가지 메모리 영역이 물리적 메모리의 연속적인 공간에 위치된다고 한다. 따라서 이러한 영역이 있다는 것 정도로만 알아도 괜찮다고 하니 그렇게 받아들이자.

C언어의 heap에 대한 접근 방식

class Obj {
     int i, j, k;
     enum { sz = 100 };
     char buf[sz];
   public:
     void initialize() { // Can't use constructor
       cout << "initializing Obj" << endl;
       i = j = k = 0;
       memset(buf, 0, sz);
}

int main() {
  Obj* obj = (Obj*)malloc(sizeof(Obj));
  require(obj != 0);
  obj->initialize();
  // ... sometime later:
  obj->destroy();
  free(obj);
} ///:~
  • 문제1.
    malloc을 통해 obj 크기 만큼의 메모리를 할당해야 하고 malloc은 void 를 반환한다. C++는 void를 다른 포인터에 할당하는 것을 허용하지 않기 때문에 반드시 타입이 Cast되어야 한다.

문제 2.
최악의 문제는 다음 부분이다.

Obj->initialize( );

만약에 객체를 초기화하는 것을 프로그래머가 까먹고 객체를 사용하게 된다면 어떻게 될까? 바로 컴파일 에러가 발생하기 때문에 프로그래머가 이를 일일이 기억했다가 객체 초기화를 해야 되는 것이다.

만약에 이런게 수백개라면..?? 지옥이기 때문에 컴파일러가 생성자를 호출하여 객체 초기화를 잊는 것을 방지한다. (아주 친절하네..^^)

C++의 접근 방식 new 연산자(operator)

MyType *fp = new MyType(1, 2);

이런식으로 new 키워드를 사용해 생성자를 호출하는데 이는 다음과 같다.

"나는 Runtime에 heap영역에
1) 해당 객체 크기만큼의 메모리 공간(location)을 할당(malloc())하고
그 주소에 2) 생성자를 호출할거야(객체 초기화)"

그리고 그 메모리 공간에 대한 주소를 반환한다.

그리고 생성자에 인자를 받지 않는 경우 다음과 같이도 생성자를 호출할 수 있다.

MyType *fp = new MyType;

delete 연산자

메모리를 할당했다면 해제(free( ))해야 한다. 이를 delete 키워드가 수행하는데 다음 의미와 같다.

"여~ 프로그래머 아까 heap에 객체 할당했잖냐 객체가 있는 주소 좀 줘볼래? 제거해야지 ㅋ 그 주소에 접근해서 소멸자 호출하고 메모리도 해제(free()) 할게."

소멸자(Destructor)와 가상 소멸자(Virtual Destructor)

생성자와 마찬가지로 소멸자는 객체에 할당단 메모리를 해제하는 역할으로 간단하게는 다음 syntax로 선언할 수 있다.

class Base {
  Base() {}
  ~Base() {}
};

위처럼 인자나 반환 타입이 없다.

그럼 가상 소멸자는 무엇이고 왜 필요한 걸까?

가상 소멸자의 필요성

객체를 다루다보면 Supertype의 포인터로 subtype의 객체를 가리키는 경우가 있다.

그럼 이 경우에는 Supertype의 포인터를 소멸시킬 때 기본 소멸자를 쓰면 어떻게 될까?

기본 소멸자의 동작 방식은 base에서 가장 파생된(derived) 클래스의 소멸자부터 하나씩 호출해서 base까지 제거해나가는데,

Supertype의 포인터를 소멸시킬 때는 파생된 클래스가 아니므로 파생된 클래스의 객체를 소멸시키지 않는 미묘한 버그가 발생할 수 있다는 문제가 있다.

이 때! 바로 다음과 같이 이 문제를 방지할 수 있다.

class Base {
  Base() {}
  virtual ~Base() {}
};

class Derived : public Base {
  Derived() {}
  ~Derived() {}
};

int main() {
  Base* bp = new Derived();
  delete bp;
};

위처럼 소멸자에 virtual 키워드를 붙이게 되면 base 클래스에서 가장 파생된 클래스 부터 소멸자를 호출하여 base 클래스의 소멸자까지 호출하여 안전하게 해당 메모리에 할당된 객체를 해제할 수 있는 것이다!

그래서 왜? 그렇게 되는 건데?

virtual 키워드가 붙으면 vptr와 vtable이 생성된다.

그리고 소멸자는 특별한 메서드이기 때문에 해당 소멸자와 그 소멸자의 주소가 vtable에 매핑되는 것이다.

따라서 Base class의 포인터를 삭제하려고 시도하면 해당 포인터가 가리키는 Derived 클래스 주소에 접근해 vptr에 접근해 vtable에 있는 가상 소멸자를 호출시키는 것이다.

위 그림과 같이 Base 클래스의 가상 소멸자를 Derived 클래스에서 재정의해서 가지고 있다. 그 파생된 클래스의 vtable에는 ~Derived()에 대한 포인터가 포함된다.

profile
아쉬움 없이 살자. 모든 순간을 100%로!

0개의 댓글