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

 

1
2
3
4
5
6
7
8
9
    Widget a;
 
    a = a;            //대놓고 미친짓.
 
    int arr1[1000];
 
    arr1[i] = arr[i]; //복잡한 반복문에서 발생할 수도 있는 일.
 
    *px = *py;        //px, py가 가리키는 대상이 같으면 자기 대입이 된다.
cs

 

이러한 자기 대입이 생기는 이유는 여러 곳에서 하나의 객체를 참조하는 상태인 중복참조 때문이다.

같은 타입으로 만들어진 객체 여러 개를 참조자 혹은 포인터로 물어 놓고 동작하는 코드를 작성할 때는

같은 객체가 사용될 가능성을 고려하는 것이 좋다.

 

특히 자원 관리와 엮여있는 경우에는 더욱 더 주의해야 한다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Bitmap { ... };
 
class Widget
{
public:
    ...
    
    Widget& operator=(const Widget& rhs)
    {
        delete pb;
        pb = new Bitmap(rhs.pb);
        return *this;
    }
 
private:
    Bitmap* pb;
};
cs

 

상기와 같이 작성된 코드에서 자기 대입이 진행된다면, 좌변 객체(this)와 우변 객체(rhs)가 동일하므로 멤버 변수인 pb 또한

this의 것이기도 하면서 rhs의 것이기도 하다.

사용자의 의도는 원래 pb를 제거하고 다시 새로운 정보를 가지고 pb를 생성하려고 했던 것인데...

bp를 제거함으로써 this의 pb 뿐만 아니라 초기화 재료로 사용하려던 rhs의 pb 또한 제거가 된다.

 

그러므로 결국 operator= 함수에서 새로 생성되는 pb는 쓰레기 값을 가진 Data가 된다. (아니면 null이 되어 null exception 발생)

 

해결 방법 1

 

전통적인 방법으로 operator=의 첫머리에 일치성 검사를 통해 자기대입을 검사한다.

 

1
2
3
4
5
6
7
8
    Widget& operator=(const Widget& rhs)
    {
        if (this == &rhs) return this//자기 대입이라면 아무것도 하지 않는다.
 
        delete pb;
        pb = new Bitmap(rhs.pb)
        return *this;
    }
cs

 

그러나 이 방법은 예외에는 안전하지 않다.

무슨 말이냐하면, new Bitmap 부분에서 예외가 터지게 되면 (동적 할당에 필요한 메모리 부족, 복사 생성자에서

예외 발생 등...), Widget 객체는 삭제된 Bitmap을 가리키는 포인터를 껴안고 남게된다.

즉, 아무것도 가리키지 않는 포인터가 하나 남게되고 이것을 delete 시켜줄 방법 또한 없다.

 

또한 if문으로 조건 처리하는데 속도 저하에도 영향을 미칠 수 있다.

 

해결 방법 2.

 

1
2
3
4
5
6
7
8
    Widget& operator=(const Widget& rhs)
    {
        Bitmap* pTemp = pb;
        pb = new Bitmap(rhs.pb);
        delete pTemp;
 
        return *this;
    }
cs

 

이 방법은 자기대입과 예외에 모두 처리가 된 코드이다. (문장 순서를 바꾸는 것만으로 예외에 안전한 코드가 만들어진다)

 

1. 원본 비트맵을 복사해놓고 (pTmpe와 pb가 가리키고 있는 비트맵을 동일하다.)

2. rhs의 pb를 가지고 새로운 Bitmap을 생성하여 가리키게 한다.

   (즉, pb는 신규 Data를 가리키고 pTemp는 이전 Data를 가리키고 있게된다.)

   (자기 대입일지 언정 같은 내용으로 새로 만든 객체를 가리키는 것이니 문제 없다.)

3. 그 후 pTemp를 delete 해줌으로써 이전 pb Data를 안전하게 제거한다.

4. 또한 new Bitmap에서 예외가 발생한다고 하더라도 pb는 이전 Data를 그대로 가리키고 있기 때문에 아무 문제가 없다.

   그리고 pTemp 변수 자체는 스택에 할당되기 때문에 예외 발생 시 operator= 함수가 종료되면 pTemp는 자동적으로 소멸된다.

 

해결 방법 3.

 

다른 방법도 있다. 복사 후 맞바꾸기(copy and swap)이라고 알려진 기법인데, 이 기법은 예외 안정성과 매우 밀접한 관계를

가지고 있어 자세한 내용은 뒤에서 다루지만 이 기법은 operator= 함수에 아주 자주 쓰이는 기법이므로 간략히 설명한다.

 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Widget
{
    void swap(Widget& rhs); //*this의 데이터 및 rhs의 데이터를 swap
    ...
};
 
Widget& Widget::operator=(const Widget& rhs)
{
    Widget temp(rhs); //rhs의 데이터 사본을 하나 만들고
 
    swap(temp);       //*this(현재 객체)의 데이터를 사본과 맞바꾼다.
 
    return *this;
}
cs

 

c++이 가진 특성을 활성해서 다르게도 구현할 수 있다.

 1. 클래스의 복사 대입 연산자는 인자를 값으로 취하도록 선언하는 것이 가능하다는 점.

 2. 값에 의한 전달을 수행하면 전달된 대상의 사본이 생긴다는 점.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
class Widget
{
    void swap(Widget& rhs); //*this의 데이터 및 rhs의 데이터를 swap
    ...
};
 
Widget& Widget::operator=(Widget rhs) //rhs는 넘어온 원래 객체의 사본
{                                      //Call by value
 
    swap(rhs);    //*this(현재 객체)의 데이터를 사본 데이터와 맞바꾼다.
 
    return *this;
}
cs

 

값에 의한 전달은 조금 걱정되는 코드이지만 객체를 복사하는 코드를 복사 생성 연산자 본문에서 하지 않고

매개변수의 생성자로 옮겨졌기 때문에, 컴파일러가 더 효율적인 코드를 생성할 수 있는 여지가 만들어진다.

 

2줄 요약

 

 - operator=을 구현할 때, 어떤 객체가 그 자신에 대입되는 경우를 제대로 처리하도록 만들자.

   원본 객체와 복사대상 객체의 주소를 비교해도 되고, 문장의 순서를 조정해도 되며, 복사 후 맞바꾸리를 해도 된다.

 

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

   동작하는지 확인해 보자.

대입 연산은 여러 개가 사슬처럼 엮일 수 있고 우측 연관 연산이라는 특징이 있다.

 

1
2
3
4
5
int x,y,z;
 
= y = z = 15;     //아래 코드와 같은 의미
 
= (y = (z = 15)); //위의 코드와 같은 의미
cs

 

이렇게 대입 연산이 사슬처럼 엮이려면 대입 연산자가 좌변 인자에 대한 참조자를 반환하도록 구현되어 있을 것 이다.

이런 구현은 일종의 관례인데, 사용자가 직접 구현하는 클래스에 대입 연산자가 들어간다면 이 관례를 지키는 것이 좋다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Widget
{
public:
    ...
    Widget& operator=(const Widget& rhs) //반환 타입은 현재 클래스에 대한 참조자.
    {
        ...
        return *this//좌변 객체의 참조자를 반환.
    }
 
    Widget& operator+=(const Widget& rhs) //+=, -=, *= 등에도 동일한 규약이 적용.
    {
        ...    
        return *this//좌변 객체의 참조자를 반환.
    }
 
    Widget& operator=(int rhs) //대입 연산자의 매개변수 타입이 일반적이 않은 경우에도
    {                          //동일한 규약을 적용.
        ...
        return *this;
    }
};
cs

 

"좌변 객체의 참조자를 반환하게 만들자"라는 규약은 모든 형태의 대입 연산자에서 지켜져야 한다.

이 관례는 모든 기본 제공 타입들이 따르고 있고 표준 라이브러리에 속한 모든 타입들에서도 따르고 있다.

 

따르지 않더라도 컴파일이 안되거나 그런건 아니지만 관례는 그냥 따르는게 좋다.

 

* 주의 사항

 

1
2
3
4
5
6
7
8
9
10
11
    Widget& operator=(const Widget& rhs) //반환 타입은 현재 클래스에 대한 참조자.
    {
        ...
        return *this//좌변 객체의 참조자를 반환.
    }
 
    Widget* operator=(const Widget* const rhs) //반환 타입은 현재 클래스에 대한 주소값.
    {
        ...
        return this//좌변 객체의 주소값을 반환.
    }
cs

 

참조 타입은 반환시 일반 객체에 대입이 가능하고 (객체 자체를 반환)

포인터 타입은 반환시 포인터 객체에 대입이 가능하다. (객체의 주소값을 반환)

헷갈리지 말 것!!

 

1줄 요약

 

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

<예시>

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Transaction
{
public:
    Transaction();
 
    virtual void logTransaction() const = 0;
};
 
Transaction::Transaction()
{
    logTransaction();
}
 
class BuyTransaction : public Transaction
{
public:
    virtual void logTransaction() const;
};
 
class SellTransaction : public Transaction
{
public:
    virtual void logTransaction() const;
};
 
 
int _tmain(int argc, _TCHAR* argv[])
{
    BuyTransaction buy;
 
    return 0;
}
cs

 

Transaction 클래스는 추상 클래스이다.

BuyTransaction, SellTransaction 클래스는 Transaction 클래스로부터 상속을 받았다.

main 함수에서 BuyTransaction 타입의 변수를 선언했다.

그렇다면 무엇이 문제의 소지가 되는 것일까??

 

상속 관계에서 생성자는 가장 위의 부모 클래스에서부터 맨 마지막 자식 클래스까지 순차적으로 호출된다.

그러면 BuyTransaction 타입의 buy 변수가 생성될 때는 Transaction 클래스의 생성자가 가장 먼저 호출된다.

그런데 Transaction 클래스의 생성자에는 logTransaction()를 호출하는 문장이 있다. 문제는 여기서 발생한다.

 

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

즉, 기본 클래스의 생성자에서 호출되는 logTransaction 함수는 BuyTransaction의 함수가 아니라 Transaction의 함수가 호출

되는 것이다.

 

이유는 기본 클래스의 생성자가 동작하는 시점에는 아직 파생 클래스의 데이터들은 초기화가 되어있지 않은 상태이기 때문이다.

만약 기본 클래스에서 호출한 가상 함수가 파생 클래스의 가상 함수를 호출하게 된다면 파생 클래스의 가상 함수에는 분명히

파생 클래스의 멤버 변수들을 사용하는 문장들이 있을텐데 아직 초기화되어 있지 않은 멤버들에 접근하는 것은 문제의 원인이 된다.

 

더 핵심적인 사실은 기본 클래스의 생성자가 동작하는 동안에는 선언한 변수 타입과 상관없이 기본 클래스 타입으로서 변경된다.

즉, BuyTransaction 타입으로 변수를 생성하였다 하더라도 기본 클래스 부분은 초기화하기 위해 Transaction 생성자가 실행되는 동안은

Transaction 타입으로 선언된 것 마냥 동작된다는 것이다.

파생 클래스의 데이터가 아직 초기화가 되지 않은 상태이므로 아예 없었던 것처럼 취급하는 편이 가장 안전하기 때문이다.

 

소멸자 또한 마찬가지이다.

파생 클래스의 소멸자가 호출되고 나면 파생 클래스의 데이터 멤버는 정의되지 않은 값으로 가정하기 때문에.

c++는 이들을 없는 것처럼 취급하고 진행한다. 기본 클래스 소멸자에 진입할 당시의 객체는 기본 클래스 객체가 된다.

 

해결 방법

 

1. logTransaction을 Transaction 클래스의 비가상 멤버 함수로 변경.

2. 파생 클래스의 생성자들로 하여금 필요한 로그 정보를 Transaction의 생성자로 넘기기.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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.. )
};
 
 
cs

 

다시 정리하자면, 기본 클래스 부분이 생성될 때 가상 함수를 호출한다고 해도 기본 클래스 범위를 벗어나지 않는다.

그러므로 필요한 초기화 정보를 파생 클래스 쪽에서 기본 클래스 생성자로 올려주도록 만듦으로써 부족한 부분을

역으로 채워 나가는 것이다.

(생각해보면 처음 배울때부터 자식 클래스에서 부모 클래스로 정보를 넘기는 방식을 당연하듯이 배웠는데...

 부모 클래스에서 자식 클래스에 접근해서 초기화하는 방식을 배우지 않은 이유 자체가 이것 때문이였던 것임)

 

그리고 또 중요한 점!!

자식 클래스에서 부모 클래스로 초기화 정보를 넘겨줄 때 private인데 static인 createLogString 함수를 이용해서

초기화를 도운다.

static 함수인데 private으로 선언되어서 미친짓이라고 생각할 수 있지만... 정적 멤버 함수이기 때문에 초기화가

되지 않은 멤버 함수를 자칫 실수로라도 건드릴 위험 자체가 없어지기 때문에 안정성이 높아진다.

(정적 멤버 함수 내에는 일반 멤버 함수 사용 불가)

 

1줄 요약

 

 - 생성자 혹은 소멸자 안에서 가상 함수를 호출하지 말자. 가상 함수라고 해도, 지금 실행 중인 생성자나 소멸자에

   해당되는 클래스의 파생 클래스 쪽으로는 내려가지 않기 때문이다.

소멸자로부터 예외가 발생한 경우를 한 번 생각해봐야 한다.

 

1
2
3
4
5
6
7
8
9
10
11
12
class Widget
{
public:
    ...
    ~Widget() { ... }
};
 
void DoSomething()
{
    std::vector<Widget> v;
    ...
}
cs

 

위와 같이 vector v 는 자신이 소멸될 때, 자신이 거느리고 있는 Widget 또한 소멸 시켜줘야 한다.

그런데 만약 첫 번째 Widget을 소멸하는데 중에 예외가 발생한다면 어떻게 할 것인가?

 

또 다른 에로 데이터베이스 연결을 나타내는 클래스를 쓰고 있다고 가정해보자.

 

1
2
3
4
5
6
7
8
class DBConnection
{
public:
    ...
    static DBConnection create(); //DBConnection 객체를 반환.
 
    void close();                  //DB 연결 해제.
};
cs

 

사용자가 실수로 close를 호출하지 않을 것을 생각해서 별도로 자원 관리 클래스를 만드는 것이 좋은 방법이다.

(자원 관리는 뒤에서 자세히 다룬다고 함.)

 

1
2
3
4
5
6
7
8
9
10
11
12
13
class DBContorl //DBConnection 객체를 관리하는 클래스
{
public:
    ...
 
    ~DBControl() //데이터베이스 연결이 항상 닫히도록 챙겨주는 함수
    {
        m_db.close();    
    } 
private:
    DBConnection m_db;
 
};
cs

 

그럼 하기와 같은 프로그래밍이 가능해진다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
int _tmain(int argc, _TCHAR* argv[])
{
    DBControl dbc(DBConnection::create());    //DBConnection 객체를 생성하고
                                              //이것을 DBControl 객체에 넘겨
                                              //관리를 맡긴다.
 
    ...
 
 
    return 0;                //Block 끝에서 DBControl 객체가 소멸되면서
                             //자동적으로 DBConnection의 close 함수가
                             //호출된다.
}
cs

 

그런데 close 과정에서 예외가 발생한다면 DBControl의 소멸자는 이 예외를 전파할 것이고, 소멸자에서 처리가

정상적으로 진행되지 않게된다.

 

여기서 선택 할 수 있는 방법은 하기 2가지 방법이다.

 

1. close에서 예외가 발생하면 프로그램을 바로 끝내기.

 

1
2
3
4
5
6
7
8
9
10
11
12
    ~DBControl()
    {
        try
        {
            m_db.close();
        }
        catch (...)
        {
            //close 호출이 실패했다는 로그 작성.
            std::abort();
        }
    }
cs

 

2. close를 호출한 곳에서 발생한 예외를 삼켜 버리기.

 

1
2
3
4
5
6
7
8
9
10
11
    ~DBControl()
    {
        try
        {
            m_db.close();
        }
        catch (...)
        {
            //close 호출이 실패했다는 로그 작성.
        }
    }
cs

 

대부분의 경우에서 예외 삼키기는 그리 좋은 방법은 아니다.

중요한 정보가 묻혀 버릴 수 있지만 24시간 내내 켜져있어야 하는 프로그램의 경우에는 예외로 인해 미정의 동작을

한다고 하더라도 그냥 예외를 먹어버리는게 나을 수도 있다.

단, '예외 삼키기'를 선택한 것이 제대로 빛을 보려면 발생한 예외를 무시한 뒤에도 프로그램이 신뢰성 있게 실행이 지속되야 한다.

 

어찌됬든 상기 2가지 방법 모두 특별히 좋을 건 없다.

(헷갈릴까봐 애기하는 것인데, 중요한 것은 close가 최초로 예외를 발생시키지 않는 방법이지만 그런 부분에 대해서 대책을 세울 수

없는 경우에 대해서 얘기하고 있는 상황이다.)

 

결국 이러한 상황에서 가장 안정적인 방법은

 

 1. 소멸자가 아닌 일반 함수에서 close() 호출.

 2. 혹시라도 일반 함수에서 close() 호출 시 문제가 생겼거나 사용자가 실수로 호출하지 않았을 것을 대비하여

 3. 소멸자에서 close 여부 체크 후 close() 호출.

 4. 만약 소멸자에서도 예외가 발생한다면 그땐 어쩔수 없이 실행을 끝내거나 예외를 무시하거나 해야함.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class DBControl
{
public:
    ...
 
    void close()
    {
        m_db.close();
        m_bClosed = true;
    }
 
    ~DBControl() 
    {
        if (!m_bClosed)
        {
           try
           {
               m_db.close();
           }
           catch (...)
           {
               //close 호출이 실패했다는 로그 작성.
               //실행을 끝내거나 예외를 삼키거나 선택.
           }
        }
    }
private:
    DBConnection m_db;
    bool m_bClosed;
};
cs

 

길게 얘기했지만 정리하자면... 내가 만든 프로그램이 아닌 Library, dll같은 곳에 있는 함수를 사용할 때에는

예외가 발생할 만한 함수를 소멸자에서 사용하지 않는 것이 좋다.

소멸자가 아닌 일반 함수에서 우선 처리를 한 후에 혹시라도 사용자가 실수로 놓칠 부분에 대해서 확인사살 용으로만

소멸자에 조건을 걸어 처리해주는 것이 가장 안정적인 방법이다.

소멸자에서 예외가 발생했더라도 try, catch 문으로 예외를 잡아줘야 한다.

 

2줄 요약

 

 - 소멸자에서는 예외가 빠져나가면 안된다. 만약 소멸자 안에서 호출된 함수가 예외를 던질 가능성이 있다면,

   어떤 예외이든지 소멸자에서 모두 받아낸 후에 삼켜 버리든지 프로그램을 끝내든지 해야 한다.

 

 - 어떤 클래스의 연산이 진행되다가 던진 예외에 대해 사용자가 반응해야 할 필요가 있다면, 해당 연산을 제공하는

   함수는 반드시 소멸자가 아닌 일반 함수이어야만 한다.

1. 가상 소멸자를 사용하는 이유.

 

 시간 정보를 저장하는 기능이 공통적으로 필요한 클래스들이 있다.

 그래서 TimeKeeper 라는 클래스를 기본 클래스로 만들어 놓은 후에 적절한 용도에 따라 이것을 파생시키도록 설계한다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//시간을 저장하는 기능이 있는 기본 클래스
class TimeKeeper 
{
public:
 TimeKeeper();
 ~TimeKeeper();
 
private:
 //기타 내용들
};
 
 
class AtomicClock : public TimeKeeper { ... };
 
class WaterClock  : public TimeKeeper { ... };
 
class WristWatch  : public TimeKeeper { ... };
 
cs

 

사용자는 시간 계산이 어떻게 되는지에 대해서는 신경 쓰고 싶지 않고, 시간 정보에 접근하고 싶어한다.

그래서 어떤 시간기록 객체에 대한 포인터를 손에 넣는 용도로 팩토리 함수(factory function)를 만들어 두면 좋을 것 같다.

 

 팩토리 함수

  - 새로 생성된 자식 클래스 객체에 대한 부모 클래스 포인터를 반환하는 함수.

 

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

 그래서 하기와 같이 포인터를 얻은 후에 포인터를 삭제해야 한다.

 

1
2
3
4
5
6
TimeKeeper *ptk = getTimeKeeper();
 
...
 
delete ptk;
 
cs

 

 그러나 위와 같이 사용하면 문제는 다음과 같다.

  1. getTimeKeeper 함수가 반환하는 포인터의 실제 객체는 파생 클래스의 객체(AtomicClock, WaterClock, WristWatch)이다.

  2. 이 포인터가 가리키는 객체가 삭제될 때는 기본 클래스의 소멸자가 호출되어 삭제된다. (포인터형은 부모클래스형이기 때문)

  3. 결정적으로 기본 클래스에 들어 있는 소멸자가 비가상 소멸자라는 점이다.

 

 우선 쉽게 설명하자면.. 하기와 같이 포인터형은 부모클래스형인데 객체 선언은 자식클래스형일 때, 부모클래스의 소멸자가

 비가상 소멸자이기 때문에 부모 클래스의 소멸자만 호출되고 자식 클래스의 소멸자는 호출되지 않게 되버린다. (하기 참조)

 즉, 부모 클래스 부분은 정상적으로 소멸이 되지만 자식 클래스 부분은 정상적으로 소멸이 되지 않게 된다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
int _tmain(int argc, _TCHAR* argv[])
{
 TimeKeeper* Atomic = new AtomicClock();
 TimeKeeper* Water = new WaterClock();
 TimeKeeper* Wrist = new WristWatch();
 
 delete Atomic;
 delete Water;
 delete Wrist;
 
 return 0;
}
 
cs

 

 

 C++의 규정에 의하면, 기본 클래스 포인터를 통해 파생 클래스 객체가 삭제될 때 그 기본 클래스에 비가상 소멸자가

 들어 있으면 프로그래밍 동작은 미정의 사항이라고 되어 있다.

 

 해결 방법

  - 기본 클래스의 소멸자에 virtual 키워드를 붙여주면 된다.

 

  가상 소멸자를 갖고 있지 않은 클래스를 보면 기본 클래스로 쓰지 않겠다는것으로 생각하면 된다.

  거꾸로 상속을 의도하지 않은 클래스를 작성할 때에는 가상 소멸자를 쓰지 않는 것이 좋다.

 

2. 가상 소멸자 주의점.

 

1
2
3
4
5
6
7
8
9
10
class Point
{
public:
 Point(int x, int y);
 ~Point();
 
private:
 int m_x, m_y;
};
 
cs

 

int가 32비트(4바이트)를 차지한다고 가정하면, 이 Point 객체는 64비트 레지스터에 딱 맞게 들어 갈 수 있다.

그리고 C나 포트란 등의 다른 언어로 작성된 함수에 넘길 일이 생길 때도 64비트 크기의 자료로 넘어 갈 것이다.

그러나 Point 클래스의 소멸자가 가상 소멸자로 선언되었다면 다른 이야기가 되버린다.

 

가상 함수를 c++에서 구현하면 해당 클래스 내부에 별도의 자료구조가 하나 들어가진다.

이 자료구조는 프로그램 실행 중 가상 함수가 호출될 때 어떤 가상 함수를 호출해야 하는지를 결정하는데 사용된다.

가상 함수를 하나라도 갖고 있는 클래스는 반드시 그와 관련된 vtbl을 갖고 있다.

1. vptr(virtual table pointer) : 가상 함수 테이블 포인터.

2. vtbl(virtual table) : 가상 함수 테이블.

 

위의 내용은 그냥 그렇다 이해하고 어찌됬든 중요한 것은 Point 클래스에 가상 함수가 들어가게 되면

Point 타입 객체의 크기가 커진다는 사실이다.

프로그램 실행 환경이 32비트 아키텍쳐라면 Ponit 타입의 사이즈는 96비트(int형 2개, vptr 1개)가 되고

64비트 아키텍쳐라면 Point 타입의 사이즈는 128비트가 되버린다.

이유는 64비트 시스템에서 포인터의 크기는 64비트이기 때문이다.

그렇게되면 결국 사이즈도 커지고 C 등의 다른 언어로 선언된 동일한 자료구조와도 호환성이 없어진다.

 

결과적으로 가상 소멸자를 선언하는 것은 그 클래스에 가상 함수가 하나라도 있는 경우를 기준으로 삼으면 된다.

 

또한 비가상 소멸자로 제작된 클래스를 상속받아 사용하는 몰지각한 프로그래머들이 가끔 있다.

한 예로 표준 string 타입인데, 표준 string은 비가상 소멸자로 제작되어 있기 때문에 string으로부터 상속받아

새로운 문자열 클래스를 제작하면 절대로 안된다. (STL 컨테이너 타입도 비가상 소멸자)

 

3. 순수 가상 소멸자 사용.

 

경우에 따라 순수 가상 소멸자를 두면 편리하게 사용할 수 있다.

순수 가상 함수는 해당 클래스를 추상 클래스(그 자체로는 인스턴스를 못 만드는 클래스)로 만든다.

프로그램을 짜다보면 추상 클래스로 만들고 싶으나 순수 가상 함수가 없을 때가 종종 있다.

이럴 때는 가상 소멸자를 순수 가상 소멸자로 선언하여 사용하면 된다.

 

여기서 주의할 점은 순수 가상 소멸자의 정의를 반드시 해줘야 한다는 것이다.

 

1
2
3
4
5
6
7
class AWOV
{
public:
    virtual ~AWOV() = 0//순수 가상 소멸자를 선언.
};
 
AWOV::~AWOV() { } //순수 가상 소멸자의 정의.
cs

 

소멸자의 호출 순서는 상속 계통 구조에서 가장 말단에 있는 파생 클라스의 소멸자가 먼저 호출되고 차례대로 기본 클래스

쪽으로 거쳐 올라가면서 각 클래스의 소멸자가 하나씩 호출된다.

그렇기 때문에 순수 가상 소멸자 또한 호출되기 때문에 정의가 없으면 링커 에러가 발생한다.

 

2줄 요약

 

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

   이 클래스의 소멸자도 가상 소멸자이어야 한다.

 

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

복사 기능을 막고 싶을 때

 

 예를 들어, 집에 대한 정보를 가지는 클래스가 있다고 치자. - Class : HomeForSale

 생각해보면 집에 대한 정보는 유일한 것이므로 HomeForSale 객체는 사본(copy)을 만드는 것 자체가 이치에 맞지 않는다.

 

1
2
3
4
5
6
HomeForSale home1;
HomeForSale home2;
 
HomeForSale home3(home1); //home1을 복사하면 안되는데 복사하려고 한다.
 
home1 = home2;            //home2를 복사하면 안되는데 복사하려고 한다.
cs

 

 상기와 같이 복사 생성자와 복사 대입 연산자는 암시적으로 자동으로 생성되기 때문에 원치않게 정상적으로 복사가 되버린다;;;

 

해결 방법 1

 

 1. 복사 생성자와 복사 대입 연산자를 직접 선언하되 어디서 호출되지 못하도록 private으로 하자!

 2. private으로만 하면 멤버 함수에서와 friend를 이용해서 접근이 가능하다는 허점이 존재함.

 3. 그러니 복사 생성자와 복사 대입 연사자를 선언만 해두고 정의는 일부러 구현하지 말자.

    그러면 복사 생성자와 복사 대입 연산자 호출 시에 링크 시점에 에러가 나기 때문이다.

 

실제로 라이브러리 내부에 있는 클래스들도 위와 같은 방법으로 구현을 많이 한다.

 

<예시>

1
2
3
4
5
6
7
8
9
10
11
class HomeForSale
{
public:
    //여러 멤버 함수들 존재.
 
private:
    //여러 멤버 변수들 존재.
 
    HomeForSale(const HomeForSale&);            //선언만 하고 정의는 X
    HomeForSale& operator=(const HomeForSale&); //선언만 하고 정의는 X
};
cs

 

 복사 생성자와 복사 대입 연산자에 매개 변수의 이름이 빠져있다.

 선언 시 매개변수 이름은 필수 사항이 아니며 그냥 읽기 편하라고 해 주는 관계일 뿐이다.

 애초에 사용될 일 자체가 업으니 매개 변수 이름을 넣을 이유도 없다.

 

 해결 방법 2

 

 해결 방법 1 만으로도 완벽하지만 다만 에러 발생 시점을 링크 시점이 아닌 컴파일 시점으로 변경시킬 수 있다.

 에러 탐지는 나중으로 미루는 것보다 미리하는 것이 좋으므로 해결 방법 2가 더 좋을 수도 있다.

 

 복사 생성자와 복사 대입 연산자를 private으로 하되, HomeForSale 클래스 내부에 넣지 말고 별도의 기본 클래스에

 넣고 이 기본 클래스로부터 상속받는 방식이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Uncopyable
{
protected:
    Uncopyable() {}
    ~Uncopyable() {}
 
private:
    Uncopyable(const Uncopyable&);
    Uncopyable& operator=(const Uncopyable&);
};
 
class HomeForSale : private Uncopyable
{
public:
    //여러 멤버 함수들 존재.
 
private:
    //여러 멤버 변수들 존재.
};
cs

 

 그런데 이렇게 하면 다중 상속을 하게될 요지가 있음.

 

 내 생각에는 그냥 해결 방법 1만으로 충분하기 때문에 해결 방법 1을 사용할거임.

 

2줄 요약

 

 - 복사 본을 만들고 싶지 않은 클래스는 복사 생성자와 복사 대입 연산자를 private으로 선언만 하고 구현은 하지 말자.

 

 - Uncopyable과 비슷한 클래스를 쓰는 것도 한 방법이다.

비어있는 class를 설계해도 컴파일러가 저절로 선언해 주도록 되어있는 멤버 함수들이 있다.

 

1) 생성자, 소멸자

2) 복사 생성자

3) 복사 대입 연산자

 

1
2
3
4
class Empty
{
 
};
cs

 

위와 같이 클래스를 만들어놓고 컴파일을 하면 하기와 같이 자동으로 컴파일러가 만들어준다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Empty
{
public:
    Empty()    //기본 생성자                        
    {
 
    }
 
    ~Empty() //소멸자
    {
 
    }
 
    Empty(const Empty& rhs)    //복사 생성자
    {
        //복사 과정
    } 
 
    Empty& operator=(const Empty& rhs)    //복사 생성자
    {
        //복사 대입 과정
    }
};
cs

 

그러나 아무 때나 컴파일러가 만드는 것이 아니라 컴파일러가 꼭 필요하다고 판단할 때만 만들어진다.

 

컴파일러가 하기 멤버 함수를 자동으로 만드는 조건

 

 1) 생성자, 소멸자 → Empty e1; 코드가 있을 때

 2) 복사 생성자 → Empry e2(e1); 코드가 있을 때

 3) 복사 대입 연산자 → e2 = e1; 코드가 있을 때

 

Detail

 

1) 생성자, 소멸자

 

 만약 개발자가 클래스 내에 인자가 있는 생성자를 만든 경우에는 컴파일러가 기본 생성자를 자동으로 만들지 않는다.

 소멸자의 경우에는 현재 클래스의 부모 클래스에 선언된 소멸자가 가상 소멸자이면 가상 소멸자로 자동으로 생성되고

 비가상 소멸자이면 비가상 소멸자로 컴파일러가 생성한다. (소멸자의 가상성을 부모로부터 물려받음)

 

2) 복사 생성자

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include "stdafx.h"
#include <iostream>
 
template<typename T>
class NamedObject
{
public:
    NamedObject(const char *name, T& value);
    NamedObject(const std::string& name, const T& value);
 
private:
    std::string nameValue;
    T ObjectValue;
};
 
template<typename T>
NamedObject<T>::NamedObject(const char *name, T& value) : nameValue(name), ObjectValue(value)
{
    std::cout<<"char"<<std::endl;
}
 
template<typename T>
NamedObject<T>::NamedObject(const std::string& name, const T& value) : nameValue(name), ObjectValue(value)
{
    std::cout<<"string"<<std::endl;
}
 
int _tmain(int argc, _TCHAR* argv[])
{
    NamedObject<int> no1("Smallest Prime Number",2);
    NamedObject<int> no2(no1); //복사 생성자 호출.
 
    return 0;
}
 
 
cs

 

 위의 코드와 같이 복사 생성자나 복사 대입 연산자는 선언되어 있지 않기 때문에, 복사 생성자를 호출하면 컴파일러는

 복사 생성자를 자동으로 만들어준다.

 

 복사 생성자는 no1.nameValue와 no1.ObjectValue를 사용하여 no2.nameValue와 no2.ObjectValue를 초기화해준다.

 nameValue는 표준 string 타입으로 자체적으로 복사 생성자를 가지고 있으므로 no2.nameValue의 초기화는 no1.nameValue를 인자로

 넘겨 호출함으로써 이루어진다.

 ObjectValue는 int형 기본 타입이므로 no2.ObjectValue의 초기화는 no1.ObjectValue의 비트를 그대로 복사해 진행한다.

 

3) 복사 대입 연산자

 

 복사 대입 연사자는 일반적인 것만 놓고 보면 복사 생성자와 크게 다르지 않지만 조건이 있다.

 조건이 맞지 않다면 컴파일 에러 발생.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include "stdafx.h"
#include <iostream>
 
template<class T>
class NamedObject
{
public:
     NamedObject(std::string& name, const T& value);
 
private:
    std::string& nameValue;
    const T ObjectValue;
};
 
template<typename T>
NamedObject<T>::NamedObject(std::string& name, const T& value) : nameValue(name), ObjectValue(value)
{
 
}
 
int _tmain(int argc, _TCHAR* argv[])
{
    std::string Dog1("딸기");
    std::string Dog2("뚱이");
 
    NamedObject<int> p(Dog1,2);
    NamedObject<int> s(Dog2,3);
 
    p = s; //error C2582 : 'operator=' 함수는 'NamedObject<T>'에서 사용할 수 없습니다.
 
    return 0;
}
cs

 

상기 코드를 보면 클래스 멤버로 참조자와 상수(const)가 있다.

 각 각 선언을 해주면 p.NamedObject는 Dog1을 참조하고 있게 되고 s.NamedObject는 Dog2를 참조하게 된다.

 그런데 p = s; 과정을 거치게 되면...

 p.NamedObject는 s.NamedObject가 가리키고 있는 Dog2를 가르키는 것으로 바뀌어야 한다.

 그리고 p.ObjectValue 또한 s.ObjectValue에 저장된 값으로 변경이 되어야 한다.

 

 그러나 C++에서는 참조자는 한 번 참조하면 다른 것을 참조하지 못하도록 되어있고 상수는 값이 설정되면 변경을 할 수가 없다.

 그러므로 컴파일러는 자기 멋대로 operator= 함수를 자동으로 만들어 참조자와 상수를 변경하기 애매해진다.

 그래서 컴파일러는 컴파일 에러를 발생시켜 버린다.

 

 즉, 참조자와 상수가 멤버로 있는 클래스의 경우는 복사 대입 연사자가 자동으로 생성되지 않고 컴파일 에러가 발생하게 된다.

 만약 복사 대입 연산자를 사용하고 싶으면 직접 선언해서 사용해야 한다.

 

 당연한 얘기지만 참조자와 상수가 멤버로 있어도 복사 연산자는 아무 상관이 없다.

 NamedObject s(p) 로 복사 연산자를 호출되어도 s 객체 내부의 참조자와 상수는 아직 초기화가 되어있지 않은 상태에서 최초로

 p의 객체 내 정보로 초기화가 되어지는 것이기 때문이다.

 

1줄 요약

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

c++에서는 객체의 멤버 변수의 값이 초기화가 보장되는 경우도 있고 아닌 경우도 있다.

어떤 플랫폼의 경우는 미초기화 객체를 읽기만 해도 프로그램이 다운되기도 한다.

c++에는 초기화가 보장되는 경우와 아닌 경우에 대해 규칙이 있지만 규칙이 조금 복잡하다.

c++내의 c언어 부분은 초기화가 보장되지 않으나 c언어가 아닌 부분에서는 초기화가 보장된다.

 

그러므로 프로그래머는 모든 객체를 사용 하기 전에 그 객체의 모든 것을 항상 초기화하자!!!

그러나 대입을 초기화와 헷갈려서는 절대 안된다.

 

이니셜라이즈를 이용한 초기화하기

 

C++ 규칙에 의하면 어떤 객체이든 그 객체의 데이터 멤버는 생성자의 본문이 실행되기 전에 초기화

되어야 한다고 명시되어 있다.

 

<대입> 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Person
{
public:
    Person(const std::string& name, const std::string& address, int Age); 
    virtual ~Person();
 
private:
    std::string m_theName;
    std::string m_theAddress;
    int m_Age;
};
 
Person::Person(const std::string& name, const std::string& address, int Age)
{
    //생성자에서 하고 있는 이 행위는
    //초기화가 아니고 대입이다...
    m_theName = name;
    m_theAddress = address;
    m_Age = Age;
}
 
cs

 

 - Person 생성자에 진입하기 전에 이미 m_theName, m_theAddress 객체의 기본 생성자가 호출되었다.

 - m_Age는 기본제공 타입인데, 기본제공 타입의 경우에는 생성자 진입전에 초기화되리란 보장이 없다.

 - 위의 대입의 경우 m_theName, m_theAddress의 기본 생성자를 호출해서 초기화를 해놓은 후에
   Person 생성자에서 대입 복사 생성자를 연달아 호출하게 된다. (비효율적)

 

<초기화>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Person
{
public:
    Person(const std::string& name, const std::string& address, int Age); 
    virtual ~Person();
 
private:
    std::string m_theName;
    std::string m_theAddress;
    int m_Age;
};
 
Person::Person(const std::string& name, const std::string& address, int Age)
: m_theName(name),
  m_theAddress(address),
  m_Age(Age)
{
    //이니셜라이즈를 이용해서 초기화..
}
 
cs

 

 - Person 생성자에 진입하기 전에 m_theName은 name으로부터 복사 생성자에 의해 초기화 되고,
   m_theAddress는 address로부터 복사 생성자에 의해 초기화되기 때문에 대입 방식보다 효율적이다.

 - 기본제공 타입의 경우에는 이니셜라이즈와 대입의 차이가 없으나 통일성을 위해 이니셜라이즈로 해주는 것이 좋다.

 

<매개변수가 없는 경우>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Person
{
public:
    Person();
    virtual ~Person();
 
private:
    std::string m_theName;
    std::string m_theAddress;
    int m_Age;
};
 
Person::Person()
: m_theName(),
m_theAddress(),
m_Age(0)
{
    //매개변수가 없더라도 이니셜라이즈를 사용할 것.
}
 
cs

 

* 상수와 참조자처럼 필수적으로 초기화를 해줘야하는 것들이 있는데 이런 경우가 있는 것처럼 어떤 변수는 초기화를 해주고

  어떤 변수는 초기화를 안하고 매번 고민할 필요없이, 모든 멤버 변수를 이니셜라이즈하자.

 

* 생성자가 여러개 있는 경우에도 모습은 이뻐보이지 않더라도 모든 생성자에 각 각 이니셜라이즈를 붙여주는게 좋다.

 

초기화 순서

 

어떤 컴파일러를 막론하고 항상 똑같은 초기화 순서가 있다. 반드시 알아둬야 한다.

 

 1) 부모 클래스는 자식 클래스보다 먼저 초기화된다.

 2) 클래스 데이터 멤버는 선언된 순서대로 초기화된다. (이니셜라이즈에 순서가 바뀐다하더라도 선언된 순서로 초기화 진행.)

     그래서 혼동을 막기 위해 선언된 순서와 이니셜라이즈 순서를 항상 맞춰주자.

 

 3) 비지역 정적 객체의 초기화 순서는 개별 번역 단위에서 정해진다.

 

  * 정적 객체 (static object)

  

   - 자신이 생성된 시점부터 프로그램이 끝날 때까지 살아 있는 객체. (main 함수의 실행이 끝날 때 정적 객체의 소멸자가 호출됨.)

     즉, 스택 객체 및 힙 기반 객체는 애초부터 정적 객체가 될 수 없다.

 

   - 비지역 정적 객체

      1) 일반 전역 객체.

      2) 네임스페이스 유효범위에서 정의된 객체.

      3) 클래스 안에서 static으로 선언된 객체.

      4) 파일 유효범위에서 static으로 정의된 객체.

 

   - 지역 정적 객체

      1) 함수 안에서 static으로 선언된 객체.

 

  * 번역 단위 (translation unit)

 

   - 컴파일을 통해 하나의 목적 파일(object file)을 만드는 바탕이 되는 소스 코드.

   - 여기서 번역은 소스의 언어를 기계어로 옮긴다는 의미.

   - 기본적으로 목적 파일은 소스 파일 하나가 되는데, #include하는 파일들까지 합쳐서 하나의 번역 단위가 됨.

   - 즉, 컴파일 전 AA.cpp, BB.cpp → 컴파일 후 AA.obj(하나의 번역 단위), BB.obj(하나의 번역 단위)

 

 ★ 별개의 번역 단위에서 정의된 비지역 정적 객체들의 초기화 순서는 정해져 있지 않다.

     즉, AA.cpp 에 있는 비지역 정적 객체를 BB.cpp에서 사용할 때 사용하는 시점에 AA.cpp의 비지역 정적 객체의 초기화가

     진행됬을수도 있고 진행되지 않았을 수도 있기 때문에 문제 발생의 원인이 된다.

 

<AA.cpp>

1
2
3
4
5
6
7
class FileSystem
{
public:
    std::size_t numDisks() const;
};
 
extern FileSystem tfs;
cs

 

 

<BB.cpp>

1
2
3
4
5
6
7
8
9
10
11
12
13
class Directory
{
private:
    std::string m_strFilePath;
public:
    Directory(std::string strFilePath);
};
 
Directory::Directory(std::string strFilePath) : m_strFilePath(strFilePath)
{
    std::size_t disks = tfs.numDisks(); //이 부분에서 tfs 객체의 초기화 여부를 알 수 없다.
                                        //만약 초기화가 되어있지 않다면 문제 발생
}
cs

 

<Main>

1
Directory oDir("C:\\My\\Direct");
cs

 

 문제점

  - oDir 객체 생성 시 Directory 생성자에서 tfs 객체가 아직 초기화가 이미 되었는지 아직 되지 않았는지 알 수 없다.

  - 서로 다른 번역 단위에 정의된 비정적 객체이기 때문.

 

 해결 방법

  - 비지역 정적 객체를 지역 정적 객체로 수정.

  - 비지역 정적 객체를 맡는 함수를 준비하고 이 함수 안에서 정적 객체를 반환. (싱글톤 패턴)

 

<AA.cpp>

1
2
3
4
5
6
7
8
9
10
11
class FileSystem
{
public:
    std::size_t numDisks() const;
};
 
FileSystem& tfs()
{
    static FileSystem fs;
    return fs;
}
cs

 

<BB.cpp>

1
2
3
4
5
6
7
8
9
10
11
12
13
class Directory
{
private:
    std::string m_strFilePath;
public:
    Directory(std::string strFilePath);
};
 
Directory::Directory(std::string strFilePath) : m_strFilePath(strFilePath)
{
    std::size_t disks = tfs().numDisks();
}
 
cs

 

 tfs 객체를 바로 쓰지않고 tfs()를 호출하여 사용.

 지역 정적 객체는 함수 호출 중에 그 객체의 정의에 최초로 닿았을 때 한번 초기화되도록 만들어져 있다. (C++ 보장)

 단일 스레드 애플리케이션에서는 상기와 같은 방식으로 하면 문제 없음.

 그러나 다중스레드에서는 비상수 정적 객체는 온갖 골칫거리의 시한폭탄임.

 

3줄 요약

 

 - 기본 제공 타입의 객체는 직접 초기화해라. (경우에 따라 되기도 하고 안되기도 하기 때문)

 

 - 이니셜라이즈를 통해 초기화를 해라.

 

 - 비지역 정적 객체들의 초기화 순서에 주의하여 설계해라.

 

+ Recent posts