ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Effective C++] 생성자, 소멸자 및 대입 연산자
    프로그래밍 기초/C++ 2022. 6. 11. 14:46

      생성자, 소멸자 및 대입 연산자 

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

    기본적으로 생성되는 멤버 함수

    개발자가 따로 생성하지 않으면 컴파일러에서 기본적인 모양으로 만드는 멤버 함수들이 존재한다.

    • 생성자 인자가 없는 기본 생성자를 생성한다.
    • 소멸자 아무 기능 없는 소멸자를 생성한다.
    • 복사 생성자 복사가 가능한 경우 복사 생성자를 생성한다. 멤버가 참조형이거나 상수인 경우 복사가 불가능하다.
    • 복사 대입 연산자 기본 클래스에서 복사 대입 연산자를 private으로 하지 않은 경우 복사 대입 연산자를 생성한다.

     

    ※ 복사 생성자와 복사 대입 연산자의 경우 생성이 불가능할 경우 컴파일 에러가 발생한다.

     

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

    private으로 선언 후 정의를 하지 않는 방법

    복사 생성자와 복사 대입 연산자를 private 내부에서 선언하면 컴파일러가 자동 생성하지 않는다.

    또, friend를 통해 사용하고자 하더라도 정의가 되어 있지 않으므로 링크 에러가 발생한다.


    class HomeForSale {

    public:

        ...

    private:

        ...

        HomeForSale(const HomeForSale&);

        HomeForSale& operator=(const HomeForSale&);

    }


     

    링크 에러를 컴파일 시점 에러로 옮기기

    복사를 방지하는 상위 클래스를 만들어 상속받으면 friend에서도 복사 관련 함수에 접근할 수 없게 되어 링크 에러 대신 컴파일 에러가 발생하게 된다.


    class Uncopyable {

    protected: // 파생된 객체에 대해 생성, 소멸은 허용한다.

        Uncopyable() {}

        ~Uncopyable() {}

    private: // 하지만 복사는 막는다.

        Uncopyable(const Uncopyable&);

        Uncopyable& operator=(const Uncopyable&);

    }

     

    class HomeForSale : private Uncopyable {};


     

    ※ 부스트 라이브러리에서 Uncopyable과 동일한 기능을 하는 noncopyable 클래스를 지원한다.

     

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

    기본 클래스의 소멸자가 가상 소멸자가 아닐 때의 문제

    파생 클래스 객체가 삭제될 때 기본 클래스에 비가상 소멸자가 들어 있으면 파생 클래스의 소멸자가 호출되지 않는다.

    즉, 기본 클래스가 가상 소멸자가 아닌 경우 파생 클래스가 정상적으로 제거되지 않는다.

     

    ※ 가상 함수를 하나라도 가진다면 소멸자를 가상 소멸자로 선언해야 한다.

     

    모든 클래스에 가상 소멸자를 넣는 것은 다른 문제

    가상 함수가 하나라도 있다면 vptr이라는 가상 함수 포인터가 생성되어 클래스의 크기가 커지게 된다.


    // 비가상 소멸자를 가진 Point 클래스, 크기는 64bit

    class Point {

    public:

        Point(int xCoord, int yCoord);

        ~Point();

    private:

        int x, int y;

    }

     

    // 가상 소멸자를 가진 Point 클래스, 크기는 32bit 아키텍처에서 96bit, 64bit 아키텍처에서 128bit

    class Point {

    public:

        Point(int xCoord, int yCoord);

        virtual ~Point();

    private:

        int x, int y;

    }


     

    ※ 상속받는 클래스에 가상 소멸자가 적용되었는지 확인하자.

     

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

    소멸자에서 예외를 회피하는 방법

    먼저, 소멸자에서 예외가 발생하는 경우 디버깅이 거의 불가능하므로 예외가 발생하지 않는 게 가장 좋다.

    하지만 예외가 발생하는 경우엔 두 가지 방법으로 처리를 할 수 있다.

    1. 소멸자에서 예외 발생 시 로그를 출력하고 프로그램을 종료한다.
    2. 소멸자에서 예외 발생 시 로그를 출력하고 예외를 소멸시킨다.

     

    ※ 사용자에게 선택권이 없으므로 둘 다 좋은 방법은 아니다.

     

    사용자에게 선택권을 부여하는 방법

    소멸자에서 호출하던 Close 함수가 있었다면 이 함수를 사용자가 호출할 수 있도록 한다면 예외 발생 시 사용자 스스로 디버깅을 진행할 수 있게 된다.

     


    class DBConn {

    public:

        ...

        void close()

        {

            db.close();

            closed = true;

        }

        ~DBConn()

        {

            if (!closed)

            try {

                db.close();

            }

            catch (...) {

                close 호출 실패했다는 로그 출력

            }

        }

    private:

        DBConnection db;

        bool closed;

    }


     

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

    파생 클래스의 초기화

    파생 클래스의 기본 클래스 초기화 시 파생 클래스는 기본 클래스로 결정된다.

    즉, 기본 클래스 초기화 시에는 typeid 같은 것을 사용하더라도 기본 클래스로 나타나게 되며 가상 함수를 호출한다면 기본 클래스의 가상 함수가 호출될 것이다.

     


    class Transaction {

    public:

        Transaction();

        virtual void logTransaction() const = 0; // 로그를 찍는 가상 함수가 있는 경우

    };

    Transaction::Transaction() {

        logTransaction(); // 이 경우 Transaction의 logTransaction을 호출하게 된다.

    }

    class SellTransaction : public Transaction {

    public:

        virtual void logTransaction() const; // 로그 가상 함수를 파생 클래스에서 구현했으나

    };

    SellTransaction a; // 클래스 생성 시 기본 클래스부터 호출되므로 에러가 발생할 것이다.


     

    공통된 함수를 안전하게 호출하는 방법

    가상 함수를 생성자에서 호출하는 게 위험하므로 비가상 함수를 호출하도록 하는 대신 생성자의 인자로 필요한 정보를 받아올 수 있도록 하면 안전하게 공용 함수를 호출할 수 있게 된다.

     


    class Transaction {

    public:

        explicit Transaction(const std::string& logInfo); // 생성자에서 필요한 정보를 받는다.

        void logTransaction(cons 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);

    }


     

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

    일종의 관례

    대입 연산은 우측 연관 연산이라는 특성을 가지며 이는 대입 시 우측에 있는 인자의 값을 대입하는 방식으로 진행된다.

    이때, 참조되는 값을 넘길 때 참조자를 반환하는 게 일종의 관례이다.

     


    int x, y, z;

    x = y = z = 15; // 이를 풀어쓰면

    x = (y = (z = 15)); 이렇게 되는데, z에 15를 대입하고 y에 값이 대입된 z를 대입하고 x에는 또 y를 대입하는 식이다.

     

    class Widget { // 클래스 구현 시 모든 대입 연산자(=, +=, -= 등)는 *this의 참조자를 반환하는 게 좋다.

    public:

        Widget& operator=(const Widget& rhs)

        {

            return *this; //이렇게 안 해도 컴파일은 된다.

        }

    }


     

    ※ STL의 vector나 string, complex 등도 이렇게 구현되어 있다.

     

    Operator=에서는 자기 대입에 대한 처리가 빠지지 않도록 하자

    자기 대입이란?

    어떤 객체가 자기 자신에 대해 대입 연산자를 적용하는 것을 말한다.


    class Widget { ... };Widget w;

    w = w;

    // 같은 변수에 대입하는 경우

    a[i] = a[j]; // 만약 i와 j가 같은 경우

    *px = *py; // 자기 대입이 될 가능성이 높음


     

    자기 대입이 문제가 되는 경우

    포인터를 사용하고 있고 대입할 때 기존 포인터 값을 삭제하고 대입하는 값을 복사해 사용하는 코드가 있을 때, 자기 대입이 발생한 경우 제거된 값을 복사하려고 시도하게 될 수 있다.


    // Widget에 Bitmap* pb가 있다.

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

    {

        delete pb;

        pb = new Bitmap(*phs.pb); // 자기 대입인 경우 전 줄에서 pb가 삭제되었으므로 문제가 될 것이다.

     

        return *this;

    }


     

    자기 대입을 막는 방법

    이 것을 회피한 전통적인 해결 방법은 다음과 같이 첫 라인에 일치성 검사 코드를 추가하는 것이다.


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

    {

        if (this == &rhs) return *this; // 자기 대입인 경우 그대로 반환하고 종료한다.

     

        delete pb;

        pb = new Bitmap(*phs.pb);

     

        return *this;

    }


     

    다른 방법으로는 문장을 조금 수정해서 안전하게 동작하도록 만들 수 있다.


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

    {

        Bitmap* pOrig = pb; // 기존의 값을 임시 변수에 저장해둔다.

        pb = new Bitmap(*phs.pb); // 새로운! 값을 생성해 pb에 저장한다.

        delete pOrig; // 안전하게 기존 값을 제거한다.

     

        return *this;

    }


     

    또 다른 방법으로 복사 후 맞바꾸기라는 기법을 사용하면 도움이 된다.


    class Widget {

        void swap(Widget& rhs); // *this의 데이터와 rhs의 데이터를 맞바꾸는 함수.

    }

     

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

    {

        Widget temp(rhs); // 임시 변수를 만들고

        swap(temp); // 데이터를 서로 바꾼 뒤 함수를 빠져나가면

        return *this; // 임시 변수가 삭제되면서 대입이 완료된다.

    }


     

    복사 후 맞바꾸기 기법은 C++의 특성을 이용해 다음과 같이 수정할 수 있다.


    Widget& Widget::operator=(Widget rhs) // 값에 의한 참조로 인해 복사되어 온다.

    {

        swap(rhs); // 값으로 참조된 인자와 데이터를 서로 바꾼다.

        return *this;

    }


     

    ※ 값에 의한 참조로 제작한 경우 명확성이 떨어질 수 있으나 변수의 복사가 생성자단에서 일어나므로 조금 더 효율적일 수 있다.

     

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

    객체를 복사하는 함수

    캡슐화한 객체 지향 시스템에 객체를 복사하는 함수는 단 두 개만 존재하며 이를 객체 복사 함수라고 부른다.

    • 복사 생성자 클래스 인스턴스의 레퍼런스를 인자로 받는 생성자
    • 복사 대입 연산자 클래스 인스턴스의 레퍼런스를 인자로 받는 대입 연산자

     

    문제가 발생하는 경우

    문제가 되는 경우도 크게 두 가지가 있다.

    • 객체 복사 함수를 따로 구현한 뒤 멤버 변수를 추가했는데 객체 복사 함수에는 추가하지 않은 경우
    • 상속받은 경우 상위 클래스의 멤버 변수를 복사해주지 않은 경우

     

    상위 클래스의 멤버 변수 복사

    문제가 발생하는 경우에서 상위 클래스의 멤버 변수 복사는 상위 클래스의 객체 복사 함수를 호출해 주는 것으로 해결할 수 있다.


    class PriorityCustomer : public Customer { ... };

     

    PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)

        : Customer(rhs), 변수 복사 진행...

    {

    }

     

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

    {

        Customer::operator=(rhs);

        변수 복사 진행...

        return *this;

    }


     

    ※ 복사 생성자와 복사 대입 연산자에 코드 중복이 있는 경우 따로 함수를 만들어 호출하면 된다.

     

    댓글

Designed by Tistory.