이펙티브 C++
https://www.yes24.com/Product/Goods/17525589
페이지 99p ~ 111p
요약
항목 9: 객체 생성 및 소멸 과정 중에는 절대로 가상 함수를 호출하지 말자
- 객체 생성 및 소멸 과정 중에는 가상 함수를 호출하면 절대로 안 된다.
- 해당 과정에서 호출한 가상 함수는 우리가 원하는 대로 돌아가지 않을 것이다.
- 주식 거래를 위해 다음과 같은 클래스를 정의했다고 가정하자.
// 주식 거래에 대한 기본 클래스
// 주식 거래 모델링에 있어 중요한 기능은 감사(audit) 기능
// 따라서 주식 거래 객체가 생성될 때마다 감사 로그를 기록하기 위한 기능이 필요
class Transaction {
public:
Transaction();
// 주식 거래 타입에 따라 달라지는 로그 기록을 생성
virtual void logTransaction() const = 0;
};
// 기본 클래스 생성자
Transaction::Transaction() {
// 생성자의 마지막 동작으로 거래를 로깅
logTransaction();
}
// 주식 거래 파생 클래스들
class BuyTransaction : public Transaction {
public:
virtual void logTransaction() const;
};
class SellTransaction : public Transaction {
public:
virtual void logTransaction() const;
};
// 사용
BuyTransaction b;
- 마지막 줄의 코드가 실행되면
BuyTransaction
의 생성자가 호출되는 것은 어쨌든 맞는 일이다.- 그러나 파생 클래스의 객체가 생성될 때 그 객체의 기본 클래스의 생성자가 파생 클래스 부분보다 먼저 호출된다.
- 이때 기본 클래스인
Transaction
의 생성자를 보면 가상 함수인logTransaction
이 호출되고 있는 것을 볼 수 있다.
- 기본 클래스의 생성자가 호출될 동안에는 가상 함수는 절대로 파생 클래스 쪽으로 내려가지 않는다.
- 파생 클래스 객체의 기본 클래스 부분이 생성되는 동안은, 그 객체의 타입은 바로 기본 클래스이다.
- 따라서 이 과정에서 호출되는 가상 함수는 모두 기본 클래스의 것으로 결정될 뿐만 아니라, 런타임 타입 정보를 사용하는 언어 요소(ex. dynamic_cast)를 사용한다고 해도 이 순간에는 모두 기본 클래스 타입의 객체로 취급한다.
BuyTransaction
객체의 경우 기본 클래스 부분을 초기화하기 위해Transaction
의 생성자가 실행되는 동안에는, 그 객체의 타입이Transaction
으로 처리되며, 파생 클래스인BuyTransaction
의 데이터가 아직 초기화되지 않은 상태이기 때문에 파생 클래스를 아예 없는 것처럼 취급하는 것이 안전하다고 볼 수 있다.
- 객체가 소멸될 때도 동일하게 생각하면 된다.
- 파생 클래스의 소멸자가 일단 호출되고 나면 파생 클래스만의 데이터 멤버는 정의되지 않은 값으로 가정하기 때문에, C++는 파생 클래스를 없는 것처럼 취급하고 소멸자를 실행한다.
생성자 혹은 소멸자에서 가상 함수가 호출되는지를 잡아내는 방법
- 기본 클래스의 생성자가 여러 개 된다고 가정하면, 각 생성자에서는 분명히 공통적으로 처리하는 부분이 생기게 될 것이다. 이러한 작업을 모아 공용으로 사용하는 초기화 코드를 만들어 두면 코드의 중복을 예방할 수 있을 것이다.
class Transaction {
public:
// 공용 초기화 함수 init() 호출
Transaction() { init(); }
// 주식 거래 타입에 따라 달라지는 로그 기록을 생성
virtual void logTransaction() const = 0;
private:
// 비가상 함수에서 가상 함수를 호출
void init() {
// 공용 초기화 로직 수행
logTransaction();
}
};
- 이런 코드는
logTransaction
이 순수 가상 함수로 선언되어 있는데도 불구하고 컴파일과 링크 단계에서 에러가 발생하지 않기 때문에 더 위험한 코드라고 볼 수 있다.- 대부분의 시스템은 순수 가상 함수가 호출될 때 프로그램을 바로 끝내 버린다.(abort)
- 아니면
logTransaction
이 가상 함수(순수 가상 함수 아님)이며, 기본 클래스Transaction
에서 가상 함수를 구현하고 있을 경우에는 더 복잡해진다.- 프로그램은 정상적으로 실행되는 것처럼 보이긴 할 것이다.
- 하지만 파생 클래스의 생성자가 호출될 때, 로그 기록은 항상
Transaction
의 타입으로 결정될 것이기 때문에 개발자를 더 피곤하게 만들 가능성이 있다.
- 따라서 생성자나 소멸자 호출 과정에서 가상 함수가 호출되는지 잡아내는 방법은 프로그래머가 코드를 철저하게 관리하고, 생성자와 소멸자가 호출하는 모든 함수들이 똑같은 제약을 따르도록 만들어야 한다.
logTransaction
을Transaction
클래스의 비가상 멤버 함수로 변경하고, 파생 클래스의 생성자들에서는 로깅에 필요한 정보를Transaction
의 생성자로 전달해야 한다는 규칙을 만들어 보자.
// 생성자와 소멸자에서 호출하는 함수들이 가상 함수가 될 수 없도록 만드는 규칙 적용
#include <string>
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(const std::string& logInfo) : Transaction{ logInfo };
BuyTransaction(parameter) : Transaction(createLogString(parameter));
private:
static std::string createLogString(parameters);
};
- 기본 클래스 부분이 생성될 때는 가상 함수를 호출한다 해도 기본 클래스의 울타리를 넘어 내려갈 수 없기 때문에, 필요한 초기화 정보를 파생 클래스 쪽에서 기본 클래스의 생성자로 올려주도록 만드는 것이다.
항목 10: 대입 연산자는 *this
의 참조자를 반환하게 하자
- C++의 대입 연산은 여러 개가 사슬처럼 엮일 수 있는 성질을 가지고 있다.
int x, y, z;
x = y = z= 15;
- 대입 연산은 우측 연관 연산이다. 따라서 위의 코드에서 발생하는 대입 연산 사슬은 다음과 같이 분석된다.
x = ( y = ( z = 15));
- 15가 z에 대입되고, 그 대입 연산의 결과가 y에 대입되고, 그 결과가 x에 대입된다.
- 이렇게 대입 연산이 사슬처럼 엮이려면 대입 연산자가 좌변 인자에 대한 참조자를 반환하도록 구현되어 있을 것이다.
- 이런 구현은 일종의 컨벤션인데, 개발자가 만드는 클래스에 대입 연산자가 혹시라도 사용된다면 이 컨벤션을 지키는 것이 좋다.
- 컨벤션은 따르지 않더라도 코드를 작성하는데 문제가 발생하거나 컴파일 과정에서 문제가 발생하지는 않는다.
- 하지만 이 컨벤션은 모든 기본제공 타입들이 따르고 있고, 표준 라이브러리에서도 따르고 있기 때문에 지키는 것이 좋다.
항목 11: operator=에서는 자기대입에 대한 처리가 빠지지 않도록 하자
- 자기대입(self assignment)이란, 어떤 객체가 자기 자신에 대해 대입 연산자를 적용하는 것을 의미한다.
class Widget { ... };
Widget w;
w = w;
- 포인터나 레퍼런스가 자기대입을 하게 되면 중복참조 현상이 발생한다.
- 중복참조: 여러 곳에서 하나의 객체를 참조하는 상태
- 같은 타입으로 만들어진 객체 여러 개를 레퍼런스, 포인터로 물어 놓고 동작하는 코드를 작성할 때는 같은 객체가 사용될 가능성을 고려하는 것이 일반적으로 바람직한 자세가 되겠다.
- 대입 연산자는 자기대입에 대해 안전하게 동작해야 한다.
class Bitmap {};
class Widget {
public:
// 안전하지 않게 구현된 대입연산자
// *this와 rhs가 같은 객체일 가능성이 있음
// 이 경우에 delete pb를 하면 this뿐만 아니라 rhs의 객체에도 적용됨
Widget& operator=(const Widget* rhs) {
delete pb;
pb = new Bitmap(*rhs->pb);
return *this;
}
private:
Bitmap* pb;
};
- 이러한 문제를 해결하기 위한 전통적인 방법은 대입 연산자의 첫 단락에 일치성 검사를 통해 자기대입을 검사하는 것이다.
- 이 방법은 예외 안전성에 대해서 문제가 있다.
new Bitmap
이라는 표현을 사용할 때, 예외가 터지게 되면(동적 할당에 필요한 메모리 부족, Bitmap 클래스 복사 생성자에서 예외 등) Widget 객체는 결국 삭제된 Bitmap을 가리키는 포인터를 가리키게 된다.- 이런 포인터는
delete
연산자를 안전하게 적용할 수도 없고, 안전하게 읽는 것도 불가능하다.
// 자기대입 검사
Widget& Widget::operator=(const Widget& rhs) {
if (this == &rhs) return *this;
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}
- 따라서 대입 연산자를 예외에서 안전하게 구현하면 된다.
- 많은 경우에 문장 순서를 세심하게 바꾸는 것만으로 예외에 안전한 코드를 만들 수 있다.
- 지금 코드에서는
pb
를 바로 삭제하는 것이 아니라 포인터가 가리키는 객체를 복사한 직후에 삭제하는 것이다.
// Bitmap 객체 생성 과정에서 발생하는 예외 처리
Widget& Widget::operator=(const Widget& rhs) {
Bitmap* pOrig = pb; // 원래의 pb를 어딘가에 복사
pb = new Bitmap(*rhs.pb); // 그 다음 pb가 *pb의 사본을 가리키게 만듬
delete pOrig; // 원래의 pb 삭제
return *this;
}
- 예외 안전성과 자기대입 안전성을 동시에 가진 대입 연산자를 구현하는 방법은 복사 후 맞바꾸기 기법을 사용하는 것이다.
// 복사 후 맞바꾸기
class Widget {
void swap(Widget& rhs);
};
Widget& Widget::operator=(const Widget& rhs) {
Widget temp(rhs); // rhs의 데이터에 대해 사본 생성
swap(temp); // *this의 데이터를 사본의 것과 스왑
return *this
}
// 클래스의 복사 대입 연산자는 인자를 값으로 취하도록 선언하는 것이 가능함
// 값에 의한 전달을 수행하면 전달된 대상의 사본이 생김을 이용하는 방법
Widget& Widget::operator=(Widget rhs) {
swap(this);
return *this;
}
메모
항목 9
- 생성자 혹은 소멸자 안에서 가상 함수를 호출하지 말자. 가상 함수라고 해도 지금 실행 중인 생성자나 소멸자에 해당되는 클래스의 파생 클래 쪽으로는 해당 정보를 넘겨줄 수 없기 때문이다.
항목 10
- 대입 연산자는
*this
의 참조자를 반환하도록 만들자. 이는 C++의 컨벤션이다.
항목 11
- 대입 연산자(
operator=
)를 구현할 때, 어떤 객체가 그 자신에 대입되는 경우를 제대로 처리하도록 만들어야 한다. 원본 객체와 복사대상 객체의 주소를 비교해도 되고, 문장의 순서를 적절히 조정할 수도 있으며, 복사 후 맞바꾸기 기법을 써도 된다. - 두 개 이상의 객체에 대해 동작하는 함수가 있다면, 이 함수에 넘겨지는 객체들이 사실 같은 객체인 경우에 정확하게 동작하는지 확인해야 한다.
'책 > 이펙티브 C++' 카테고리의 다른 글
이펙티브 C++(121p ~ 132p) (0) | 2024.01.16 |
---|---|
이펙티브 C++(111p ~ 121p) (0) | 2024.01.15 |
이펙티브 C++(89p ~ 99p) (1) | 2024.01.12 |
이펙티브 C++(80p ~ 89p) (1) | 2024.01.11 |
이펙티브 C++(71p ~ 79p) (1) | 2024.01.10 |