Patrick's Devlog

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

Programming Language/C++

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

Patrick_ 2022. 5. 26. 17:52

Item 5 : C++가 은근슬쩍 만들어 호출해 버리는 함수들에 촉각을 세우자

C++에서 복사 생성자(copy constructor), 복사 대입 연산자(copy assignment operator), 소멸자(destructor)를 클래스 안에 직접 선언해 넣지 않으면 컴파일러가 저절로 선언해주도록 되어있다. 이때 컴파일러가 만드는 함수의 형태는 기본형이며 생성자조차 선언되어 있지 않으면 컴파일러가 선언해놓는다. 모두 public 멤버이며, inline 함수이다. 

class Empty{};

위와같이 썼다면 다음과 같이 쓴것과 근본적으로 대동소이하다는 의미다. 

class Empty {
public:
    Empty() { ... } // 기본생성자
    Empty(const Empty& rhs) { ... } // 복사 생성자
    ~Empty() { ... } // 소멸자
    
    Empty& operator=(const Empty& rhs) { ... } // 복사 대입 연산자 
};

이는 꼭 필요하다고 컴파일러가 판단할 때만 만들어지도록 되어있지만, 필요 조건이 그렇게 복잡하지 않다. 이들이 만들어지는 조건을 만족하는 코드는 아래와 같다.

Empty e1; // 기본 생성자, 소멸자
Empty e2(e1); // 복사 생성자
e2 = e1; // 복사 대입 연산자

위의 코드를 통해 컴파일러가 함수를 만들어 준다. 

Item 6 : 컴파일러가 만들어낸 함수가 필요 없으면 확실히 이들의 사용을 금해버리자

하나의 상황을 예시로 들어보자. 부동산 중개업자의 주문으로 부동산 중개업 지원용 SW를 만들어야 한다. 여기서 주의할 점은 모든 자산은 세상에 하나밖에 없다는 것이다. 따라서 우리가 부동산 객체인 HomeForSale의 사본을 만드는 것 자체가 이치에 맞지 않다. 

HomeForSale h1, h2;
HomeForSale h3(h1); // h1 복사, 컴파일 되면 안됨
h1 = h2; // h2 복사, 컴파일 되면 안됨

앞서 말한 Item 5를 생각하면 복사 생성자와 복사 대입 연산자는 선언하지 않고 외부에서 호출하면 컴파일러가 우리를 대신해 선언해버리므로 문제가 커진다. 우리는 이러한 복사를 막아야 한다.

해결책은 간단하게 복사 생성자와 복사 대입 연산자를 public 멤버 대신 private 멤버로 선언 및 명시하면 된다. 하지만 C++에는 friend 함수가 호출 될 수 있으므로, 이것 또한 막기 위해서는 정의를 하지 않으면 된다.

class HomeForSale {
public:
    ...
private:
    ....
    HomeForSale(const HomeForSale&); // 선언만 하면 됨
    HomeForSale& operator=(const HomeForSale&);

위의 코드처럼 선언만 진행하고 정의를 하지 않으면 이를 사용할 때 링크 시점에서 에러를 보게 된다. 이제 에러 시점을 링크에서 컴파일로 옮길 수도 있다. 복사 생성자와 복사 대입 연산자를 private로 선언하되 이를 HomeForSale 자체에 넣지 말고 별도의 기본 클래스에 넣고 이것으로부터 HomeForSale을 파생시키는 것이다. 

class Uncopyable {
protected:
    Uncopyable() {} // 생성과 소멸 허용
    ~Uncopyable() {}
private:
    Uncopyable(const Uncopyable&); // 복사는 방지
    Uncopyable& operator=(const Uncopyable&);
}

여기서 복사를 막고싶은 HomeForSal 객체는 아래와 같이 바꾸면 된다. 

class HomeForSale: privateuncopyable {
 ... // 복사 생성자, 복사 대입 연산자 모두 선언 X
 };

Item 7 : 다형성을 가진 기본 클래스에서는 소멸자를 반드시 가상 소멸자로 선언하자

시간 기록을 유지하는 방법은 다양하다. TimeKeeper의 이름을 가진 클래스를 기본 클래스로 생성한 후 적절한 용도에 따라 파생시키도록 설계하면 알맞다.

class TimeKeeper {
public:
    TimeKeeper();
    ~TimeKeeper();
    ...
};
class AtomicClock: public TimeKeeper { .. };
class WaterClock: public TimeKeeper { .. };
class WristWatch: public TimeKeeper { .. };

어떤 시간 기록 객체에 대한 포인터를 손에 넣는 용도로 팩토리 함수(새로 생성된 파생 클래스 객체에 대한 기본 클래스 포인터를 반환하는 함수)를 만들어두면 좋다.

TimeKeeper* getTimeKeeper(); // TimeKeeper에서 파생된 클래스를 통해 동적으로 할당된 객체 포인터 반환

팩토리 함수 기존 규약을 그대로 따라갈 시 getTimeKeeper 함수에서 반환되는 객체는 힙에 존재하므로 메모리 및 기타 자원 누출을 막기위해 해당 객체를 삭제해야 한다. 

TimeKeeper *ptk = getTimeKeeper(); // TimeKeeper 클래스로부터 동적으로 할당된 객체 터득
... // 객체 사용
delete ptk; // 자원 누출을 막기 위해 해제

여기서 getTimeKeeper 함수가 반환하는 포인터는 파생 클래스(AtomicClock) 객체에 대한 포인터라는 점과 이 포인터가 가리키는 객체가 삭제될 때 기본 클래스 포인터(Timekeeper *)를 통해 삭제된다. 결정적으로 기본 클래스(TimeKeeper)에 들어있는 소멸자가 비가상 소멸자라는 점에서 문제가 된다. 

getTimeKeeper에서 포인터를 통해 날아오는 AtomicClock 객체는 기본 클래스 포인터를 통해 삭제될 때 AtomicClock 부분의 소멸자가 실행되지 않는다. 하지만 기본 클래스(TimeKeeper)는 소멸 과정이 제대로 끝나므로 부분 소멸 객체가 되어버린다. 이는 간단하게 가상 소멸자로 변경하면 해결된다.

class TimeKeeper {
public:
    TimeKeeper();
    virtual ~TimeKeeper();
    ...
};

TimeKeeper *ptk = getTimeKeeper();
...
delete ptk; // 이제 제대로 동작

가상 함수를 하나 지니게 되면 가상 소멸자는 반드시 가져야 한다. 하지만 기본 클래스로 의도하지 않은 클래스에 대해 소멸자를 가상으로 선언하는 것은 좋은 것이 아니다. 예시로 2차원 공간에 잇는 한 점을 나타내는 클래스를 들어보자.

class Point {
public:
    Point(int xCoord, int yCoord);
    ~Point();
    
private:
    int x, y;
};

int가 32bit를 차지한다고 가정하면, Point 객체는 64bit 레지스터에 딱 맞게 들어간다. C나 포트란(FORTRAN) 등 다른 언어로 작성된 함수에 넘길일이 생길때도 64bit 크기 자료로 넘어간다. 하지만 Point 클래스 소멸자가 가상 소멸자로 만들어지는 순간 달라진다.

가상함수를 구현할 때 프로그램 실행 중 주어진 객체에 대해 어떤 가상함수를 호출해야하는지 결정하는데 쓰이는 정보가 생성된다. 실제로는 포인터의 형태를 취하는 것이 대부분이며, 보통 vptr(가상 함수 테이블 포인터)이라는 이름으로 불린다. vptr은 가상 함수의 주소, 즉 포인터들의 배열을 가리키고 있으며 가상 함수 테이블 포인터의 배열은 vtbl(가상 함수 테이블)이라고 불린다. 가상 함수를 하나라도 지니고 있는 클래스는 반드시 vtbl을 가지고 있다. 어떤 객체에 대해 가상 함수가 호출될 시, 호출되는 실제 함수는 그 객체의 vptr이 가리키는 vtbl에 따라 결정된다. 

이제 여기서 Point 클래스에 가상 함수가 들어가게 되면 Point 타입 객체의 크기가 커진다. 실행 환경이 32bit라면 크기가 64bit에서 96bit(int 두개, vptr 하나)로 커진다. 실행 환경이 64bit 아키텍처라면 64bit에서 128bit로 커질 수 있다. 포인터의 크기가 64bit이기 때문이다. 이렇게 되어버리면 다른 언어로 선언된 동일한 자료구조와 호환성이 사라진다. 다른언어로 Point와 동일한 데이터 배치를 써서 선언했어도, vptr은 만들수 없기 때문이다. 

소멸자를 가상 소멸자로 선언하는 일은 가상 함수가 들어 있을 때 하는 것이 좋다. 하지만 간혹 가상 함수가 없는데도 비가상 소멸자로 인해 문제가 생긴 경우가 존재한다. 아래의 코드를 확인해보자.

class SpecialString: public std::string{ // std::string은 가상 소멸자가 없음
    ...
};

위의 코드를 이용한 응용 프로그램 어딘가에서 SpecialString의 포인터를 string 포인터로 변환 후 그 string 포인터에 delete를 적용하면 미정의 동작이 되어버린다. 

Special String *pss = new SpecialString("Impending Doom");
std::string *ps;
...
ps = pss; // SpecialString* -> std::string*
...
delete ps; // 정의되지 않은 동작 발생
           // 실질적으로 *ps의 SpecialString 부분에 있는 자원이 누출
           // Why? SpecialString의 소멸자가 호출되지 않으므로

이 현상은 가상 소멸자가 없는 클래스면 어떤 것이든 전부 적용된다. 가상 소멸자가 없는 클래스의 예로는 STL 컨테이너(vector, list, set etc) 타입으로 확인할 수 있다. 

경우에 따라서 순수 가상 소멸자를 두면 편하게 쓸 수 있다. 순수 가상 함수는 해당 클래스를 추상 클래스(그 자체로는 인스턴스를 생성할 수 없는 클래스)로 만든다. 하지만 어떤 클래스가 마땅히 넣을만한 순수 가상 함수가 없을때도 종종 생기기 마련이다. 이럴 땐 추상 클래스로 만들고싶은 클래스에 순수 가상 소멸자를 선언하면 된다. 

class AWOV { // AWOV = "Abstract w/o Virtuals"
public:
    virtual ~AWOV() = 0; // 순수 가상 소멸자 선언
};

AWOV는 순수 가상 함수를 갖고 있으므로 추상 클래스이다. 이 동시에 순수 가상 함수가 가상 소멸자이므로 앞에서 말한 소멸자 호출 문제로 고민할 필요가 없다. 하지만 이 순수 가상 소멸자의 정의를 두지 않으면 안된다.

AWOV::~AWOV() {} // 순수 가상 소멸자 정의

소멸자의 동작 순서는 아래와 같다.

 1. 상속 계통 구조에서 가장 말단에 있는 파생 클래스의 소멸자 먼저 호출

 2. 기본 클래스로 거쳐 올라가며 각 기본 클래스의 소멸자가 하나씩 호출

컴파일러는 ~AWOV 호출 코드를 생성하기 위해 파생 클래스의 소멸자를 사용할 것이므로 함수의 본문을 준비해두어야 한다. 준비를 못할  시 링크 에러와 마주하게 된다. 

 

기본 클래스의 손에 가상 소멸자를 쥐어주는 규칙은 다형성(polymorphic)을 가진 기본클래스, 즉 기본 클래스 인터페이스를 통해 파생 클래스 타입의 조작을 허용하도록 설계된 기본 클래스에만 적용된다. 기본 클래스로는 쓰일 수 있지만 다형성은 갖지않도록 설계된 클래스도 존재하는데, 이런 클래스는 기본 클래스의 인터페이스를 통한 파생 클래스 객체의 조작이 허용되지 않는다. 

 

★ 다형성을 가진 기본클래스에는 반드시 가상 소멸자를 선언해야 한다. 즉, 어떤 클래스가 가상 함수를 하나라도 지닐 시 이 클래스의 소멸자도 가상 소멸자여야 한다.

★ 기본 클래스로 설계되지 않았거나 다형성을 갖도록 설계되지 않은 클래스에는 가상 소멸자를 선언하지 말아야 한다.

Item 8 : 예외가 소멸자를 떠나지 못하도록 붙들어 놓자

소멸자로부터 예외가 나가는 경우를 C++에서 막진 않지만, 우리가 막을 수 밖에 없다. 아래의 예시를 보자

class Widget {
public:
    ...
    ~Widget() { ... } // 이 함수로부터 예외 발생된다고 가정
};

void doSomething() {
    std::vector<Widget> v;
    ...
} // v는 여기서 자동 소멸

v가 소멸될 때 본인이 거느리고 있는 Widget들 전부 소멸시킬 책임은 이 벡터에 있다. v에 들어있는 Widget이 열 개 일때, 처음 것을 소멸시키는 도중 예외가 발생되었다고 가정하자. 나머지 아홉개는 소멸되어야 하므로 이들에 대해 소멸자를 호출한다. 그러나 이 과정에 또 문제가 터졌다고 가정한다. 두 번째 Widget에 대해 호출된 소멸자에서 예외가 터지면 어떻게 되나? 현재 활덩화된 예외가 두 개나 동시에 만들어진 상태이며, C++ 입장에서는 감당하기 버겁다.

이 두 예외가 동시에 발생한 조건이 어떤 미묘한 조건이냐에 따라 프로그램이 종료되든지, 정의되지 않은 동작을 보이게 된다. 이 경우에는 프로그램이 정의되지 않은 동작을 보일 것이다. 벡터뿐만 아니라 다른 STL라던지, TR1의 컨테이너를 사용하더라도 결과는 마찬가지이며, 배열을 써도 동일하다. 이의 원인은 바로 예외가 터져 나오는 것을 내버려두는 소멸자에게 원인이 존재한다. 위의 예시에 이어서 여기서 이제 데이터베이스 연결을 나타내는 클래스를 쓰고있다고 가정하자.

class DBConnection {
public:
    ...
    static DBConnection create(); // DBConnection 객체를 반환하는 함수, 매개변수는 편의상 생략
    void close(); // 연결을 닫음. 이때 연결이 실패하면 예외 발생
};

사용자가 DBConnection객체에 대해 close를 직접 호출해야하는 상황이다. 사용자의 망각을 사전에 차단하는 좋은 방법이라면 DBConnection에 대한 자원 관리 클래스를 만들어 그 클래스의 소멸자에서 close를 호출하게 만드는 것이다. 자원 관리 클래스의 소멸자가 어떤 형태인지 살펴보자.

class DBConn { // DBConnection 객체를 관리하는 클래스
public:
    ...
    ~DBConn() { db.close(); } // DB 연결이 항상 닫히도록 확실히 챙겨주는 함수
private:
    DBConnection db;
}

이러한 배려 덕에 다음과같은 프로그래밍이 가능하다.

{ // 블록 시작
    DBConn dbc(DBConnection::create()); // DBConnection 객체 생성 및 DBConn로 넘겨 관리를 맡김
    ... // DBConn 인터페이스를 통해 그 DBConnection 객체 사용
} // 블록끝. DBConn 객체 여기서 소멸. 따라서 DBConnection 객체에 대한 close 함수 호출 자동으로 실행

close 호출만 성공하면 문제될 것이 없다. 그러나 close를 호출했는데 예외가 발생하면, DBConn 소멸자는 분명 이 예외를 전파한다. 쉽게 말해 그 소멸자에서 예외가 나가도록 내버려 둔다는 문제점이다. 이를 피하는 방법은 두 가지가 존재한다.

 

 1. close에서 예외 발생시 프로그램 종료(대개 abort 호출)

DBConn::~DBConn() {
    try { db.close(); }
    catch (...) {
        close 호출이 실패했다는 로그를 작성합니다;
        std::abort();
    }
}

객체 소멸이 진행되다가 에러 발생 후 프로그램 실행을 계속할 수 없는 상황일 시 선택하면 된다. 

 

 2. close를 호출한 곳에서 일어난 예외를 삼킴

DBConn::~DBConn() {
    try { db.close(); }
    catch (...) {
        close 호출이 실패했다는 로그를 작성합니다;
    }
}

이 방법은 무엇이 잘못됐는지 알려주는 중요한 정보가 묻혀버리므로 좋은 선택은 아니다. 하지만 때에 따라 불완전한 프로그램 종료, 미정의 동작으로 인해 입는 위험을 감수하는 것보다 이 방법이 나을 수도 있다. 이 방법이 제대로 빛을 보려면 예외를 그냥 무시한 후에도 프로그램이 신뢰성 있게 실행을 지속할 수 있어야 한다. 

 

아무래도 둘 다 문제점이 존재하므로 특별히 좋은 건 없다. 더 좋은 전략을 생각해보다. DBConn 인터페이스를 잘 설계해서, 발생할 소지가 있는 문제에 대처할 기회를 사용자가 가질 수 있도록 하면 어떨까? DBConn에서 close 함수를 직접 제공하게 되면 이 함수의 실행중 발생하는 예외를 사용자가 직접 처리할 수 있다. DBConnection이 닫혔는지 여부를 유지했다가, 닫히지 않았으면 DBConn의 소멸자에서 닫을 수 있을 것이다. 이렇게 하면 DB 연결 또한 누출되지 앟는다. 하지만 소멸자에서 호출하는 close마저 실패하면 위의 두 방법으로 돌아갈 수 밖에 없다.

class DBConn {
public:
    ...
    void close() { // 사용자 호출을 배려해서 새로만든 함수
        db.close();
        closed = true;
    }
    
    ~DBConn() {
        if (!closed) {
            try { db.close(); } // 사용자가 연결을 안닫았으면 여기서 닫는다.
            catch (...) { close 호출이 실패했다는 로그 작성;}
            // 연결을 닫다가 실패할 시 실패를 알린 후에 실행을 끝나거나 예외를 삼킴
        }
    }
private:
    DBConnection db;
    bool closed;
};

close 호출의 책임을 DBConn의 소멸자에서 DBConn의 사용자로 넘기는 일은 책임 전가로 보일 수 있다. 하지만 이는 아니며, 어떤 동작이 예외를 일으키면서 실패할 가능성이 있고 또 그 예외를 처리해야할 필요가 있다면, 그 예외는 소멸자가 아닌 다른 함수에서 비롯된 것이어야 한다는 것이 포인트이다.

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

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