5. C++가 은근슬쩍 만들어 호출해 버리는 함수들에 촉각을 세우자
복사 생성자, 복사 대입 연산자, 소멸자는 직접 선언하지 않으면 컴파일러가 암시적으로 public 인라인 함수로 만들어버린다.
멤버 변수가 레퍼런스이거나 상수라면 복사 대입 연산자는 자동으로 만들어지지 않고 컴파일이 거부된다.
부모 클래스가 복사 대입 연산자를 private으로 선언한 경우에 자식 클래스는 복사 대입 연산자를 가질 수 없게 된다.
◾ 컴파일러는 경우에 따라 클래스에 대해 기본 생성자, 복사 생성자, 복사 대입 연산자, 소멸자를 암시적으로 만들어 놓을 수 있습니다.
6. 컴파일러가 만들어낸 함수가 필요 없으면 확실히 이들의 사용을 금해 버리자
복사 생성자, 복사 대입 연산자 등 컴파일러가 암시적으로 만들어 낸 함수가 필요 없으면(또는 절대 사용해서 안된다면) private으로 선언하면 된다. friend를 통한 접근까지 완벽하게 막으려면 정의를 하지 않으면 된다. 접근 시 링커 에러가 발생한다.
링크 시점 에러를 컴파일 시점으로 옮기려면 복사 생성자와 복사 대입 연산자를 선언 및 정의한 부모 클래스를 private으로 상속 받으면 된다.
◾ 컴파일러에서 자동으로 제공하는 기능을 허용치 않으려면, 대응되는 멤버 함수를 private로 선언한 후에 구현은 하지 않은 채로 두십시오. Uncopyable과 비슷한 기본 클래스를 쓰는 것도 한 방법입니다.
7. 다형성을 가진 기본 클래스에서는 소멸자를 반드시 가상 소멸자로 선언하자
기본 클래스 포인터를 통해 파생 클래스가 삭제될 때, 기본 클래스의 소멸자가 비가상 소멸자라면 프로그램의 동작은 미정의 사항이 되어버린다. 파생 클래스의 소멸자가 호출되지 못하고 메모리의 누수가 발생해버린다.
그렇기 때문에 파생될 여지가 있는 기본 클래스의 소멸자는 반드시 가상 소멸자로 선언해주어야 한다.
◾ 가상함수가 하나라도 포함된 클래스는 가상 함수 테이블 포인터가 추가되기 때문에 객체의 크기가 커진다. 가상 함수가 하나라도 포함된 클래스에만 가상 소멸자를 추가해주도록 하자.
◾ STL 컨테이너는 모두 비가상 소멸자이므로 상속 받아서 사용하는 것은 자제하도록 한다.
◾ 추상 클래스를 만들고 싶은데 순수 가상 함수로 만들 함수가 하나도 없다면 소멸자를 순수 가상 함수로 만들면 된다. 이 때, 순수 가상 소멸자라도 반드시 구현을 해두어야 한다.
모던 C++은 순수 가상 함수가 아니더라도 클래스에 abstract 키워드를 붙임으로써 추상 클래스임을 명시할 수 있다.
◾ 다형성을 가진 기본 클래스에는 반드시 가상 소멸자를 선언해야 합니다. 즉, 어떤 클래스가 가상 함수를 하나라도 갖고 있으면, 이 클래스의 소멸자도 가상 소멸자이어야 합니다.
◾ 기본 클래스로 설계되지 않았거나 다형성을 갖도록 설계되지 않은 클래스에는 가상 소멸자를 선언하지 말아야 합니다.
8. 예외가 소멸자를 떠나지 못하도록 붙들어 놓자
예외가 발생할 소지가 있는 문제에 대해서는 소멸자에서 모든것을 처리하게 하지 않고 사용자가 대처할 기회를 가질 수 있게 하는 것이 좋다.
class DBConn {
public:
void close()
{
db.close();
close = true;
}
~DBConn()
{
if (!close)
{
try {
db.close(); // 사용자가 연결을 안 닫았다면 여기서 시도
}
catch (...) {
// close 호출 실패 로그 작성
// 프로그램 종료 또는 예외 삼키기
}
}
}
private:
DBConnection db;
bool closed;
};
close 호출의 책임을 소멸자에서 사용자에게로 넘김으로써 예외를 처리할 기회를 준다.
예외가 발생한다면 그 예외는 소멸자가 아닌 다른 함수에서 비롯된 것이어야만 한다.
◾ 소멸자에서는 예외가 빠져나가면 안 됩니다. 만약 소멸자 안에서 호출된 함수가 예외를 던질 가능성이 있다면, 어떤 예외이든지 소멸자에서 모두 받아낸 후에 삼켜 버리든지 프로그램을 끝내든지 해야 합니다.
◾ 어떤 클래스의 연산이 진행되다가 던진 예외에 대해 사용자가 반응해야 할 필요가 있다면, 해당 연산을 제공하는 함수는 반드시 보통의 함수(즉, 소멸자가 아닌 함수)이어야 합니다.
9. 객체 생성 및 소멸 과정 중에는 절대로 가상 함수를 호출하지 말자
기본 클래스의 생성자가 호출되는 동안에는 가상 함수가 절대 파생 클래스 쪽으로 내려가지 않는다.
class Transaction
{
public:
Transaction() { logTransaction(); }
virtual void logTransaction() const = 0;
};
class BuyTransaction: public Transaction
{
public:
virtual void logTransaction() const { ... }
};
BuyTransaction b;
// Transaction 생성자 -> logTransaction() -> BuyTransaction 생성자
// 하지만 logTransaction은 순수 가상 함수..?
파생 클래스의 객체가 생성될 때 호출 순서는 기본 클래스의 생성자부터이고 기본 클래스의 생성자가 호출되는 동안에는 파생 클래스의 정보를 모르는 상태이다. 이 때 만큼은 객체의 타입이 기본 클래스가 된다. 그래서 BuyTransaction의 logTransaction 함수가 아닌 Transaction의 logTransaction 함수가 호출 된다. 순수 가상 함수이므로 링크 에러가 발생하게 된다.
순수 가상 함수가 아니라 그냥 가상 함수이고 구현까지 되어있다면 오류가 전혀 발생하지 않기 때문에 디버깅에 애를 먹을 수 있다.
미초기화된 데이터 멤버는 정의되지 않은 상태에 있기 때문에 기본 클래스의 생성과 소멸이 진행되는 동안에는 가상 함수가 파생 클래스 쪽으로 내려가지 않는 것이다.
◾ 생성자 혹은 소멸자 안에서 가상 함수를 호출하지 마세요. 가상 함수라고 해도, 지금 실행중인 생성자나 소멸자에 해당되는 클래스의 파생 클래스 쪽으로는 내려가지 않으니까요.
10. 대입 연산자는 *this의 참조자를 반환하게 하자
대입 연산은 여러 개가 사슬처럼 엮일 수 있다.
int x, y, z;
x = y = z = 15; // x = (y = (z = 15));
대입 연산이 연쇄적으로 이루어지려면 반드시 참조자가 반환되어야 한다는 것을 직관적으로 알 수 있다.
◾ 대입 연산자는 *this의 참조자를 반환하도록 만드세요.
11. operator=에서는 자기대입에 대한 처리가 빠지지 않도록 하자
w = w; // 자기 대입
a[i] = a[j] // 자기 대입 가능성 있음
*px = *py // 자기 대입 가능성 있음
Widget& Widget::operator=(const Widget& rhs)
{
if (this == &rhs) return *this;
delete pb;
pb = new Bitmap(rhs.pb); // 만약 자기 자신이면 삭제된것으로 생성을 시도함
return *this;
}
자기 참조의 위험이 있는 코드는 일치성 검사를 통해 처리해 주어야 한다.
만약 자기 참조가 허용된다면 자기 자신을 삭제 후 다시 할당하는 문제가 발생할 여지가 있다.
위의 경우는 동적 할당에서 예외가 발생하면 문제가 발생할 여지가 여전히 남는다.
또한 일치성 검사가 많이, 자주 일어날수록 비용이 커진다.
대부분 operator=를 예외에 안전하게 구현하면 대개 자기대입에도 안전한 코드가 나오게 되어있다.
Widget& Widget::operator=(const Widget& rhs)
{
Bitmap *pOrig = pb;
pb = new Bitmap(*rhs.pb);
delete pOrig;
return *this;
}
일치성 검사 대신 문장 순서를 바꾸는 것만으로도 효율적이지는 않지만 예외에 안전해 질 수 있다.
class Widget
{
void swap(Widget& rhs);
};
Widget& Widget::operator=(const Widget& rhs)
{
Widget temp(rhs); // rhs의 사본 생성
swap(temp); // *this와 사본을 교체
return *rhis;
}
또는 복사 후 맞바꾸기(copy and swap)를 통해 효율성과 예외 안전성까지 챙길 수 있다.
객체를 복사하는 코드가 함수 본문에서 생성자로 옮겨졌기 때문에 더 효율적인 코드를 생성할 수 있는 여지가 생기고 객체의 교체가 이루어지기 전에 예외가 발생하면 원본을 그대로 유지할 수 있기 때문에 예외에도 안전해진다.
◾ 복사 후 맞바꾸기
어떤 객체를 수정하고 싶으면 그 객체의 사본을 만들어서 사본을 수정한 뒤에 맞바꾸는 것
◾ operator=을 구현할 때, 어떤 객체가 그 자신에 대입되는 경우를 제대로 처리하도록 만듭시다. 원본 객체와 복사대상 객체의 주소를 비교해도 되고, 문장의 순서를 적절히 조정할 수도 있으며, 복사 후 맞바꾸기 기법을 써도 됩니다.
◾ 두 개 이상의 객체에 대해 동작하는 함수가 있다면, 이 함수에 넘겨지는 객체들이 사실 같은 객체인 경우에 정확하게 동작하는지 확인해 보세요.
12. 객체의 모든 부분을 빠짐없이 복사하자
복사 생성자와 복사 대입 연산자를 통틀어서 객체 복사 함수라고 한다.
객체 복사 함수를 명시적으로 구현할 때, 두 가지를 유의해야 한다.
- 데이터 멤버가 추가될 때 마다 복사 함수에 추가해 주어야 한다.
- 파생 클래스를 복사할 때 기본 클래스의 복사 함수도 같이 호출해 주어야 한다.
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs) // 복사 생성자
: Customer(rhs),
priority(rhs.priority)
{
...
}
PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs) // 복사 대입 연산자
{
Customer::operator=(rhs);
priority = rhs.priority;
return *this;
}
복사 함수의 기능이 서로 비슷하기 때문에 복사 대입 연산자에서 복사 생성자를 호출할 수 있을 것 같지만 그렇지 않다. 초기화와 대입이라는 서로 다른 매커니즘을 가지기 때문이다.
대신 양쪽에서 겹치는 부분은 별도의 멤버 함수로 분리한 후에 호출하는 방법을 이용할 수 있다.
보통 private으로 선언되어있고 init으로 시작하는 경우가 많다.
◾ 객체 복사 함수는 주어진 객체의 모든 데이터 멤버 및 모든 기본 클래스 부분을 빠뜨리지 말고 복사해야 합니다.
◾ 클래스의 복사 함수 두 개를 구현할 때, 한쪽을 이용해서 다른 쪽을 구현하려는 시도는 절대로 하지 마세요. 그 대신, 공통된 동작을 제3의 함수에다 분리해 놓고 양쪽에서 이것을 호출하게 만들어서 해결합시다.
'도서 > Effective C++' 카테고리의 다른 글
6. 상속, 그리고 객체 지향 설계 (0) | 2022.12.04 |
---|---|
5. 구현 (0) | 2022.12.03 |
4. 설계 및 선언 (0) | 2022.12.01 |
3. 자원 관리 (0) | 2022.11.30 |
1. C++에 왔으면 C++의 법을 따릅시다 (0) | 2022.11.27 |