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

안녕하세요! 두두코딩 입니다 ✋
오늘은 Effective C++ 항목 7장에 대해 알아보겠습니다.

책에서는 부모클래스를 기본 클래스라고 설명하지만, 본인은 기반 클래스라고 해석하도록 하겠습니다!

🖇 소스코드에 마우스를 올리고 copy 버튼을 누를 경우 더 쉽게 복사할 수 있습니다!

궁금한 점, 보안점 남겨주시면 성실히 답변하겠습니다. 😁
+ 감상평 댓글로 남겨주시면 힘이됩니다. 🙇

가상 소멸자의 필요성

시간을 기록하는 시스템을 만든다고 생각해보자. 시간을 기록하는 방법의 시스템을 만들면 활용은 무궁무진 해진다. 따라서, 다양한 활용을 위해 TimeKeeper 라는 이름으로 기반 클래스를 만든다고 생각하자.

class TimeKeeper {
public:
	TimeKeeper();
	void currentTime() { ... } // 현재 시간을 얻는 함수.
	...

	~TimeKeeper();
};

class AtomicClock : public TimeKeeper { ... };
class WristClock	: public TimeKeeper { ... };

우리는 기반 클래스를 생성해, 손목시계등과 같은 시계를 만들 때는 TimeKeeper라는 기반 클래스로 묶어 사용하고자한다. Upcasting을 통해 동족을 묶는 형식으로 파생클래스들을 생성했지만, 파생클래스 입장에서는 TimeKeeper의 currentTime()만 얻어 사용하고 싶을 때가 있다. (재정의가 아닌 기반 클래스 함수 사용) 즉, 시간을 어떻게 구성하고 어떻게 계산하는지는 알고싶지않고, 만들어진 함수만 사용하고 있다.

이때 사용하는 패턴이 Factory 패턴 이다. 여기 를 클릭하면 좀 더 자세한 내용을 볼 수 있다.

해당 클래스를 Factory 패턴으로 간략하게 만들면 아래와 같다. 아래의 예시는 손목시계를 Factory로 만들어 사용하도록 한다.

class TimeKeeper {
public:
	TimeKeeper();
	void currentTime() { ... } // 현재 시간을 얻는 함수.
	...

	virtual void design() = 0;

	~TimeKeeper();
};

class WristClock	: public TimeKeeper
{
	void design() {
		cout << "WristClock" << endl;
	}
};

// Factory 구성
class TimeKeeperFactory() {
public:
	void newClock(const string& name) {
		TimeKeeper *tk = getTimeKeeper();
		objPool[name] = tk;
	}

	virtual TimeKeeper* getTimeKeeper() = 0;

private:
	map<string, Timekeeper *> objPool;
};

class WristTimeKeeperFactory() : public TimeKeeperFactory {
	TimeKeeper* getTimeKeeper() {
		return new WristClock;
	}
};

int main() {
	WristTimeKeeperFactory wtkf;
	wtkf.newClock("apple watch");
}

Factory 패턴을 사용하게 되면, 해당 Factory에 등록을 해야되는데, 이때 getTimeKeeper()라는 함수를 만들어 사용하게 된다. (이름은 자유롭게 사용해도 된다😆) getTimeKeeper()함수를 통해 객체를 생성하고 objPool에 등록하는 것을 볼 수 있다.

해당 Factory 패턴에서는 동적할당을 통해 객체를 생성한다. 우리가 동적할당을 통해 객체를 생성하면 메모리 및 기타 자원 누수를 막기 위해 해당 객체를 사용하고 나서는 꼭 delete한다.

❗ 포스팅이 길어 질 것 같아 delete하는 부분은 팩토리로 구성하지 않았다. 팩토리 패턴을 다루는 포스팅이 아니기 때문에 팩토리 패턴에서 TimeKeeper *ptk = getTimeKeeper();를 통해 동적할당 받아서 사용하는 부분을 강조하기 위해 팩토리 패턴을 예시로 들었다는 점을 기억하자!

...

TimeKeeper* getTimeKeeper() {
	return new WristClock;
}
...

// TimeKeeper 클래스 계통으로 동적할당
TimeKeeper *ptk = getTimeKeeper();

...	// 객체 사용

delete ptk;	// 객체 제거

자원 누수를 막기 위해 delete를 하지만, 문제는 getTimeKeeper() 반환하는 포인터가 파생클래스 객체 라는 것이다. 구체적으로, delete를 하는 객체는 TimeKeeper이기 때문에, 기반클래스의 일부분만 자원이 해제된다. 우리가 이전 포스팅 에서 봤던 그림을 가져와 생각해보자.

design_pattern

위의 그림을 보면, WristClock객체는 TimeKeeper 기반 클래스를 포함하고 있고, 포인터로는 WristClock이 아닌 TimeKeeper를 가리키고 있다. 따라서, delete를 할 경우 포인터가 가리키는 영역만 지워진다. 즉, 제대로 된 객체가 해제 되지 않는 문제가 있다.

❗ C++ 규정에 의하면, 기반 클래스 포인터를 통해 파생클래스 객체가 삭제될 때, 비가상 소멸자에 들어가 있으면 동작은 “미정”의 사항이다

동작이 미정이라는 것은 잘될수도 혹은 객체의 파생 클래스 부분이 소멸되지 않아 자원 누수가 발생할수도 있다는 뜻이다. 보통 최악의 경우를 생각하고 프로그래밍을 한다고 가정하면, 자원 누수로훗날 큰 문제가 발생할 수 있고, 프로그래머는 또 밤을 세야하는 문제가 있다. 😷

자원 누수를 막는 방법

해당 문제를 없애는 방법은 간단하다. 우리가 이전에 배웠던 내용으로 해결가능하다. 바로, 기반 클래스에 가상 소멸자를 넣어주면 된다.

우리가 늘 파생클래스에서 기반클래스의 함수를 재정의 하기 위해서는 virtual(가상) 이라는 키워드를 적어주면 된다. 위와같이 virtual를 활용해 소멸자를 만들게 되면 “자원 누수”가 발생하는 것을 막을 수 있다.

class TimeKeeper {

public:
TimeKeeper();
...

virtual ~TimeKeep();
};

TimeKeeper *ptk = getTimeKeeper();

...

delete ptk;

위와 같이, 기반 클래스 TimeKeeper 내 소멸자를 virtual로 변경해 문제를 해결해보자😂

우리는 기반 클래스를 작성할 때, 보통 소멸자외에도 virtual를 활용해 가상함수를 재정의 하곤 한다. 생각해보면, virtual를 가진 가상함수가 존재한다면 무조건 virtual 소멸자를 만드는 것이 맞다.

만약 독자가 기반 클래스에서 virtual 소멸자를 만들지 않은 클래스를 볼 경우 “아 저 클래스는 쓰일 의지를 상실한 것이구나.. 🤔” 라고 생각하면 된다.

입장을 바꿔 생각해도 마찬가지로, 기반클래스로 쓰이지 않을 즉, 파생클래스를 만들지 않을 클래스라면 virtual 소멸자를 선언 하지 않는 것이 좋다.

왜 그런가 생각해보자 🤔

class Point {
public:
	Point(int xCoord, int yCoord);
	~Point();

private:
	int x, y;
};

위의 클래스를 생각해보자. 위 클래스에 virtual 소멸자를 생성하는 것이 좋을까? 우선 생각해볼 것이, Point 클래스는 Upcasting역할을 할 수 있는가이다. 물론 안하니까 기반 클래스화 할 필요가 없다 라는 것을 알 수 있다. 그렇다면, virtual 소멸자를 선언 하지 않는 것이 좋다.

만약 virtual 소멸자를 선언하게 되면 어떤 일이 발생할까?

int가 32비트를 차지한다고 가정하면, Point 객체는 64비트이다. 딱 64비트 레지스터에 알맞게 들어간다. 따라서, 레지스터를 한번만 사용해서 Point처리가 가능해진다. 여기에 만약 virtual 소멸자가 추가될경우 기존 64비트의 포인터의 크기가 늘어나게 된다.

virtual 이라는 키워드를 넣게 되면 virtual 테이블이라는 것을 만들어 해당 가상함수들을 관리하게된다. 즉, virtual table을 가리키는 포인터가 하나 더 추가되게 된다. 따라서, 64비트가 아닌 96비트가 되게되고, 시스템 특성상 64비트 얼라인 되어져 있기 때문에 128비트를 할당 받아서 사용하게 된다. 즉, 100%의 메모리를 더 사용하게 되는 것이다.

가상 소멸자 사용해야되는 시점

위의 상황과 같이, 시스템 작성이 많은 C++을 사용할때는 잘 구별해서 사용하는 것이 좋고, virtual이 들어가 있는 클래스에서만 virtual 소멸자를 한정해서 사용하는 것이 좋다😇

하지만, 가상함수가 전혀 없는데도 비가상 소멸자 덕에 골치아픈 경우가 있는데, 한 예시가 string 타입이다. 보통 우리는 string 타입을 그냥 가져다 쓰는데 아래와 같이 사용하는 경우가 드물게 있다.. (이렇게 사용하면 안된다.. 😲)

class SpecialString : public std::string {
	// 위와 같이 상속받으면안된다.
	// string에는 가상 소멸자가 없다 ㅠㅠ..
	...
};

위의 코딩이 괜찮아 보이지만, string에는 가상 소멸자가 없어서 ‘미정의동작’을 만나게 되고 최악의 경우 “자원 누수”가 발생하게 된다.

이 현상(virtual 소멸자가 없는..)은 string 뿐 아니라 STL 컨테이너 타입은 전부 여기에 속한다.

비가상 소멸자를 가진 표준컨테이너나 특정 기반 클래스에서는 “상속” 받는일을 자제해야한다🙄

순수가상 소멸자

경우에 따라 “순수 가상 소멸자”를 두면 편리하게 사용할 수 있다. 알아보자 👣

우리는 문법에서순수 가상 함수라는 추상 클래스 개념을 배운다. virtual 함수에 =0을 추가하는 개념이다.

해당 추상클래스는 “자체로는 인스턴스를 못 만드는 클래스” 이다. 조금 더 자세한 내용을 보고 싶다면 여기를 클릭해서 알아보자.

하지만, 어떤 클래스가 추상클래스였으면 좋겠는데, 마땅히 넣을만한 순수 가상 함수가 없을 경우가 존재한다. 이때는 기반 클래스에 순수 가상 소멸자를 넣어 해당 기반 클래스를 추상 클래스로 만든다.

찬찬히 생각해보자.

추상클래스는 본래 기본 클래스로 쓰일 목적이고, 기본 클래스에는 가상 소멸자가 필요하다. 두가지 개념을 합쳐서 생각해보면, “순수 가상 소멸자”를 넣자 라는 생각이 나온다.

class AWOV {
	// AWOV = "Abstract w/o virtuals"
public:
	virtual ~AWOV() = 0;
}

위의 AWOV는 순수 가상 함수를 갖고 있음으로 “추상클래스” 이고, 동시에 가상 소멸자를 갖고있다. 명쾌하게 문제가 해결된다. 여기서 복병은 순수 가상 소멸자의 정의를 두면 안된다는 것이다. (순수 가상 소멸자니까 정의가 없음.. ㅠㅠ)

만약 이렇게 구현할 경우 파생 클래스에서 해당 클래스를 처리하는 부분을 잊지 않아야한다! (기억하자)

Appendix