Language/C++

[C++] 싱글톤 패턴의 생성자와 소멸자 동작 이해 - new, delete키워드 with C++

nowkoes 2023. 8. 13. 16:38

new 키워드의 이해

 지난 게시글에서는 static 키워드에 대해 알아보았었다. 이번 시간에는 싱글톤 패턴으로 디자인되었을 때 소멸자가 호출되지 않는 이유를 new 키워드에 초점을 맞춰 설명해 보도록 하겠다.

 

출처: cpp reference

 

 C++ Reference에서는 new로 생성된 오브젝트가 그것이 생성된 범위에서 해제되지 않는 동적 메모리 할당을 위해 사용된다고 설명하고 있다. 즉, 프로그램 실행 중에 할당되며, 그 크기나 수명이 컴파일 시점에서 결정되지 않고, 필요에 따라 할당되거나 해제될 수 있다는 것을 의미한다.

 

 new 키워드를 사용할 때 몇 가지 주의사항이 있다. 이를 꼭 체크하고 사용하는 습관을 기르자.

 

  • 메모리 누수: new로 동적 메모리를 할당하면, 반드시 delete를 사용하여 할당된 메모리를 반환해야 함. 그렇지 않으면 메모리 누수가 발생함.
  • 초기화: new로 할당한 메모리는 자동으로 초기화되지 않음. 원하는 값을 가진 객체를 생성하려면 초기화가 필요함. C++11 이후로는 uniform initialization을 사용하여 초기화할 수 있음.
  • nullptr 검사: new로 할당한 포인터를 사용하기 전에 항상 nullptr인지 검사하는 습관이 좋음.
  • 다중 할당: 같은 포인터에 여러 번 new를 호출하면, 이전에 할당된 메모리 주소를 잃어버림. 이렇게 되면 해당 메모리를 반환할 방법이 없어져 메모리 누수가 발생함.

 

#include <iostream>

class singleton
{
	static singleton* _instance;
	
	singleton();
	~singleton();
	singleton(const singleton&) = delete;
	singleton operator = (const singleton&) = delete;

public:
	static singleton* GetInstance();
};

singleton* singleton::_instance = nullptr;

singleton::singleton()
{
	std::cout << "생성자 호출\n";
}

singleton* singleton::GetInstance()
{
    if (!_instance)
		_instance = new singleton();

    return _instance;
}

singleton::~singleton()
{
	if (_instance)
	{
        delete _instance;
        _instance = nullptr;
	}

	std::cout << "소멸자 호출\n";
}

 이 코드에서 싱글톤 클래스의 인스턴스는 GetInstance 함수를 통해 동적으로 생성되며, 이때 new 키워드를 사용한다. 따라서 프로그램이 종료될 때 자동으로 소멸되지 않고, 명시적으로 delete를 호출해야만 메모리가 해제되며, 소멸자가 호출된다. 즉, 소멸자가 호출되려면 다음과 같은 상황 중 하나가 발생해야 한다.

 

  • singleton 인스턴스에 대해 명시적으로 delete를 호출
  • 프로그램 종료 시에 전역 객체의 소멸자와 함께 singleton 인스턴스를 삭제하는 코드를 추가

 

 하지만 해당 코드에서는 singleton의 소멸자 내에서 delete를 호출하려고 시도하지만, 싱글톤 객체는 클래스 자체에 속한 정적 멤버 변수로 저장되며, 이 객체는 프로그램의 수명 동안 단 한 번만 생성된다. 만약 소멸자 내에서 singleton 객체 자신을 delete 하면 다음과 같은 문제가 발생할 수 있다.

 

  • 재귀 호출: 객체의 소멸자 내에서 해당 객체를 delete 하면, 그 객체의 소멸자가 다시 호출될 수 있어 재귀 호출이 발생됨. 이로 인해 스택 오버플로가 발생할 수 있음.
  • 다중 해제: 프로그램의 다른 부분에서 싱글톤 객체를 명시적으로 delete 한 후, 다시 singleton 객체의 소멸자가 호출되면, 이미 해제된 메모리를 다시 해제하려고 시도할 수 있음. 이로 인해 런타임 오류가 발생.

 

 반면 생성자는 호출되는데, 이는 new 키워드를 사용하여 객체를 생성할 때 메모리를 할당한 후, 생성자를 호출하며 객체를 초기화하기 때문이다. 


생성자와 소멸자의 호출 방식

 그렇다면 생성자와 소멸자는 어떻게 호출되는 것일까? 그전에 생성자와 소멸자에 대해 간략하게 알아보자.

 

출처: cppreference

 

 생성자(constructor)클래스의 객체가 생성될 때 자동으로 호출되는 특별한 멤버 함수다. 클래스의 이름과 동일하며, 반환 타입이 없다는 것이 특징이다. 주로 객체의 초기화 작업을 수행하며, 멤버 변수의 초기값을 설정하거나, 필요한 자원을 할당하는 등의 작업을 수행할 수 있다.

 

 호출되는 방식은 클래스의 객체가 스택 메모리에 선언될 때 생성자가 호출되는 스택 메모리 호출 방식과, new 키워드를 사용하여 객체를 동적으로 생성될 때 호출되는 동적 할당 방식이 있다. 

 

출처: cppreference

 

 소멸자(destructor)객체의 수명이 끝날 때 자동으로 호출되는 함수다. 클래스의 이름 앞에 ~를 붙여 표시하며, 반환 타입이 없다. 주로 객체의 정리 작업을 수행하는 소멸자는, 동적으로 할당된 메모리를 해제하거나, 열려 있는 파일을 닫는 등의 작업을 수행한다. 

 

 소멸자 또한 생성자처럼 호출 방식이 스택 메모리에서 호출하는 방식과 힙 메모리에서 호출하는 방식으로 나눌 수 있다. 

 

#include <iostream>

class Example
{
public:
	Example()
	{
		std::cout << "Example()\n";
	}

	~Example()
	{
		std::cout << "~Example()\n";
	}
};

class Example2
{
public:
	Example2()
	{
		std::cout << "Example2()\n";
	}

	~Example2()
	{
		std::cout << "~Example2()\n";
	}
};

int main()
{
	Example ex1;
	Example* ex2 = new Example();
	Example2 ex3;
	Example2* ex4 = new Example2();
}

 

 다음과 같은 코드가 있을 때 생성자 및 소멸자의 호출 순서는 ex1 생성자 ex2 생성자 → ex3 생성자 → ex4 생성자  main 함수 종료 → ex3 소멸자 → ex1 소멸자 순이다. 여기서 동적으로 할당된 객체의 소멸자는 자동으로 호출되지 않는다. 즉, main 함수가 종료되는 시점과 동적으로 할당된 객체가 소멸되는 시점(프로그램이 종료될 때)이 다르기 때문에 소멸자가 호출되지 않을 수 있다

 

 따라서 해당 객체의 소멸자를 호출하고 싶으면 다음과 같은 코드를 추가해야 한다.

 

#include <iostream>

class Example
{
public:
	Example()
	{
		std::cout << "Example()\n";
	}

	~Example()
	{
		std::cout << "~Example()\n";
	}
};

class Example2
{
public:
	Example2()
	{
		std::cout << "Example2()\n";
	}

	~Example2()
	{
		std::cout << "~Example2()\n";
	}
};

int main()
{
	Example ex1;
	Example* ex2 = new Example();
	Example2 ex3;
	Example2* ex4 = new Example2();
	
	delete ex2;
	delete ex4;
}

 

 여기서 동적으로 할당된 객체의 메모리는 프로그램이 종료될 때 운영 체제에 의해 회수될 수 있다. 하지만 이는 운영 체제의 동작 방식에 따라 다를 수 있으므로, 안정적인 프로그램 동작을 위해서는 delete를 사용하여 프로그램 내에서 명시적으로 메모리를 해제하는 것을 권장하고 있다.


delete 키워드

출처: cppreference

 

 위에서 반복적으로 언급한 delete 연산자는 일반적으로 동적 할당을 해제하는 키워드로 자주 사용된다. 즉, 동적 메모리는 new 연산자를 사용하여 할당되며, delete를 사용하여 해제된다. 

 

 이러한 delete는 주로 메모리를 해제하거나, 객체를 소멸시키거나, 다중 해제를 방지하는 역할을 한다는 특징이 있다. 예를 들면 다음과 같은 코드를 짤 수 있다.

int *p = new int;
delete p; // 단일 객체 할당 해제

int* pAry = new int[10];
delete[] pAry; // 배열 할당 해제

 

 이러한 특징을 가진 delete는 몇 가지 주의사항을 지켜야 한다. 그렇지 않으면, 메모리를 해제하지 않아 메모리 누수가 발생하거나, 예기치 않은 동작을 유발할 수 있다.

 

  • 동일한 메모리 주소에 대해 delete를 여러 번 호출하면 안 됨: 메모리를 한 번 해제한 후 해당 메모리 주소에 다시 접근하려고 하면, 그 메모리는 더 이상 프로그램이 관리하는 범위에 있지 않음. 따라서 그 메모리에 대한 접근은 정의되지 않는 동작이 됨. 
  • 초기화되지 않은 포인터를 delete로 해제하면 안 됨: 이는 임의의 메모리 주소를 가리킬 수 있는데, 이 주소에 delete를 호출하면 프로그램이 예기치 않게 다른 부분의 메모리를 해제할 수 있음.
  • 배열을 동적 할당으로 초기화한 경우, 반드시 delete[]를 사용하여 해제: new[]로 할당된 메모리는 연속된 객체들의 블록임. 단순히 delete를 사용하면 첫 번째 객체만 제대로 소멸되고 나머지 객체는 제대로 소멸되지 않을 수 있음. 

요약

new / delete 키워드
1. new 연산자
 a. 정의: 동적 메모리 할당을 위해 사용되는 연산자. 프로그램 실행 중에 메모리를 할당하며, 그 크기나 수명이 컴파일 시점에 결정되지 않음
 b. 특징: 할당된 메모리는 자동으로 초기화되지 않으며, 필요한 메모리 크기나 수명이 실행 시점에 결정됨.
 c. 주의사항
  - 메모리 누수, 예외 처리, nullptr 검사, 다중 할당
2. delete 연산자
 a. 정의: 동적 메모리 해제를 위해 사용되는 연산자.
 b. 특징: new로 할당된 메모리를 반환하며, 동적으로 할당된 객체의 소멸자를 호출함.
 c. 주의 사항
  - 다중 해제, 초기화되지 않은 포인터 해제 금지, 배열 해제

생성자와 소멸자
1. 생성자(constructor)
 a. 정의: 객체의 초기화를 위한 멤버 함수. 객체 생성 시 자동 호출됨.
 b. 특징: 클래스의 이름과 동일하며, 반환 타입이 없음. 주로 멤버 변수 초기화나 자원 할당 등의 작업 수행.
2. 소멸자(destructor)
 a. 정의: 객체의 수명이 끝날 때 자동으로 호출되는 함수. 주로 동적 할당된 메모리 해제나 자원 반환 등의 작업을 수행.
 b. 특징: 클래스 이름 앞에 ~를 붙여 표시하며, 반환 타입이 없음.
3. 순서: 생성자는 객체가 초기화되는 순서로 실행, 소멸자는 생성자의 역순으로 실행됨.

 

 다음 시간에는 해당 내용들을 토대로 싱글톤 패턴에서 명시적으로 메모리를 해제하는 법에 대해 알아보도록 하자.

반응형