태크놀로지

[Effective C++] 생성자와 소멸자, 제대로 메모리 관리를 하고 있는가? 본문

C++

[Effective C++] 생성자와 소멸자, 제대로 메모리 관리를 하고 있는가?

원택 2020. 11. 19. 17:29

생성자 : 새로운 객체를 메모리에 만드는데 필요한 과정을 제어하고 객체의 초기화를 맡는다.

소멸자 : 객체를 없앰과 동시에 그 객체가 메모리에서 적절히 사라질 수 있도록 하는 과정을 제어한다.

대입연산자 : 기존의 객체에 다른 객체의 값을 줄 때 사용하는 함수 : 다음포스팅에서 진행

 

1. C++이 자동으로 만들어서 호출해 버리는 함수들에 촉각을 세우자

클래스안에 직접 선언해 넣지 않으면 컴파일러가 스스로 선언해 주도록 되어 있는 함수들

class Empty{
public:
	Empty() = default;
	Empty(const Empty& rhs) = default;
	~Empty() = default;

	Empty& operator=(const Empty& rhs) = default;
}

암시적(Implicit) 으로 만들어 놓음 (모두 기본형이며 public이다.)

  • 기본생성자
  • 복사생성자
  • 복사대입연산자
  • 소멸자 

1. 복사생성자 얕은복사로 인한 에러발생

template<class T>
class NameObject {
public:
	NameObject(const char* name, const T& value) : nameValue(name), objectValue(value) {}
	NameObject(const string name, const T& value) : nameValue(name), objectValue(value) {}

public:
	string nameValue;
	T objectValue;
};

int main()
{
	NameObject<const char*> no1("Smallest Prime Number", "test");
	NameObject<const char*> no2(no1);		// 복사 생성자 호출
							// 자동으로 생성되는 기본 복사생성자에서, 
                            				// 각 비트만 그대로 복사함 : 얕은복사가 일어남.
	cout << no1.nameValue << endl;
	cout << no2.nameValue << endl;

	delete no1.objectValue;
	cout << no2.objectValue << endl;		// Error 발생 
    							// 기본 복사생성자에서 얕은복사로 인해,
                                    			// no1.objectValue와 no2.objectValue는 같은 주소를 갖게됨
}

자동으로 생성되어 호출되는 기본 복사생성자 - 객체가 아닌 포인터를 갖고 있을때, 얕은복사로 인한 문제점발생

-> 자동으로 생성되는 기본 복사생성자는, 각 객체의 비트만 그대로 복사하기 때문에 얕은복사가 일어난다.

 

2. 자동으로 생성된 복사생성자에서 참조자 복사

template<class T>
class NameObject {
public:
	NameObject(string& name, const T& value) : nameValue(name), objectValue(value) {}

public:
	string& nameValue;
	const T objectValue;
};

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

	NameObject<int> d1(newDog, 2);
	NameObject<int> d2(oldDog, 2);

	d2 = d1;	// Error : 참조자는 원래 자신이 참조하고 있는것과 다른 객체를 참조할 수 없음. 
    			//또한 상수객체도 동일한 에러가 발생
}

참조자는 원래 자신이 참조하고 있는것과 다른 객체를 참조할 수 없음. 또한 상수객체도 동일한 에러가 발생

-> 따라서 이러한 경우 복사 대입연산자를 직접 정의해 주어야한다.

 

2. 컴파일러가 만들어낸 함수가 필요없으면, 확실히 이들의 사용을 막아놓자

복사가 일어나지 않는(사본이 존재하지 않는) 객체를 만들어보자.

class NonCopyable{
public:
	NonCopyable() { cout << "Non 생성자 호출" << endl; };
	~NonCopyable() { cout << "Non 소멸자 호출" << endl; };

protected:
	// 선언만 함으로서 friend를 통한 호출도 막음
	NonCopyable(const NonCopyable&);
	NonCopyable& operator=(const NonCopyable&);
};

class UniqueObj : private NonCopyable {
public:
	UniqueObj() { cout << "Unique 생성자 호출" << endl; }
	~UniqueObj() { cout << "Unique 소멸자 호출" << endl; }
};

int main()
{
	// private 상속이기 때문에, NonCopyable을 기본베이스로 생성할 수 없음. 
    	// 따라서 virtual 소멸자도 필요없음
	// NonCopyable* a = new UniqueObj; // private 상속으로 인해 불가능
	
	UniqueObj a;
	UniqueObj b;
	b = a; // compile error
}

1. 컴파일러가 자동으로 생성하는 함수는 모두 public이다. 따라서 private으로 사용하지 않는 함수들을 감춰놓자. 
2. friend로 접근할 경우는? 선언만 하고 정의를 안 해 버린다.

 

UniqueObj 클래스 특징

상속은 public일 필요가 없음(private 상속)

  • NonCopyable을 기본클래스로 지정하지 못하도록 함.

소멸자는 가상소멸자가 아니어도 됨

  • private으로 NonCopyable을 기본클래스로 지정하지 못하도록 막아놓았기 때문에, 쓸데없이 가상 소멸자를 지정할 필요가 없다.

※ 기본클래스로 호출된 객체를 삭제했을경우에, 가상소멸자가 등록되어있지 않으면 파생클래스의 소멸자는 불리지 않는다.

 

UniqueObj를 생성했을시 생성자와 소멸자 결과

 

3. 다형성을 가진 기본 클래스에서는 소멸자를 반드시 가상소멸자로 선언하자

소멸자 호출순서 : 파생클래스 소멸자 -> 기본클래스 쪽으로 거쳐 올라가며 소멸자 호출

 

- 기본 클래스 포인터를 통해 파생 클래스 객체가 삭제될때, 기본 클래스의 소멸자가 비가상으로 설정되어있다면 프로그램 동작은 미정의 사항이라고 되어있음.

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

Base* a = new Data;
delete a; // Base의 소멸자는 호출되지만, Data의 소멸자는 호출되지 않음. (Data클래스 메모리릭 발생)

 

- 무조건 소멸자를 가상으로 설정하는것 또한 좋지 않다.

virtual 소멸자를 지정하면, 가상함수테이블을 생성하기 때문에 객체 크기가 커짐.

class Point{
public:
	Point(); ~Point();
	
private:
	int x,y;
}
  • int 2개 (64bit)
  • 가상소멸자일경우: int 2개(64bit) + vtable(32bit) = 96bit

위와 같이 쓸데없이 가상소멸자를 설정할경우, 64비트 레지스터에 한번에 못들어감. 

 

- 가상 소멸자를 설정하는 클래스에 대한 상황들

1. 모든 기본 클래스가 다형성으로 설정되어 있지 않음.

  • string, STL 컨테이너 타입경우 모두 비가상 소멸자이다.

2. 기본 클래스로 쓰일 수 있지만, 다형성은 갖지 않도록 설계된 클래스

  • 위와 같은 NonCopyable, 표준 라이브러리의 input_iterator_tag (기본 클래스의 인터페이스를 통한 파생 클래스 객체의 조작이 허용되지 않음. private 상속)

 

(1~3) 정리

  • 컴파일러는 경우에 따라 클래스에 대해 기본생성자, 복사생성자, 복사대입연산자, 소멸자를 암시적으로 만들어 놓을 수 있다.(모두 default)
  • 컴파일러에서 자동으로 제공하는 기능을 허용하지 않으려면, 사용하지 않는 멤버 함수를 private으로 선언한 후 구현은 하지 않은채로 둔다. (NonCopyable과 같은 경우로도 사용가능)
  • 다형성을 가진 기본 클래스반드시 가상소멸자를 선언해야함. 즉 어떤 클래스가 가상 함수를 하나라도 갖고있으면, 이 클래스의 소멸자도 가상소멸자여야 함.
  • 무조건 가상 소멸자를 선언하는건 좋지 않음. (상황에 따른, 다형성을 가진 기본클래스만 가상소멸자 선언)

 

4. 소멸자에서의 예외 발생을 개발자가 관리할 수 있도록 설정하자.

소멸자에서 예외처리를 하여 프로그램 안전성을 높이자.

  • 상황에 따라 선택 1. 예외로그남기기 / 2. 프로그램종료
  • 소멸자에서 예외가 발생하면 안된다. 최소한이라도 try catch를 통해 위와같은 상황으로 대비하자.

 

예제1 소멸자에서 예외발생

class DBConnection {
public:
	DBConnection() = default;
	DBConnection(const DBConnection& rhs) = default;

	static DBConnection& create() 
	{
		static DBConnection a;
		return a;
	}

	bool close() 
	{
		//...
		return false;
	}
};

// DBConnection을 관리하는 클래스
class DBConn {
public:
	DBConn(const DBConnection _db) : db(_db) {
	}
	~DBConn()
	{
		db.close(); 	// 여기서 예외가 발생할경우?
				// db는 닫히지 않은채로 객체가 소멸해버림.
				// 해결방법 : 상황에 따라 선택지 선택
				// 1. 프로그램을 끝내거나 / 2. 호출 실패 로그 남김
	}

private:
	DBConnection db;
};

위 코드의 위험성 : db는 닫히지 않은채로 객체가 소멸해버림.

해결방법 : 상황에 따라 선택지 선택

  • 1. 프로그램을 끝내거나 
  • 2. 호출 실패 로그 남김

 

예제2 소멸자에서 예외발생

class DBConn {
public:
	DBConn(const DBConnection _db) : db(_db), closed(false) {
	}
	~DBConn()
	{
		// 실패했을때를 대비해 예외처리 추가
		if(!closed)
		try
		{
			closed = db.close(); // 사용자가 연결을 안닫았으면 여기서 닫기 시도
			if (!closed) throw closed;
		}
		catch (const bool& e)
		{
			// 1. exit
			//exit(true);
			// 2. close log 출력
			cout << "Exception Log : DB Is not closed" << endl;
		}
	}

	// 사용자 편의 함수
	void close()
	{
		db.close();
		closed = true;
	}

private:
	DBConnection db;
	bool closed;
};

 

DBConn 소멸자에서 실패했을때를 대비해 예외처리 추가

 

5. 객체 생성 및 소멸 과정중에 절대로 가상함수를 호출하지말자. 

- 생성자, 소멸자에서 가상함수 호출 금지

호출과정에서 기본클래스의 생성자 실행도중, 파생클래스가 아직 초기화 되어있지 않기때문에 가상함수를 알수없다.
소멸자 또한 파생클래스 소멸 -> 기본클래스 소멸과정에서 파생클래스가 소멸되었기때문에 정보를 알수없다.

 

생성자 호출순서 : 기본클래스 -> 파생클래스

소멸자 호출순서 : 파생클래스 -> 기본클래스
* 파생클래스 객체의 기본클래스 부분이 생성되는 동안은, 그 객체의 타입은 기본클래스이다.

 

대처방법

class Transaction {
public:
	explicit Transaction(const string& logInfo) {
		logTransaction(logInfo);
	}
	virtual ~Transaction() = default;
	
	/*virtual*/ void logTransaction(const string& logInfo) const{
		cout << "Log : " << logInfo << endl;
	}

};

class BuyTransaction : public Transaction {
private:
	// 미초기화된 데이터 멤버
	int param1;
	int param2;
	int param3;

public:
	// 필요한 초기화 정보를 파생클래스 쪽에서 기본클래스 생성자로 올려주도록 만듬
	BuyTransaction(const string& param) : Transaction(createLogString(param)) {}

private:
	// 중요! 정적함수인 이유
	// 정적함수이기 때문에 생성이 끝나지도 않은
    	// BuyTransaction의 미초기화된 데이터 멤버를 실수로 건드릴 위험도 없다.
	static string createLogString(const string& param) {
		return param;
	}
};

 

1. 생성자에서 호출되는 logTransaction을 비가상 멤버함수로 변경

2. 파생클래스 생성자들로 하여금 로그정보를 기본생성자에게 전달한다.

 

※ 중요! createLogString가 정적함수인 이유

정적함수이기 때문에 생성이 끝나지도 않은 BuyTransaction의 미초기화된 데이터 멤버를 실수로 건드릴 위험도 없다.

 

(4-5) 정리

  • 소멸자에서 예외가 발생하면 안된다. 예외처리로 안정성 업!
  • 생성자 혹은 소멸자안에서 가상함수를 호출하면 안된다. 실행중인 생성/소멸자에서 파생클래스는 아직 생성되지 않음.

스터디 진행후 토론내용 정리

1. 기본클래스 함수들은 호출한것만 생성한다.

en.wikipedia.org/wiki/Special_member_functions

 

Special member functions - Wikipedia

Special member functions[1] in C++ are functions which the compiler will automatically generate if they are used, but not declared explicitly by the programmer. The automatically generated special member functions are: If a destructor is declared generatio

en.wikipedia.org

 

2. 기본생성자를 정의하지않고, 파라미터값이 있는 생성자만 정의했을때 operator()를 사용하지 않으면 컴파일에러발생

class TEST { 
public: TEST(int a); 
} 
TEST a; // compile error : operator()를 사용하여 기본생성자 호출

출처

effective c++ / 스콧 마이어스