본문 바로가기
프로그래밍/C & C++

[C++] 자동 생성되는 생성자, 소멸자 및 대입 연산자

by 별준 2021. 7. 28.

Reference

  • Effective C++ (항목 5, 6)

Contents

  • 컴파일러가 자동으로 생성하는 생성자, 소멸자, 복사 대입연산자
  • 자동으로 생성되는 함수의 사용을 금지하는 방법

우리가 사용하는 거의 모든 C++ 클래스에서 한 개 이상 꼭 들어있는 것들이 생성자와 소멸자, 대입연산자입니다. 이들을 C++ 프로그램에 있어서 매우 중요한 역할들을 수행합니다.

생성자는 새로운 객체를 메모리에 할당하는 데 필요한 과정들을 제어하고 객체의 초기화를 담당하는 함수이고, 

소멸자는 객체를 없애면서 동시에 그 객체가 메모리에서 적절히 해제하는 과정을 제어하는 함수이며, 

대입연산자는 기존의 객체에 다른 객체의 값을 대입할 때 사용하는 함수입니다.

 

이번 글에서는 Effective C++의 항목 5, 6에서 이야기하는 내용들을 정리해보도록 하겠습니다.

 


컴파일러가 자동으로 생성하는 생성자, 소멸자, 복사 대입연산자

class Empty {};

클래스의 내용이 비어있는 Empty 클래스가 있습니다. 하지만, 이 클래스가 비어있지 않을 때가 존재합니다.

C++ 컴파일러는 클래스의 어떤 멤버 함수가 선언되어 있지 않다면, 컴파일러 스스로 선언해주도록 되어 있습니다.

어떤 멤버 함수에 해당하는 함수는 바로 복사 생성자(copy constructor), 복사 대입연산자(copy assignment operator), 그리고 소멸자(destructor)입니다. (조금 더 자세히 말하자면 컴파일러가 만드는 함수의 형태는 모두 기본형입니다.)

또한, 생성자조차도 선언되어 있지 않다면, 역시 컴파일러가 스스로 기본 생성자를 선언해 놓습니다.

이렇게 컴파일러 스스로 선언한 함수들은 모두 public 멤버이며 inline 함수입니다.

만약 위처럼 빈 Empty 클래스를 선언했다면, 이는 아래와 같은 의미가 됩니다.

class Empty {
public:
	Empty() { ... }			// 기본 생성자
    Empty(const Empty& rhs) { ... }	// 복사 생성자
    ~Empty() { ... } 			// 소멸자
    
    Empty& operator=(const Empty& rhs) { ... }	// 복사 대입연산자

위 멤버 함수들은 항상 만들어지는 것은 아니고, 이들이 꼭 필요하다고 컴파일러가 판단할 때만 만들어지는데, 필요한 조건이 대단한 것은 아닙니다. 이 멤버 함수들이 만들어지는 조건을 만족하는 코드를 보면 다음과 같습니다.

Empty e1;		// 기본 생성자, 소멸자
Empty e2(e1);		// 복사 생성자
e2 = e1;		// 복사 대입 연산자

컴파일러가 스스로 만드는 함수가 대체 무엇이길래 없으면 저절로 생성이 되도록 할까요?

기본 생성자와 소멸자가 하는 일은 일차적으로 컴파일러에게 '배후의 코드'를 깔 수 있는 자리를 마련하는 것이라고 합니다. '배후의 코드'는 기본(base) 클래스나 비정적(non-static) 데이터 멤버의 생성자와 소멸자를 호출하는 코드를 의미합니다. (ex, std::string의 데이터 멤버가 있을 때, 객체가 생성되면 이 string의 생성자/소멸자가 호출되는 코드)

 

이때, 소멸자는 이 클래스가 상속한 기본(base) 클래스의 소멸자가 가상 소멸자로 되어 있지 않다면 역시 비가상 소멸자로 만들어집니다.

 

복사 생성자와 복사 대입연산자의 경우에는 어떨까요? 컴파일러가 스스로 생성한 복사 생성자와 복사 대입 연산자가 하는 일은 아주 간단합니다. 원본 객체의 non-static 데이터를 사본 객체 쪽으로 그냥 복사하는 것이 전부입니다.

이해를 위해서 T type의 객체에 연결시켜주는 NamedObject 템플릿을 예제로 살펴보겠습니다.

template<typename T>
class NamedObject {
public:
	NamedObject(const char* name, const T& value) {
		nameValue = name;
		objectValue = value;
	}
	NamedObject(const std::string& name, const T& value) {
		nameValue = name;
		objectValue = value;
	}

private:
	std::string nameValue;
	T objectValue;
};

이 NamedObject 템플릿 안에는 생성자가 선언되어 있는 것을 볼 수 있습니다. 따라서 컴파일러는 기본 생성자를 만들지 않게 됩니다. 다시 말하자면, 만약 사용자가 생성자 파라미터가 꼭 필요한 클래스를 만드는 것이 목적이고, 컴파일러가 파라미터를 받지 않는 생성자를 만들면 어떡하지라는 걱정을 하지 않아도 된다는 것입니다.

 

반면에 복사 생성자나 복사 대입연산자는 NamedObject에 선언되어 있지 않기 때문에, 복사 생성자와 복사 대입연산자가 필요하다면 컴파일러에 의해서 생성될 것입니다.

NamedObject<int> no1("Smallest Prime Number", 2);
NamedObject<int> no2(no1);	// 복사 생성자 호출

 

컴파일러에 의해 만들어진 복사 생성자는 no1.nameValue와 no1.objectValue를 사용해서 no2.nameValue와 no2.objectValue를 각각 초기화해야 합니다.

nameValue의 타입은 string인데, 표준 string 타입은 자체적으로 복사 생성자를 갖고 있으므로 no2.nameValue의 초기화는 string의 복사 생성자에 no1.nameValue를 인자로 넘겨 호출함으로써 복사가 이루어집니다.

 

한편, NamedObject<int>::objectValue의 타입은 int인데, int는 기본제공 타입이므로 no2.objectValue의 초기화는 no1.objectValue의 각 비트를 그대로 복사해 오는 것으로 끝나게 됩니다.

 

컴파일러가 자동으로 생성하는 NamedObject<int>의 복사 대입연산자도 근본적으로는 동작 원리가 똑같습니다. 하지만 일반적인 것만 놓고 보면, 이 복사 대입연산자의 동작이 위의 설명처럼 되려면 최종 결과 코드가 legal 해야하고, reasonable 해야 합니다. 둘 중 어느 한 가지도 만족하지 못하면 컴파일러는 operator=을 자동으로 생성하지 않게 됩니다. 예시를 통해서 살펴보도록 하겠습니다. 위 NamedObject 템플릿에서 조금 변형되었습니다.

template<typename T>
class NamedObject {
public:
	NamedObject(const std::string& name, const T& value) {
		nameValue = name;
		objectValue = value;
	}

private:
	std::string& nameValue;	// 참조자
	const T objectValue;	// 상수
};

이렇게 생성된 템플릿으로 아래 코드를 실행하면 어떻게 될지 한 번 생각해봅시다.

int main()
{
	std::string newDog("Persephone");
	std::string oldDog("Satch");

	NamedObject<int> p(newDog, 2);
	NamedObject<int> s(oldDog, 36);

	p = s;

	return 0;
}

대입 연산이 일어나기 전, p.nameValue 및 s.nameValue는 각각의 string 객체를 참조하고 있습니다. 이때 대입 연산이 일어나면 p.nameValue는 어떻게 될까요? C++의 참조자는 원래 참조하는 있는 것이 아닌 다른 객체를 참조할 수 없기 때문에 대입이 성립되지 않습니다. 따라서 결론적으로 operator= 함수가 자동으로 생성되지 않아서 복사 대입이 불가능하다고 컴파일 에러가 발생합니다. (error C2582: 'operator =' function is unavailable in 'NamedObject<int>')

 

이러한 문제에 대해서 C++은 컴파일을 거부합니다. 그렇기 때문에 참조자를 데이터 멤버로 갖고 있는 클래스에 대입 연산을 지원하려면 직접 복사 대입연산자를 정의해주어야 합니다. 데이터 멤버가 상수 객체인 경우에도 비슷하게 동작하므로 꼭 주의해야합니다. (상수 멤버를 수정하는 것은 문법에 어긋나기 때문)

 

추가로, 복사 대입연산자를 private로 선언한 base 클래스로부터 파생된 클래스의 경우에는 파생 클래스에서는 컴파일러가 복사 대입연산자를 스스로 생성할 수 없습니다. 이는 파생 클래스 쪽에서 기본 클래스의 복사 대입연산자를 호출한 권한이 없기 때문입니다.


자동으로 생성되는 멤버 함수 사용을 금지하는 방법

위처럼 컴파일러가 자동으로 생성하는 함수들의 사용을 금지하는 방법을 알아보도록 하겠습니다.

 

만약, 집을 팔고 사기 위한 부동산 중개업 지원용 SW를 만들었고, 이런 SW 시스템에는 매물로 내어놓은 집을 나타내는 클래스가 있다고 가정해봅시다.

class HomeForSale { ... };

부동산에서 모든 자산은 세상에 하나밖에 없기 때문에, HomeForSale 객체는 사본(copy)를 만드는 것 자체가 올바르지 않습니다. 원래부터 유일한 것을 복사한다는 것은 말이 되지 않죠.

HomeForSale h1;
HomeForSale h2;
HomeForSale h3(h1);	// 올바르지 않음
h1 = h2;		// 올바르지 않음

따라서 위의 3,4 line의 코드는 적절하지 않습니다.

하지만, 복사 대입연산자를 정의하지 않더라도 컴파일러가 복사 대입연산자를 생성하기 때문에 컴파일에서는 오류가 발생하지 않게 됩니다. 일반적인 경우에 어떤 클래스에서 특정한 종류의 기능을 지원하지 않았으면 하는 의도를 반영하는 방법은 그런 기능을 제공하는 함수를 선언하지 않는 것입니다만, 이러한 상황에서는 해당되지 않습니다.

 

위와 같은 문제의 해결 방법은 다음과 같습니다.

바로 컴파일러가 생성하는 함수는 모두 공개되는 public 멤버가 되기 때문에, 복사 생성자 및 복사 대입연산자를 private 멤버로 선언하는 것입니다.

하지만 이는 그 클래스의 멤버 함수나 friend 함수가 호출할 수 있다는 허점을 가지고 있습니다. 이것까지 막으려면 '선언'만 하고 '정의'를 하지 않으면 됩니다.

 

정리하자면, 멤버 함수를 private 멤버로 선언하고 일부러 정의(구현)하지 않는 방법입니다. 이는 하나의 기법으로 사용되며, iostream 라이브러리에 속한 몇몇 클래스에서도 복사 방지책으로 쓰이고 있습니다. (ios_base, basic_ios, sentry)

 

이런 기법을 이제 HomeForSale에 적용하면, 다음과 같이 적용할 수 있습니다.

class HomeForSale {
public:

private:
	HomeForSale(const HomeForSale&);
	HomeForSale*& operator=(const HomeForSale&);
};

이제 사용자가 HomeForSale 객체의 복사를 시도하려고 하면 컴파일 에러가 발생될 것이고, 깜박하고 멤버 함수 혹은 friend 함수 안에서 호출하려고 한다면 링커 에러가 발생하게 됩니다.


한 가지 덧붙이자면, 이 링커 시점 에러를 컴파일 시점 에러로 옮길 수도 있습니다. (링커 시점에서 에러를 잡는 것보다는 컴파일 단계에서 미리 에러를 탐지하는 것이 대체로 좋습니다.)

 

이 방법은 복사 생성자와 복사 대입연산자를 private로 선언하되, 이것을 HomeForSale 자체에 넣지 말고 별도의 기본(base) 클래스에 넣고 이것으로부터 HomeForSale을 파생시키는 것입니다. 그리고 그 별도의 base 클래스는 복사 방지만 맡는다는 특별한 의미를 부여하게 됩니다. 

잘 이해가 안될 수는 있지만 코드 자체는 매우 단순합니다.

class Uncopyable {
protected:
	Uncopyable();
    ~Uncopyable();
private:
	Uncopyable(const Uncopyable&);
    Uncopyable& operator=(const Uncopyable&);
};

class HomeForSale: private Uncopyable {
	
};

HomeforSale 클래스는 Uncopyable 클래스를 상속받기만 하면 되고, 복사 생성자나 복사 대입연산자를 선언할 필요도 없습니다. 

위 코드에서 HomeForSale 객체를 복사를 시도하려고 할 때, 컴파일러는 HomeForSale 클래스만의 복사 생성자와 복사 대입연산자를 만드려고 하지만, base 클래스인 Uncopyable의 복사 생성자/복사 대입연산자에 접근할 수 없기 때문에 컴파일 에러가 발생하게 됩니다.

댓글