본문 바로가기
프로그래밍언어/C++

[Effective C++] 항목 5 ~ 12

by 목가 2018. 1. 23.
반응형

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

- 복사 생성자(const &...), 복사 대입 연산자(operator=), 소멸자(~)는 사용자가 선언하지 않으면 컴파일러가 기본적인 형태로 선언하게 됩니다.


- 만약 class Empty{}; 라는 공백의 클래스를 선언하면 컴파일러에서는 기본적으로 아래와 같은 형태의 기본적인 구조를 만든다고 생각하시면 됩니다.

   class Empty{

        public:

            Empty(){...}

            Empty(const Empty& rhs){...} 

            ~Empty(){...}

            Empty& operator=(const Empty& rhs){...}

    };


- 소멸자는 이 클래스가 상속한 기본 클래스의 소멸자가 가상소멸자로 되어있지 않으면 역시 비가상 소멸자로 만들어 집니다.(항목7 참고)


- 생성자가 선언되어 있으면 컴파일러에서 기본 생성자를 생성하지 않으니 이러한 걱정은 할 필요 없습니다.


**이것만은 잊지말자!**

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



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

- 복사를 막고 싶다. 기본적인 문제점 두가지가 있습니다.

   1. 복사 생성자나 복사 대입 연산자를 사용자가 생성하지 않으면 컴파일러에서 생성을 합니다.

   2. 그렇다고 복사 생성자나 복사 대입 연산자를 선언하면 복사하는것과 마찬가지 입니다.


- 따라서 위의 방법을 막기 위해 private에 선언하고 정의(구현)하지 않는 방법이 있습니다. 이 방법을 사용하게 되면 다른 곳에서 복사생성자를 사용하려하면 링크시 에러를 발생하게 됩니다.


- 링크시 에러 발생이 아닌 컴파일 시점에서 에러가 발생하도록 변경하는 방법은 다음과 같습니다.

   private에 선언하되 별도의 class에 선언하고 기존 class에 파생 시키는 방법입니다.(이 때 상속은 public일 필요가 없습니다.)

   class Uncopyable{

       protected:

           Uncopyable() {}    //생성과 소멸을 허용합니다.

           ~Uncopyable(){}


       private:

           Uncopyable(const Uncopyable&);                       //복사 방지

           Uncopyable& operator=(const Uncopyable&);


   복사를 막고싶은 HomeForSale이라는 객체는 Uncopyable로부터 상속받으면 복사를 막을 수 있습니다.

   class HomeForSale: private Uncopyable{

   };


**이것만은 잊지말자!**

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



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

- 기본 클래스 포인터를 통해 파생 클래스 객체가 삭제될 때 그 기본 클래스에 비가상 소멸자가 들어 있으면 프로그램은 미정의 동작 상황으로 빠지게 됩니다.

ex) TimeKeeper가 기본 클래스인 상황입니다.

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

      class AtomicClock:public TimeKeeper{...}; 일 때

      기본 클래스 포인터를 통한 소멸상태에 들어가게되면 AtomicClock 클래스의 소멸자는 호출되지 않음.

      이 상태에서 기본 클래스인 TimeKeeper의 소멸자는 동작을 하게되어 부분 소멸이 일어나게 됩니다.

      부분 소멸은 자원의 심각한 낭비가 됩니다.


- 위의 문제의 해결 방법은 기본 소멸자를 가상소멸자로 변경하는 방법입니다.

   virtual ~TimeKeeper();


- 가상 멤버 함수를 하나라도 가지고 있으면 가상소멸자를 생성해야 합니다.


- 가상 멤버 함수가 하나도 없을 경우에는 가상 소멸자를 생성하지 말아야 합니다.

   -> 만약 32비트 아키텍처에서 기본 클래스 class Point가 있고 생성자로 Point(int x, int y) 가 있다고 가정했을 때 가상 소멸자로 생성을 하게 되면 int 두개에 vptr(가상함수 테이블 포인터)하나로 총 96비트가 됩니다. (원래는 64비트)

   -> 다른 언어와의 호환성 문제가 있습니다.



**이것만은 잊지말자!**

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

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



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

- class를 직접 호출해야하는 설계에서는

   -> close를 호출하는 클래스를 생성해서 그 클래스에서 관리하도록 한다.

        이 때 소멸자는 두가지 형태로 나누어 지는데 한가지는 프로그램을 바로 끝내는 방법과 나머지 한가지는 예외를 삼키는 방법입니다.

   1. 바로 끝내는 방법(abort 사용)

       DBConn:~DBConn()

       {

            try{db.close()};

            catch(...)

            {

             //close 호출 실패 로그

                 std:abort();

             }

        }

    2. 예외를 삼키는 방법

       위의 방법에서 std:abort만 삭제합니다. 이 방법은 발생한 예외를 무시해도 프로그램이 신뢰성 있게 지속될 수 있을 때만 사용합니다.


- 소멸자에서 닫기를 기다리기 전에 사용자가 함수를 호출하여 닫도록 하는 방법도 있습니다.

   -> 하지만 이방법도 실패하면 다시 끝내기(abort)를 하거나 삼키기를 해야합니다.



**이것만은 잊지말자!**

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

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



항목 9. 객체 생성 및 소멸 과정 중에는 절대로 가상 함수를 호출하지 말자

- 파생 클래스 객체 생성시 기본 클래스가 먼저 생성된다.

- 기본 클래스의 생성자가 호출될 동안에는 가상함수는 절대 파생 함수 쪽으로 흐르지 않는다.

- 대처 방법은 가상함수를 기본 클래스의 비가상 멤버 함수로 바꾸고 파생 클래스의 생서자들로 하여금 필요한 정보를 기본 클래스의 생성자로 넘긴다.


**잘못된 예시**

class A{                                               //기본 클래스

    public:

        A();

        virtual void log() const = 0;             //로그기록 생성

        ....

};


A::A()

{

    .....

    log();

}


class B: public A{                               //A의 파생 클래스

     public:

        virtual void log() const;

     ......

};


여기서 B b; 이런식으로 b를 선언하게되면 B의 생성자가 호출되긴 하지만 그 이전에 A의 생성자가 먼저 호출됩니다.

따라서 현재 여기서 불리는 log()는 클래스 A의 log()가 호출되는 것입니다.



**위의 코드에 대한 대처 방법**

class A{

    public:

        explicit A(const std::string& logInfo);             //explicit -> 암시적 형변환을 방지하기 위함

        void log(const std::string& logInfo);              //이제는 비가상 함수 입니다.

        ......

};


A::A(const std::string& logInfo)

{

    ....

    log(logInfo);

}


class B: public A{

    public:

        B(parameters)

          : A(createLog(parameters))

        {.....}

        ....

    private:

        static std::string createLog(parameters);

};


**이것만은 잊지 말자!**

- 생성자 혹은 소멸자 안에서 가상 함수를 호출하지 마세요. 가상 함수라고 해도 , 지금 실행중인 생성자나 소멸자에 해당되는 클래스의 파생 클래스 쪽으로는 내려가지 않으니까요.



항목 10. 대입 연산자는 *this의 참조자를 반환하게 하자

- 대입 연산은 우측 연관 연산으로 좌변 객체의 참조자를 반환합니다. (이것은 프로그램의 관례로 사용됩니다.)

  ex) x=y=z=15; 

따라서 대입 연산자를 사용할 때는 아래와 같이 사용합니다.

class A{

    public:

    .....

    A operator=(const A& rhs)                      //반환 타입은 현재의 클래스에 대한 참조자

    {

         ....

        return *this;                                 //좌변 객체(의 참조자)를 반환합니다.

    }


- 모든 형태의 대입 연산자에 해당합니다. (+=, -=, *= ...)


**이것만은 잊지 말자!**

- 대입 연산자는 *this의 참조자를 반환하도록 하세요.



항목 11. operator=에서는 자기대입에 대한 처리가 빠지지 않도록 하자.

- 자기 대입이란 어떤 객체가 자신에 대한 대입연산자를 적용하는 것으로

  ex) class widget{...};

      widget w;

      w = w;             //자기 대입 연산에 빠지게 됩니다. 이러한 경우에는 쉽게 찾을 수 있지만 아래와 같은 경우에는 쉽게 발견하기가 어렵습니다.


 a[i] = a[j];    //i 와 j가 같은 변수일 때

 *px = *py;     //같은 주소를 참고할 때


- 이렇게 사용되면 여러곳에서 하나의 객체를 참조해서 중복 참조가 발생하게 됩니다.


class A;


class B{

    public:

        ....

       B& operator=(const B& rhs)

       {

           delete rhs.a;

           a = new A(*rhs.a);

           return *this;

       }

    private:

        A* a;

};

만약 위의 코드에서 자기대입이 일어났다고 가정했을 때 rhs와 *this가 같은 객체일 경우에는 오류가 발생하게 됩니다.

즉 B객체는 자신의 포인터 멤버를 통해 물고있던 객체가 삭제된 상태가 될 수 있습니다.


- 이를 막기위해 함수의 첫부분에 일치성 검사 코드를 넣어줍니다.

   ex) if(this == &rhs) return *this;   //자기 대입인지 검사 후 자기 대입이면 return

   하지만 이 부분은 예외에 안전하지 않습니다. 일치성 검사를 넘어갔다고 해도 new 부분에서 예외가 발생하게되면

   B객체는 결국 삭제된 A를 가리키는 포인터를 가지고 홀로 남게 됩니다. 이 포인터는 delete도 안되고 안전하게 읽는것 또한 불가능 합니다.


- 위의 예외 처리보다 더 완벽한 예외 처리 기법이 있습니다.

   포인터가 가리키는 객체를 복사한 후 삭제하는 방법입니다.

B& B::operator=(const B& rhs)

{

    A *pOrig = a;            //원래의 a(위의 private에 선언된)를 어딘가에 기억해 둡니다.

    a = new A(*rhs.a);   //다음, a가 *a의 사본을 가리키게 됩니다.

    delete pOrig;           //원래의 a를 삭제합니다.


    return *this;

}

위와 같이 코딩을 하게되면 new A 부분에서 예외가 발생하더라도 a는 변경되지 않은 상태가 유지 됩니다.

또한 원본 a를 복사한 후 복사한 사본을 포인터가 가리키게 만든 후 원본을 삭제 하므로 이상이 없습니다.


- 세번째 방법으로 복사 후 맞바꾸기 기법이 있습니다. (자세한 내용은 29항목을 공부할 때 살표보도록 하겠습니다.)

class B{

    public:

        ......

        void swap(B& rhs) //*this의 데이터 및 rhs의 데이터를 맞바꿉니다.
        {
            A* pOrig = a;
            a = new A(*rhs.a);
            rhs.a = pOrig;
         }

        B& operator=(const B& rhs)
       {
              B temp(rhs);      //rhs의 데이터에 대해 사본을 하나 만듭니다.

              swap(temp);       //*this의 데이터를 그 사본의 것과 맞바꿉니다.

             

              return *this;
        }

....

};


- 위의 코드를 값에 의한 전달로도 변경할 수 있는데 그 경우는 다음과 같습니다.

B& B::operator=(B rhs)      //rhs는 넘어온 원래 객체의 사본입니다.

{

      swap(rhs);                //*this의 데이터를 이 사본의 데이터와 맞바꿉니다.


     return *this;

}            


- 하지만 일치성 검사가 들어가면 코드가 커지고, 실행 시간 속력이 줄어들 수 있으니 잘 생각하며 사용하길 권장합니다.


** 이것만은 잊지 말자! **

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

- 두 개 이상의 객체에 대해 동작하는 함수가 있다면, 이 함수에 넘겨지는 객체들이 사실 같은 객체인 경우에 정확하게 동작하는지 확인해 보세요.


    

항목 12. 객체의 모든 부분을 빠짐없이 복사하자

- 클래스에 데이터를 추가했으면 추가한 데이터를 처리하도록 복사합수를 다시 작성하자.

void logCall(const std::string& funcName);  //로그 기록 내용을 만듭니다.


class Customer{

    public:

        ....

        Customer(const Customer& rhs);

        Customer& operator=(const Customer& rhs);

        ....

    private:

        std::string name;

};


Coustomer::Customer(const Customer& rhs)

: name(rhs.name)                                              //rhs의 데이터를 복사합니다.

{

     logCall("Customer copy constructor");

}


Costomer& Customer::operator=(const Customer& rhs)

{

    logCall("Customer copy assignment operator");

    name = rhs.name;                                           //rhs의 데이터를 복사합니다.

   

    return *this;

}

위의 소스는 문제될 것이 하나도 없습니다. 하지만 데이터 멤버를 하나라도 추가하게되면 문제가 발생합니다.

class Date{...};   //날짜 정보를 위한 클래스


class Customer

{

    public:

    ....

    private:

        std::string name;

        Date lastTransaction;

};

이렇게 데이트 멤버를 추가하게되면 name은 복사를 하지만 lastTrasaction은 복사하지 않는 부분복사가 일어나게 됩니다.


- 파생 클래스 사용시 기본 클래스의 복사 생성자를 호출하고 기본 클래스 부분을 대입한다.

만약 PriorityCustomer라는 클래스가 Customer의 파생 클래스라고 한다면

1. PriorityCustomer의 복사 생성자 호출 시 Customer의 복사 생성자를 호출합니다.

PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)

Customer(rhs), priority(rhs.priority)

{.....}

2. 대입 연산자에서는 기본 클래스 부분을 대입합니다.

PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs)

{

    logCall("PriorityCustomer copy assignment operator");


    Customer::operator=(rhs);        //기본 클래스 부분 대입

    priority = rhs.priority;


    return *this;

}


** 이것만은 잊지 말자! **

- 객체 복사 함수는 주어진 객체의 모든 데이터 멤버 및 모든 기본 클래스 부분을 빠뜨리지 말고 복사해야 합니다.

- 클래스의 복사 함수 두 개를 구현할 때, 한쪽을 이용해서 다른 쪽을 구현하려는 시도는 절대로 하지 마세요. 그 대신, 공통된 동작을 제3의 함수에다 분리해 놓고 양쪽에서 이것을 호출하게 만들어서 해결합시다.

반응형

'프로그래밍언어 > C++' 카테고리의 다른 글

[Effective C++] 항목 26~28  (0) 2018.04.10
[Effective C++] 항목 22 ~ 25  (0) 2018.01.23
[Effective C++] 항목 18 ~ 21  (0) 2018.01.23
[Effective C++] 항목 13 ~ 17  (0) 2018.01.23
[Effective C++] 항목1 ~ 4  (0) 2018.01.23

댓글