관리 메뉴

기억을 위한 기록들

[Fundamental C++] 6. 클래스 (2) - 생성자와 소멸자, 복사생성자와 대입연산자 본문

C & CPP/Fundamental C++

[Fundamental C++] 6. 클래스 (2) - 생성자와 소멸자, 복사생성자와 대입연산자

에드윈H 2021. 5. 3. 15:29

생성자와 소멸자

클래스 객체가 생성될때 생성자가 호출하고, 사라지기 직전에 소멸자가 호출된다. 따라서 초기화작업이나 자원 마무리를 각각 생성자와 소멸자에서 처리해주면 된다. 

 

암시적 생성자와 소멸자

컴파일러는 생성자나 소멸자가 특별히 필요하지 않다면 굳이 암시적으로 생성자,소멸자를 정의하지 않는다. 하지만, 명시되어있지 않지만, 반드시 필요한 경우라면 컴파일러는 암시적인 생성자와 소멸자를 정의한다.

 

암시적으로 생성되는 예)

#include <iostream>
using namespace std;

class A
{
public:
	A() {
		cout << "A 생성자 호출" << endl;
	}
	//..
};


class B : public A
{
public:
	int m_Data;
};


int main()
{
	B myB;  //B생성자 호출되면서 부모생성자 호출
	return 0;
};

출력결과

위에서는 B에서 명시적인 생성자가 있지 않지만, B클래스가 상속하는 A클래스의 생성자가 명시적으로 정의되어 있다. 그 내용을 어디서 불러준것일까? 그게 바로 B클래스의 암시적인 생성자이다.

반대로 소멸자도 마찬가지이다.

 

#include <iostream>
using namespace std;

class A
{
public:
	~A() {
		cout << "A 소멸자 호출" << endl;
	}
	//..
};


class B : public A
{
public:
	int m_Data;
};


int main()
{
	B myB;  
	return 0;
};

해당 코드를 실행해도 A클래스 소멸자 호출이라는 문장이 찍히는것을 확인할 수 있다.

 

생성자 소멸자 호출순서

class A
{
public:
	A() {
		cout << "A 생성자 호출" << endl;
	}

	~A() {
		cout << "A 소멸자 호출" << endl;
	}
	//..
};


class B : public A
{
public:
	B() {
		cout << "B 생성자 호출" << endl;
	}

	~B() {
		cout << "B 소멸자 호출" << endl;
	}
	//..
	int m_Data;
};


int main()
{
	B myB;  
	return 0;
};

A클래스를 상속받는 B클래스의 객체를 생성하게 되면(위 코드)

부모클래스의 생성자가 먼저 호출되고 자식클래스(B)가 호출된다고 나온다. 하지만, 실제로는 자식 (B)클래스의 생성자가 먼저 호출된다. 코드 상으로는 cout << "A 생성자 호출" << endl; 가 제일먼저 실행되지만 그 보다 먼저 실행되는 부분은 [선처리영역]이라고 할 수 있다. 해당 선처리 영역은 부모클래스의 생성자 호출과 멤버가 클래스 타입일 경우에 생성자 호출하여 초기화한다. 명시적으로 코드에 적으면 실행순서는 이러하다

//..
class B : public A
{
public:
	B() 
		: A() //[선처리영역]
	{
		cout << "B 생성자 호출" << endl;
	}

	~B() {
		cout << "B 소멸자 호출" << endl;
	}
	//..
	int m_Data;
};
//..

B클래스의 생성자가 호출되다가 상속받고 있는 A클래스의 생성자가 선처리영역(초기화 리스트 처럼)에서 호출이 된다고 볼 수 있다.

 

이와 같은 내부동작으로 인해  생성자 호출순서는 자식->부모가 맞긴하지만, 생성자 영역(scope)의 호출 순서는 부모->자식이 된다.

 

소멸자도 비슷한 원리로 동작한다. 위의 생성자처럼 초기화리스트처럼 할순 없지만, 소멸자 호출순서는 자식->부모가 맞다.

 

 

virtual 소멸자

 

어떤 클래스를 만들때 절대 부모클래스로 사용되지 않지 않는 이상 소멸자는 가상 소멸자로 선언해주는 것이 낫다. 무조건 기본으로 가상소멸자로 선언해주기엔 또 vfptr(가상함수 테이블 포인터)때문에 크기를 차지하게 되긴한다. (x86은 4바이트, x64는 8바이트)

 

 

복사 생성자와 복사 대입 연산자

#include <iostream>
using namespace std;

class MyClass
{
public:
	MyClass()
		:m_Data(5) //5로 초기화
	{}
	
	void ShowData()
	{
		cout << m_Data << endl;
	}
	int m_Data;
};



int main()
{
	MyClass a; //기본
	a.m_Data = 1; 
	a.ShowData();

	MyClass b(a); //복사생성자
	b.ShowData();

	MyClass c=a; //복사생성자
	c.ShowData();

	MyClass d;
	d = a; //대입연산자
	d.ShowData();


	return 0;
};

 

 

기본 생성자 안에서 m_Data의 값은 5로 초기화해주지만, MyClass 클래스 객체 a에서 1로 초기화해준 뒤,

나머지 b,c,d 클래스를 선언하면서 a객체를 대입해주었더니 결과는 아래와 같이 출력되었다.

 

MyClass에는 복사생성자나 대입연산자 없지만 5가 아닌 1이 출력되었다. 이는 명시적으로 복사생성자나 대입연산자가 없이 암시적으로 실행된 것이다. 즉, 어떤 클래스가이건 복사 생성자는 반드시 존재하며, 명시적으로 없을 경우에 암시적으로 컴파일러가 생성해준다.

 

이렇게 암시적으로 생기는 이유는 객체를 복사한다는 것은 두 객체의 메모리 영역은 다를 뿐 갖고 있는 값은 일치시켜야 하는 것을 의미한다. 그렇게 되면 얕은 복사가 이루어지게 된다.

 

포인터 타입이라면 동일한 대상을 가리킬 수 있게 되고 한쪽이 소멸하게 되면서 파괴되면, 나머지 한쪽은 파괴된 대상을 참조하는 문제가 발생 할 수 있다.

 

예)

class MyClass
{
public:
	MyClass()
	{
		m_Data = new int(5);
	}

	void ShowData()
	{
		
		cout << *m_Data << endl;
	}
	~MyClass()
	{
		delete m_Data; //해제
	}
	int* m_Data;
};



int main()
{
	
	MyClass* a=new MyClass;
	a->m_Data = new int(1);
	a->ShowData();

	MyClass b(*a); //복사생성자
	b.ShowData();

	MyClass c = *a; //복사생성자
	c.ShowData();

	MyClass d;
	d = *a; //대입연산자
	d.ShowData();

	delete a;
	cout << "삭제" << endl;

	b.ShowData();
	c.ShowData();
	d.ShowData();
	return 0;
};

위 코드와 같이 a객체가 가리키고 갖고 있던 값 1이 다른 b,c,d 포인터 객체들도 같은 곳을 가리키다가 a 포인터객체가 소멸되면서 a객체의 m_Data 포인터도 소멸되어, 다른 객체들이 가리킬수 있는 곳이 사라져 쓰레기 값(-572662307)이 나오게 된다...!

 

기본적으로 컴파일러는 암시적인 복사생성자/복사대입연산자를 제공해주나, 얕은복사를 함으로 필요에 따라 복사생성자를 직접 선언해주어야 한다. 

//..
class MyClass
{
public:
	MyClass()
	{
		m_Data = new int(5);
	}

	MyClass(const MyClass& ref) //복사생성자
	{
		delete m_Data;
		m_Data = new int(*(ref.m_Data)); //깊은 복사
	}

	MyClass& operator=(const MyClass& ref) //복사 대입 연산자
	{
        if(this==&ref)
        {
           return *this;
        }
		delete m_Data;
		m_Data = new int(*(ref.m_Data)); //깊은 복사
		return *this;
	}

	void ShowData()
	{
		
		cout << *m_Data << endl;
	}
	~MyClass()
	{
		delete m_Data;
	}
	int* m_Data;
};

//..

위 코드에서 명시적으로 복사생성자와 복사대입연산자를 명시적으로 선언해준뒤, 깊은 복사를 해주게 되면 이전과 같은 코드도 문제없이 동작한다.

	MyClass* a=new MyClass;
	a->m_Data = new int(1);
	a->ShowData();

	MyClass b(*a); //복사생성자
	b.ShowData();

	MyClass c = *a; //복사생성자
	c.ShowData();

	MyClass d;
	d = *a; //대입연산자
	d.ShowData();

	delete a;
	cout << "삭제" << endl;

	b.ShowData();
	c.ShowData();
	d.ShowData();

복사 생성자 복사대입연산자 막는 2가지 방법

1. private에 선언만 해준다.

#include <iostream>
using namespace std;

class MyClass
{
public:
	MyClass()
	{
		m_Data = 5;
	}

	int m_Data;
private: //private 접근지정자에 선언만 해주면 된다.
	MyClass(const MyClass& ref);
	MyClass& operator=(const MyClass& ref);
};



int main()
{
	MyClass a;
	a.m_Data = 10;

	MyClass b(a); //컴파일 에러 발생!

	return 0;
};

2. delete 키워드 적용(C++ 11이후)

#include <iostream>
using namespace std;

class MyClass
{
public:
	MyClass()
	{
		m_Data = 5;
	}

	int m_Data;

	MyClass(const MyClass& ref) = delete;
	MyClass& operator=(const MyClass& ref) = delete;
};



int main()
{
	MyClass a;
	a.m_Data = 10;

	MyClass b(a); //에러!

	return 0;
};

 

 

RValue 참조 복사생성자 사용하기 (&&)

 

#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <string>
#include <string.h>
using namespace std;

class MyClass
{
public:
	MyClass(const char* text)
	{
		mLen = strlen(text);
		mText = new char[mLen + 1];
		strcpy(mText, text);
	}

	MyClass(const MyClass& ref)
	{

		mText = new char[ref.mLen + 1];
		strcpy(mText, ref.mText);
		mLen = ref.mLen;
	}

	MyClass(MyClass&& ref)
	{

		mText = ref.mText;
		mLen = ref.mLen;

		ref.mText = nullptr;
		ref.mLen = 0;
	}

	~MyClass()
	{
		if (mText)
		{
			delete[] mText;
			mLen = 0;
		}
	}
	char* mText;
	int mLen;
};

MyClass GetText()
{
	MyClass temp("Hello");
	return temp;
}

int main()
{
	MyClass t = GetText();

	return 0;
};