리얼 개발

[Effective C++] 공부 정리 3 본문

C++/효과적인 C++

[Effective C++] 공부 정리 3

econo-my 2024. 7. 13. 18:08

2. 생성자, 소멸자 및 대입 연산자

클래스에 꼭 한개 이상 들어있는 생성자와 소멸자, 대입 연산자를 어떻게 잘 사용하는 방법


항목 5 : C++가 은근슬쩍 만들어 호출해 버리는 함수들에 촉각을 세우자.

  • C++에서 빈 클래스를 만들면 컴파일러가 저절로 선언해주는 함수들이 있다.
    • 생성자 (다른 생성자를 만들지 않은 경우)
    • 복사 생성자
    • 대입 연산자
    • 소멸자
class Empty {};

//아래와 같다
/********************************************************************/

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

/********************************************************************/

Empty e1 //기본 생성자, 그리고
				 //소멸자
				 
Empty e2(e1); //복사 생성자

e2 = e1; //복사 대입 연산
  • 클래스의 멤버 변수가 참조자나 상수로 선언된 경우, 대입 연산 실행시 참조자가 원래 가리키던 것과 다른 객체를 참조해야하는 이치에 맞지 않는 상황이 올 수 있다. 이때 컴파일러는 ‘컴파일 거부’를 한다.

 

요약

컴파일러는 경우에 따라 클래스에 기본 생성자, 복사 생성자, 복사 대입 연산자, 소멸자를 암시적으로 만들 수 있단 것을 명심하자.

 

 

 

항목 6 : 컴파일러가 만들어낸 함수가 필요 없으면 확실히 이들의 사용을 금해 버리자.

  • 컴파일러가 자동으로 만들어내는 함수를 사용하고 싶지 않다면 멤버 함수를 private 멤버로 선언하고 정의를 하지 말자.
class HomeForSale //복사하면 안되는 클래스
{
public:
	//구현들

private: //복사 생성자, 복사 대입 연산자를 private에 선언
	HomeForSale(const HomeForSale&);	//선언만 달랑 있다.
	HomeForSale& operator=(const HomeForSale&);
};

 

  • 에러 시점을 컴파일로 옮기는 방법
class UnCopyable
{
public:
	UnCopyable();
	~UnCopyable();

private:
	UnCopyable(const UnCopyable&);
	UnCopyable& operator=(const UnCopyable&);
};

class HomeForSale : private UnCopyable
{
	//복사 생성자, 복사 대입 연산자를 선언하지 않고 복사를 방지한다.
}

 

요약

컴파일러에서 자동으로 제공하는 기능을 허용치 않으려면, 대응되는 멤버 함수를 private로 선언한 후에 구현은 하지 않은채로 두라. UnCopyable과 비슷한 기본 클래스를 쓰는 것도 한 방법이다.

 

 

 

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

  • 기본 클래스 타입의 포인터로 파생 클래스를 업캐스팅 할 때, 기본 클래스의 소멸자가 비가상 소멸자라면 파생 클래스의 소멸자는 호출되지 않는다.
#include <iostream>

class TimeKeeper
{
public:
	TimeKeeper() { std::cout << "TimeKeeper 생성자" << std::endl; }
	~TimeKeeper() { std::cout << "TimeKeeper 소멸자" << std::endl; }
};

class AtomicClock : public TimeKeeper { };
class WaterClock : public TimeKeeper { };
class WristWatch : public TimeKeeper 
{
public:
	WristWatch() { std::cout << "Wrist 생성자" << std::endl; }
	~WristWatch() { std::cout << "Wrist 소멸자" << std::endl; }

};

int main()
{
	TimeKeeper* t = new WristWatch();

	delete t;
}

//실행결과
TimeKeeper 생성자
Wrist 생성자
TimeKeeper 소멸자
  • 이 문제를 해결하는 방법은 기본 클래스의 소멸자에 virtual 키워드를 붙여주는 것이다.
  • 가상 소멸자를 가지고 있지 않다면 기본 클래스로 쓰일 일은 없다고 생각하면 편하다. STL 컨테이너 타입 전부는 가상 소멸자가 없다.
  • 하지만 virtual 키워드를 붙일 때 vptr (virtual table pointer) 가 생겨 32bit에서 64bit의 추가 용량이 클래스에 생길 수 있다.

 

  • 순수 가상 소멸자
    • 순수 가상 함수를 만들고 싶지 않을 때, 소멸자를 순수 가상으로 선언하여 추상 클래스를 만들 수 있다.
    • 주의해야할 점은 이 순수 가상 소멸자의 정의를 반드시 해줘야한다는 것이다.
class A
{
public:
	virtual ~A() = 0;
};

A::~A() { }

 

요약

기본 클래스에 가상 소멸자를 사용하는 경우는 다형성을 가진 기본 클래스에만 적용된다. 또한 어떤 클래스가 가상 함수를 하나라도 갖고 있으면, 이 클래스의 소멸자도 가상 소멸자이어야 한다. / 기본 클래스로 설계되지 않았거나 다형성을 갖도록 설계되지 않은 클래스에는 가상 소멸자를 선언하지 말아야 한다.

 

 

 

항목 8 : 예외가 소멸자를 떠나지 못하도록 붙들어 놓자

  • 소멸자에서 예외가 발생하는 경우 어떻게 처리해야할까?
  • 데이터베이스를 연결하는 코드를 봐보자.
class DBConnection
{
public:
	static DBConnection* Create() { return new DBConnection; }
	
	void Close() {}
};

class DBConn
{
public:
	DBConn(DBConnection* DB) : DB(DB) {}

	~DBConn()
	{
		DB->Close();
	}

private:
	DBConnection* DB;
};

만약 DB 포인터가 Close 함수를 호출했을 때 예외가 발생한다면, 소멸자는 이 예외를 바깥으로 전파할 것이다. 이를 피하는 방법은 세가지가 정도가 있다.

 

  • 프로그램 바로 끝내기
~DBConn()
{
	try { DB->Close(); }
	catch (/* throw에서 던진 예외 */)
	{
		//Close 호출 실패 로그
		std::abort();
	}
}

 

  • 예외 삼켜버리기
//예외를 무시한 뒤 프로그램이 신뢰성 있게 실행을 지속할 수 있어야한다.
~DBConn()
{
	try { DB->Close(); }
	catch (/* throw에서 던진 예외 */)
	{
		//Close 호출 실패 로그
	}
}

 

  • 사용자에게 책임 전가
class DBConn
{
public:
	DBConn(DBConnection* DB) : DB(DB) {}
	
	void Close() //사용자 호출을 위해 새로 만든 함수
	{
		DB->Close();
		bClosed = true;
	}

	~DBConn()
	{
		if(!bClosed) //사용자가 연결을 안 닫았으면
		try { DB->Close(); } //여기서 닫는다.
		catch (/* throw에서 던진 예외 */) //연결 닫기를 실패하면 예외를 삼킨다.
		{
			//Close 호출 실패 로그
		}
	}

private:
	DBConnection* DB;
	bool bClosed;
};
  • 어떤 동작이 예외를 일으켜 처리해야 할 필요가 있다면, 그 예외는 소멸자가 아닌 다른 함수에서 비롯된 것이어야 한다.

 

요약

소멸자에서는 예외가 빠져나가면 안된다. 만약 소멸자 안에서 호출된 함수가 예외를 던질 가능성이 있다면, 어떤 예외이든지 소멸자에서 모두 받아낸 후에 삼켜버리든지 프로그램을 끝내든지 해야한다.
어떤 클래스의 연산이 진행되다가 던진 예외에 대해 사용자가 반응해야 할 필요가 있다면, 해당 연산을 제공하는 함수는 반드시 보통의 함수(소멸자가 아닌)이어야 한다.

 

 

 

 

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

생성자, 소멸자에서 가상함수를 호출하면 안되는 이유 2가지

  • 첫 번째는 기본 클래스 생성자는 파생 클래스 생성자보다 앞서서 실행되기 때문에, 기본 클래스 생성자가 돌아가고 있을 시점에 파생 클래스 데이터 멤버는 아직 초기화된 상태가 아니다.
    • 따라서 파생 클래스의 초기화 되지 않은 데이터 멤버를 건드려 ‘미정의 동작’을 실행할 수 있다.
  • 두 번째는 파생 클래스 객체의 기본 클래스 부분이 생성되는 동안은, 그 객체의 타입은 바로 기본 클래스이다.
    • 파생 클래스만의 데이터는 아직 초기화된 상태가 아니기 때문에, 아예 없었던 것처럼 취급한다.

 

요약

생성자 혹은 소멸자 안에서 가상 함수를 호출하지 말자. 가상 함수라고 해도, 지금 실행중인 생성자나 소멸자에 해당하는 클래스의 파생 클래스 쪽으로 내려가지 않는다.

 

 

항목 10 : 대입 연산자는 *this의 참조자를 반환하게 하자

  • C++에는 대입 사슬 연산자라는게 있다.
int x, y, z;
x = y = z = 15;
  • 이렇게 대입 연산이 사슬처럼 엮이려면 대입 연산자가 좌변 인자에 대한 참조자를 반환하도록 되어 있을 것이다.
  • 이런 구현은 일종의 관례로 지키면 좋다.
class Widget
{
public:
	Widget& operator=(const Widget& rhs)
	{
		/* 구현 */
		return *this;
	}
	Widget& operator+=(const Widget& rhs)
	{
		/* 구현 */
		return *this;
	}
	Widget& operator+=(int rhs)
	{
		/* 구현 */
		return *this;
	}
}

 

요약

 

대입 연산자는 *this의 참조자를 반환하도록 만들자.

 

 

 

항목 11 : operator=에서는 자기 대입에 대한 처리가 빠지지 않도록 하자.

  • **자기대입(self assignment)**이란, 어떤 객체가 자기 자신에 대해 대입 연산자를 적용하는 것을 의미한다.
class Widget { };
Widget w;

w = w;

a[i] = a[j]; //i와 j가 같은 값을 갖게 되면 자기 대입문이 된다.
*px = *py; //px, py 포인터가 가리키는 대상이 같으면 자기 대입이 된다.

 

  • 자기참조의 문제점
    • 동적 할당된 비트맵을 가리키는 원시 포인터를 데이터 멤버로 갖는 클래스를 하나 만들었다고 가정해보자.
class Bitmap { };

class Widget
{
public:
	Widget& operator=(const Widget& rhs) //안전하지 않게 구현된 operator=
	{
		delete pb;
		pb = new Bitmap(*rhs.pb);
		
		return *this;
	}

private:
	Bitmap* pb;
};
  • operator=로 들어오는 인자가 *this, 즉 자기 자신이라면 delete 연산자가 *this 객체와 rhs 객체 모두에게 적용된다.

 

  • 해결 방법은 operator=의 첫머리에서 **일치성 검사(identity test)**를 해주는 방법이다.
//자기대입 안전성을 가지는 코드
Widget& operator=(const Widget& rhs)
{
	if(this == &rhs) return *this;
		
	delete pb;
	pb = new Bitmap(*rhs.pb);
		
	return *this;
}

 

  • 새로운 Bitmap을 할당하는 과정 중 예외가 터지는 상황을 예방하는 방법도 있다.
//예외 안전성을 가지는 코드
Widget& operator=(const Widget& rhs)
{
	Bitmap* pOrig = pb;
	pb = new Bitmap(*rhs.pb);
	delete pOrig;
	
	return *this;
}

 

  • 예외 안전성과 자기대입 안전성을 동시에 가지는 ‘복사 후 맞바꾸기’ 기법
class Widget
{
public:
	void Swap(Widget& rhs);
	
	Widget& operator=(const Widget& rhs) 
	{
		Widget temp(rhs); //rhs의 데이터에 대해 사본을 하나 만든다.
		swap(temp); //*this의 데이터를 그 사본의 것과 맞바꾼다.
		return *this;
	}
};

 

요약

operator=을 구현할 때, 어떤 객체가 그 자신에 대입되는 경우를 제대로 처리하도록 만들자.
두 개 이상의 객체에 대해 동작하는 함수가 있다면, 이 함수에 넘겨지는 객체들이 사실 같은 객체인 경우에 정확하게 동작하는지 확인하자.

 

 

 

책에 나온대로 코드를 실험하는 중 왜 정상 작동되는지 모르겠는 부분이 있다.

#include <iostream>
class Bitmap 
{
public:
	void Func_Bit()
	{
		std::cout << "Bitmap" << std::endl;
	}
	~Bitmap()
	{
		std::cout << "Bitmap 소멸자" << std::endl;
	}
};

class Widget
{
public:
	Widget() : pb(new Bitmap) {}
	Widget& operator=(const Widget& rhs) //안전하지 않게 구현된 operator=
	{
		delete pb;
		pb = nullptr;
		pb->Func_Bit(); //이게 왜 호출 가능할까?
		pb = new Bitmap(*rhs.pb);
		return *this;
	}
	
	Bitmap* GetPb() const { return pb; }
private:
	Bitmap* pb;
};

int main()
{
	Widget w;
	w = w;

	w.GetPb()->Func_Bit();
	return 0;
}

처음에는 delete만 해서 포인터 pb는 아직 원래 가리키던 힙메모리를 가리켜서 실행되는건가? 라는 생각이 들어 pb 포인터를 nullptr로 만들고 다시 해봐도 정상 실행이 된다. 뭐가 문제일까!

 

 

 

항목 12 : 객체의 모든 부분을 빠짐없이 복사하자.

  • 객체를 복사하는 복사 함수는 복사 생성자와 복사 대입 연산자 두가지이다.
class A
{
public:
	A() {}
	A(int value) : a(value) {}

	A(const A& rhs)
	{
		a = rhs.a;
	}

	A& operator=(const A& rhs)
	{
		a = rhs.a;
		return *this;
	}

private:
	int a;
};
  • 해당 코드는 복사 함수들을 직접 구현하여 별 문제 없는 것처럼 보인다. 하지만 멤버 변수가 추가되기 시작하면 귀찮아진다.

 

class A
{
public:
/* 기존 코드 */

private:
	int a, b; //멤버 변수 b 추가
};
  • 해당 b도 복사해주기 위해 복사 함수를 들여다보고 작업을 반복해주어야 한다.

 

  • 클래스 상속시 복사는 더 복잡해질 수 있다.
class B : public A //방금 전 만든 클래스 A
{
public:
	B(const B& rhs)
	{
		c = rhs.c;
	}

	B& operator=(const B& rhs)
	{
		c = rhs.c;
		return *this;
	}

private:
	int c;
};
  • A 클래스의 멤버 변수들은 초기화가 되고 있지 않은 모습이다. B의 복사 생성자에서도 A의 클래스를 명시적으로 부르고 있지 않기에 A의 기본 생성자만이 호출될 것이다.
  • 복사 대입 함수의 경우에는 A의 생성자를 건드릴 시도조차 하지 않는다.

 

  • 해결 방법은 파생 클래스의 복사 함수 안에서 기본 클래스의 복사 함수를 호출하도록 만들자.
B(const B& rhs) : A(rhs)
{
	c = rhs.c;
}

B& operator=(const B& rhs)
{
	A::operator=(rhs);
	c = rhs.c;
	return *this;
}

 

요약

객체 복사 함수는 주어진 객체의 모든 데이터 멤버 및 모든 기본 클래스 부분을 빠뜨리지 말고 복사해야 한다.
클래스의 복사 함수 두 개를 구현할 때, 한쪽을 이용해서 다른 쪽을 구현하려는 시도는 하지 말자.

'C++ > 효과적인 C++' 카테고리의 다른 글

[Effective C++] 공부 정리 5  (0) 2024.10.25
[Effective C++] 공부 정리 4  (0) 2024.08.20
[Effective C++] 공부 정리 2  (0) 2024.07.13
[Effective C++] 공부 정리 1  (1) 2024.07.13