[Effective C++] Chapter 2. 생성자, 소멸자 및 대입 연산자
Table of Contents
이펙티브 C++ 책을 읽고 공부한 노트입니다.
Item 5: C++가 은근슬쩍 만들어 호출해 버리는 함수들에 촉각을 세우자 #
- 컴파일러는 필요하다고 생각하면 다음의 함수들을 자동으로 만들어 낸다.
- 모두
public
멤버이며,inline
함수이다. - (1) 기본 생성자
- (2) 복사 생성자
- (3) 소멸자 (비가상으로 만들어진다.)
- (4) 복사 대입 연산자
- 모두
// 이것은
class Empty {};
// 다음과 같다.
class Empty
{
public:
Empty() { ... } // 기본 생성자
Empty(const Empty& e) { ... } // 복사 생성자
~Empty() { ... } // 소멸자
Empty& operator=(const Empty& e) { ... } // 복사 대입 연산자
};
- 만약, 멤버 변수가 참조자 이거나,
const
이면, 반드시 복사 대입 연산자를 정의해 주어야한다.- 그렇지 않으면 컴파일 에러가 발생한다.
template<class T>
class NamedObject
{
private:
std::string& namedValue; // 참조자
const T objectValue; // const
public:
NamedObject(std::string& name, const T& value);
};
int main()
{
std::string newDog("Persephone");
std::string oldDog("Satch");
NamedObject<int> p(newDog, 2);
NamedObject<int> s(oldDog, 36);
p = s; // 컴파일 에러! 복사 대입 연산자 정의 필요!
}
Item 6: 컴파일러가 만들어낸 함수가 필요 없으면 확실히 이들의 사용을 금해 버리자 #
- 객체의 사본이 만들어지면 안되는 경우에 어떻게 해야할까?
- 복사 생성자와 복사 대입 연산자를 선언하지 않으면 컴파일러가 마음대로 만들어버린다.
- 따라서
private
으로 선언하면 되겠다! - 하지만 클래스의 멤버 함수, friend 함수에서 호출할 수 있다는 허점이 있다.
- 그럼 선언만 하고, 정의를 빼먹자!
class Uncopyable
{
protected:
Uncopyable() {}
~Uncopyable() {}
// 선언만 하고 정의는 하지 않는다.
private:
Uncopyable(const Uncopyable&);
Uncopyable& operator=(const Uncopyable&);
};
// 복사 생성자도, 복사 대입 생성자도 선언되지 않는다.
class HomeForSale : private Uncopyable { };
Item 7: 다형성을 가진 기본 클래스에서는 소멸자를 반드시 가상 소멸자로 선언하자 #
- 비가상 소멸자의 경우, 기본 클래스의 소멸자만 호출하고 파생 클래스의 소멸자는 호출하지 않는다.
- 따라서, 다형성을 가진 기본 클래스의 소멸자는 반드시 가상 소멸자여야 한다.
class TimeKeeper
{
public:
TimeKeeper();
virtual ~TimeKeeper(); // 가상 소멸자
};
class AtomicClock: public TimeKeeper { ... };
class WaterClock: public TimeKeeper { ... };
class WristClock: public TimeKeeper { ... };
int main()
{
TimeKeeper* t = getTimeKeeper(); // 생성된 파생 클래스의 기본 클래스 포인터를 반환하는 팩토리 함수이다.
delete t; // 파생 클래스의 소멸자 -> TimeKeeper의 소멸자
}
- 기본 클래스가 아닌 클래스거나, 다형성을 갖도록 설계되지 않은 클래스에 가상 소멸자를 선언하는 것도 옳지 않다.
- 가상 함수를 구현하려면 C++에서는 클래스에 vptr (가상함수 테이블 포인터; virtual table pointer) 가 클래스에 별도로 들어간다.
- 이것은 vtbl (가상함수 테이블; virtual table) 을 가리키고 있다.
- 따라서 예를들어, 64비트 시스템에서는 포인터의 크기가 64비트이므로, 클래스의 크기에 64비트가 추가되는 셈이다.
- 또한 vtpr을 따로 만들기는 어렵기 때문에 C 등의 다른 언어로 호환성이 없어진다.
- 가상 함수를 구현하려면 C++에서는 클래스에 vptr (가상함수 테이블 포인터; virtual table pointer) 가 클래스에 별도로 들어간다.
- 가상 소멸자가 없는 타입은 상속해서 사용하면 안 된다.
string
- STL의 모든 컨테이너
- 기본 클래스를 추상 클래스로 만들고 싶은 데 마땅한 순수 가상 함수가 없는 경우에는?
- 순수 가상 소멸자를 두면 편리하다.
- 주의할 점은, 순수 가상 소멸자의 정의를 꼭 두어야 한다는 점이다.
class AbstractWithVirtual
{
public:
virtual ~AbstractWithVirtual() = 0; // 순수 가상 소멸자
};
AbstractWithVirtual::~AbstractWithVirtual() {}; // 꼭 정의를 두어야 한다.
Item 8: 예외가 소멸자를 떠나지 못하도록 붙들어 놓자 #
- 소멸자에서 예외가 발생하면?
- 프로그램이 불완전 종료되거나, 미정의 동작의 위험을 내포하고 있다.
- 따라서 예외가 발생될만한 코드는 소멸자가 아닌 다른 함수에 존재해야 할 것이다.
class DBConnection()
{
public:
static DBConnection Create();
// 연결을 닫을 때 실패하면 예외를 던진다.
// 소멸자가 아닌 다른 함수에서 예외가 발생하도록 했다.
void Close();
};
class DBHandler
{
private:
DBConnection db;
public:
// 데이터베이스 연결이 항상 닫히도록 소멸자에서 Close해준다.
~DBHandler()
{
db.Close();
// 하지만 여기서 예외가 발생했을 때 어떤 조취를 취해야 할까?
// (1) 프로그램을 바로 종료하거나 (2) 아무일 없었던 듯 무시한다
// 단순히 위와같은 조치를 바로 취하는 건 좋지 않겠다.
// 그렇다면 사용자가 직접 예외에 대처할 수 있게 하는 것이 좋겠다.
}
};
// 유저가 이렇게 사용할 수 있겠다.
int main()
{
DBHandler h(DBConnection::Create());
}
- 해결법: 사용자가 직접 예외를 처리할 기회를 갖도록 한다.
class DBHandler
{
private:
DBConnection db;
bool closed;
public:
// 사용자가 처리할 수 있도록 책임을 전가한다.
void Close()
{
db.Close();
closed = true;
}
~DBHandler()
{
// 만약 사용자가 Close를 안 했을 때만 여기서 Close해본다.
if (closed == false)
{
try
{
db.Close();
}
catch (...)
{
// Close 호출이 실패했다는 로그를 작성한다.
// (1) 프로그램을 바로 종료하거나 (2) 아무일 없었던 듯 무시한다
}
}
}
};
Item 9: 객체 생성 및 소멸 과정 중에는 절대로 가상 함수를 호출하지 말자 #
- 생성자나 소멸자에서 가상 함수를 호출하면 절대 안 된다.
- 기본 클래스의 생성자가 호출될 때,
- 파생 클래스의 멤버들은 아직 초기화가 안 되었으므로
- 객체는 기본 클래스 타입으로 작동된다.
- 기본 클래스의 소멸자가 호출될 때,
- 파생 클래스의 소멸자가 호출된 후이므로
- 객체는 기본 클래스 타입으로 작동된다.
class Transaction
{
public:
Transaction()
{
// 생성자에서 가상 함수를 호출해버렸다.
LogTransaction();
// 하지만 파생 클래스의 멤버들은 아직 초기화가 안 되었으므로
// 이 시점에서 b의 타입은 Transaction이다.
// (2) 따라서 Transaction의 LogTransaction()이 호출된다.
}
virtual void LogTransaction() const = 0;
};
class Buy : public Transaction
{
public:
virual void LogTransaction() const;
};
class Sell : public Transaction
{
public:
virual void LogTransaction() const;
};
int main()
{
// 이 코드가 실행되면 어떻게 될까?
Buy b;
// 가장 먼저 Buy의 기본 클래스인 (1) Transaction의 생성자가 호출될 것이다.
}
- 해결법: 함수를 비가상으로 바꾸고, 생성자의 매개변수로 필요한 정보를 넘기도록 한다.
class Transaction
{
public:
explicit Transaction(const str::string logInfo)
{
LogTransaction(logInfo);
}
// 비가상 함수이다.
// 매개변수로 필요한 정보를 받는다.
void LogTransaction(const str::string logInfo) const;
};
class Buy : public Transaction
{
public:
// 매개변수로 필요한 정보를 넘긴다.
Buy() : Transaction(createLogString())
{
// ...
}
private:
static std::string createLogString();
};
Item 10: 대입 연산자는 *this
의 참조자를 반환하게 하자 #
- C++의 대입 연산은 사슬처럼 엮일 수 있다.
- 따라서 대입연산자는 좌변 인자에 대한 참조자를 반환해야 한다는 일종의 관례(convention)가 있다.
class Widget
{
public:
// 단순 대입 연산자
Widget& operator=(const Widget& rhs)
{
// ...
return *this; // 참조자를 반환한다.
}
// 다른 대입 연산자도 마찬가지이다.
Widget& operator+=(const Widget& rhs)
{
// ...
return *this;
}
};
int main()
{
Widget a, b, c;
// 대입이 사슬처럼 이루어진다.
a = b = c;
// 이것은 다음과 같이 우측 연관 연산(right-associative)으로 진행된다.
// (a = (b = c))
}
Item 11: operator=
에서는 자기대입에 대한 처리가 빠지지 않도록 하자 #
- 자기대입(self assignment)이란?
- 자기 자신에 대해 대입 연산자를 적용하는 것을 말한다.
- 하나의 객체를 여러 곳에서 참조할 수 있기(중복참조; aliasing) 때문에 자기대입이 발생한다.
class Widget {};
Widget w;
w = w; // 자기 자신에 대해 대입 연산자를 적용하는 것을 말한다.
- 자기대입은 위험성을 내포한다.
class Bitmap {};
class Widget
{
private:
Bitmap * pb;
public:
Widget& operator=(const Widget& rhs);
};
Widget& Widget::operator=(const Widget& rhs) // 만약 rhs가 자기 자신이라면?
{
delete pb; // 자기자신의 데이터를 지웠기 때문에
pb = new Bitmap(*rhs.pb); // rhs.pb는 이미 삭제된 상태가 된다. 아뿔싸!
return *this;
}
- 해결법
- (1) 첫머리에 일치성 검사(identify test) 를 한다.
- 이것은 공짜가 아니다. 일치성 검사 코드가 들어가면 그만큼 코드가 커지는데다가, 처리 흐름에 분기를 만들게 되므로 실행 시간 속력이 줄어들 수 있다.
Widget& Widget::operator=(const Widget& rhs)
{
// 객체가 같은지 확인해서 같으면 아무것도 하지 않는다.
if (this == &rhs) return *this;
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}
- (2) 객체를 복사하고 삭제한다.
Widget& Widget::operator=(const Widget& rhs)
{
Bitmap * pOrig = pb; // 원래의 pb를 복사한다.
pb = new Bitmap(*rhs.pb); // 새로운 값을 대입한다.
delete pOrig; // 원래의 pb를 삭제한다.
return *this;
}
- (3) 복사 후 맞바꾸기(copy and swap) 를 한다.
- Item 29에서 자세히 설명한다.
class Widget
{
private:
Bitmap * pb;
void swap(Widget& rhs); // *this와 rhs의 데이터를 맞바꾼다. Item 28에서 자세히 설명한다.
public:
Widget& operator=(const Widget& rhs);
};
Widget& Widget::operator=(const Widget& rhs)
{
Widget temp(rhs); // rhs의 사본을 만든다.
swap(temp); // *this와 사본을 맞바꾼다.
return *this;
}
- 이것은 사본을 매개변수로 넘겨서 좀 더 간소화할 수 있다.
Widget& Widget::operator=(Widget rhs) // 사본이 전달된다.
{
swap(rhs); // *this와 사본을 맞바꾼다.
return *this;
}
Item 12: 객체의 모든 부분을 빠짐없이 복사하자 #
- 복사 함수(copying function)에서는 모든 부분을 복사해야 한다.
- 복사 생성자와 복사 대입 연산자가 있다.
void logCall(const std::string& funcName);
class Customer
{
private:
std::string name;
public:
// 복사 생성자
Customer(const Customer& rhs) : name(rhs.name)
{
logCall("Customer copy constructor");
}
// 복사 대입 연산자
Customer& operator=(const Customer& rhs)
{
logCall("Customer copy assignment operator");
name = rhs.name;
return *this;
}
};
class PriorityCustomer : public Customer
{
private:
int priority;
public:
PriorityCustomer(const PriorityCustomer& rhs);
PriorityCustomer& operator=(const PriorityCustomer& rhs);
};
// 파생 클래스의 복사 생성자에서
// 기본 클래스의 멤버 데이터를 복사하지 않고 있다!
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs) : priority(rhs.priority)
{
logCall("PriorityCustomer copy constructor");
}
// 파생 클래스의 복사 대입 연산자에서도 마찬가지다!
PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
logCall("PriorityCustomer copy assignment operator");
priority = rhs.priority;
return *this;
}
- 복사 함수를 작성할 때는 다음의 두 가지를 꼭 확인하자
- (1) 해당 클래스의 데이터 멤버를 모두 복사했는가?
- (2) 이 클래스가 상속한 기본 클래스의 복사 함수를 호출했는가?
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
: Customer(rhs), // 기본 클래스의 복사 생성자를 호출한다.
priority(rhs.priority)
{
logCall("PriorityCustomer copy constructor");
}
PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
logCall("PriorityCustomer copy assignment operator");
priority = rhs.priority;
Customer::operator=(rhs); // 기본 클래스 부분을 대입한다.
return *this;
}
- 코드 간소화를 위해 두 복사함수에서 겹치는 내용을
init()
멤버 함수를 따로 두고 호출할 수도 있겠다.