Skip to content

Latest commit

 

History

History
357 lines (256 loc) · 21.4 KB

chapter-2..md

File metadata and controls

357 lines (256 loc) · 21.4 KB
description
C++ 클래스에 있어 중요한 생성자, 소멸자 및 대입 연산자는 필수적인 요소이며 이에 대한 실수는 큰 영향을 미칠 수 있습니다. 따라서 이에 대한 깊은 이해가 필요합니다.

Chapter 2

Item 5: Know what functions C++ silently writes and calls

컴파일러는 C++의 클래스의 기본 생성자, 복사 생성자, 복사 대입 연산자, 소멸자가 선언되어 있지 않으면, 암시적으로 기본형을 생성합니다. 이들이 사용될 때, 즉 필요하다고 판단될 때 이를 생성합니다.

  • 소멸자는 해당 클래스가 상속한 기본 클래스의 소멸자가 가상 소멸자로 되어 있지않다면 비가상 소멸자로 만들어집니다.
  • 복사 생성자와 복사 대입자의 경우는 비정적 데이터를 사본 객체 쪽으로 복사하여 줍니다. 이때 최종 결과 코드가 legal하고 reasonable해야만 합니다.
  • 만약, 클래스의 데이터 중 일부가 참조자로 구성되어 있다면, 어떻게 해야할지 애매해집니다. 참조되는 객체를 복사하기에는 클래스 외의 부분에 영향을 주게 되고, 참조하는 객체를 참조하는 것은 참조자의 이치에 맞지 않습니다. 따라서 C++은 컴파일을 거부하고, 사용자가 이에 대한 정의를 하도록 합니다. 이는 데이터 멤버가 상수 객체인 경우도 동일합니다.
  • 복사 대입 연산자를 private로 선언한 클래스로부터 파생된 경우도 복사 대입 연산자를 가질 수 없습니다.

즉, 기본 함수의 경우 컴파일러가 대신 생성하는 경우가 있으므로, 코드로 확인하지 않고 넘어간다면 추후에 버그의 원인이 될 가능성이 높다. 따라서 잘 확인하자.

이것만은 잊지 말자!

  • 컴파일러는 경우에 따라 클래스의 기본 생성자, 복사 생성자, 복사 대입 연산자, 소멸자를 생성할 수 있습니다.

Item 6: Explicitly disallow the use of compiler-generated functions you do not want

복사 생성자와 복사 대입 연산자가 정의되어있지 않는 경우 컴파일러가 이를 생성해버리므로, 컴파일러가 만들어낸 함수가 필요없다면, 확실하게 막아 두는것이 좋다.

  • 함수를 정의하지 않는 방법은 불가능하므로, 함수를 public이 아닌 private으로 정의하여 이를 막아두자.
  • 하지만 멤버 함수 및 friend 함수가 호출하는 것도 가능하다.
  • 이를 막기 위해서는 선언만 하되 정의(define)를 하지 않으면 된다. 만약 사용되더라도 링커 타임에서 에러를 발생시키게 된다.
  • Link Error : C/C++ 컴파일러는 소스 파일 하나를 독립적으로 ‘컴파일’한다. 그런데 컴파일할 때는 함수 원형 선언만 있고 구현이 없어도 문제 없이 컴파일을 할 수 있다. 단순히 함수 원형의 리턴 및 파라미터 타입만 맞으면 아무런 문제 없이 컴파일은 진행된다. 이렇게 개별 파일의 컴파일 작업이 끝나면 최종 실행 파일을 만들기 위해 모으는 작업, 즉 ‘링킹’을 한다. 이 때 하는 일이 ‘심볼’들을 찾아 연결하는 작업이다. 심볼이라는 단어는 컴파일러의 관점에서 나온 단어다. 컴파일러는 함수 및 변수 같은 것을 ‘심볼 테이블’로 관리하기 때문에 나온 말이다. 링크 과정에서는 각각의 소스 파일이 사용한 ‘심볼’을 해결해야 한다. 쉽게 말하면 사용하고 있는 함수의 실제 몸통을 어디선가 찾아야만 한다. 만약 이 몸통을 못 찾거나 두 개 이상 찾으면 링킹은 실패한다.
  • 링크 에러를 컴파일 시점 에러로 옮길 수 있는데, 이를 복사 생성자와 대입자를 private으로 하는 별도의 기본 클래스를 만들고, 이를 상속받는 방법으로 해결 가능합니다. 이때 상속받는 클래스에서 따로 복사 생성자와 대입자를 정의를 해주면 안됨.
  • Uncopyable의 구현에 있어, 상속을 public으로 할 필요가 없고(항목 32, 39) virtual 소멸자가 아니어도 됩니다(항목 7). 또한 Boost 라이브러리에 noncopyable이라는 비슷한 클래스가 존재합니다.
class Uncopyable {
protected:                                // 파생된 객체에 대해서
    Uncopyable(void) {}                    // 생성과 소멸을
    ~Uncopyable(void) {}                // 허용합니다.
    
private:
    Uncopyable(const Uncopyable&);        // 하지만 복사는 방지합니다.
    Uncopyable& operator=(const Uncopyable&);
};
 
class HomeForSale: private Uncopyable {    // 복사 생성자도,
    ...                                    // 복사 대입 연산자도
};                                        // 이제는 선언되지 않습니다.

이것만은 잊지 말자!

  • 컴파일러에서 자동으로 제공하는 기능을 무시하려면, 미리 해당 함수를 private로 선언한 후 구현을 하지 않거나, 이를 구현한 클래스의 상속을 받으면 됩니다.

Item 7: Declare destructors virtual in polymorphic base classes

생성된 파생 클래스 객체에 대한 기본 클래스 포인터를 반환하는 함수팩토리 함수라 부르며, 이는 파생 클래스의 객체를 동적 할당하기에 메모리를 적절히 삭제해야한다. 이때 기본 클래스의 소멸자가 가상이 아닌 경우, 파생 클래스의 객체를 기본 클래스의 포인터에 담아 소멸(delete)시킬때, 기본 클래스의 소멸자만 호출되고 파생 클래스의 소멸자는 호출되지 않아 자원이 누출될 수 있습니다. 따라서 다형성을 가진 기본 클래스에는 반드시 가상 소멸자를 선언해야 합니다. 즉, 어떤 클래스가 다형성을 갖도록 설계된 클래스 혹은 가상 함수를 하나라도 갖고 있으면, 이 클래스의 소멸자도 가상 소멸자이어야 합니다.

  • 기본 클래스로 설계되지 않았거나 다형성을 갖도록 설계되지 않은 클래스, 혹은 클래스에 가상 함수가 하나라도 들어 있지 않은 경우에는 가상 소멸자를 선언하지 말아야 합니다.
  • 가상소멸자를 선언해준 경우, 해당함수를 호출하기위해서 Vptr이라는 녀석이 필요한데 이녀석도 포인터이기에 32비트 시스템에서 4바이트를 차지한다. 따라서 다른언어로 만들어진 소스의 해당 객체를 넘길시 vptr도 같이 넘어가기 때문에 가상소멸자가 없는 경우 기본 클래스로서의 이식성은 포기해야한다
  • STL 컨테이너 타입은 전부가 가상소멸자가 없는 클래스에 해당하므로, 이를 상속하지 말아야한다.
  • 기본 클래스의 가상 소멸자로 인한 파생 클래스의 소멸 시점
    1. 기본 클래스의 가상 소멸자 호출
    2. 1번으로 부터 파생 클래스의 소멸자 호출
    3. 2번으로 부터 기본 클래스의 소멸자 호출
  • 순수 가상 소멸자의 호출 매카니즘
    1. 파생 클래스의 소멸자 호출
    2. 컴파일러가 강제로 순수 가상 소멸자 호출(vtbl에는 순수 가상 함수 0으로 초기화 되어 있기 때문이다!)

순수 가상 함수

C++에서 순수 가상 함수의 기계적 의미는 해당 추상 클래스의 vtbl에서 그 순수 가상 함수의 포인터가 0(=null)로 들어 있다는 것입니다. 즉, AWOV 클래스가 이렇게 되어 있다면,

class AWOV {
	public:
		virtual ~AWOV()=0;
		virtual foo();
};

이 클래스의 vtbl은 다음과 같이 나옵니다.

vptr -> [ 0                   ]   // 첫째 : AWOV::~AWOV()의 주소
[ &(AWOV::foo)]   // 둘째 : AWOV::foo()의 주소

하지만, 가상 소멸자의 호출 매커니즘은 파생 클래스의 소멸자가 기본 클래스의 그것을 직접 호출하도록 (컴파일러에 의해) 만들어집니다. vtbl을 통하지 않는다는 것이죠. 따라서 AWOV::~AWOV의 본문이 정의되어 있지 않으면 링크 에러를 내게 됩니다. 이것은 "이렇게 해도 컴파일이 되네" 수준이 아니라 "꼭 이렇게 해야 합니다"라는 의무 조항입니다.

이것만은 잊지 말자!

  • 다형성을 지닌 기본 클래스는 반드시 가상 소멸자를 선언해야하며, 어떤 클래스가 가상 함수를 지니고 있다면, 해당 클래스의 소멸자도 가상 소멸자이어야합니다.
  • 기본 클래스가 아니거나 다형성을 갖지 않는 경우 가상 소멸자를 선언하지 말야아 합니다.

Item 8: Prevent exceptions from leaving destructors

소멸자에서는 예외가 빠져나가면 안 됩니다. 이는 메모리 leak 혹은 프로그램 강제 종료를 일으킬 수 있는 여지를 남기게 됩니다. 만약 소멸자 안에서 호출된 함수가 예외를 던질 가능성이 있다면, 어떤 예외든지 소멸자에서 모두 받아낸 후에 삼켜 버리든지 프로그램을 끝내든지 해야 합니다.

발생한 예외를 처리하기 위해 스택 되감기를 하는 도중, (소멸자에서)또 다른 예외가 발생하면 상황에 따라 프로그램이 종료되던지, 정의되지 않은 동작을 보이게 됩니다. 따라서 소멸자에서 예외가 빠져나가도록 두면 안됩니다. 이에 대한 해결 방법으로는 3가지가 존재합니다.

해결 방법 1.프로그램 종료

// close에서 예외가 발생하면 프로그램을
// 바로 끝냅니다. 대개 abort를 호출합니다.
DBConn::~DBConn(void)
{
    try { db.close(); }
    catch (...) {
        //close 호출이 실패했다는 로그를 작성합니다;
        std::abort();
    }
}

해결 방법 2. 예외 삼키기

// 에러가 발생한 후에 프로그램을 실행을 계속할 수 없는 상황이라면 꽤 괜찮은 선택입니다.
// close를 호출한 곳에서 일어난 예외를 삼켜 버립니다.
DBConn::~DBConn(void)
{
    try { db.close(); }
    catch (...) {
        close 호출이 실패했다는 로그를 작성합니다;
    }
}

대부분의 경우에서 예외 삼키기는 그리 좋은 발상이 아닙니다. 무엇이 잘못됐는지를 알려 주는 정보가 묻혀 버리기 때문입니다. 예외 삼키기를 선택한 것이 제대로 빛을 보려면, 발생한 예외를 그냥 무시한 뒤라도 프로그램이 신뢰성있게 실행을 지속할 수 있어야합니다.

해결방법3. 문제 대처기회를 사용자에게 선 제공하자.

어떤 클래스의 연산이 진행되다가 던진 예외에 대해 사용자가 반응해야 할 필요가 있다면, 해당 연산을 제공하는 함수는 반드시 소멸자가 아닌 보통의 함수이어야 합니다.

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

close 호출의 책임을 DBConn의 소멸자에서 DBConn의 사용자로 떠넘기는 방법입니다. 사용자는 close가 발생한 예외를 처리할 필요가 있다면 DBConn::close 함수를 try 블록 내에서 호출하여 예외 처리를 할 수 있습니다. 반면 예외 처리를 할 필요가 없다면 DBConn의 소멸자가 예외를 삼키거나, 프로그램을 종료시키도록 두면 됩니다.

이것만은 잊지 말자!

  • 소멸자에서 예외를 묶어두자. 방법은 삼키던가 프로그램 강제종료시키던가
  • 예외가 발생하는 함수는 보통의 함수여야 한다.(소멸자가 아닌 함수)

Item 9: Never call virtual functions during construction or destruction

객체의 생성과 소멸과정 중 가상 함수가 날라간 상태라면 생성자 혹은 소멸자 안에서 가상 함수를 호출하면 안된다. 기본 클래스의 생성자가 호추로디는 동안, 현재 생성자나 소멸자가 호출되는 동안, 클래스의 파생 클래스 쪽으로는 내려가지 않는다. 즉, 기본 클래스 생성이 된 후에 파생 클래스가 생성되는데, 이 사이에 오버로딩한 가상함수를 호출하면 미정의 동작이 일어난다.

좀 더 근본적인 이야기를 하면, 파생 클래스 객체의 기본 클래스 부분이 생성되는 동안은 그 객체의 타입이 바로 기본 클래스가 됩니다.

상속 관계에 있는 객체의 생성자와 소멸자 호출 시점

  1. 생성자 호출 시점
  • 첫째, 베이스 생성자 호출 후 베이스 생성자의 멤버 객체들 초기화
  • 둘째, 파생 생성자 호출 후 파생 생성자의 멤버 객체들 초기화
  1. 소멸자 호출 시점
  • 첫째, 파생 소멸자 호출 후 파생 생성자 멤버 객체 소멸
  • 둘째, 베이스 생성자 호출 후 베이스 생성자 멤버 객체들 초기화

이를 어떻게 해결할 수 있을 것인가? logTransaction을 Transaction의 비가상 멤버함수로 변경한다. 이후 파생클래스의 생성자들로 하여금 필요한 정보를 기본클래스인 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); // 밑에서 내용확인해보자. 
};

createLogString 정적함수를 살펴보자. 이 함수는 기본클래스 생성자쪽으로 데이터를 넘기는 도우미 함수역할을 한다. 예제코드를 보면 생성이 채끝나지 않은 BuyTransaction객체의 미초기화된 데이터 멤버를 건드릴 위험도 없다. 왜냐면 정적함수는 정적멤버변수만 사용하기때문이다.

이것만은 잊지 말자!

  • 생성자 or 소멸자에서 가상함수를 호출하지 말자. 가상함수라고 해도, 실행중인 생성자 or 소멸자에 해당하는 클래스로 내려가진 않으니...

Item 10: Have assignment operators return a reference to *this

대입 연산자는 *this의 참조자를 반환하도록 만들면, 연계적으로 대입이 가능토록 한다.

int x, y, z;
x = y = z = 15; //우측 연관(right-associative) 연산
x = (y = (z = 15)); // 다음과 같이 분석됨.
  • 이것은 단순 대입형 연산자 뿐만 아니라, 모든 형태의 대입 연산자(예를 들어 +=)에서 지켜져야 합니다.
  • 이러한 관례를 따르지 않아도 컴파일은 되지만, 관례를 따르는 편이 여러 모로 좋습니다.
  • 헷갈린다면, int(기본 제공 타입)의 작동 원리대로 만드세요.

이것만은 잊지 말자!

  • 대입 연산자는 *this 참조자를 반환하도록 만드세요.

Item 11: Handle assignment to self in operator=

자기 대입, operator=을 구현할 때 어떤 객체가 그 자신에 대입되는 경우를 제대로 처리하도록 만듭시다. 원본 객체와 복사대상 객체의 주소를 비교해도 되고, 문장의 순서를 적절히 조정할 수도 있으며, 복사 후 맞바꾸기 기법을 써도 됩니다.

자기 대입이 위험한 이유는, 여러 곳에 하나의 객체를 참조하는 상태, 중복 참조(aliasing)으로 인해, 자체적으로 제거 될 수가 있기 때문이다.

문제 코드

class Bitmap {...};

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

// 불안정한 '=' 연산자 구현코드
// case1. 만약 rhs와 this가 같다면 pb가 사라져버린다.
Widget& Widget::operator=(const Widget& rhs) {
	delete pb;
	pb = new Bitmap(*rhs.pb);
	return *this;
}

// case2.만약 new에서 예외가 발생한다면, pb는 삭제된 Bitmap을 가리킨채 남게 된다.
Widget& Widget::operator=(const Widget& rhs) {
	if (this == &rhs) return *this; // 일치성 검사
	delete pb;
	pb = new Bitmap(*rhs.pb);
	return *this;
}

해결 코드

// case1. 문장 순서를 세심하게 바꾸는 것만으로 예외에 안전한 코드 작성
Widget& Widget::operator=(const Widget& rhs) {
	if (this == &rhs) return *this; // 일치성 검사
	delete pb;
	pb = new Bitmap(*rhs.pb);
	return *this;
}

------------------------------------------------
// case2. 복사 후 맞바꾸기(copy and swap) 기법

class Widget {
    ...
    void swap(Widget &rhs);    // *this의 데이터 및 rhs이 데이터를 맞바꿉니다.
    ...
};
 
Widget& Widget::operator=(const Widget& rhs)
{
    Widget temp(rhs);        // rhs의 데이터에 대해 사본을 하나 만듭니다.
    swap(temp);                // *this의 데이터를 그 사본의 것과 맞바꿉니다.
    return *this;
}
 
Widget& Widget::operator=(Widget rhs)    // rhs는 넘어온 원래 객체의 사본입니다.
{                                        // (값에 의한 전달)
    swap(rhs);                            // *this의 데이터를 이 사본의 데이터와 맞바꿉니다.
    return *this;
}

이것만은 잊지 말자!

  • operator=을 구현할 때, 자기 대입에 대비하여, 주소 비교, 문장 순서 조절 및 copy and swap을 통해 해결할 수 있다.
  • 두 개 이상의 객체에 대해 동작하는 함수가 있다면, 이 함수에 넘겨지는 객체들이 사실 같은 객체 인 경우에 정확히 동작하는 지 확인해보자.

Item 12: Copy all parts of an object

복사 함수

복사 생성자와 복사 대입 연산자를 통 틀어 객체 복사 함수라 한다. 항목5에서 확인할 수 있듯이, 객체 복사 함수는 컴파일러에 의해 자동생성된다. 따라서 복사함수를 정의한다는 것은 기본 복사함수가 맘에 안 든다는 뜻이다. 컴파일러는 이러한 사용자의 행동이 틀려도(구현이 엉망이어도) 입을 닫는다.

복사 함수가 모든 부분을 복사하는 것이 아닌 일부를 유실하거나 틀리게 하면 부분 복사가 됩니다. 따라서 객체 복사 함수는 주어진 객체의 모든 데이터 멤버 및 모든 기본 클래스 부분을 빠뜨리지 말고 복사해야 합니다. 클래스의 복사 함수 두 개(복사 생성자, 복사 대입 연산자)를 구현할 때, 한쪽을 이용해서 다른 쪽을 구현하려는 시도는 절대로 하지 마세요. 그 대신, 공통된 동작을 제3의 함수에다 분리해 놓고 양쪽에서 이것을 호출하게 만들어서 해결합시다.

주의할 점

  1. 기존의 클래스에서 멤버를 추가하려 할때, 복사생성자, 대입 연산자 들을 수정해야 한다.
  2. 포인터 멤버 변수일때, 깊은 복사를 해야 하는지 얕은 복사만을 해야 하는지 염두해두어야 한다.

3. 파생 클래스에서 베이스 클래스의 복사생성자와 파생 클래스의 대입연산자도 수정해야 한다.

(파생 클래스에서 베이스 클래스의 복사 생성자와 대입연산자를 호출해 주지 않기 때문이다.)

3번, 파생 클래스에서 어떻게 베이스 클래스의 멤버 변수까지 복사 하는가?

파생 클래스의 복사 생성자의 초기화 리스트에 베이스 클래스의 복사 생성자를 호출하고, 파생 클래스의 대입 연산자의 내부에서는 베이스클래스의 대입 연산자를 호출 하면 된다!

class PriorityCustomer : public Customer {
	public:PriorityCustomer(const PriorityCustomer& rhs);
		PriorityCustomer& operator=(const PriorityCustomer& rhs);
	private:
		int priority;
};

PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
: Customer(rhs), priority(rhs.priority) { … }  // 파생클래스에서 기본클래스의 복사생성자를 호출한다.

PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs) {
	Customer::operator=(rhs); // 기본클래스 부분을 대입합니다.
	priority = rhs.priority; // 파생클래스의 멤버변수를 대입합니다.
	return *this;
}

상속을 구현할때 해당클래스의 데이터 멤버 변수 뿐만 아니라, 기본클래스의 복사함수도 꼬박꼬박호출합시다. 복사 생성자와 대입연산자는 const CBabo& rhs 식의 참조자 매개변수를 띠기 때문에, 슬라이스(Slice) 문제가 있어, 파생 클래스의 복사 생성자와 대입 연산자는 정의 부분에 파생 클래스 부분만을 복사하는 것을 꼭 넣어야 한다.

이것만은 잊지 말자!

  • 객체 복사 함수는 객체의 모든것을 복사 해야 한다!
  • 대입 연산자에서 복사 생성자를 호출하는 방법, 복사 생성자에서 대입 연산자를 호출하는 방법은 쓰지말자!