◆ 무한한 가능성/& Visual C/C++

[요약] Effective C++ 2장-1 (항목05~08)

치로로 2009. 12. 7. 17:12
[요약] Effective C++ 2장-1 (항목05~08)



2장 생성자, 소멸자 및 대입 연산자 - 1



항목 05: C++이 은근슬쩍 만들어 호출해 버리는 함수들에 촉각을 세우자



빈 클래스(empty class)를 프로그래머가 선언할때에
자동적으로 컴파일러는 기본생성자(default constructor), 복사생성자(copy constructor),
복사대입연산자(copy assignment operator), 그리고 소멸자(destructor)를 선언한다.

1: class Empty{};

만약 여러분이 위와 같이 썼다면,

1: class Empty{
2: public:
3: Empty() { ... } // 기본 생성자
4: Empty(const Empty &rhs) { ... } // 복사 생성자
5: ~Empty() { ... } // 소멸자
6:
7: Empty &operator=(const empty &rhs) { ... } // 복사 대입 연산자
8: };

컴파일러가 꼭 필요하다고 판단 할 때만 이렇게 위와 같이 자동적으로 만들어진다.

꼭 필요하다는 조건은...

1: Empty e1;                             // 기본 생성자, 그리고 소멸자
2: Empty e2(e1); // 복사 생성자
3: e2 = e1; // 복사 대입 연산자

위와 같다.
즉, 기본 클래스 및 비정적 데이터 멤버의 생성자와 소멸자를 호출하는 코드가 여기서 생기는 것이다.



 *이것만은 잊지 말자!

  ** 컴파일러는 경우에 따라 클래스에 대해 기본 생성자, 복사 생성자,
복사 대입 연산자, 소멸자
를 암시적으로 만들어 놓을 수 있습니다.




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


함수의 사용을 금해버리는 예제를 보도록 하자.

상황을 만들자면...
부동산 APP을 만드는데, 그 APP에서 매물로 내놓은 가옥을 하나의 클래스로 정의한다.
그리고 모든 자산은 세상에 하나 밖에 없다고 가정한다.

1: class HomeForSale { ... };

위와 같은 클래스를 정의하고
세상에 하나 밖에 없는 자산이니, 이 HomeForSale 클래스 객체에 대한
사본을 만드는 것은 이치에 맞지 않다.

1: HomeForSale h1;
2: HomeForSale h2;
3:
4: HomeForSale h3(h1); // h1을 복사하려 하나, 컴파일 되면 안됨!
5:
6: h1 = h2; // h2를 복사하려 하나, 컴파일 되면 안됨!

그러므로 앞서 말한 복사 생성자나 복사 대입 연산자를 쓸 수 없다.
컴파일러가 자동으로 만들어 주기도 하는 이것들을 쓸 수 없다는 말이다.

그렇다면 해결 방법은 무엇일까??

컴파일러가 자동적으로 생성하는 함수는 모두 public: 이 된다.
그러므로 우리는 컴파일러가 자동적으로 생성하는 함수들을 모두 private: 로 선언한다.

또 한가지 막아주어야 할 것이 있는데, 이는
friend를 이용해 private: 안의 함수들을 이용하는 것을 막아야 한다.
어떻게? 바로 함수에 '정의(define)'을 안하면 되는 것이다.

자, 예제를 보자.

1: Class HomeForSale {
2: public:
3: ...
4: private:
5: ...
6: HomeForSale(const HomeForSale&) // 선언만 달랑ㅋ
7: HomeForSale& operator=(const HomeForSale&);
8: };

만약, 이를 호출 하려 한다면 에러를 보는 것이므로 괜찮다. (어차피 호출하지 않을 것이므로)
매가변수가 없는게 걸리지만 이는 필수가 아니라 그냥 편하자고 하는 관례니 패스.
이제 이 함수를 호출하려 하면, 컴파일러가 걸러내 준다. :)

그리고 위의 소스에서 보는 것들은...
하나의 '기법'으로 굳어지기까지 했으니... 말이다.

한 가지 덧 붙이면,
링크 시점의 에러를 컴파일 시점으로 옮길 수도 있다. (이게 더 좋은 방법, 에러를 빨리 잡음)

복사생성자와 복사대입연산자를 private로 선언하되,
이것을 HomeForSale 자체에 넣지 말고 별도의 기본 클래스에 넣고
이것으로부터 HomeForSale을 파생시키는 것이다.
그리고 그 별도의 기본 클래스는 복사 방지만 맡으면 되는 것이다.

01: class Uncopyable {
02: protected: // 파생된 객체에 대해서
03: Uncopyable() {} // 생성과
04: ~Uncopyable() {} // 소멸을 허용함
05:
06: private:
07: Uncopyable(const Uncopyable&); // 하지만 복사는 방지함
08: Uncopyable& operator=(const Uncopyable&);
09: };
10:
11:
12: class HomeForSale: private Uncopyable { // 복사생성자도,
13: ... // 복사대입연산자도
14: }; // 이제 선언되지 않음

복사를 막고자 하는 HomeForSale 객체는
Uncopyable로부터 상속받게 하고 그냥 두면 끝.

마지막으로,
Uncopyable의 구현과 사용법에 대해 기술적으로 미묘한 부분 몇가지를 지적하자.
 - Uncopyable로부터의 상속은 public일 필요가 없다. (항목 32, 39)
 - Uncopyable의 소멸자는 가상 소멸자가 아니어도 된다. (항목 7)



 *이것만은 잊지 말자!

  ** 컴파일러에서 자동으로 제공하는 기능을 허용치 않으려면,
대응되는 멤버 함수를 private로 선언한 후에 구현은 하지 않은 채로 두십시오.
Uncopyable과 비슷한 기본 클래스를 쓰는 것도 한 방법입니다.




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


많은 곳에서 시간 기록에 대한 클래스를 사용 한다.
이를 기본클래스로 하는 다음의 Timekeeper 클래스를 보자.

01: class TimeKeeper {
02: public:
03: TimeKeeper();
04: ~TimeKeeper();
05: ...
06: };
07:
08: class AtomicClock: public TimeKeeper { ... };
09: class WaterClock: public TimeKeeper { ... };
10: class WristWatch: public TimeKeeper { ... };

위의 AtomicClock, WaterColck, WristWatch는 Timekeep의 파생 클래스이다.

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

*팩토리 함수
 - factory function, 새로 생성된 파생 클래스 객체에 대한 기본 클래스 포인터를 반환하는 함수

팩토리 함수인, getTimeKeeper 함수에서 반환되는 객체는 힙에 있다.
메모리/자원 누출을 막기 위해 해당 객체를 적절히 삭제(delete)해야 한다.

1: TimeKeeper *ptk=getTimeKeeper();   // TimeKeeper 클래스 계통으로 부터 동적으로 할당된 객체 받음
2:
3: ... // 이 객체를 사용
4:
5: delete ptk; // 자원 누출을 막기위해 메모리 해제(삭제)

하지만, 이렇게 삭제해주는 것은 문제가 있다.
그것은 ptk 는 삭제가 가능하겠지만, 기본 클래스(TimeKeeper) 안에 있는
소멸자가 비가상 소멸자(nono-virtual destructor)라는 점이다.
이래서는 소멸이 진행되지 않는 다는 것이다.

이는 ptk만 소멸되고, TimeKeeper 내부의 소멸자는 진행되지 않아서,
부분적 소멸(partially destroyed)이라는 결과를 가져온다

해결 방법은...

1: class TimeKeeper {
2: public:
3: TimeKeeper();
4: virtual ~TimeKeeper();
5: ...
6: };
7:
8: TimeKeeper *ptk = getTimeKeeper();
9: ...
10: delete ptk;

virtual 을 붙여서, 가상 소멸자로 선언하는 것이다.

가상 소멸자를 갖고 있지 않은 클래스를 보면, 기본 클래스로 쓰일 의지를 상실한 것이라고 보면 된다.

다르게 생각해서...
기본 클래스로 쓰지 않을 클래스에 대해 소멸자를 가상으로 선언하는 것은 좋지 않다.

정리하자면-
 - 기본클래스: 소멸자를 virtual 로 선언한다.  ex) virtual ~TimeKeeper();
 - 파생클래스: 소멸자를 그냥 선언한다.  ex) ~ElapsedTime();

* vptr[]
 : 가상 함수 테이블 포인터(virtual table pointer)

* vtbl[]
 : 가상 함수 테이블(virtual table)

어느 경우를 막론하고 소멸자를 전부 virtual 로 선언하는 일은
virtual로 절대 선언하지 않는 것 만큼이나 편찮은 마인드 이다.

가상 소멸자를 선언하는 것은 그 클래스에 가상 함수가 하나라도
들어있는 경우에만 한정하는 것이 좋다.

가상 함수가 전혀 없는데도 비가상 소멸자 때문에 뒤통수를 맞는 경우도 있다.
한 예가 표준 string 타입이다.

1: class SpecialStirng: public std::string{      // 가당찮은 아이디어! std::string은
2: ... // 가상 소멸자가 없다!!
3: };

위와 같은 클래스를 선언하고 사용하면,
알 수 없는 동작이 발생 할 수 있다.

1: SpecialStirng *pss = new SpecialString("Impending Dooom");
2: std::string *ps;
3: ...
4: ps = pss; // SpecialString* => std::string*
5: ...
6: delete ps; // 정의되지 않은 동작이 발생!!!
7:
8: // 실질적으로 *ps의 SpecialString 부분에 있는 자원이 누출되는데,
9: // 그 이유는 SpecialString의 소멸자가 호출되지 않기 때문.

이 현상은 가상 소멸자가 없는 클래스이면 어떤 것에든 전부 적용된다.

가상 소멸자가 없는 클래스는... 우리가 자주 접하기도 하는-
STL 컨테이너 타입> ex) vector, list, set, tr1::unordered_map 등 이 있다.


경우에 따라서 순수(pure) 가상 소멸자를 두면 편리하게 쓸 수 도 있다.
순수 가상 함수는 해당 클래스를 추상 클래스(abstract class) 로 만든다.

추상 클래스는 본래 기본 클래스로 쓰일 목적으로 만들어진 것이고,
기본 클래스로 쓰이려는 클래스는 가상 소멸자를 가져야 한다.
한편 순수 가상 함수가 있으면 바로 추상 클래스가 된다.

종합하면,
추상 클래스로 만들고 싶은 클래스에 순수 가상 소멸자를 선언한다는 것!!

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

AWOV 클래스는 순수 가상 함수를 가지므로, 우선 추상 클래스이다.
동시에 이 순수 가상 함수가 가상 소멸자 이므로, 앞에서 말한 소멸자 호출문제도 OK~

그런데, 예상외의 복병이 하나 있다.
이 순수 가상 소멸자의 정의를 두지 않으면 안된다는 것이다.

1: AWOV::~AWOV() {}

소멸자가 동작하는 순서는 이렇다.
상속 계통 구조에서 가장 말단에 잇는 파생 클래스의 소멸자가 먼저 호출 되는 것을 시작으로,
기본 클래스 쪽으로 거쳐 올라가면서 각 기본 클래스의 소멸자가 하나씩 호출 된다.
컴파일러는 ~AWOV의 호출 코드를 만들기 위해 파생 클래스의 소멸자를 사용할 것이므로,
잊지 말고 이 함수의 본문을 준비해 두어야 하는 것이다. 이를 잊으면 링커 에러 발생.

기본 클래스의 손에 가상 소멸자를 쥐어 주자는 규칙은 다형성(polymorphic)을 가진 기본 클래스,
그러니까 기본 클래스 인터페이스를 통해 파생 클래스 타입의 조작을 허용하도록 설계된
기본 클래스에만 적용된다는 사실을 알아야 한다.
즉 TimeKeeper 가 이에 속하는 데, AtomicClock 및 WaterClock 객체를 보면
TimeKeeper 포인터만 가지고도 이것들을 조작할 수 있다.



 *이것만은 잊지 말자!

  ** 다형성을 가진 기본 클래스에는 반드시 가상 소멸자를 선언해야 한다.
즉, 어떤 클래스가 가상 함수를 하나라도 갖고 있으면, 이 클래스의 소멸자도 가상 소멸자이어야 한다.

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




항목 08: 예외가 소멸자를 떠나지 못하도록 붙들어 놓자


소멸자로부터 예외가 터져 나가는 경우를 C++ 언어에서 막는 것은 아니지만,
실제 상황을 들춰보면 확실히 우리가 막을 수 밖에 없는 것 같다.

01: class Widget {
02: public:
03: ...
04: ~Widget() { ... } // 이 함수로부터 예외가 발생된다고 가정
05: };
06:
07: void doSommething()
08: {
09: std::vector<Widget> v;
10: ...
11: } // v는 여기서 자동으로 소멸됨

vector 타입인 벡터 v가 소멸 될 때, 자신이 거느리고 있는 Widget들 전부를 소멸시킬
책임은 바로 이 벡터에게 있다.

v에 들어 있는 Widget이 10개인데, 첫째것이 소멸도중 예외가 발생되었다고 가정하자.
나머지 9개는 여전히 소멸되야 하므로(소멸안되면 자원누출), v는 나머지에 대한 소멸자를 호출해야 한다.
근데 두번째 Widget에 대해 소멸자를 호출하는데 예외가 발생되면 C++입장에서 감당하기 힘들다.
여기서는 프로그램이 예상치 못한 작동을 하거나, 종료가 될 것이다.

이 소멸자의 예외 처리에 대해서 알아보도록 하겠다.

1: class DBConnection {
2: public:
3: ...
4: static DBConnection create(); // DBConnection 객체를 반환하는 함수.
5: // 매개변수는 생략
6: void close(); // 연결을 닫고 실패하면 예외처리.
7: };

DBConnection객체에 대해 close 를 직접 호출 해야 하는 설계이다.

1: class DBConn {              // DBConnection 객체를 관리하는 클래스
2: public:
3: ...
4: ~DBConn() // DB연결이 항상 닫히도록 챙겨주는 함수
5: {
6: db.close();
7: }
8: private:
9: DBConnection db;
10: };

이런 배려로 인하여, 다음과 같은 프로그래밍이 가능하다.

1: {                                      // 블록 시작
2: DBConn dbc(DBConnection::create()); // DBConnection 객체를 생성하고 이것을
3: // DBConn 객체로 넘겨서 관리한다.
4:
5: ... // DBConn 인터페이스를 통해
6: // 그 DBConnection 객체를 사용한다.
7:
8: } // 블록 끝, DBConn 객체가 여기서 소멸됨
9: // 따라서 DBConnection 객체에 대한
10: // close 함수 호출이 자동적으로 이루어짐

close 호출만 잘 진행되면, 아무 문제가 없는 코드이다.

하지만...
close를 호출했는데 예외가 발생한다면, DBConn의 소멸자는 분명히 이 예외를
그 소멸자(~DBConn)에서  예외가 나가도록 내버려 둘 것이다. 이는 걱정거리이다.

걱정거리를 피하는 방법은 두 가지가 있다.


* 프로그램 즉시 종료 (close에서 예외가 발생하면, abort 호출)

1: DBConn::~DBConn()
2: {
3: try { db.close(); }
4: catch (;...) {
5: close 호출이 실패했다는 로그 작성;
6: std::abort();
7: }
8: }


* 예외를 삼키기 (close를 호출한 곳에서 일어남)

1: DBConn::~DBConn()
2: {
3: try { db.close(); }
4: catch (;...) {
5: close 호출이 실패했다는 로그 작성;
6: }
7: }


두 가지 방법을 소개하였지만, 어느 쪽도 좋다고 말할 수 없다.
둘 다 문제점을 가지고 있기 때문이다.

더 좋은 방법을 생각해 보자.

DBConn 인터페이스를 잘 설계해서, 발생할 소지가 있는 문제에
대처할 기회를 사용자가 가질 수 있도록 하면 어떨까?
즉, 사용자가 예외를 직접 처리하게끔 하고, 그 후에 DBConnection이
닫혔는지의 여부를 보고 그래도 안 닫혔으면 DBConn의 소멸자에서 닫도록 하자는 것이다.
그래도 실패라면.. 즉시종료하거나 삼켜버리는 수밖에 없다.

01: class DBConn {
02: public:
03:
04: ...
05:
06: void close() // 사용자가 제어하는 함수
07: {
08: db.close();
09: closed = true;
10: }
11:
12: ~DBConn()
13: {
14: if (!closed)
15: try{ // 사용자가 연결을 안닫으면 여기서 닫기
16: db.close();
17: }
18: catch (...) {
19: close 호출실패 로그작성; // 연결 닫다가 실패하면, 실패를 알린 후
20: ... // 강제종료 또는 예외를 삼키기
21: }
22: }
23:
24: private:
25: DBConnection db;
26: bool closed;
27: };

어떤 동작이 예외를 일으키면서 실패할 가능성이 있고 또 그 예외를 처리해야 할 필요가 있다면,
그 예외는 소멸자가 아닌 다른 함수에서 비롯되어야 한다는 것이 포인트이다.

이유는 예외를 일으키는 소멸자는 시한폭탄이나 마찬가지라서 프로그램의 불완전 종료 혹은
미정의 동작의 위험을 내포하고 있기 때문이다.




 *이것만은 잊지 말자!

  ** 소멸자에서는 예외가 빠져나가면 안 된다. 만약 소멸자 안에서 호출된 함수가 예외를 던질 가능성이
있다면, 어떤 예외이든지 소멸자에서 모두 받아낸 후에 삼켜 버리든지 프로그램을 끝내든지 해야 한다.

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







※ Effective C++ 의 책을 개인적으로 요약한 것입니다.
책의 저작권 등등 각종 권한은 출판사와 지은이/옮긴이에 있음
 - 출판: Addison Wesley
 - 지음: 스콧 마이어스
 - 옮김: 곽용재