프로그래밍/C++

Effective C++ - 2장 생성자 소멸자 및 대입 연산자

ZenoAhn 2021. 5. 12. 21:19

C++의 클래스에 한개이상 꼭 들어있는 것들은 생성자, 소멸자 대입연산자다.

5. C++가 암시적으로 호출하는 함수를 주의하자

  • 컴파일러는 클래스에 대해 기본 생성자, 복사생성자, 복사대입연산자, 소멸자를 암시적으로 만들어 놓을 수 있다.
class Empty();
class Empty()
{
    public:
    Empty() {...};
    Empty(const Empty& rhs) {...};
    ~Empty() {...};
    Empty& operator={const Empty& rhs} {...};
}

디폴트 생성자는 멤버 변수 생성자를 호출할수 있도록 자리를 마련함
디폴트 복사, 대입 생성자는 원본 객체의 비정적 객체를 복사함

주의점 : 복사,대입 생성자의 경우 legal & Reasonable 해야만 자동 생성해줌

class Object {
    public:
    Object(std::string& name) {...}

    private:
    std::string& nameValue;
}

Object a("A");
Object b("B");
a = b; // compile error

6. 컴파일러가 만든 함수가 필요 없으면 삭제해버리자

방법 1. private member 함수로 선언

하지만 private 멤버 함수는 클래스의 멤버 함수 및 프렌드 함수가 호출할 수 있다.

//개선 방법
// 선언만 하고 정의하지 않는다.

class HomeForSale
{
    private:
    HomeForSale(const HomeForSale&);
    HomeForSale& operator=(const HomeForSale&);
};

링크 타임에 알게됨

방법 2. 컴파일 타임에 알게하는법

class Uncopyable
{
public:
    Uncopyable() {}
    ~Uncopyable() {}
private:
    Uncopyable(const Uncopyable);
    Uncopyable& operator= (const Uncopyable&);
};

class HomeForSale : private Uncopyable
{

}

c++11 feature

    struct Z {
        // ...

        Z(long long);     // can initialize with an long long
        Z(long) = delete; // but not anything less
    };
See also

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

비 가상 소멸자를 선언하지 않은경우

class Timer
{
    public:
    Timer(){};
    ~Timer(){};
}
class AtomicTimer:public Timer
{

}

Timer* timer = new AtomicTimer;
delete timer; //누수 발생

해결법

class Timer
{
    public:
    Timer(){};
    virtual ~Timer(){};
}

주의할점

과거 라이브러리 객체를 상속받아서 사용시에는 확인하고 상속받아라. 예) std::string

C++11이후부터는 final keyword가 등장함으로써 상속을 허용하고 싶지 않을 경우에는 final keyword를 사용

  • 다형성을 가진 기본 클래스에서는 반드시 가상 소멸자를 선언하자 (가상 함수를 가진)
  • 기본 클래스로 설계 되지 않았거나 다형성을 갖도록 설계되지 않은 클래스는 가상 소멸자를 선언하지 말자

8. Exception이 소멸자를 떠나지 못하도록 붙들어 놓자

  • 소멸자에서는 예외가 빠져나가면 안됨, 소멸자에서 호출된 함수가 예외를 던질 가능성이 있다면, 어떤 예외라도
    소멸자에서 Catch해서 삼키거나, 프로그램을 종료해라
  • 어떤 클래스의 연산이 진행되다 던진 예외에 대해서 사용자가 반응해야 할 필요가잇다면, 해당연산을 제공하는 함수는 반드시 보통의 함수여야한다.

소멸자 호출중 예외 처리가 다른곳에서 catch되면, 소멸자가 자원 해제를 올바르게 하지 못한다.

vector<DestructorThorwExceptionClass> v;

try
{
    while (!v.empty())
    {
        v.pop_back();   
    }
}
catch(...)
{
    ...
}

데이터 베이스 연결을 나타내는 클래스를 구현해본다면

class DBConnection
{
    public:
    static DBConnection create();

    void close();
};

class DBConnectionWrapper
{
    public:
    DBConnectionWrapper(DBConnection db) : db(db){}
    ~DBConnectionWrapper
    {
        db.close();

        //... 여기에서 소멸되어야할 자원들
    }
private:
    DBConnection db;
}


{
    DBConnectionWrapper(DBConnection::create());
}

걱정거리를 피하는 방법

1. 프로그램을 바로 끝낸다.

 ~DBConnectionWrapper
    {
        try{db.close();}
        catch
        {
            //error log
            std::abort();
        }
    }

2. 예외를 삼킨다.

 ~DBConnectionWrapper
    {
        try{db.close();}
        catch
        {
            //error code
        }

        //... 여기에서 소멸되어야할 자원들
    }

대부분의 경우 예외 삼키기는 그리 좋은 발상이 아니다. 무엇이 잘못되었는지 알려주는 정보가 묻혀버리기 때문.

하지만 때에 따라서 불완전한 프로그램 종료, 미정의 동작으로 인해 입는 위험을 감수하기보단 예외 삼키기를 하는것이 낫다.

단, 예외 삼키기를 한것이 빛을 보려면 예외를 무시한 뒤라도 프로그램이 신뢰성 있게 실행을 지속 할 수 있어야 한다.

1,2보다 조금 더 나은 방법으로는 사용자(다른 프로그래머)가 예외 처리를 할수 있도록 함수를 열어주는 것

예외를 처리해야 할 필요가 있다면 소멸자가 아닌 다른 함수에서 비롯된 것이어야 한다.

class DBConnectionWrapper
{
    public:
    void close()
    {
        db.close();
        closed = true;
    }

    DBConnectionWrapper(DBConnection db) : db(db){}
    ~DBConnectionWrapper
    {
        try{
            if (!closed){
            db.close();
            }
        }
        catch(...) {

        }

        //... 여기에서 소멸되어야할 자원들
    }
private:
    DBConnection db;
    bool closed = false;
}

위의 코드는 사용자가 에러 처리를 할수 있도록 close 함수를 두었지만, 부담을 떠넘기는(강제하는) 형태는 아니다.

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

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

생성자 혹은 소멸자 안에서 가상함수를 호출하지 말아라.
Java 혹은 C#을 하다가 오신분들이라면 주의해서 볼것.

여기서 다시 C++ 생성자의 호출 순서를 살펴보자

class A { ... };
class B : public A {...};

생성자의 호출 순서는
A->B

소멸자의 호출 순서는
B->A

이때, A의 생성자에 OnConstruct라는 가상함수를 정의해서 호출하고,
B에서 오버라이딩해서 사용한다고 가정한다면 아래와 같이 작성할 수 있다.

class A
{
    public: 
    A() 
    {
        OnConstruct();
    }

    virtual void OnConstructPrint()
    {
        cout << "hello A"
    }
}

class B
{
    public:
    B() {}

    virtual void OnConstructPrint()
    {
        cout << "hello B " << endl;
        cout << "variable value : " << variable;
    }
    private:
    int variable = 0;
}

int main(){
    B instance;

    return 0;
}

A 생성자가 호출되면서 B::OnConstructPrint가 호출되기를 기대했거나 가정하지마라.

생성자의 호출 순서를 다시보면, A 생성자내부의 OnConstruct가 호출되는 시점에서는 B 클래스의 메모리 초기화도 이루어지지 않았으며, 생성자에서는 B의 절대 가상 함수를 호출하지 않는다.

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

C++은 아래와 같은 대입 연산 사슬을 지원한다.

int x,y,z;

x = y = z = 15;

*right-associative 연산의 특성을 띄고 있어, 우측부터 연산이 이루어진다.*

따라서 관례적으로 객체를 구현할때 대입 연산자에 대해서는 *this의 참조자를 반환하도록 구현하여
대입 연산 사슬을 지원하도록 한다.

class Widget
{
    public:
    Widget& operator=(const Widget& rhs)
    {
        return *this;
    }

    Widge& operator+=(const Widget& rhs)
    [
        return *this;
    ]
};

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

자기 대입의 경우를 간과하기 쉬우나 아래 예제 코드를 보면, 객체 대입 연산시 자기 자신을 대입하게 되는 경우도 있다는것을 알게된다.

Widget w;

//이렇게는 잘 안쓸것 같죠?
w = w;

std::vector<Widget> v;
//이렇게는 언젠가 써질거 같죠?
v[i] = v[j]

//이렇게도
*px = *py;

그럼 어떤 경우 문제가 될까?

아래의 예제처럼 어떤 객체가 자원에 대한 관리를 하고 있다면, 문제가 발생 할 수있다.

class Bitmap;

class Widget 
{
    Widget& operator=(const Widget& rhs)
    {
        if(pb) 
        {
            delete pb;
            pb = nullptr;
        }

        pb = new BitMap(*rhs.pb);
    }
    private:
    Bitmap* pb;
}

int main()
{
    Widget w;
    w = w;
}

이처럼 문제가 생기는 것을 방지하기 위해 적절한 처리를 해주어야한다.

일치성 검사(Identity Test)

Widget& Widget::operator=(const Widget& rhs)
{
    if (&rhs == this)
    {
        return * this;
    }

    ...
}

Copy and Release

class Widget 
{
    Widget& operator=(const Widget& rhs)
    {
        Bitmap* origin = pb;        
        pb = new BitMap(*rhs.pb);
        delete origin;
        return *this;
    }
    private:
    Bitmap* pb;
}

Copy and swap

class Widget 
{
    Widget& operator=(const Widget rhs)
    {
        //rhs가 사본이기때문에 사본의 정보를 현재의 정보로 교체함
        swap(rhs)
        return *this;
    }
    private:
    Bitmap* pb;
}

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

잘 구현된 객체지향 시스템 코드를 살펴보면 객체를 복사하는 함수가 딱 둘만 있는것을 알 수 있다.

복사 생성자와 복사 대입 연산자 이다.

컴파일러가 만들어주는 기본적인 복사 함수는 기본적인 요구기능에 아주 충실하게 복사해주나,
우리가 정의하게 되는 순간 올바르지 않더라도 무시한다.

간단한 예로는, 멤버 변수가 복사되지 않았더라도 어떠한 경고도 주지않는다.

그 다음으로는 상속된 경우에 복사가 누락이 되지 않도록 주의를 기울여야한다.

아래 예제를 살펴보면 다음과 같다.

class PriorityCustomer : public Customer
{
    public:
    PriorityCustomer(const PriortyCustomer& rhs)
    : priority(rhs.priority)
    {
        //PriorityCustomer의 멤버인 복사되었지만, Customer의 멤버들은 복사되지 않았다.
    }

    PriorityCustomer& operator=(const PriortyCustomer& rhs)
    {
        priority = rhs.priority;
    }
    private:
    int priority = 0;
}
class PriorityCustomer : public Customer
{
    public:
    PriorityCustomer(const PriortyCustomer& rhs)
    : Customer(ths) //Customer 멤버 복사
    , priority(rhs.priority)
    {
    }

    PriorityCustomer& operator=(const PriortyCustomer& rhs)
    {
        Customer::operator=(rhs); // customer 대입 연산 복사
        priority = rhs.priority;
    }
    private:
    int priority = 0;
}

또 하나 빠지기 쉬운 유혹은 복사 생성자, 대입연산자에서 멤버를 복사하는 코드를 다른쪽에 이용하고 싶은 유혹이다.

복사하는 코드는 한쪽에만 구현하고 다른 연산자에서 쓰고 싶은 유혹이 올 수 있으나

두개의 용도가 다르다는 것을 기억하자.

대입 연산자는 이미 생성된 객체를 복사하는것
복사 생성자는 객체를 생성하면서 복사하는것이다. (이전 항목 4 에서 생성자 초기화 리스트 사용과 대입은 다르다는것을 기억하자)

복사 생성자와 대입 연산자의 본문 이 비슷하다면 제 3의 멤버함수로 빼서 코드 중복을 피하는 길이 있을 수 있다.

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

SIMD Compiler ISPC 소개  (0) 2019.07.29
Memory Allocation API  (0) 2018.05.27
LValue RValue  (0) 2018.02.13
Uniform Initialization  (0) 2018.02.06
if statement  (0) 2018.02.06