일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |
- Frustum Culling
- 장치 초기화
- FrameResource
- gitscm
- DirectX
- 디퍼드 렌더링
- light
- gitlab
- 노멀 맵핑
- 동적 색인화
- 절두체 컬링
- DirectX12
- 게임 클래스
- 게임 프로그래밍
- 네트워크 게임 프로그래밍
- effective C++
- Direct3D12
- 조명 처리
- Dynamic Indexing
- 네트워크
- Render Target
- 입방체 매핑
- 게임 디자인 패턴
- C++
- direct3d
- InputManager
- 큐브 매핑
- Deferred Rendering
- 직교 투영
- TCP/IP
- Today
- Total
코승호딩의 메모장
생성자, 소멸자 및 대입 연산자 본문
05 C++가 은근슬쩍 만들어 호출해 버리는 함수들에 촉각을 세우자
C++의 어떤 멤버 함수는 클래스 안에 직접 선언하지 않으면 컴파일러가 저절로 선언해 주도록 되어 있다. 복사 생성자, 복사 대입 연산자, 생성자, 소멸자 등이 그렇다.
template <typename T>
class NamedObject {
public:
NamedObject(const char* name, const T& value);
NamedObject(const string& name, const T& value);
private:
string nameValue;
T objectValue;
};
위 코드에서 NamedObject 템플릿 안에는 생성자가 선언되어 있으므로 컴파일러는 기본 생성자를 만들어내지 않는다. 반면, 복사 생성자, 복사 대입 연산자는 선언되어 있지 않기 때문에 이 두 함수의 기본형이 컴파일러에 의해 만들어진다(복사가 필요한 경우).
NamedObject<int> no0; // X
NamedObject<int> no1("Smallest Prime Number", 2); // O
NamedObject<int> no2(no1); // O
다음과 같이 기본 생성자는 컴파일러가 만들어내지 않기 때문에 컴파일러 오류가 뜬다.
컴파일러 복사 생성자는 no1.nameValue와 no1.objectValue를 사용해서 no2.nameValue, no2.objectValue를 각각 초기화해야 한다. 그러나 nameValue의 표준 string 타입은 자체적으로 복사 생성자를 갖고 있기 때문에 no2.nameValue의 초기화는 string 복사 생성자에 no1.nameValue를 인자로 넘겨 호출하여 이루어 지지만, objectValue는 기본 제공 타입인 int 이므로 각 비트를 그대로 복사해 오는 것으로 끝난다.
이렇게 컴파일러가 만들어 주는 NamedObject<int>의 복사 대입 연산자도 근본적으로는 동작 원리가 같다. 그러나 최종 결과 코드가 적법하고 이치에 닿아야 한다. 만약 둘 중 어느 검사도 통과하지 못하면 operator=의 자동 생성을 거부하게 된다.
template <typename T>
class NamedObject {
public:
NamedObject(const string& name, const T& value);
// ...
private:
string& nameValue;
const T objectValue;
};
int main()
{
string newDog("Happy");
string oldDog("Poppy");
NamedObject<int> p(newDog, 2);
NamedObject<int> s(oldDog, 9);
p = s; // X
}
만약 다음과 같이 nameValue가 string에 대한 참조자이고 objectValue는 상수로 되어 있다고 가정하자. 대입 연산 전, p.nameValue, s.nameValue는 string 객체를 참조하고 있다. 이때 대입 연산이 일어나면 p.nameValue는 s.nameValue가 참조하는 string을 가리켜야 할까? C++은 원래 자신이 참조하고 있는 것과 다른 객체는 참조할 수 없다. 따라서 참조자를 데이터 멤버로 갖고 있는 클래스에 대입 연산을 지원하려면 직접 복사 대입 연산자를 정의해야 한다. 상수 객체인 경우에도 비슷하게 동작하니 주의해야 한다.
06 컴파일러가 만들어낸 함수가 필요 없으면 확실히 이들의 사용을 금하자
class HomeForSale{...};
다음과 같은 가옥을 나타내는 클래스가 있다고 하자. 모든 자산은 세상에 하나밖에 없기 때문에 HomeForSale 객체는 사본을 만드는 것 자체가 이치에 맞지 않다. 따라서 HomeForSale 객체를 복사하려는 코드는 컴파일이 되지 않았으면 하는 생각이 든다.
HomeForSale h1;
HomeForSale h2;
HomeForSale h3(h1); // X
h1 = h2; // X
다음과 같이 말이다. 그러나 복사 생성자와 복사 대입 연산자는 직접 선언하지 않고 외부에서 이들을 호출하려고 한다면 컴파일러가 자동으로 선언하여 호출할 것이다. 해결 방법으로는 이 함수들을 public이 아닌 private으로 외부로부터의 호출을 차단하는 것이다. 그러나 이 방법은 해당 클래스의 멤버 함수 및 friend 함수가 호출할 수 있다는 점에서 허점이다. 이 허점까지 막기 위해서는 선언만 하고 정의하지 않는 것이다.
class HomeForSale {
public:
...
private:
HomeForSale(const HomeForSale&);
HomeForSale& operator=(const HomeForSale&);
};
이처럼 정의되지 않은 함수를 호출하려 한다면 링크 시점에 에러를 보게 된다. 이 링커 시점 에러를 컴파일 시점 에러로 옮길 수 있는 방법도 있다.
class Uncopyable {
protected:
Uncopyable() {}
~Uncopyable() {}
private:
Uncopyable(const Uncopyable&);
Uncopyable& operator=(const Uncopyable&);
};
바로 다음과 같이 복사 생성자와 대입 연산자를 private로 선언하되 HomeForSale 자체에 넣지 않고 기본 클래스에 넣어 HomeForSale을 파생시키는 것이다. 이 기본 클래스는 단지 복사 방지만 맡는다는 특별한 의미이다.
class HomeForSale : private Uncopyable {
};
다음과 같이 HomeForSale가 Uncopyable을 상속받은 경우, 외부에서 복사를 시도하려고 할 때, 컴파일러는 HomeForSale 복사 생성자와 복사 대입 연산자를 만들려고 한다. 이때, 컴파일러는 기본 클래스의 대응 버전을 호출하려 한다. 그러나 복사 함수들이 기본 클래스에서 공개되어 있지 않기 때문에 불가능하다.
07 다형성을 가진 기본 클래스에서 소멸자를 반드시 가상 소멸자로 선언하자
class TimeKeeper {
public:
TimeKeeper();
~TimeKeeper();
};
class AtomicClock : public TimeKeeper {};
class WaterClock : public TimeKeeper {};
class WristWatch : public TimeKeeper {};
시간 계산에 신경 쓰지 않고 다른 클래스에서 시간 정보에 접근하고 싶을 경우 시간기록 객체에 대한 포인터를 얻는 용도로 팩토리 함수를 만들어 놓을 수 있다.
TimeKeeper* getTimeKeeper();
위 함수는 TimeKeeper에서 파생된 클래스를 통해 동적으로 할당된 객체의 포인터를 반환한다. 그리고 반환되는 객체는 힙에 있으므로 메모리 누수를 막기 위해서는 객체를 적절히 삭제해야 한다.
TimeKeeper *ptk = getTimeKeeper();
// ...
delete ptk;
다음과 같이 TimeKeeper 객체를 얻은 후 사용한 다음, 객체를 해제해야 한다. 문제는 getTimerKeeper 함수가 반환하는 포인터가 파생 클래스 객체에 대한 포인터라는 점과 이 포인터가 가리키는 객체가 삭제될 때는 기본 클래스 포인터를 통해 삭제된다는 점이다. 그리고 기본 클래스의 소멸자가 비가상 소멸자라는 점이다. C++ 규정에 의하면 기본 클래스 포인터를 통해 파생 클래스 객체가 삭제될 때 기본 클래스에 비가상 소멸자가 있으면 프로그램 동작은 미정의 사항이다.
결국 이 함수를 통해 얻은 AtomicClock 객체가 기본 클래스 포인터를 통해 삭제될 때, AtomicClock 클래스에 정의된 데이터 멤버들은 삭제되지 않을 뿐더러 AtomicClock의 소멸자도 실행되지 않는다. 다만 기본 클래스 부분은 삭제가 제대로 되기 때문에 부분 소멸(partially destroyed) 객체가 되는 것이다. 따라서 기본 클래스에서 소멸자 앞에 virtual을 붙여줘야 한다.
class TimeKeeper {
public:
TimeKeeper(){}
virtual ~TimeKeeper(){}
};
이처럼 기본 클래스를 구현할 때 무조건 소멸자를 반드시 가상 소멸자로 선언해야 한다. 그러나 기본 클래스로 의도하지 않은 클래스에 대한 소멸자를 가상으로 선언하는 것은 하지 말아야 한다.
class Point {
public:
Point(int x, int y);
~Point();
private:
int x, y;
};
한 예로 다음과 같이 2차원 공간의 한 점을 나타내는 클래스를 살펴보자. int가 32비트라면 Point 객체는 64비트 레지스터에 딱 맞게 들어간다. 그러나 Point가 가상 소멸자로 만들어지는 순간 변하게 되는데, C++에서 가상 함수를 구현하려면 클래스에 별도의 자료구조가 하나 들어간다. 이 자료구조는 어떤 가상 함수를 호출해야 하는지를 결정하는 데 쓰이는 가상 함수 테이블 포인터인 vptr이다. 이 포인터는 가상 함수 테이블이라 불리는 vtbl 즉, 포인터들의 배열을 가리키고 있으며 가상 함수가 호출되려고 하면, 호출되는 실제 함수는 그 객체의 vptr이 가리키는 vtbl에 따라 결정된다.
따라서 Point에 가상 함수가 들어가게 되면 Point 타입 객체가 커지게 된다. 64비트였던 Point는 포인터를 포함하여 64비트 아키텍처에서 128비트로 커지게 될 것이다. 따라서 어느 경우를 막론하고 소멸자를 전부 virtual로 선언하는 것은 편찮은 마인드이다.
class String : public string { };
int main()
{
String* pS = new String("Impending Doom");
string* ps;
ps = pS;
delete ps;
}
표준 string은 가상 함수를 가지고 있지 않다. 그러나 새로운 사용자 정의 타입이 string을 상속받게 한 다음 위와 같은 코드를 작성하게 되면 string 포인터에 delete를 한 순간, 미정의 동작을 하게 된다. *ps의 String 부분에 있는 자원이 String 소멸자가 호출되지 않아 누출되기 때문이다. 이 현상은 가상 소멸자가 없는 클래스이면 모두 적용된다. string 타입 뿐만 아니라 STL(vector, list, set) 등 전부가 여기에 속한다.
경우에 따라서는 소멸자를 순수 가상 소멸자로 두면 편리하게 사용할 수 있다. 순수 가상 클래스로 만든 추상 클래스는 인스턴스를 만들지 못한다. 그러나 만약 어떤 클래스가 추상 클래스였으면 좋겠는데 마땅히 순수 가상 함수로 만들 함수가 없을 때는 어떻게 해야 할까? 추상 클래스는 기본 클래스로 쓰일 목적이고 기본 클래스는 가상 소멸자를 가져야 한다. 그리고 순수 가상 함수가 있으면 바로 추상 클래스가 되기 때문에 추상 클래스로 만들고 싶을 때는 클래스에 순수 가상 소멸자를 선언하면 된다.
class AWOV {
public:
virtual ~AWOV() = 0;
};
AWOV::~AWOV()
{
}
AWOV는 순수 가상 함수를 가지고 있으므로 추상 클래스이다. 동시에 순수 가상 함수가 가상 소멸자가 되는 것이다. 그러나 이 순수 가상 소멸자의 정의를 두지 않으면 안된다. 상속 구조에서 가장 말단에 있는 파생 클래스의 소멸자가 먼저 호출이 되고 기본 클래스 쪽으로 거쳐 가며 각 기본 클래스 소멸자가 하나씩 호출되므로 ~AWOV의 호출 코드를 만들기 위해 파생 클래스의 소멸자를 사용할 것이기 때문이다.
참고로, 기본 클래스에 가상 소멸자를 두는 규칙은 다형성을 가진 기본 클래스 즉, 기본 클래스 인터페이스를 통해 파생 클래스 타입의 조작을 허용하도록 설계된 기본 클래스에만 적용된다.
08 예외가 소멸자를 떠나지 못하도록 붙들어 놓자
class Widget {
public:
~Widget() {
// 예외 발생!
}
};
void DoSomething()
{
vector<Widget> v;
// ...
}
위 코드에서 v에 Widget 객체가 10개가 있고, 블록이 끝나 첫 번째 객체의 소멸자가 호출되는 중 예외가 발생했다고 가정하자. 나머지 아홉 객체는 여전히 소멸되어야 하므로 v는 이들에 대해 소멸자를 호출해야 한다. 그러나 이 과정에서 또 예외가 발생했다고 한다. 현재 활성화된 예외가 두 개이기 때문에 프로그램 실행이 종료되거나 정의되지 않은 동작을 보이게 될 것이다. 이렇게 미정의 동작의 원인은 예외가 터져 나오는 것을 내버려 두는 소멸자에게 있다.
class DBConnection {
public:
static DBConnection Create();
void Close();
};
다음과 같이 데이터 베이스 연결을 나타내는 클래스를 쓰고 있다고 가정하자. 사용자가 직접 Close를 호출하여 연결을 닫아야 하며 연결이 실패하면 예외를 던지게 된다.
class DBConn {
public:
~DBConn() {
db.Close();
}
private:
DBConnection db;
};
그리고 DBConnection에 대한 자원 관리 클래스를 만들어서 이 클래스에서 Close를 호출하도록 하였다.
{
DBConn dbc(DBConnection::Create());
}
이제 DBConnection 객체를 생성하여 DBConn 객체에 넘기고 관리를 맡긴다. DBConn 인터페이스를 통해 객체를 사용하고 블록이 끝난다음, DBConn 객체가 소멸되며 자동으로 Close가 호출이 될 것이다. 그러나 만약 Close를 호출하였는데 여기서 예외가 발생했다고 가정하면 어떻게 될까? DBConn의 소멸자는 이 예외를 전파할 것이고 소멸자에서 예외가 나가도록 내버려 두게 될 것이다. 이 문제를 피하는 방법은 두 가지이다.
1. Close에서 예외가 발생하면 프로그램을 바로 끝낸다. 대게 abort를 호출한다.
DBConn::~DBConn()
{
try { db.Close(); }
catch (...) {
Close 호출 실패 로그;
std::abort();
}
}
객체 소멸이 진행되다 에러가 발생한 경우 바로 프로그램을 종료하게 된다. 미리 예외를 잡는 것이다.
2. Close를 호출한 곳에서 예외를 삼켜 버린다.
DBConn::~DBConn()
{
try { db.Close(); }
catch (...) {
Close 호출 실패 로그;
}
}
대부분 예외를 삼키는 것은 좋지 않은 발상이다. 중요한 정보가 묻혀 버리기 때문이다. 그러나 때에 따라서는 불완전한 종료 혹은 미정의 동작으로 인해 입는 위험을 감수하는 것보다 그냥 예외를 먹어버리는 것이 나을 수도 있다.
두 방법 모두 특별히 좋은 것은 없다. 둘 다 문제점이 있기 때문이다. 중요한 것은 Close가 최초로 예외를 던지게 된 요인에 대해 프로그램이 어떤 조치를 취할 수 있는가인데, 이런 부분의 대책이 전무한 상태이다.
DBConn 인터페이스를 잘 설계하여 발생할 소지가 있는 문제에 대해 대처할 기회를 사용자가 갖도록 하면 어떨까? 바로 DBConn에서 Close 함수를 직접 제공하게 하면 이 함수의 실행 중에 발생하는 예외를 사용자가 직접 처리할 수 있을 것이다. DBConnection이 닫혔는지의 여부를 유지했다가, 닫히지 않았다면 DBConn의 소멸자에서 닫을 수 있을 것이다. 이렇게 하면 데이터베이스 연결이 누출되지 않는다. 하지만 소멸자에서 호출하는 Close마저 실패한다면 끝내거나 삼켜 버리거나로 다시 돌아올 수밖에 없다.
class DBConn {
public:
void Close()
{
db.Close();
closed = true;
}
~DBConn()
{
if (!closed)
try {
db.Close();
}
catch () {
Close 호출 실패 로그
}
}
private:
bool closed;
DBConnection db;
};
Close의 호출의 책임을 DBConn의 소멸자에서 DBConn의 사용자로 떠넘기는 아이디어이다. 즉, 어떤 동작이 예외를 일으켜 실패할 가능성이 있고 또 그 예외를 처리해야 할 필요가 있다면, 그 예외는 소멸자가 아닌 다른 함수에서 비롯된 것이어야 한다는 것이 포인트이다. 예외를 발생시키는 소멸자는 시한폭탄과 마찬가지라 프로그램의 불완전 종료 혹은 미정의 동작의 위험을 내포하고 있다.
09 객체 생성 및 소멸 과정 중에는 절대로 가상 함수를 호출하지 말자
class Transaction {
public:
Transaction() {
LogTransaction();
}
virtual void LogTransaction() const = 0;
};
class BuyTransaction : public Transaction {
public:
virtual void LogTransaction() const override;
};
class SellTransaction : public Transaction {
public:
virtual void LogTransaction() const override;
};
BuyTransaction b;
이 코드에서는 Transaction의 생성자가 먼저 호출이 되고, Transaction의 LogTransaction 함수가 호출이 될 것이다. 즉, 기본 클래스의 생성자가 호출될 동안에는, 가상 함수는 절대로 파생 클래스 쪽으로 내려가지 않는다. 그 대신, 객체 자신이 기본 클래스 타입인 것처럼 동작한다. 기본 클래스 생성자는 파생 클래스 생성자보다 앞서 실행되기 때문에, 기본 클래스 생성자가 돌아가고 있을 시점에는 파생 클래스 데이터 멤버는 아직 초기화되지 않은 상태이다. 요약하여 가상 함수라 해도 지금 실행 중인 생성자나 소멸자에 해당되는 클래스의 파생 클래스 쪽으로는 내려가지 않는다.
10 대입 연산자는 *this의 참조자를 반환하게 하자
int x, y, z;
x = y = z = 15;
x = (y = (z = 15));
C++의 대입 연산의 특성은 우측 연관 연산이라는 점이다. 위 코드를 풀어 보면 15가 z에 대입되고 갱신된 z가 y에 대입되고 갱신된 y가 x에 대입이 되는 것이다.
class Widget {
Widget& operator=(const Widget& rhs)
{
return *this;
}
Widget& operator+=(const Widget& rhs)
{
return *this;
}
Widget& operator=(int rhs)
{
return *this;
}
};
이렇게 대입 연산자가 좌편 인자에 대한 참조자를 반환하도록 구현되어 있는 것은 일종의 관례이다. 따라서 사용자 정의 클래스에서도 대입 연산자가 들어간다면 이 관례를 지키는 것이 좋다.
11 operator=에서는 자기대입에 대한 처리가 빠지지 않도록 하자
class Widget { };
Widget w;
w = w;
어떤 객체가 자기 자신에 대해 대입 연산자를 적용하는 것을 자기대입(self assignment)이라고 한다.
a[i] = a[j]
*px = *py
위와 같은 코드는 자기대입의 가능성을 품은 문장이다. 이처럼 명확하지 않은 자기대입이 생기는 이유는 여러 곳에서 하나의 객체를 참조하는 상태 중복참조 때문이다. 같은 타입으로 만들어진 객체 여러 개를 참조자 혹은 포인터로 물어 놓고 동작하는 코드를 작성할 때는 같은 객체가 사용될 가능성을 고려하는 것이 일반적으로 바람직하다.
class Bitmap {
//...
};
class Widget {
public:
Widget& operator=(const Widget& rhs)
{
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}
private:
Bitmap* pb;
};
위 코드는 의미적으로는 문제가 없어 보이지만, 자기 참조의 가능성이 있는 위험한 코드이다. 여기서 찾을 수 있는 자기 참조 문제는 *this와 rhs가 같은 객체일 가능성이 있다는 점이다. 만약 둘이 같은 객체일 경우, delete 연산자가 *this 객체의 비트맵에만 적용되는 것이 아니라 rhs의 객체까지 적용되어 버린다. 따라서 이 함수가 끝나는 시점에 Widget 객체는 자신의 포인터 멤버를 통해 물고 있던 객체가 삭제된 상태가 되어 버린다.
Widget& operator=(const Widget& rhs)
{
if (this == &rhs)
return *this;
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}
위 문제는 다음과 같이 전통적인 방법 일치성 검사를 통해 자기대입을 점검할 수 있다. 그러나 operator=은 자기대입에 안전하지 못할 뿐만 아니라 예외에도 안전하지 못하다. new Bitmap 부분에서 동적 할당에 필요한 메모리가 부족하거나 복사 생성자에서 예외가 발생한다거나 예외가 터지게 되면, Widget 객체는 결국 삭제된 Bitmap을 가리키는 포인터를 껴안고 홀로 남는다.
Widget& operator=(const Widget& rhs)
{
Bitmap *pOrig = pb;
pb = new Bitmap(*rhs.pb);
delete pOrig;
return *this;
}
위에서 발생한 문제는 다음과 같이 객체를 복사한 직후 삭제하면 해결된다. 원래의 pb를 pOrig에 복사해두고 pb가 새로운 사본을 가리키게 만든다. 그리고 원래의 pb를 삭제하는 것이다. 이렇게 되면 new Bitmap 부분에서 예외가 발생하더라도 pb는 변경되지 않은 상태가 유지된다. 게다가 일치성 검사도 필요 없다. 이 방법 말고도 또 다른 방법이 있는데 복사 후 맞바꾸기 기법이다.
class Widget {
public:
void Swap(Widget& rhs);
public:
Widget& operator=(const Widget& rhs)
{
Widget temp(rhs);
Swap(temp);
return *this;
}
private:
Bitmap* pb;
};
이 기법은 operator= 작성에 아주 자주 쓰인다. Swap 함수는 *this의 데이터와 rhs의 데이터를 맞바꾼다. 결국 operator=에서 rhs의 데이터에 대한 사본을 하나 만들고 *this와 사본의 것을 맞바꾸는 기법이다.
class Widget {
public:
void Swap(Widget& rhs);
public:
Widget& operator=(Widget rhs)
{
Swap(temp);
return *this;
}
private:
Bitmap* pb;
};
이렇게 값에 의한 전달을 수행하면 대상의 사본이 생긴다는 점을 이용하여 객체가 복사하는 코드를 함수 본문으로부터 매개변수의 생성자로 옮겨 컴파일러가 더 효율적인 코드를 생성할 수 있도록 만들 수 있다.
12 객체의 모든 부분을 빠짐없이 복사하자
void LogCall(const string& funcName);
class Customer {
public:
Customer(const Customer& rhs);
Customer& operator=(const Customer& rhs);
private:
string name;
};
Customer::Customer(const Customer& rhs)
:name(rhs.name)
{
LogCall("customer copy constructor");
}
Customer& Customer::operator=(const Customer& rhs)
{
LogCall("customer copy assignment constructor");
name = rhs.name;
return *this;
}
다음과 같이 고객을 나타내는 클래스가 하나 있고 이 클래스의 복사 함수를 개발자가 직접 구현하였으며, 복사 함수를 호출할 때마다 로그를 남기도록 했다고 가정하자. 여기에 하나도 문제가 없어 보인다. 그러나 만약 데이터 멤버 하나를 추가하였다고 하자.
class Data {...};
class Customer {
public:
// ...
private:
string name;
Data lastTransaction;
};
이렇게 되면, 복사 함수의 동작은 완전 복사가 아니라 부분 복사가 된다. name은 복사하지만 lastTransaction은 복사하지 않는다. 직접 복사 함수를 작성하였기 때문에 컴파일러는 복사 함수를 만들어주지 않기 때문이다. 결국 클래스에 데이터 멤버를 추가했다면, 추가한 데이터 멤버를 처리하도록 복사 함수를 다시 작성해야 한다. 그런데 이 문제는 상속에서 더 골치가 아파진다.
class PriorityCustomer : public Customer {
public:
PriorityCustomer(const PriorityCustomer& rhs);
PriorityCustomer& operator=(const PriorityCustomer& rhs);
private:
int priority;
};
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
:priority(rhs.priority)
{
LogCall("PriorityCustomer copy constructor");
}
PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
LogCall("PriorityCustomer copy assignment constructor");
priority = rhs.priority;
return *this;
}
위와 같이 Customer을 상속받는 클래스 PriorityCustomer이 있다고 하자. 이 클래스의 복사 함수는 모든 것을 복사하는 것처럼 보이지만, Customer의 데이터 멤버들은 하나도 복사되고 있지 않다. 이 복사 생성자에는 기본 클래스 생성자에 넘길 인자들도 명시되어 있지 않아서 Customer 생성자, 즉 기본 생성자에 의해 초기화된다. 심지어 복사 대입 연산자는 기본 클래스의 멤버를 건드릴 시도도 하지 않기 때문에 기본 클래스의 데이터 멤버는 변경되지 않고 그대로이다.
class PriorityCustomer : public Customer {
public:
PriorityCustomer(const PriorityCustomer& rhs);
PriorityCustomer& operator=(const PriorityCustomer& rhs);
private:
int priority;
};
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
:Customer(rhs),
priority(rhs.priority)
{
LogCall("PriorityCustomer copy constructor");
}
PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
LogCall("PriorityCustomer copy assignment constructor");
Customer::operator=(rhs);
priority = rhs.priority;
return *this;
}
다음과 같이 기본 클래스의 대응되는 복사 함수를 호출하게 만들면 된다. 즉, 이번 항목의 제목인 모든 부분을 복사하자라는 말이 이해가 될 것이다.
'C++ Study > Effective C++' 카테고리의 다른 글
자원 관리 (1) | 2024.01.11 |
---|---|
C++에 왔으면 C++의 법을 따르자 (0) | 2023.12.31 |