Patrick's Devlog

[Effective C++] Chapter 2 정리 - 2 본문

Study/C++

[Effective C++] Chapter 2 정리 - 2

Patrick_ 2022. 9. 2. 16:24

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

객체 생성, 소멸 중에는 가상함수를 호출해선 안된다. 호출 결과가 원하는대로 돌아가지 않을 수 있다. 주식 거래를 본떠 만든 클래스 계통 구조가 있다고 가정하자. 주식 거래이므로 매도, 매수 주문 등이 존재할 것이다. 이러한 거래를 모델링하는 데 있어서 중요한 포인트는 감사 기능이다. 그러므로 주식 거래 객체가 생성될 때마다 감사 로그에 적절한 거래 내역이 만들어지도록 해야한다. 아래의 예시를 확인해보자.

public Transaction { // 모든 거래에 대한 기본 클래스
public:
    Transaction();
    virtual void logTransaction() const = 0; // 타입에 따라 달라지는 로그 기록 생성
    ...
};

Transaction::Transaction() { // 기본 클래스 생성자의 구현
    ...
    logTransation(); // 마지막 동작으로 이 거래를 로깅하기 시작
};

class BuyTransaction: public Transaction { // Transaction의 파생 클래스
public:
    virtual void logTransaction() const; // 이 타입에 따른 거래내역 로깅 구현
    ...
};

class SellTransaction: public Transaction { // 파생 클래스
public:
    virtual void logTransaction() const; // 이 타입에 따른 거래내역 로깅 구현
    ...
};

위의 코드를 BuyTransaction b;를 통해 실행시킨다. 우선 이때 Transaction 생성자가 호출된다. 파생 클래스 객체가 생성될 때 그 객체의 기본 클래스 부분이 파생 클래스 부분보다 먼저 호출되는 것이 정석이다. 여기서 호출되는 logTransaction함수는 Transaction의 것이다. 기본 클래스의 생성자가 호출될 동안에는 가상 함수는 절대 파생 클래스 쪽으로 내려가지 않는다. 그 대신, 객체 자신이 기본 클래스 타입인 것처럼 동작한다. 즉, 기본 클래스 생성 과정에서는 가상 함수가 먹히지 않는다. 

이와 같이 동작하는데에 이유는 존재한다. 기본 클래스의 생성자는 파생 클래스 생성자보다 앞서 실행되기 때문에, 기본 클래스 생성자가 돌아가고 있을 시점에 파생 클래스 데이터 멤버는 아직 초기화된 상태가 아니라는 것이 핵심이다. 만약 이때 파생 클래스로 내려가면, 파생 클래스의 데이터를 건들이게 될텐데, 아직 초기화가 되지 않은 상태이다. 초기화되지 않은 영역을 건들이면 치명적인 위험을 내포하므로, 이를 막은 것이다. 

파생 클래스 객체의 기본 클래스 부분이 생성되는 동안, 그 객체의 타입은 기본 클래스이다. 호출되는 가상 함수는 모두 기본 클래스의 것으로 결정된다. 위의 코드를 통해 예를 들어보자. BuyTransaction 객체의 기본 클래스 부분을 초기화하기 위해 Transaction 생성자가 실행되고 있는 동안에는, 그 객체 타입이 Transaction이라는 소리이다. 아무래도 초기화가 되지 않으니 없는 것으로 판단하게 된다. 객체 소멸 때도 똑같이 생각하면 된다. 

 

이 문제에 대한 해결법은 여러가지지만 하나만 언급한다. logTransaction을 Transaction 클래스의 비가상 멤버 함수로 바꾸면 된다. 

class Transaction {
public:
    explicit Transaction(const std::string& logInfo);
    void logTransaction(const std::string& logInfo) const; // 이제 비가상 함수.
    ...
};

Transaction::Transaction(const std::string& logInfo) {
    ...
    logTransaction(logInfo); // 비가상 함수를 호출
}

class BuyTransaction: public Transaction {
public:
    BuyTransaction(parameters) : Transaction(createLogString(parameters)) // 로그 정보를 기본 클래스 생성자로 넘김
    { ... }
    ...
private:
    static std::string createLogString(parameters);
};

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

 

★ 생성자 혹은 소멸자 안에서 가상 함수를 호출하면 안된다. 가상 함수라 한들, 지금 실행중인 생성자나 소멸자에 해당되는 클래스의 파생 클래스로 내려오지 않는다. 

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

C++의 대입 연산은 여러개가 엮일 수 있는 성질을 가지고 있다.

int x,y,z;
x = y = z = 15;

대입 연산이 가진 또 하나의 특성은 우측 연관 연산이다. 즉, 위의 대입 연산 사슬은 x = (y = (z = 15));로 해석된다. 코드를 풀면 15가 z에 대입되고, 그 연산의 결과가 y에 대입된 후에 y에 대한 대입 연산의 결과가 x에 대입된다. 

이렇게 대입 연산이 사슬처럼 엮이려면 대입 연산자가 좌변 인자에 대한 참조자를 반환하도록 구현되어 있을 것이다. 우리가 클래스를 만들 때 대입 연산자가 들어간다면 이러한 관례는 지키는 것이 좋다

class Widget {
public:
    ...
    Widget& operator=(const Widget& rhs) { // 반환 타입은 현재 클래스에 대한 참조자
        ...
        return *this; // 좌변 객체(의 참조자)를 반환
    }
    ...
};

좌변 객체의 참조자를 반환하게 만들자는 규약은 위에 있는 단순 대입형 연산자 말고 모든 형태(+=, -=, *= etc)의 대입 연산자에서 지켜져야 한다. 

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

자기대입은 어떤 객체가 자기 자신에 대해 대입 연산자를 적용하는 것을 일컫는다. 

a[i] = a[j]; 같은 경우도 i와 j가 동일하면 자기대입문이 된다. *px = *py; 이같은 경우 또한 가리키는 대상이 같으면 자기대입이 된다. 언뜻 보기에 명확하지 않은 자가대입이 생기는 이유는 여러 곳에서 하나의 객체를 참조하는 상태(중복참조) 때문이다. Item 13, 14에 나온 조언을 따르면 우리는 자원 관리 용도로 항상 객체를 생성해야 하고, 이렇게 만든 자원 관리 객체들이 복사될 때 나름 잘 동작하도록 코딩해야 한다. 이때 조심해야 하는것이 대입 연산자이다. 코딩을 하다보면 자원을 사용하기 전에 해제할 수도 있기 때문이다.

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

class Bitmap { ... };
class Widget {
    ...
private:
    Bitmap *pb; // 힙에 할당한 객체를 가리키는 포인터
};

다음은 operator= 구현 코드를 보자. 자기 참조의 가능성이 있는 위험한 코드이다.

Widget& Widget::operator=(const Widget& rhs) // 안전하지 않게 구현된 operator=
{
    delete pb; // 현재 비트맵 사용 중지
    pb = new Bitmap(*rhs.pb); // rhs의 비트맵 사용하도록 생성
    return *this; // item 10 참조
}

operator= 내부에서 *this와 rhs가 같은 객체일 가능성이 있다는 점이 존재한다. 둘이 같은 객체면 delete 연산자가 *this와 rhs 객체까지 적용되어 버린다. 따라서 이 함수가 끝나는 시점이 되면 Widget 객체는 자신의 포인터 멤버를 통해 물고 있던 객체가 삭제된 상태가 되는 불상사를 당하게 된다. 

이러한 에러에 대한 전통적인 해결 방법은 identtity test를 통해 자기 대입을 점검한다

if (this == &ths) return *this;

operator= 첫줄에 위의 코드를 넣어 객체가 같은지, 즉 자기 대입인지 검사한다. 하지만 이 코드를 통해서 모든 것이 완벽하게 해결되지 않는다. new Bitmap 표현식에서 예외가 터지면 Widget 객체는 결국 삭제된 Bitmap을 가리키는 포인터를 껴안고 혼자 남게된다. 이런 포인터는 delete를 적용할 수 없고, 읽는 것 조차 불가능하다. 이러한 일을 막기위해 operator=을 예외에 안전하게 구현하면 자기 대입에도 안전한 코드가 나오게 된다. 

Widget& Widget::operator=(const Widget rhs) {
    Bitmap *pOrig = pb; // 원래 pb를 어딘가에 기억
    pb = new Bitmap(*rhs.pb); // 다음 pb가 *pb 사본을 가리키게 만듦
    delete pOrig; // 원래 pb 삭제
    
    return *this
}

new Bitmap에서 예외가 발생해도 pb는 변경되지 않은 상태가 유지되므로 예외에도 안전하게 된다. 

 

★ operator=을 구현할 때 어떤 객체가 그 자신에 대입되는 경우를 제대로 처리하도록 만든다. 원본 객체와 복사대상 객체 주소 비교, 문장 순서 조정, 복사 후 맞바꾸기 기법 등을 사용하면 된다.

두 개이상 객체에 대해 동작하는 함수가 존재할 시 이 함수에 넘겨지는 객체들이 사실 같은 객체인 경우 정확하게 동작하는지 확인한다.

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

객체 안쪽부분을 캡슐화한 객체 지향 시스템 중 설계가 잘된 것을 보면 객체 복사 함수 두개가 보일 것이다. 복사 생성자와 복사 대입 연산자라고 이름이 지어져 있을 것이다. 이는 객체 복사 함수라 부른다. 객체 복사 함수는 컴파일러가 필요에 따라 만들어 내는 것을 우리는 이미 알고있다. 객체 복사 함수를 우리가 선언한다는 것은, 컴파일러가 만든 함수의 동작에 부족한 점이 있다는 것이다. 우리가 구현한 복사 함수가 틀렸어도 컴파일러는 경고를 생성하지 않는다.

예를 하나 들어보자. customer를 나타내는 클래스가 존재한다. 이 클래스의 복사함수는 개발자가 직접 구현하였으며, 복사함수를 호출할 때마다 로그를 남긴다.

void logCall (const std::string& funcName); //log 기록내용 생성
class Customer {
public:
    ...
    Customer(const Customer& rhs);
    Customer& operator=(const Customer& rhs);
    ...
private:
    std::string name;
};

Customer::Customer(const Customer& rhs) : name(rhs.name) { // rhs 데이터 복사
    logCall("Customer copy constructor");
}

Customer& Customer::operator=(const Customer& rhs) {
    logCall("Customer copy assignment operator");
    name = rhs.name; // rhs 데이터 복사
    return *this;
}

위의 코드만 보면 문제 될것이 없지만, 데이터 멤버 하나를 Customer에 추가하게 되면 문제가 생긴다.

class Date { ... }; // 날씨 정보를 위한 클래스

class Customer {
public:
    ...
private:
    std::string name;
    Date lastTransaction;
};

이렇게 되면 복사 함수의 동작은 부분복사가 되어버린다. name은 복사하지만, lastTransacrtion은 복사하지 않는다. 클래스에 데이터 멤버를 추가했으면, 추가한 데이터 멤버를 처리하도록 복사 함수를 다시 작성할 수 밖에 없다. 

이러한 문제중 제일 심각한 문제가 하나 존재한다. 바로 클래스 상속이다. 아래의 코드를 확인해보자.

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 operator");
    priority = rhs.priority;
    return *this;
}

PriorityCustomer 클래스 복사 함수는 모든 것을 복사하고 있는 것처럼 보이지만, Customer로부터 상속한 데이터 멤버들의 사본은 복사가 되지 않고 있다. 복사 함수를 우리가 직접 만든다고 결심했으므로, 기본 클래스 부분을 복사에서 빠트리지 않도록 주의해야 한다. 파생 클래스의 복사함수 안에서 기본 클래스의 복사 함수를 호출하면 된다. 

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");
    Customer::operator=(rhs);
    priority = rhs.priority;
    return *this;
}

객체 복사 함수를 작성할 때 다음 두가지를 꼭 확인해야 한다

 1. 해당 클래스의 데이터 멤버 모두 복사

 2. 이 클래스가 상속한 기본 클래스 복사함수도 꼬박꼬박 호출

 

객체 복사 함수는 주어진 객체의 모든 데이터 멤버 및 모든 기본 클래스 부분을 빠트리지 않고 복사해야 한다. 

클래스 복사 함수 두개를 구현할 때 한쪽을 이용해 다른쪽을 구현하려는 시도는 금지한다. 

'Study > C++' 카테고리의 다른 글

[Effective C++] Chapter 3 정리  (0) 2022.10.27
[Effective C++] Chapter 2 정리 - 1  (0) 2022.05.26
[Modern C++] Lvalue 및 Rvalue  (0) 2022.05.20
[Modern C++] Smart Pointer  (0) 2022.05.17
[Modern C++] 시작하기  (0) 2022.05.16