2016년 3월 18일 금요일

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

자기대입(self assignment)
 자기대입(self assignment)이란, 어떤 객체가 자기 자신에 대해 대입 연산자를 적용하는 것을 말합니다. 
  1. #include <iostream>  
  2. using namespace std;  
  3.   
  4. class Widget{};  
  5.   
  6. void main()  
  7. {  
  8.     Widget w;  
  9.     w = w;     //자기대입  
  10. }  

 문제는 무엇인가?
 같은 타입으로 만들어진 객체 여러 개를 참조자 혹은 포인터로 물어 놓고 동작하는 코드를 작성할 때는 같은 객체가 사용될 가능성을 고려하는 것이 일반적으로 바람직한 자세라고 할 수 있겠습니다. 사실, 같은 클래스 계통에서 만들어진 객체라 해도 굳이 똑같은 타입으로 선언할 필요까지는 없습니다. 파생 클래스 타입의 객체를 참조 하거나 가리키는 용도로 기본 클래스의 참조자나 포인터를 사용하면 되니까 말이죠. 그럼 대입연산자에서는 무엇을 조심해야 할까요? 아래와 같이 동적 할당된 비트맵을 가리키는 원시 포인터를 데이터 멤버로 갖는 클래스가 있다고 한번 해봅시다.
  1. #include <iostream>  
  2. using namespace std;  
  3.   
  4. class Bitmap   
  5. {  
  6. public:  
  7.     Bitmap()  
  8.     {  
  9.         cout << "Bitmap ()" << endl;  
  10.     }  
  11. };  
  12.   
  13. class Widget      
  14. {  
  15. public:  
  16.     Widget()   
  17.     {  
  18.         cout << "Widget ()" << endl;  
  19.         pb = new Bitmap;  
  20.         pb = NULL;  
  21.   
  22.     }  
  23.   
  24.     Widget (const Bitmap& pbitmap)  
  25.     {  
  26.         cout << "Widget (const char * data)" << endl;  
  27.   
  28.         pb = new Bitmap(pbitmap);  
  29.   
  30.         memcpy(pb, &pbitmap, sizeof(pbitmap));  
  31.   
  32.     }  
  33.       
  34.     Widget& operator=(const Widget& rhs)    // 안전하지 않게 구현된 operator=  
  35.     {  
  36.         cout << "Widget& operator=(const Widget& rhs)" << endl;  
  37.   
  38.         delete pb;  
  39.         pb = new Bitmap(*rhs.pb);  
  40.   
  41.         return *this;  
  42.     }         
  43.   
  44. private:  
  45.     Bitmap * pb;    //힙에 할당된 객체를 가르키는 포인터  
  46. };  
  47.   
  48. int main()  
  49. {  
  50.     Bitmap b;  
  51.     Widget w(b);  
  52.   
  53.     Widget w2;  // 디폴트 생성자에서 꼭 초기화를 해줘야 한다.  
  54.     w2 = w;     // 자기 대입 검사를 하지 않으면, 심각한 오류가 발생한다.  
  55.       
  56.     return 0;  
  57. }  
 위의 예제 코드를 의미적으로 문제가 없을 것 같지만 자기 참조의 가능성이 있는 위험한 코드 입니다. 여기서의 자기 참조 문제는 operator= 내부에서 *this와 rhs가 같은 객체일 가능성이 있다는 것입니다. 이 둘이 만약 같은 객체라고 한다면, delete 연산자가 *this 객체의 비트맵에만 적용되는 것이 아니라 rhs의 객체까지 적용되어 버립니다. 즉, 이 함수가 끝나는 시점이 되면 해당 Widget객체는 자신의 포인터 멤버를 통해 물고 있던 객체가 삭제된 상태가 되어버릴 수 있다는 말입니다. 
 물론 이것에 대한 대책은 있습니다.
  1. Widget& operator=(const Widget& rhs)  
  2. {  
  3.     cout << "Widget& operator=(const Widget& rhs)" << endl;  
  4.     if (this == &rhs)   //  일치성 검사, 즉 객체가 같은지, 자기 대입인지 검사한다.  
  5.         return *this;  
  6.   
  7.     delete pb;  
  8.     pb = new Bitmap(*rhs.pb);     
  9.   
  10.     return *this;  
  11. }  
 위와 같이 일치성 검사(identity test)를 통해 자기대입을 검사를 통해 자기 대입이면 아무것도 하지 않도록 하는 것입니다. 하지만 이 방법은 예외에 안전하지 않습니다. 만약 'new Bitmap' 표현식 부분에서 예외가 발생하게 되면, Widget 객체는 결국 삭제된 Bitmap을 가리키는 포인터를 가지고 홀로 남게 됩니다. 이런 포인터는 delete 연산자를 안전하게 적용할 수도 없고, 안전하게 읽는 것조차 불가능하다는 문제가 있습니다. 

 전부는 아니지만, operator=을 예외에 안전하게 구현하면 대개 자기대입에도 안전한 코드가 나오게 되어 있습니다. 즉, 예외 안전성에만 집중하면 자기대입 문제에 대해서는 그렇게 걱정을 안해도 된다는 의미죠. 여기에서는 pb를 무조건 삭제 하지 말고 이 포인터가 가리키는 객체를 복사한 직후에 삭제하면 예외에 대한 문제를 해결 할 수 있습니다. 아래와 같이 말이죠. 
  1. Widget& operator=(const Widget& rhs) // 이 코드는 예외에 안전하다.  
  2. {  
  3.     cout << "Widget& operator=(const Widget& rhs)" << endl;  
  4.   
  5.     Bitmap * pOrig = pb;        // 원래의 pb를 pOrig에 저장해둔다.  
  6.     pb = new Bitmap(*rhs.pb);   // 다음, pb가 *pb의 사본을 가리키게 만든다.  
  7.     delete pOrig;               // 원래의 pb를 삭제한다.  
  8.   
  9.     return *this;  
  10. }  
 위의 코드는 new Bitmap 부분에서 예외가 발생하더라도 pb는 변경되지 않는 상태가 유지되기 때문에 예외에 안전합니다. 또한 원본 비트맵을 복사해 놓고, 복사해 놓은 사본을 포인터가 가리키게 만든 후, 원복을 삭제하는 순서로 실행되기 때문에 일치성 검사 없이도 자기대입 현상을 완벽히 처리 하고 있죠. 물론 이 방법 말고도 다른 복사 후 맞바꾸기(Copy and Swap) 이라는 방법도 있습니다.

 복사 후 맞바꾸기(Copy and Swap)
 : 이 기법은 사실 예외 안전성과 아주 밀접한 관계가 있기 때문에 이후 항목 29에 자세히 설명이 나와있습니다. 하지만 어떤 식으로 구현 되는지 간단히 알아 보죠. 
  1. #include <iostream>  
  2. using namespace std;  
  3.   
  4. class Bitmap   
  5. {  
  6. public:  
  7.     Bitmap()  
  8.     {  
  9.         cout << "Bitmap ()" << endl;  
  10.     }  
  11. };  
  12.   
  13. class Widget      
  14. {  
  15. public:  
  16.     Widget()   
  17.     {  
  18.         cout << "Widget ()" << endl;  
  19.         pb = new Bitmap;  
  20.         pb = NULL;  
  21.     }  
  22.   
  23.     Widget (const Bitmap& pbitmap)  
  24.     {  
  25.         cout << "Widget (const char * data)" << endl;  
  26.         pb = new Bitmap(pbitmap);  
  27.         memcpy(pb, &pbitmap, sizeof(pbitmap));  
  28.     }  
  29.   
  30.     void swap(Widget& rhs)  // *this의 데이터 및 rhs의 데이터를 맞바꾼다.  
  31.     {  
  32.         Bitmap * pOrig = pb;  
  33.         pb = new Bitmap(*rhs.pb);  
  34.         rhs.pb = pOrig;       
  35.     }  
  36.   
  37.     Widget& operator=(const Widget& rhs)   
  38.     {  
  39.         cout << "Widget& operator=(const Widget& rhs)" << endl;  
  40.   
  41.         Widget temp(rhs);   // rhs의 데이터에 대해 사본을 하나 만든다.  
  42.   
  43.         swap(temp);         // *this의 데이터를 그 사본의 것과 맞바꾼다.  
  44.   
  45.         delete temp.pb;  
  46.         return *this;  
  47.     }  
  48.   
  49. private:  
  50.     Bitmap * pb;    // 힙에 할당된 객체를 가르키는 포인터  
  51. };  
  52.   
  53. int main()  
  54. {  
  55.     Bitmap b;  
  56.     Widget w(b);  
  57.   
  58.     Widget w2;  // 디폴트 생성자에서 꼭 초기화를 해줘야 한다.  
  59.     w2 = w;     // 자기 대입 검사를 하지 않으면, 심각한 오류가 발생한다.  
  60.   
  61.     return 0;  
  62. }  

 이 방법은 C++가 가진 두가지 특징을 활용해서 조금 다르게 구현할 수도 있습니다. 
 - 첫째, 클래스의 복사 대입 연산자는 인자를 값으로 취하도록 선언하는 것이 가능하다.
 - 둘째, 값에 의한 전달을 수행하면 전달된 대상의 사본이 생긴다
  1. //rhs는 넘어온 원래 객체의 사본 -- '값에 의한 전달'  
  2. Widget& operator=(Widget rhs)   
  3. {  
  4.     cout << "Widget& operator=(const Widget& rhs)" << endl;  
  5.   
  6.     swap(rhs);  // *this의 데이터를 그 사본의 데이터와 맞바꾼다.  
  7.   
  8.     delete rhs.pb;  
  9.     return *this;  
  10. }  
 위으 코드는 객체를 복사하는 코드가 함수 본문으로부터 매개변수의 생성자로 옮겨졌기 때문에, 컴파일러가 더 효율적인 코드를 생성할 수 있는 여지가 만들어지는 장점을 가지고 있습니다. 

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

댓글 없음:

댓글 쓰기

[Effective C++] 항목 30 : 인라인 함수는 미주알고주알 따져서 이해해 두자.

인라인 함수를 사용하면 컴파일러가 함수 본문에 대해 문맥별 최적화를 걸기가 용이해집니다. 인라인 함수의 아이디어는  함수 호출문을 그 함수의 본문으로 바꿔치기하자는 것  남발했다가는 코드의 크기가 커질 게 뻔하다. 인라인 함수로 부풀려진 ...