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. 객체의 모든 부분을 빠짐없이 복사하자

복사 생성자와 복사 대입 연산자를 통틀어서 객체 복사 함수라고 한다.

객체 복사 함수를 명시적으로 구현할 때, 두 가지를 유의해야 한다.

 

  1. 데이터 멤버가 추가될 때 마다 복사 함수에 추가해 주어야 한다.
  2. 파생 클래스를 복사할 때 기본 클래스의 복사 함수도 같이 호출해 주어야 한다.

 

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

1. C++를 언어들의 연합체로 바라보는 안목은 필수

C++이 단일 언어가 아닌 여러개의 하위 언어를 제공한다고 생각하자.

 

◾ C

◾ 객체 지향 개념의 C++

◾ 템플릿 C++

◾ STL

 

◾ C++을 사용한 효과적인 프로그래밍 규칙은 경우에 따라 달라집니다. 그 경우란, 바로 C++의 어떤 부분을 사용하느냐입니다.

 

 

2. #define을 쓰려거든 const, enum, inline을 떠올리자

#define을 상수로 교체할 때 상수 포인터를 정의하는 경우와 클래스 멤버를 상수로 정의하는 경우를 유의해야한다.

 

우선 클래스 멤버를 #define으로 정의하는 것 자체가 불가능 하다는 것을 알아야한다.

클래스 멤버를 상수로 정의하는 경우, 상수의 유효 범위를 클래스로 한정짓고자 한다면 해당 상수를 멤버 변수로 만들어야한다. 다만 사본 개수가 한 개를 넘지 못하게 하려면 정적 멤버로 만들어주어야 한다.

 

class GamePlayer
{
private:
    static const int NumTurns = 5; // 구식 컴파일러에서는 오류 발생
    int scores[NumTurns];
};

 

구식 컴파일러에서는 나열자 둔갑술을 대신 사용할 수 있다.

 

class GamePlayer
{
private:
    enum { NumTurns = 5 };
    int scores[NumTurns];
};

 

◾ 단순한 상수를 쓸 때는, #define보다 const 객체 혹은 enum을 우선 생각합시다.

◾ 함수처럼 쓰이는 매크로를 만들려면, #define 매크로보다 인라인 함수를 우선 생각합시다.

 

 

3. 낌새만 보이면 const를 들이대 보자!

const가 * 왼쪽에 있으면 포인터가 가리키는 대상이 상수, 오른쪽에 있으면 포인터 자체가 상수이다

 

void f1(const Widget *pw);
void f2(Widget const *pw); // 둘 다 같은 의미

 

매개변수, 지역 객체를 수정할 수 없게 하는 것이 목적이라면 const를 반드시 붙여주도록 한다. 실수 발생시 컴파일 에러 발생으로 오류를 쉽게 잡을 수 있다.

 

◾ 상수 멤버 함수

객체를 reference-to-const로 전달하면 실행 성능을 높일 수 있다. (상수 멤버 함수 필요)

또한 const 키워드가 있고 없고의 차이만 있는 멤버 함수들은 오버로딩이 가능하다.

 

const char& operator[](std::size_t position) const // 상수 객체
char& operator[](std::size_t position) // 비상수 객체

 

어떤 멤버 함수가 상수 멤버라는것은 비트수준(물리적) 상수성, 논리적 상수성을 만족한다는 의미이다.

하지만 상수 멤버 함수는 물리적 상수성은 만족하지만 논리적 상수성은 만족하지 못한다.

 

class CTextBlock {
public:
    char& operator[](std::size_t position) const
    { return pText[position]; }
private:
    char *pText;
};
...
const CTextBlock cctb("Hello"); // 상수 객체 선언
char *pc = &cctb[0]; // 상수 버전 연산자 호출
*pc = 'J'; // 값이 바뀐다..?

 

 

컴파일러 입장에서는 비트수준 상수성을 만족하기 때문에 컴파일 오류 없이 정상 동작한다.

우리는 추가적으로 논리적 상수성을 지켜서 프로그래밍 해야 한다.

 

◾ 상수 멤버 및 비상수 멤버 함수에서 코드 중복을 피하는 방법

두 멤버 함수가 상수/비상수 차이만 있을 뿐 로직과 기능이 동일하다면 비상수 버전이 상수 버전을 호출하도록 구현하면 코드 중복을 피할 수 있다.

 

class TextBlock{
public:
    const char& operator[](std::size_t position) const
    {
        ...
        return text[position];
    }
    char& operator[](std::size_t position)
    {
        return const_cast<char&>( // 캐스팅으로 const 제거
            static_cast<const TextBlock&> // *this 타입에 const 추가
            (*this)[position]; // 상수 버전 호출
        );
    }
};

 

 

두 번의 캐스팅 없이 그냥 operator[]를 호출하면 자기 자신(비상수)을 재귀적으로 무한하게 호출한다.

그래서 *this를 const 캐스팅 시켜서 상수 버전을 호출하고 다시 캐스팅을 통해 const를 떼버린다.

 

반대로 상수 버전에서 비상수 버전을 호출하는것은 상수 멤버가 깨지기 때문에 불가능하다.

 

◾ const를 붙여 선언하면 컴파일러가 사용상의 에러를 잡아내는 데 도움을 줍니다. const는 어떤 유효범위에 있는 객체에도 붙을 수 있으며, 함수 매개변수 및 반환 타입에도 붙을 수 있으며, 멤버 함수에도 붙을 수 있습니다.

◾ 컴파일러 쪽에서 보면 비트수준 상수성을 지켜야 하지만, 여러분은 개념적인(논리적인) 상수성을 사용해서 프로그래밍해야 합니다.

◾ 상수 멤버 및 비상수 멤버 함수가 기능적으로 서로 똑같게 구현되어 있을 경우에는 코드 중복을 피하는 것이 좋은데, 이때 비상수 버전이 상수 버전을 호출하도록 만드세요.

 

 

4. 객체를 사용하기 전에 반드시 그 객체를 초기화하자

대입과 초기화를 헷갈려서는 안된다.

대입의 경우 기본 생성자-복사 생성자 순으로 이뤄지지만 초기화는 인자가 직접 생성자의 인자로 쓰이기 때문에 호출 비용이 줄어든다.

데이터 멤버를 기본 생성자로 초기화 하는 경우에도 초기화 리스트를 사용하는 것이 좋다.

 

기본제공 타입의 멤버가 상수나 참조자인 경우에는 반드시 초기화가 이루어져야 한다.

 

C++은 기본 클래스가 파생 클래스보다 먼저 초기화되고 클래스 데이터 멤버는 선언된 순서대로 초기화된다.

 

비지역 정적 객체의 초기화 순서는 개별 번역 단위에서 정해진다.

◾ 정적 객체: 함수 내부의 정적 객체를 제외한 나머지

◾ 번역 단위: obj 파일을 만드는 기반 소스 파일

 

서로 다른 번역 단위에 정의된 비지역 정적 객체들의 상대적인 초기화 순서는 정해져 있지 않다.

 

초기화 순서를 정하고싶다면 비지역 정적 객체를 하나씩 맡는 함수를 만들어서 지역 정적 객체로 바꾸면 된다. (≒싱글톤)

 

class FileSystem { ... };
FileSystem& tfs()
{
    static FileSystem fs; // 지역 정적 객체 정의/초기화
    return fs; // 참조자 반환
}

 

 

다중스레드 시스템에서 비상수 정적 객체(지역/비지역)는 문제가 발생할 수 있다.

참조자 반환 함수를 모두 호출해주면 초기화에 관계된 경쟁 상태가 발생하지 않는다.

 

◾ 기본제공 타입의 객체는 직접 손으로 초기화합니다. 경우에 따라 저절로 되기도 하고 안되기도 하기 때문입니다.

◾ 생성자에서는, 데이터 멤버에 대한 대입문을 생성자 본문 내부에 넣는 방법으로 멤버를 초기화하지 말고 멤버 초기화 리스트를 즐겨 사용합시다. 그리고 초기화 리스트에 데이터 멤버를 나열할 때는 클래스에 각 데이터 멤버가 선언된 순서와 똑같이 나열합시다.

◾ 여러 번역 단위에 있는 비지역 정적 객체들의 초기화 순서 문제는 피해서 설계해야 합니다. 비지역 정적 객체를 지역 정적 객체로 바꾸면 됩니다.

'도서 > Effective C++' 카테고리의 다른 글

6. 상속, 그리고 객체 지향 설계  (0) 2022.12.04
5. 구현  (0) 2022.12.03
4. 설계 및 선언  (0) 2022.12.01
3. 자원 관리  (0) 2022.11.30
2. 생성자, 소멸자 및 대입 연산자  (0) 2022.11.30

+ Recent posts