Language/C++

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

nowkoes 2023. 8. 10. 18:36

싱글톤 패턴의 원리 이해

개요

#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";
}

 지난 게시글에서 싱글톤 패턴에 대해 다뤘었다. 일반적으로 클래스에서 동적 할당된 메모리를 해제할 때 소멸자를 통해 이를 수행하는 경우가 많아, 필자도 다음과 같이 인스턴스를 삭제하는 코드를 작성했었다. 그런데 이 코드를 실행시키면 예상과 달리 소멸자가 호출되지 않는 것을 볼 수 있다.

 

int main()
{
	singleton* singleton_ex = singleton::GetInstance();
}

소멸자가 호출되고 있지 않음

 

 하지만 다른 싱글톤으로 디자인된 클래스를 찾아보았을 때 소멸자가 호출되는 경우를 발견하였다. 이는 정적 변수를 사용하여 싱글톤 인스턴스를 생성하는 예제였는데, 정적 변수가 함수 내에서 한 번만 초기화되며, 프로그램이 종료될 때 자동으로 소멸된다는 특징 때문에 가능한 것이었다.

 

#include <iostream>

class singleton2
{
	singleton2();
	~singleton2();

public:
	static singleton2* GetInstance();
};

singleton2::singleton2()
{
	std::cout << "singleton2 생성자\n";
}

singleton2* singleton2::GetInstance()
{
	static singleton2 s;
	return &s;
}

singleton2::~singleton2()
{
	std::cout << "singleton2 소멸자\n";
}

int main()
{
	singleton2* s = singleton2::GetInstance();
}

생성자와 소멸자가 호출되고 있음

 

 이번 시간에는 이러한 차이점에 대해 먼저 정적 변수 static 키워드와 new 연산자에 초점에 맞춰 설명해 보도록 하겠다.


본문

static 키워드의 이해

 싱글톤 패턴은 일반적으로 생성자와 소멸자의 접근 지정자를 public으로 두지 않고, 해당 클래스의 포인터 또는 참조를 반환하는 공개된 정적 함수를 제공한다. 이는 static 함수를 제외하고는 해당 클래스의 인스턴스를 다루는 것을 미연에 방지하기 위한 것이다. 


출처: cppreference

 

1. static 변수는 함수 내에 선언될 경우, 함수 호출 시마다 초기화되지 않는 변수를 의미한다. 즉, 첫 함수 호출 시에만 초기화되며, 이후의 호출에서는 그 값을 유지한다. 이는 프로그램의 수명 동안 메모리에 남아 있고, 프로그램이 종료될 때 자동으로 소멸된다는 특징이 있다.

 

 예를 들어 다음과 같은 코드가 있다고 가정해 보자.

 

#include <iostream>

void count_example()
{
    int count = 0;
    count++;
    std::cout << count << "\n";
}

int main()
{
    for (int i = 0; i < 3; ++i)
        count_example();
    return 0;
}

 일반적인 변수는 지역성에 의해 함수가 종료될 때 메모리에서 사라져 다음과 같이 1이 3번 출력될 것이다.

 

 

 하지만 static 변수로 선언할 경우 해당 변수가 메모리의 데이터 영역에 한 번만 할당되기 때문에, 프로그램이 시작될 때 초기화되고, 프로그램이 종료될 때까지 메모리에서 해제되지 않는다. 따라서 다음과 같이 출력이 변화한 것을 알 수 있다.

 

#include <iostream>

void count_example()
{
	static int count = 0;
	count++;
	std::cout << count << "\n";
}

int main()
{
	for (int i = 0; i < 3; ++i)
		count_example();
	
	return 0;
}


2. 만약 클래스 내에 멤버 변수를 static 키워드로 선언하면, 이 변수는 해당 클래스의 모든 객체가 공유하는 변수가 된다. 따라서 하나의 객체에서 이 변수의 값을 변경하면, 다른 모든 객체에서도 그 변경된 값을 볼 수 있다. 이때 중요한 점은 static 멤버 변수는 클래스 외부에서 초기화해야 한다는 점이다.

 

 이러한 특징은 static 멤버 변수가 전체 클래스에 대해 하나의 메모리 공간만을 사용하며, 각 객체별로 별도의 메모리를 할당받지 않는다는 점에서 기인한다. 그래서 static 멤버 변수는 클래스의 객체가 생성되기 전에도 존재하게 된다. 

  • 이 때문에 다른 소스 파일에서 정의된 static 키워드로 지정한 변수들 사이의 초기화 순서가 정해지지 않는다는 문제가 있다. 즉, static 변수들 사이에서 의존성이 있으면 초기화 순서 문제가 발생할 수 있다.

 

 이러한 특징 때문에, static 멤버 변수를 클래스 외부에서 초기화하는 것이 필요한데, 그 이유는 다음과 같이 정리할 수 있다.

 

  1. 메모리 할당 위치
  2. 초기화 중복 방지: static 멤버 변수는 전체 클래스에 하나만 존재해야 하므로, 여러 번 초기화하는 것을 방지하기 위해 클래스 외부에서 명시적으로 초기화
  3. 명확한 스코프 지정: 클래스 내부에서 static 멤버 변수를 초기화는 언어 설계와 링킹 과정의 문제상 허용되지 않는다. 즉, 내부에서는 선언만 가능하다.

 

#include <iostream>

class example
{
	int num;

public:
	example()
	{
		num = 0;
	}

	void AddNum()
	{
		num++;
	}

	int GetNum()
	{
		return num;
	}
};

 

 예를 들어, 다음과 같이 클래스에 다음과 같이 정수형 변수 num이 있고, num을 하나씩 증가시키는 함수가 있다고 해보자.

 

int main()
{
	example ex1;
	
	for (int i = 0; i < 3; ++i)
	{
		ex1.AddNum();
		std::cout << ex1.GetNum() << "\n";
	}

	example ex2;

	std::cout << ex2.GetNum() << "\n";
	
	return 0;
}

 

 example 클래스에 대한 객체를 두 개 생성해서 num을 각각 증가시킨 후 값을 출력해 보면 다음과 같이 서로 다른 값을 출력하는 것을 확인할 수 있다.

 

 하지만 클래스 내의 멤버 변수를 static으로 선언하고 main 함수에서 코드를 실행시켜 보면, 객체끼리 같은 변수를 공유하고 있다는 것을 확인할 수 있다.

#include <iostream>

class example
{
	static int num;

public:
	example()
	{
		
	}

	void AddNum()
	{
		num++;
	}

	int GetNum()
	{
		return num;
	}
};

int example::num = 0;

int main()
{
	example ex1;
	
	for (int i = 0; i < 3; ++i)
	{
		ex1.AddNum();
		std::cout << ex1.GetNum() << "\n";
	}

	example ex2;

	std::cout << ex2.GetNum() << "\n";
	
	return 0;
}


3. 이번엔 클래스 내에서 static 키워드로 선언된 멤버 함수에 대해 알아보자. static 멤버 함수는 클래스의 특정 객체 인스턴스에 종속되지 않기 때문에 객체 생성 없이 호출이 가능하다.

 

 일반 멤버 함수는 호출 시 해당 객체의 멤버 변수에 접근하며, this 포인터를 사용하여 객체에 접근한다. 반면 static 키워드로 선언된 함수는 this 포인터가 없기 때문에, 객체의 비정적 멤버에 접근할 수 없다.

  • 일반 멤버 함수는 호출될 때 해당 객체의 멤버 변수와 상호 작용하며, 그 객체의 this 포인터에 접근할 수 있다. 이때 this 포인터는 해당 함수를 호출한 객체의 주소를 참조하는 포인터다.
  • 하지만 static 멤버 함수는 멤버 함수가 클래스의 특정 객체에 종속되지 않기 때문에 this 포인터가 없다.

 

 대신, static 멤버 함수는 클래스 자체에 연관되어 있어 특정 객체의 상태나 데이터와 독립적이기 때문에, 모든 객체에 대해 동일한 방식으로 작동한다. 따라서 이 함수는 클래스 이름과 범위 지정 연산자를 통해 호출된다. 

 

class example
{
	int num = 3;
	static int static_num;

public:
	example()
	{
		
	}

	int GetNum()
	{
		return num;
	}

	static int StaticGetNum()
	{
		// return num; // 에러 
		return static_num;
	}
};

int example::static_num = 1;

 다음과 같이 클래스가 작성되었을 때, StaticGetNum 함수에서는 num은 비정적 멤버 변수이기 때문에 에러가 생긴다. 

 

int main()
{
	int num1 = example::StaticGetNum(); 
	example ex;
	int num2 = ex.GetNum();

	std::cout << num1 << "\n" << num2;
	return 0;
}

 그리고 num1을 보면, example 객체가 생성되지 않았음에도 불구하고 범위 지정 연산자로 함수를 호출해 초기화할 수 있다. 

 

int main()
{
	return 0;
}

  _instacne의 멤버 변수가 static으로 선언되어 있기 때문에 프로그램의 시작 시점에 자동으로 생성되고, 프로그램 종료 시점에 자동으로 소멸된다. 따라서 클래스를 생성하지 않았고, 객체를 호출하지 않았음에도 생성자와 소멸자가 호출된다.


4. 마지막으로 전역 상태로서 static 키워드를 활용할 수 있다. 전역 변수나 전역 함수에 static을 사용하면, 해당 변수나 함수의 접근 범위가 해당 소스 파일로 제한된다. 이는 static 키워드를 전역 변수나 전역 함수 앞에 사용하면, 해당 변수나 함수는 내부 연결(internal linkage)을 갖게 된다. 내부 연결은 해당 심볼이 선언된 소스 파일 내에서만 접근 가능하다는 것을 의미한다. 

 


요약

static 키워드
1. 정의: 메모리 데이터 영역에 한 번 할당되어 프로그램이 종료될 때까지 유지되며, 프로그램이 종료될 때 자동으로 해제되는 키워드.
2. 특징
 - 프로그램 수명 동안 값이 유지
 - 초기화는 한 번만 이루어짐
 - 내부 연결을 통해 해당 소스 파일 내에서만 접근 가능
3. 사용 범위
 - 변수: 함수 내에서 한 번만 초기화되고, 내부 연결 상태을 가짐
 - 함수: 해당 함수의 접근 범위를 해당 소스 파일 내로 제한하며, 다른 파일에서 동일한 함수를 선언해도 충돌이 발생하지 않음
 - 멤버 변수: 클래스의 모든 객체가 공유하는 변수로서, 클래스 외부에서 초기화됨
 - 멤버 함수: 객체를 생성하지 않고 호출할 수 있으며, 비정적 멤버 변수나 함수에 접근 불가

 

반응형