◆ 무한한 가능성/& Visual C/C++

[요약] Effective C++ 2장-2 (항목09~12)

치로로 2009. 12. 17. 11:32
[요약] Effective C++ 2장-2 (항목09~12)



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



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



객체 생성 및 소멸 과정 중에는 절대로 가상 함수를 호출하지 말아야 하는-

* 두. 가. 지. 이. 유. !!!
1) 우선 호출 결과가 여러분이 원하는 대로 돌아가지 않을 것이고,
2) 원하는 대로 돌아간다고 해도 여러분은 여전히 방귀를 세 시간은
참은 것 같은 얼굴을 하고 있을 것이기 분명하기 때문이다.

주식 거래를 본뜬 클래스 계통 구조가 있다고 가정하자. 여기엔 매도주문, 매수주문 등등이 있다.
주식거래 모델링에서 가장 중요한 포인트라면 감사(audit) 기능이 있어야 한다는 것이다.
그렇기 때문에 감사로그(audit log)가 만들어지도록 해야 한다.

01: class Transaction {                             // 모든 거래에 대한 기본 클래스
02: public:
03: Transaction();
04: virtual void logTransaction() const = 0; // 타입에 따라 달라지는
05: ... // 로그 기록을 만든다
06: };
07:
08: Transaction::Transaction() // 기본 클래스 생성자의 구현
09: {
10: ...
11: logTransaction(); // 마지막 동작으로 거래 로그남김
12: }
13:
14: class BuyTransaction: public Transaction { // Transaction의 파생 클래스
15: public:
16: virtual void logTransaction() const; // 이 타입에 따른 거래내역 로깅 구현
17: ...
18: };
19:
20: class SellTransaction: public Transaction { // 역시 파생 클래스
21: public:
22: virtual void logTransaction() const; // 이 타입에 따른 거래내역 로깅 구현
23: ...
24: };

이 코드를 가지고, 어떤 동작을 하는지 알아보자.

1: BuyTransaction b;

위의 BuyTransaction 과 SellTransaction 은 파생클래스 이기 때문에,
기본 클래스의 Transaction 의 생성자가 먼저 호출 되어야 한다.

하지만 위의 코드를 보면 기본 클래스에서 정의한 가상 함수가
호출되어 아직 초기화되지도 않은 BuyTransaction 클래스에서 처리될 수가 있다.
이는 예상치 못한 동작으로 아주 안좋다.

간단히 말하면...
기본 클래스인 Transaction 의 생성자가 생성되고 있는 과정에는 가상함수가 기본 클래스 소속이고
파생클래스 소속이 되려면 기본 클래스가 생성된 후 파생클래스가 생성되어야 파생클래스 소속이라는 것이다.

소멸자 역시-
거꾸로 보면 된다.
파생클래스가 소멸되면, 가상함수는 파생클래스의 소속이 아니고 기본 클래스의 소속이 된다는 것이다.

Transaction 클래스에서 가상함수를 호출하는 부분을 보자.
이는 당연히 이번 항목에 위배되는 코드이다. 이런 코드를 쓰면 안된다는 것을 알려주는 것이다.

또한 logTransaction 함수는 Transaction 클래스에서 가상 함수로 선언되었기 때문에
이 함수의 정의가 존재 하지 않으면 에러가 생기는 것이 당연하기 때문이다.


다른 이야기로 넘어가서...
만약, Transaciton 클래스에서 생성자가 여러개 호출 된다고 가정해보자.
그때에 중복되는 생성자의 코드도 있을 것이다. 이를 효율적으로... 중복 코드를 모아
공동의 초기화 코드를 만들어 두면, 코드의 중복 현상을 막을 수 있다.

01: class Transaction {
02: public:
03: Transaction() { init(); } // 비가상 멤버 함수를 호출하고 있...
04: virtual void logTransaction() const = 0;
05: ...
06: private:
07: void init()
08: {
09: logTransaction(); // ...는데 이 비가상 함수에서 가상함수를 호출하고있음
10: }
11: };

위의 코드는 개념적으로 먼저 본 코드와 같지만...사악한 걸로 따지만 아까보다 이 코드가 훨씬 사악하다.
이유는 컴파일러가 걸러내지 못하고 링크도 말끔하게 되기때문이다.

이 문제를 피하는 방법은 생성/소멸 중인 객체에 대해 생성자나 소멸자에서 가상 함수를 호출하는
코드를 철저히 솎아내고 생성자/소멸자에서 호출하는 모든 함수들이 똑같은 제약을 따르도록 하는 것이다.


어쨋든 Transaction 생성자에서 가상 함수를 호출하는 것은 잘못된 일이다.


이 문제의 대처방법을 살펴보자.

01: class Transaction {
02: public:
03: explicit Transaction(const std::string& logInfo);
04: void logTansaction(const std::string& logInfo) const; // 이제는 비가상 함수
05: ...
06: };
07:
08: Transaction::Transaction(const std::string& logInfo)
09: {
10: ...
11: logTransaction(logInfo); // 이제는 비가상 함수를 호출
12: }
13:
14: class BuyTransaction: public Transaction {
15: public:
16: BuyTransaction(parameters)
17: :Transaction(createLogString(parameters)) // 로그 정보를 기본 클래스
18: { ... } // 생서자로 넘김
19: ...
20: private:
21: static std::string createLogString(parameters);
22: };

기본 클래스 부분이 생성될 때는 가상 함수를 호출한다 해도 기본 클래스의 울타리를 넘어 내려갈 수 없기
때문에, 필요한 초기화 정보를 파생 클래스 쪽에서 기본 클래스 생성자로 '올려'주도록 만듦으로써
부족한 부분을 역으로 채울 수 있다.



 *이것만은 잊지 말자!

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





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


1: int x, y, z;
2:
3: x = y = z = 15;

위의 코드을 보면...
x = (y = (z = 15));
로 연산이 되는 것을 알 수 있다.

이는 C++ 에서 기본적으로 우에서 좌로 연산한후 좌변 인자를 참조자로 반환되도록 구현되어 있을 것이다.
이런 구현은 일종의 관례이므로 우리가 만드는 클래스에 대입연산자가 들어간다면,
이 관례를 지키는 것이 좋다.

01: class Widget {
02: public:
03: ...
04: Widget& operator= (const Widget& rhs) // 반환 타입은 현재의 클래스에 대한 참조자
05: {
06: ...
07: return *this; // 좌변 객체(의 참조자)를 반환
08: }
09: ...
10: };
11:
12: class Widget {
13: public:
14: ...
15: Widget& operator+=(const Widget& rhs) // +=, -=, *= 등에도 동일한 규약이 적용
16: {
17: ...
18: return *this;
19:
20: }
21:
22: Widget& operator=(int rhs) // 대입 연산자의 매개변수 타입이
23: { // 일반적이지 않은 경우에도
24: ... // 동일한 규약을 적용
25: return *this;
26: }
27: ...
28: };



 *이것만은 잊지 말자!

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






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


자기대입(self assignment)
 : 어떤 객체가 자기 자신에 대해 대입 연산자를 적용하는 것

1: class Widget { ... };
2: 
3: Widget w;
4: ...
5: 
6: w = w;        // 자기에 대한 대입


위의 코드는 적법한(legal) 코드이지만, 문제가 있는 코드이다.

1: a[i] = a[j];            // 자기대입의 가능성을 아름드리 품은 문장
2: 
3: *px = *py;            // 자기대입의 가능성을 하나 가득 품은 문장


위의 문장에서...
'i'와 'j'가 동일한 값이 되면 자기대입문이 되고,
'*px'와 '*py'가 가리키는 대상이 같으면 자기대입이 된다.

이처럼, 언뜻 보기에 명확하지 않은 이러한 자기대입이 생기는 이유는
여러 곳에서 하나의 객체를 참조하는 상태. 즉, 중복참조(aliasing)로 불린다.

같은 타입으로 만들어진 객체 여러 개를 참조자 혹은 포인터로 물어 놓고
동작하는 코드를 작성할 때는 같은 객체가 사용될 가능성을 고려하는 것이 일반적이다.

같은 클래스 계통에서 만들어진 객체라 해도 굳이 똑같은 타입으로 선언할 필요까지는 없다.
파생 클래스 타입의 객체를 참조하거나 가리키는 용도로 기본 클래스의 참조자나 포인터를 사용하면 된다.

1: class Base { ... };
2: 
3: class Derived: public Base { ... };
4: 
5: void doSomething(const Base& rb,          // rb 및 *pd는 원래 같은
6:                  Derived* pd);            // 객체였을 수도 있다.


다음 글에 나올 항목 13, 14를 따르면...

또한 자원 관리의 용도로 항상 객체를 만들고,
이 객체들이 복사될 때 나름대로 잘 동작하도록 코딩을 해야한다.

이때 조심해야 할 것이 대입 연산자이다.

연산자는 우리가 신경을 안써도 제대로 안전하게 동작해야 맞는 것인데,
이 자원관리를 완벽하게 하기가 어렵다.
(자원을 사용하기 전에 덜컥 해제해 버릴수도 있고 모르니...;)

동적 할당된 비트맵을 가리키는 원시포인터를
데이터 멤버로 갖는 클래스를 하나 만들었다고 가정해 보자.

1: class Bitmap { ... };
2: class Widget {
3:         ...
4: 
5: private:
6:         Bitmap *pb;          // 힙에 할당한 객체를 가리키는 포인터
7: };


이제 멀쩡하게 생겼지만, 자기참조의 가능성이 있는
operator= 코드를 보자.

1: Widget &Widget::operator=(const Widget& rhs)     // 안전하지 않게 구현된 operator=
2: {
3:         delete pb;                             // 현재의 비트맵 사용을 중지
4:         pb = new Bitmap(*rhs.pb);              // 이제 rhs의 비트맵을 사용하도록 만듬
5: 
6:         return *this;                          // 항목 10 참조
7: }

*this 와 rhs 가 같은 객체일 가능성이 있다.
그렇다면 rhs인 pb를 delete pb; 를 이용해서 지워버리게 된다.
그렇다면 이 함수가 끝나는 시점이 되서, 해당 Widget 객체(rhs)는
자신의 포인터 멤버를 통해 물고 있던 객체가 삭제된 상태가 되는 불상사가 생긴다.

하지만 이런 에러 대첵은 예전부터 있었다.
전통적인 방법은 operator= 의 첫머리에 일치성 검사(identity test)를 통해
자기대입을 점검하는 것이다.

1: Widget &Widget::operator=(const Widget& rhs)
2: {
3:         if (this == &rhs) return *this;     // 객체가 같은지, 즉 자기대입인지 검사
4:                                             // 자기대입이면 아무것도 안 함
5:         delete pb;
6:         pb = new Bitmap(*rhs.pb);
7: 
8:         return *this;
9: }

이처럼 자기대입인지 검사를 한다 하더라도, 아직 신경 쓰이는 것이 있다.
'new Bitmap'의 표현식을 말하는 것이다.

new Bitmap을 할때에 동적 할당에 필요한 메모리가 부족하다거나,
Bitmap 클래스 복사생성자에서 예외를 던지면 어김없이 Widget 객체는
삭제된 Bitmap을 가리키는 포인터가 되는 것이다.

이를 위한 예외 안전성을 보자.
"많은 경우에 문장 순서를 세심하게 바꾸는 것만으로
 예외에 안전한 코드가 만들어 진다"
라는
법칙 한 가지를 여기서 써먹어 보도록 하자.

지금의 코드에서 pb를 무턱대고 삭제하지 말고, 이 포인터가 가리키는 객체를
복사한 직후에 삭제하면 깔금히 해결될 것 같다.

1: Widget &Widget::operator=(const Widget& rhs)
2: {
3:         Bitmap *pOrig = pb;           // 원래의 pb를 어딘가에 기억해 둔다.
4:         pb = new Bitmap(*rhs.pb);     // 다음, pb가 *pb의 사본을 가리키게 만든다.
5:         delete pOrig;                 // 원래의 pb를 삭제한다.
6: 
7:         return *this;
8: }

위의 코드는 이제 예외에 안전하다
new Bitmap에서 예외가 발생하더라도 pb는 복사본 이기때문에 상관 없다.

일치성 검사 코드가 빠졌는데 그것에 대한 문제는 없다. (복사본으로 하기때문에)
또한 일치성 검사 코드가 들어가면 매번 일치성 검사를 해줘야 해서 처리 흐름에
분기를 만들게 되므로 실행 시간 속력이 줄어들 수 있다.



예외 안전성과 자기대입 안전성을 동시에 가진 operator=을 구현하는 방법으로
위의 코드와 다른 방법을 소개한다. (2가지)


(1) 클래스의 복사 대입 연산자는 인자를 값으로 취하도록 선언하는 것이 가능하다는 점

01: class Widget {
02:         ...
03:         void swap(Widget& rhs);      // *this의 데이터 및 rhs의 데이터를 맞바꾼다.
04:         ...                          // 자세한 내용은 항목 29 참조
05: };
06: 
07: Widget& Widget::operator=(const Widget& rhs)
08: {
09:         Widget temp(rhs);            // rhs의 데이터에 대해 사본을 하나 만든다.
10: 
11:         swap(temp);                  // *this의 데이터를 그 사본의 것과 맞바꾼다.
12: 
13:         return *this;
14: }



(2) 값에 의한 전달(call by value)을 수행하면 전달된 대상의 사본이 생긴다는 점

1: Widget& Widget::operator=(Widget rhs)      // rhs는 넘어온 원래 객체의 사본
2: {                                          // - call by value
3: 
4:         swap(temp);                  // *this의 데이터를 그 사본의 것과 맞바꾼다.
5: 
6:         return *this;
7: }


 *이것만은 잊지 말자!

  ** operator=을 구현할 때, 어떤 객체가 그 자신에 대입되는 경우를 제대로 처리하도록
     만듭시다. 원본 객체와 복사대상 객체의 주소를 비교해도 되고, 문장의 순서를 적절히
     조정할 수도 있으며, 복사 후 맞바꾸기 기법을 써도 됩니다.
  ** 두 개 이상의 객체에 대해 동작하는 함수가 있다면, 이 함수에 넘겨지는 객체들이 사실
     같은 객체인 경우에 정확하게 동작하는지 확인해 보세요.







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


객체의 안쪽 부분을 캡슐화한 객체 지향 시스템 중 설계가 잘 된 것들을 보면,
객체를 복사하는 함수가 딱 둘만 있는 것을 알 수 있다.

복사 생성자와 복사 대입 연산자 라고 명명된 것이다.
이는 '복사 함수(copying function)'라고도 한다.


01: void logCall(const std::string& funcName);  // 로그 기록내용을 만듬
02: 
03: class Customer {
04: public:
05:         ...
06:         Customer(const Customer& rhs);
07:         Customer& operator=(const Customer& rhs);
08:         ...
09: private:
10:         std::string name;
11: };
12: 
13: Customer::Customer(const Customer& rhs) : name(rhs.name)
14: {
15:         logCall("Customer copy constructor");
16: }
17: 
18: Customer& Customer::operator=(const Customer& rhs)
19: {
20:         logCall("Customer copy assignment operator");
21:         name = rhs.name;                     // rhs의 데이터를 복사
22:         return *this;                        // 항목 10을 참고
23: }

위의 소스가 문제가 될 것이 하나도 없어 보인다.
실데로도 그렇다. 근데 여기에 데이터 멤버 하나를 Customer에 추가하면서 문제가 생긴다.



01: class Date { ... };               // 날씨 정보를 위한 클래스
02: 
03: class Customer {
04: public:
05:         ...
06:         Customer(const Customer& rhs);
07:         Customer& operator=(const Customer& rhs);
08:         ...
09: private:
10:         std::string name;
11:         Date lastTransaction;
12: };
13: 


이렇게 되고 나면, 복사 함수의 동작은 완전 복사가 아니라 ' 부분 복사(partial copy)'이다.
이러면 name 은 복사 되지만, lastTransaction 은 복사하지 않는 문제점을 낳는다.
컴파일러는 별로 알려주지도 않고... -_-;;

이뿐만이 아니다.
이것을 이용하여 파생클래스로 가면 상황은 더더욱 복잡해진다.


01: class PriorityCustomer: public Customer {     // 파생 클래스
02: public:
03:         ...
04:         PriorityCustomer(const PriorityCustomer& rhs);
05:         PriorityCustomer& operator=(const PriorityCustomer& rhs);
06:         ...
07: private:
08:         int priority;
09: };
10: 
11: PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs) : priority(rhs.priority)
12: {
13:         logCall("PriorityCustomer copy constructor");
14: }
15: 
16: PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer &rhs)
17: {
18:         logCall("PriorityCustomer copy assignment operator");
19:         priority = rhs.priority;
20:         return *this;
21: }


PriorityCustomer 은 Customer 의 파생클래스이다.
이는 언뜻 보기에는 모든 것을 복사하는 것 같지만 그렇지 않다.
Customer로부터 상속한 데이터 멤버들의 사본도 엄연히 PriorityCustomer 클래스에 있지만...
복사가 안된다...-_-;

그래서 파생클래스에서 복사함수를 스스로 만들어야 한다.
기본 클래스 부분의 복사에서 빠뜨리지 않도록 잘 주의해서 만들어야 한다.



01: PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
02:  : Customer(rhs), priority(rhs.priority)     // 기본 클래스의 복사 생성자를 호출
03: {
04:         logCall("PriorityCustomer copy constructor");
05: }
06: 
07: PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer &rhs)
08: {
09:         logCall("PriorityCustomer copy assignment operator");
10:         Customer::operator=(rhs);     // 기본 클래스 부분을 대입
11:         priority = rhs.priority;
12:         return *this;
13: }


이제 항목 12.의 제목 처럼 '모든 것을 복사하자' 라는 의미를 알아야 한다.

즉, 객체의 복사 함수를 작성할 때는...
1. 해당 클래스의 데이터 멤버를 모두 복사
2. 이 클래스가 상속한 기본 클래스의 복사함수도 꼬박꼬박 호출
하도록 해야 한다.



또한 복사생성자 함수에서 복사 대입연산자를 쓰면 안되고...
복사 대입연산자 함수에서 복사생성자를 쓰면 안된다.


그리고 이것도 생각해 보자.
복사 생성자와 복사 대입 연산자의 코드 본문이 비슷하게 나온다는 느낌이 들면,
양쪽에서 겹치는 부분을 별도의 멤버 함수에 분리해 놓은 후에 이 함수를 호출하게 하는 것이다.
대게 이런 용도의 함수는 private 멤버로 두는 경우가 많고, 이름이 init 어쩌고 하는 이름을 가진다.
안전할 뿐만 아니라 검증된 방법이니, 복사 생성자와 복사 대입연산자에 나타나는 코드 중복을
제거하는 방법으로 사용해보기 바란다.





 *이것만은 잊지 말자!

  ** 객체 복사 함수는 주어진 객체의 모든 데이터 멤버 및 모든 기본 클래스 부분을
     빠뜨리지 말고 복사해야 한다.
  ** 클래스의 복사 함수 두 개를 구현할 때, 한쪽을 이용해서 다른 쪽을 구현하려는
     시도는 절대로 하지 말자.  그 대신, 공통된 동작을 제3의 함수에다 분리해놓고
     양쪽에서 이것을 호출하게 만들어서 해결하자.














※ Effective C++ 의 책을 개인적으로 요약한 것입니다.
책의 저작권 등등 각종 권한은 출판사와 지은이/옮긴이에 있음
 - 출판: Addison Wesley
 - 지음: 스콧 마이어스
 - 옮김: 곽용재