ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Effective C++] 2장(2) - 생성자, 소멸자 및 대입 연산자
    쾌락없는 책임 (공부)/Effetive C++ 요약본 2022. 3. 10. 13:38
    반응형
    본 카테고리는 프로텍 미디어의 '이펙티브 C++'을 보고 요약하는 카테고리입니다.

    3판을 기준으로 하며 전체 내용이 아닌 간략한 내용만을 요약하고 있습니다.

     

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

     이유는 2가지입니다. 호출 결과가 원하는대로 돌아가지 않을 것이고 돌아간다고 해도 이상할 것이기 때문이죠.

    class Transactino {
    public:
    	Transaction();
    
    	virtual void LogTransaction() const = 0;
    	...
    };
    
    Transaction::Transaction(){
    	...
    	LogTansaction();
    }
    
    class BuyTransaction : public Transaction{
    public:
    	virtual void LogTransaction() const;
    };
    
    class SellTransaction : public Transaction{
    public:
    	virtual void LogTransaction() const;
    };
    ...
    BuyTransaction b;

     만일 위 코드에서 b를 생성을 하게 되면 파생 클래스에서 호출하는 가상 함수는 부모의 가상 함수가 됩니다! 이는 C++ 에서 아직 초기화되지 않은 파생 클래스로 접근을 막아버려서 생성자에서는 파생 클래스로 내려갈 수 없기 때문입니다.

    파생 클래스의 기본 클래스 부분이 생성되는 동안에는 그 객체의 타입은 기본 클래스가 된다

     위 내용이 이제 핵심이 되는 것이죠. 호출되는 가상 함수도 기본 클래스의 것이 되고 런타임 정보를 활용하는 typeid 같은 것들도 기본 클래스 취급을 하게 됩니다.

     

     이런 문제들로 인해 생성자에서 가상 함수가 호출되는지를 잡아야 하는데 이게 쉬운 일이 아닙니다. 만약 위 문제를 피하기 위해서 생성자 -> 다른 함수 호출 -> 그 함수에서 가상 함수 호출 을 하면 더 사악한 문제가 튀어나오게 됩니다. 그러니 아래 항목을 잘 따라야 합니다.

     

    📃 생성자, 소멸자에서 가상 함수를 호출하는 걸 피하고 
    생성자, 소멸자가 호출하는 함수도 같은 제약을 따르도록 하자

     

     위 문제를 본격적으로 해결하는 방법은 여러개가 있지만 Effective C++에서는 1가지 방법을 알려주고 있습니다.

    - 이전에 가상 함수로 만들어진 LogTransaction을 비가상 함수로 선언

    - 이후 파생 클래스 생성자로 하여금 필요한 로그 정보를 Transaction 생성자로 넘긴다

    class Transaction{
    public:
    	explict Transaction(const string& logInfo);
    	void LogTransaction(const string& logInfo) const;
    	...
    };
    
    Transaction::Transaction(const std::string& logInfo)
    {
    ...
    	LogTransaction(logInfo);
    }
    
    class BuyTransaction: public Transaction{
    public:
    	BuyTransaction (params)
    		:Transaction(CreateLogString (params) );
    	{...}
    	...
    private:
    	static string CreteLogString(params);
    };

    => 이를 통해 필요한 초기화 정보를 파생 클래스에서 기본 클래스로 올려 보냄으로서 부족한 부분을 역으로 채우게 됩니다.

     

     

     

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

    x = y = z = 15;

     C++ 에서 위와 같은 연산이 당연하게도 가능합니다. 추가적인 재미있는 특성이 있다고 하면 위 연산은 우측 연관 연산이 되어 z, y, x 순으로 대입이 되게 됩니다.

     

     이런 대입 연산이 사슬처럼 엮이려면 대입 연산자가 좌변 인자에 대한 참조자를 반환하도록 구현되어 있습니다. 그래서 클래스 대입 연산자가 들어간다면 이 관례를 지키는 게 좋습니다.

    class Widget{
    public:
    	Widget& operator = (const Widget& rhs)
    	{
    		...
    		return *this;
    	}
    ...
    };

     이렇게 *this를 반환하는 규약은 모든 기본 제공 타입과 STL의 모든 타입들도 따르고 있으니 순순히 따라간다면 평탄한 인생을 보장해준다고 하네요.

     

     

     

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

    arr[i] = arr[j];
    *px = *py;

     위 두 수식은 자기 대입의 가능성이 충분히 있습니다. 만일 포인터를 delete를 한다고 했을 때 한쪽에만 delete가 되었다면 다른 곳에서는 비어있는 곳을 참조할 가능성이 있습니다. 때문에 일치성 검사를 통해 자기 대입에 대한 상태를 점검해야 합니다.

    Widget&
    Widger::operator = (const Widget & rhs)
    {
    	if(this == &rhs) return *this;
    
    	delete pb;
    	pb = new Bitmap(*rhs.pb);
    
    	return *this;
    }

     그런데 이건 자기대입에 안전하지 못하며 예외시 위험한 코드가 됩니다. 만약 메모리가 부족해 new 가 안되었다면? 위에 delete 된 pb는 어떻게 될까요?

     그래서 자기 대입에 대한 처리를 해줄 때 예외에 안전하게 구현하면 자기 대입에도 안전한 코드가 됩니다.

    Widget::Widget::operator=(const Widget& rhs)
    {
    	Bitmap *pOrig = pb;
    	pb = new Bitmap(*rhs.pb);
    	delete pOrig;
    
    	return this;
    }

     효율성은 살짝 떨어질 수 있어도 자기 대입에 완벽하게 대응을 했고 예외 발생 시에도 기존 객체 pb는 변경되지 않은 채로 있습니다.   ( 원본 비트맵 복사 -> 복사한 사본을 포인터가 가리키게 함 -> 이후 원본 삭제 )

     만일 효율성이 신경 쓰여서 일치성 검사를 앞에 넣는다면 처리 흐름에 분기문이 만들어져서 파이프라이닝이 어그러지게 됩니다.

     

     추가적으로 이후 항목 29에서 '복사 후 맞바꾸기'라는 기법으로 소개되는 것도 있으니 알아보는 게 좋습니다.

     

     

     

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

    객체 안쪽 부분을 캡슐화 한 객체 지향 시스템중 설계가 잘 된 것들은 복사하는 함수가 딱 2개만 잇는 모습을 볼 수 있습니다. 바로 복사 생성자, 복사 대입 연산자로 이 둘을 통틀어 '객체 복사 함수'라고 합니다.

     

     일단 위 함수가 없는 경우 컴파일러가 자동으로 만들어 주는데 직접 정의를 할 때 이 구현이 거의 틀린 경우 입을 열지 않습니다.

    void logCall(const string & funcName);  // 로그 기록내용을 만듭니다
    
    class Customer{
    public:
    	...
    	Customer(const Customer& rhs);
    	Customer& operator= (constCustomer & 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 operator");
    	name = rhs.name;
    	return *this;
    };

     위 클래스를 만든 뒤 이후 멤버 함수를 하나 추가하고 객체 복사 함수들을 변경하지 않으면 무슨 일이 일어날까요? 이 경우에 대해서 컴파일러는 아무 말도 하지 않습니다. 위에서 입을 닫는다는 이야기는 경고조차 안 해준다는 이야기죠. 그래서 프로그래머가 체크를 해서 처리를 다 해줘야 합니다.

     

     그리고 위 문제는 클래스 상속이 있는 경우 더 심하게 되어서 기본 클래스가 부분 복사라면 파생 클래스도 부분 복사가 되고 파생 클래스에서 기본 클래스의 복사를 까먹는 경우도 있습니다. 그러니 파생 클래스에서 기본 클래스 복사를 빠뜨리면 안 됩니다.

    PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
    	:Customer(rhs), ...{...}    // 기본 클래스의 복사 생성자를 호출
    
    PriorityCustomer&
    PriorityCustomer::operator = (const PriorityCustomer& rhs)
    {
    	logCall("...");
    	Customer::operator=(rhs);   // 기본 클래스 부분을 대입
      ...
    }

     

     그래서 이번 항목의 핵심 2가지를 요약하면 

    '해당 클래스 데이터 멤버 모두를 복사하게 하고 기본 클래스가 있다면 기본 클래스의 복사 함수도 호출해라'

    입니다.

     

    +

     추가적으로 복사 생성자, 복사 대입 연산자가 하는 일이 같다고 이 둘을 통합하려는 경우가 있는데 그건 안됩니다!

     

    - 복사 생성자가 복사 대입 연산자를 호출?

       => 대입 연산은 '이미 생성된' 객체에 대한 일을 해 초기화가 안된 이 경우(생성자 호출)에는 불가능

    - 복사 대입 연산자에서 복사 생성자 호출?

       => 이미 만들어진 객체를 '생성'한다는 게 넌센스이며 객체 훼손 우려가 있습니다.

     

     그러니 동일한 일을 처리해주고 싶다면 private 함수를 만들어 그 함수를 호출하는 식으로 처리해주는 게 좋습니다.

    (이미 충분히 많은 곳에서 검증이 된 방법이라고 합니다)

     

    반응형

    댓글

Designed by Tistory.