1
2
3
4
5
std::string* stringArray = new std::string[100];
 
...
 
delete stringArray;
cs

 

위 코드를 보면 stringArray가 가리키는 100개의 string 객체들 가운데 99개는 정상적인 소멸 과정을 거치지 못할 것이다.

 

객체 배열 해제 시에는 []delete를 해야만 하는 이유를 알아보자.

 

new 연사자를 사용하면 다음과 같은 2가지 내부 동작을 진행한다.


 1. 메모리 할당. (operator new라는 이름의 함수가 쓰임.)
 2. 할당된 메모리에 대해 한 개 이상의 생성자가 호출.

 

이와 마찬가지로 delete 연산자를 사용하면 2가지 내부 동작을 진행한다.

 

 1. 할당된 메모리에 대해 한 개 이상의 소멸자가 호출.
 2. 메모리 해제. (operator delete라는 이름의 함수가 쓰임.)

 

여기서 단일 객체를 new / delete 하는 경우와 객체 배열을 new/delete 하는 경우의 메모리 배치구조는 서로 다르다.

 

단일 객체의 힙 메모리 배치 구조.

 

Object

           

 

객체 배열의 힙 메모리 배치 구조.

 

6

Object

Object 

Object 

Object 

Object 

Object 

 

상기와 같이 대다수의 컴파일러의 경우에는 객체 배열이 힙 메모리에 할당되면 맨 앞에 배열의 크기 정보가 함께 배치된다.

이 때문에, delete 연산자는 소멸자가 몇 번 호출될지를 쉽게 알 수 있게된다.

즉, []delete는 앞쪽의 배열 크기를 읽고, 배열 크기에 해당하는 횟수만큼 소멸자를 호출하게 된다.


만약 delete를 사용하게되면 그냥 단일 객체라 간주하고 소멸자 호출 횟수가 부족해져 메모리 누수가 발생할 것이다.

 

 

1
2
3
std::string stringObjec = new std::string//단일 객체.
 
[]delete stringObjec; //단일 객체를 []delete로 해제.
cs

 

반대로 위와 같이 단일 객체를 해제하려고 할 때, []delete 연산자를 사용한다면... []delete 연산자는 stringObject 객체 메모리 앞쪽의

몇 바이트를 읽어 배열 크기라고 해석할 것이다. 그리고 배열 크기만큼 소멸자를 호출할텐데 결국 엉뚱한 메모리를 해제하려 할테고

알 수 없는 동작이 진행될 것이다.

 

그러므로 new 연산자에 []를 썼으면, delete 연산자에도 []를 써야 한다는 간단한 규칙만 생각해두면 된다.

 

 

1
2
3
4
5
6
7
typedef std::string stringArray[4];
 
std::string *pa1 = new stringArray; //new stringArray이 new string[4]라는 점을 잊으면 안된다.
 
...
 
delete[] pa1;
cs

 

또한 typedef로 정의된 어떤 타입의 배열을 생성하려고 new를 썼다면 delete 또한 []를 잊지 말아야 한다.

위의 예제와 같이 typedef를 사용한 경우도 결국 배열이다.
그러나 저렇게 배열 타입이 눈에 명확하게 보이지 않기 때문에, 배열 타입을 typedef 타입으로 만들지 않는 것이 좋다.

 

1줄 요약


 - new 표현식에 []를 썼으면, 대응되는 delete 표현식에도 []를 쓰자.
   마찬가지로 new 표현식에 []를 안 썼으면, 대응되는 delete 표현식에도 []를 쓰지 말자.

자원 관리 클래스는 실수로 터질 수 있는 자원 누출을 튼튼히 막아 주는 보호벽 역할을 해준다.
그러나 이미 많이 사용하고 있는 API들이 직접 참조하도록 만들어져 있어서, 자원 관리 객체의 보호벽을 슬그머니 넘어가

실제 자원을 직접 조작해야 할 일이 있다.

 

13장에서 작성했던 Investment 예제를 사용해서 shared_ptr을 통해 Investment 객체를 가리킨다고 하자.

 

1
2
3
4
5
6
7
8
9
10
11
int dayHeld(const Investment *pi)
{
    ...
}
 
void func()
{
    std::tr1::shared_ptr<Investment> pInv(createInvesment());
 
    int days = dayHeld(pInv);    //컴파일 에러 발생.
}
cs

 

위와 같이 dayHeld라는 Investment* 타입의 매개변수를 가진 함수에 스마트 포인터 객체를 전달한다면 컴파일 에러가 발생한다.

이유는 dayHeld 함수는 Investment* 타입의 실제 포인터를 원하는데, tr1::shared_ptr<Investment> 타입의 객체를 넘겨주었기

때문이다. 

사정이 이렇다 보니, RAII 클래스(shared_ptr)의 객체를 그 객체가 감싸고 있는 실제 자원(Investment*)으로 변환해주어야 한다.

 

변환할 방법으로는 명시적 변환(explicit conversion)과 암시적 변환(implicit convension)이 있다.

tr1::shared_ptr과 auto_ptr은 명시적 변환을 수행하는데 get이라는 멤버 함수를 통해 실제 자원을 얻어낼 수 있다.

 

1
    int days = dayHeld(pInv.get());
cs

 

잘 설계된 스마트 포인터 클래스라면 거의 모두가 그렇듯, 포인터 역참조 연산자(operator-> 및 operator*)를 오버로딩

하고 있다. 따라서 스마트 포인터 클래스가 자신이 관리하는 실제 포인터에 대한 암시적 변환도 쉽게 가능하게 된다.

 

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 CTest
{
public:
    void PRINT(std::string strText)
    {
        printf("%s\n",strText.c_str());
    }
};
 
CTest* CreateTest()
{
    return new CTest();
}
 
int _tmain(int argc, _TCHAR* argv[])
{
    std::tr1::shared_ptr<CTest> spTest(CreateTest());
    std::auto_ptr<CTest> apTest(CreateTest());
 
    //shared_ptr
    spTest->PRINT("AAAA");
    (*spTest).PRINT("BBBB");
 
    //auto_ptr
    apTest->PRINT("CCCC");
    (*apTest).PRINT("DDDD");
 
    return 0;
}
 
cs

 

RAII 객체 안에 들어 있는 실제 자원에 대한 접근을 매끄럽게 할 수 있도록 암시적 변환을 제공하는 설계자들도 있다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
FontHandle getFont();            //C API에서 가져온 함수, 매개변수는 생략.
 
void releaseFont(FontHandle fh); //C API에서 가져온 함수.
 
class Font
{
public:
    explicit Font(FontHandle fh) //자원 획득. 값에 의한 전달이 수행되는 것은
    : f(fh)                      //자원 해제를 C API로 하기 때문.
    {
 
    }
    ~Font()
    {
        releaseFont(f);
    }
 
private:
    FontHandle f; //실제 Font 자원

 };

cs

 

FontHand의 규모가 무척 크다고 가정하면, Font 객체를 FontHandle로 변환해야 할 경우도 무척 많을 것이다.

그래서 Font 클래스에서는 이를 위한 명시적 변환 함수로 get을 제공하도록 만들 수 있다.

 

<명시적 형변환 추가.>

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
FontHandle getFont();        //C API에서 가져온 함수, 매개변수는 생략.
 
void releaseFont(FontHandle fh); //C API에서 가져온 함수.
 
class Font
{
    ...
    FontHandle get() const //명시적 변환.
    {
        return f;
    }
private:
    FontHandle f;
};
 
void changeFontSize(FontHandle f, int newsize); //폰트 API의 일부

 

 

//Form Class
int func()
{
    Font font(getFont());
    int newFontSize;
 
    ...
 
    changeFontSize(f.get(), newFontSize);    //Font에서 FontHandle로 명시적 변환.
}
cs

 

그런데 저렇게 자주 변형을 해줘야하는 경우에 사용자는 변환할 때마다 get을 호출해줘야 하기 때문에 짜증이나 Font 클래스를

사용하지 않게 될 수 있다. 그래서 FontHandle로의 암시적 변환 함수를 Font에서 제공하도록 한다.

 

<암시적 형변환 추가>

 

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
FontHandle getFont();        //C API에서 가져온 함수, 매개변수는 생략.
 
void releaseFont(FontHandle fh); //C API에서 가져온 함수.
 
class Font
{
    ...
    
    operator FontHandle() const //암시적 변환 함수.
    {
        return f;
    }
private:
    FontHandle f;
};
 
void changeFontSize(FontHandle f, int newsize); //폰트 API의 일부
 
//Form Class
 
int func()
{
    Font font(getFont());
    int newFontSize;
 
    ...
 
    changeFontSize(f, newFontSize);    //Font에서 FontHandle로 암시적 변환.
}
cs

 

암시적으로 변환이 되기 때문에 매끄럽게 사용하기는 좋지만 진짜 Font를 쓰려고 한 부분에서 원하지 않게 FontHandle로 바뀔 수 있다.

 

1
2
3
4
5
6
7
8
9
10
int func1()
{
    Font f1(getFont());
 
    ...
 
    FontHandle f2 = f1; //여기서 문제 발생.
                        //원래 의도는 Font 객체를 복사하는 것이었는데
                        //엉뚱하게도 f1이 FontHandle로 바뀌고 나서 복사됨.
}
cs

 

이렇게 되면 Font 객체인 f1이 관리하고 있는 Fonthandle이 f2를 통해서도 직접 사용할 수 있는 상태가 된다.

하나의 자원이 양다리를 걸치고 있는 상황은 좋지 않다. 특히 f1이 소멸되버리면 f2는 해제된 폰트를 가리키고 있는 꼴이 된다.

 

RAII 클래스를 실제 자원으로 바꾸는 방법은 특정한 용도와 사용 환경에 따라 달라지는데, 어쨌든 가장 잘 설계된 클래스라면 뒤에서 배울

맞게 쓰기에는 쉽고, 틀리게 쓰기에는 어려워야 한다.

항상 그런것은 아니지만 명시적 변환을 제공하는 쪽이 나을 때가 많다. 그러나 암시적 변환에서 생기는 사용 시의 자연스러움이 빛을 발하는

경우도 있다.

 

2줄 요약

 

 - 실제 자원을 직접 접근해야 하는 기존 API들도 많기 때문에, RAII 클래스를 만들 때는 그 클래스가 관리하는 자원을 얻을 수 있는 방법을

   열어주어야 한다.

 

 - 자원 접근은 명시적 변환과 암시적 변환이 있는데, 안정성만 따진다면 명시적 변환이 대체적으로 더 낫지만, 고객 편의성을 놓고 보면

   암시적 변환이 괜찮다. (즉, 스스로 잘 판단해서 만들어라.)

힙 기반 자원에 대해서 auto_ptr과 tr1::shared_ptr 클래스로 관리하는 내용을 알아봤었다.
그러나 모든 자원은 힙에서 생기지 않고 힙에 생기지 않는 자원을 스마트 포인터로 처리해주는 것 또한 말이 되지 않는다.
항상 그런 것은 아니지만 자원 관리 클래스를 스스로 만들어야 할 필요를 느끼는 경우들이 있다.

 

예를 들어 Mutex 타입의 뮤텍스 객체를 조작하는 C API를 사용하는 중이라고 가정해보자.
C API에서 제공하는 함수 중엔 lock 및 unlock이 있다.

 

1
2
3
void lock(Mutex* pm);  //pm이 가리키는 뮤텍스에 잠금을 건다.
 
void unlock(Mutex* pm); //pm이 가리키는 뮤테스의 잠금을 푼다.
cs

 

그런데 뮤텍스 잠금을 관리하는 클래스를 하나 만들고 싶은데 이는 뮤텍스 잠금을 잊지 않고 풀어 주고싶기 때문이다.
이런 용도의 클래스는 기본적으로 RAII 버칙을 따라 구성한다. 즉, 생성 시에 자원을 획득하고 소멸 시에 자원을 해제하는 것이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Lock
{
public:
    explicit Lock(Mutex* pm) : m_pMutex(pm)
    {
        lock(m_pMutex); //자원 획득
    }
 
    ~Lock()
    {
        unlock(m_pMutex); //자원 해제
    }
 
private:
    Mutex* m_pMutex;
};
 
cs

 

사용자는 이제 Lock을 사용할 때 RAII 방식에 맞춰 사용하면 된다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
void Func()
{
    Mutex m;        //뮤텍스 정의
 
    ...
 
    {                //임계 영역을 정하기 위해 블록 생성
        Lock(&m);    //뮤텍스 잠금
 
        ...
    }                //Block이 끝나면서 뮤텍스에 걸렸던 잠금이 자동으로 풀림.
                    
}
cs

 

여기까지 보면 문제가 없을 것 같지만 만약 Lock 객체가 복사가 된다면 어떤 동작이 이루어져야 할지 생각해봐야 한다.

 

1
2
Lock m1(&m); //m에 잠금을 건다.
Lock m2(m1); //m1을 m2로 복사한다.
cs

 

선택 1 : 복사를 금지한다.

 

 RAII 객체가 복사되도록 놔두는 것 자체가 말이 안되는 경우가 꽤 많다.
 앞에 항목6에서 했던 것처럼 복사 연산을 private 멤버로 만들어준다.

 

선택 2 : 관리하고 있는 자원에 대해 참조 카운팅을 수행한다.

 

 자원을 사용하고 있는 마지막 객체가 소멸될 때까지 자원을 해제시키지 않아야하는 경우도 종종 있다.
 그렇다면 자신의 자원 관리 클래스에 참조 카운팅 방식의 복사 동작을 넣고 싶을 때, tr1::shared_ptr을 데이터 멤버로 넣으면

 간단히 해결될 것이다. 즉, Lock이 참조 카운팅 방식으로 돌아가면 좋을 것 같다고 생각하여 m_pMutex의 타입을 Mutex*에서

 tr1::shared_ptr<Mutex>로 바꾸라는 것이다. 그러나 아쉽게도 tr1::shared_ptr은 참조 카운트가 0이 될 때 자신이 가리키는 객체를

 삭제해 버리도록 되어있다. 내가 원한 것은 Mutex를 다 썼을 때 잠금만 해제하는 것이지 삭제까지는 하고 싶은 것은 아니였다.

 

 다행히도 tr1::shared_ptr이 '삭제자' 지정을 허용한다는 점이다. 삭제자란, tr1::shared_ptr이 유지하는 참조 카운트가 0이 되었을 때

 호출되는 함수 혹은 함수 객체를 말한다. (auto_ptr은 삭제자 기능 없음.)

 삭제자는 tr1::shared_ptr 생성자의 두 번째 매개변수로 선택적으로 넣어 줄 수 있다.

 그래서 하기와 같은 클래스 형태로 변경될 수 있다.

 

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
void lock(std::_Mutex* pm)
{
    pm->_Lock();
}
 
void unlock(std::_Mutex* pm)
{
    pm->_Unlock();
}
 
class Lock
{
public:
    explicit Lock(std::_Mutex* pm) : m_pMutex(pm, unlock) //삭제자로 unlock 함수 설정.
    {
        lock(m_pMutex.get()); //get에 대한 것은 다음 항목에서 설명.
    }
 
private:
    std::tr1::shared_ptr<std::_Mutex> m_pMutex; //shared_ptr 사용.
};
 
 
int _tmain(int argc, _TCHAR* argv[])
{
    std::_Mutex mutex;
 
    {
        Lock oLock(&mutex);
 
        //현재 Block 동안에는 Lock이 걸려져 있다.
    }
 
    return 0;
}
cs

 

 여기서 특이한 점은 Lock 클래스의 소멸자가 없어졌다는 점이다. 이유는 필요 없기 때문이다.

 항목5에서 얘기했듯이 클래스의 소멸자는 비정적 멤버 변수들의 소멸자를 자동으로 호출해준다.

 m_pMutex 스마트 포인터도 비정적 멤버 변수이기 때문에 Lock 객체가 소멸될 때 자동으로 소멸이 된다.

 

 즉, m_pMutex는 참조 카운트가 0이 되면 소멸되는 것이 아니라 삭제자로 지정된 unlock 함수를 호출하여

 Mutex 잠금을 해제하고 나서 해당 Lock 객체가 소멸될 때에 비로서 Mutex 멤버 변수는 해제되는 것이다.

 

 여기서 주의할 점

 만약 외부에서 다른 shared_ptr 포인터가 mutex를 참조하고 있다면 어떻게 되는 것일까??

 하기 주석 참고...

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int _tmain(int argc, _TCHAR* argv[])
{
    std::_Mutex mutex;
    std::tr1::shared_ptr<std::_Mutex> pMutex;
 
    {
        Lock oLock(&mutex); //Lock 동작.
 
        pMutex = oLock.GetPtr(); //oLock 객체의 스마트 포인터 반환. (mutex를 가리키고 있는 스마트 포인터)
                                 //이 구간에서 pMutex와 oLock의 m_pMutex 2개의 스마트 포인터가 mutex를 가리키고 있다.
    
    }                            //Block이 종료되면서 oLock 객체는 소멸되기 때문에 자동적으로 m_pMutex 또한 소멸된다.
                                 //그런데... 아직 pMutex는 mutex를 가리키고 있는 중이다. (즉, mutex를 pMutex만 가리키게 됨) 
                                 //Lock 클래스에서 m_pMutex의 삭제자로 지정한 unlock은 mutex를 가리키는 참조자가 0개여야지만
                                 //삭제자를 호출한다. 그러므로 이 구간이 종료되어 oLock 객체가 소멸되었지만 unlock 함수는 호출되지
                                 //않는다.
 
    ...                          //여러가지 처리 중....
 
    return 0;                     
 
}                                //이 부분에 도달해야 pMutex 또한 소멸되어진다. 그러므로 mutex를 가르키고 있는 객체는 0개가 되고
                                 //unlock 함수가 호출된다.
                                 //이렇게 되면 결국 lock 범위는 내가 생각한 것처럼 되지 않는다.
cs

 

선택 3 : 관리하고 있는 자원을 진짜로 복사한다.

 

 때에 따라서는 자원을 원하는 대로 복사할 수도 있다. 이때는 자원을 다 썼을 때 각각의 사본을 확실히 해제해줘야 한다.
 자원 관리 객체를 복사하면 이 객체가 관리하는 자원까지 모두 복사가 되어야 한다.
 즉, 깊은 복사를 수행해야 한다는 이야기이다.

 

선택 4 : 관리하고 있는 자원의 소유권을 옮긴다.

 

 흔한 경우는 아니지만, 특정원 자원에 대해 실제로 참조하는 RAII 객체가 딱 하나만 존재하도록 만들고 싶다면,

 객체가 복사될 때 그 자원의 소유권을 사본 쪽으로 아예 옮겨야 한다. auto_ptr이 바로 이런 방식이다.

 

 

2줄 요약

 

 - RAII 객체의 복사는 그 객체가 관리하는 자원의 복사 문제를 안고 가기 때문에, 그 자원을 어떻게 복사하느냐에 따라 RAII 객체의
   복사 동작이 결정된다.

 

 - RAII 클래스에 구현하는 일반적인 복사 동작은 복사를 금지하거나 참조 카운팅을 해 주는 선으로 마무리하는 것이다.

   하지만 이 외의 방법들도 가능하니 참고하자.

투자를 모델링해주는 클래스 라이브러리를 가지고 어떤 작업을 한다고 가정하자.

 

1
2
3
4
5
6
class Investment
{
    ...
};
 
Investment* createInvestment(); //팩토리 함수.
cs

 

createInvestment 함수를 통해 객체를 얻기 때문에 사용자가 직접 객체를 해제시켜줘야 한다.

 

1
2
3
4
5
6
7
8
9
void func()
{
    Investment *pInv = createInvestment(); //팩토리 함수를 통해 객체 생성.
 
    ...          //pInv를 가지고 이것 저것 처리.
 
    delete pInv; //반드시 사용자가 객체 해제.
}
 
cs

 

그러나 pInv를 가지고 처리를 하는 도중 문제가 발생하여 delete 부분에 도달하지 않고 함수를 빠져나가거나

사용자가 실수로 delete 구문을 빼먹을 수도 있다.
createInvestment 함수로 얻어낸 자원이 항상 해제되도록 만들 방법은 객체가 소멸될 때 소멸자에서 메모리를

해제시키는 방법이다.

 

표준 라이브러리를 보면 auto_ptr이라는 것이 있는데 auto_ptr은 포인터와 비슷하게 동작하는 객체(스마트 포인터)로서,

가리키고 있는 대상에 대해 소멸자가 자동으로 delete를 불러주도록 설계되어 있다.

 

1
2
3
4
5
6
7
void func()
{
    std::auto_ptr<Investment> pInv(createInvestment()); //팩토리 함수를 통해 객체 생성.
 
    ...
}        //auto_ptr의 소멸자를 통해 pInv를 삭제한다.
        //그러므로 사용자가 직접 delete를 호출할 필요가 없다.
cs

 

자원 관리에 객체를 사용하는 방법의 중요한 두 가지 특징이 있다.


1. 자원을 획득한 후에 자원 관리 객체에게 넘긴다.

 상기 예제를 보면, createInvestment 함수가 만들어준 자원은 그 자원을 관리할 auto_ptr 객체를 초기화하는 데 쓰이고 있다.
 실제로, 이렇게 자원 관리에 객체를 사용하는 아이디어에 대한 용어도 자주 통용되는데 '자원 획득 즉 초기화' (RAII)라 부른다.

 

2. 자원 관리 객체는 자신의 소멸자를 사용해서 자원이 확실히 해제되도록 한다.

 

 소멸자는 어떤 객체가 소멸될 때 자동적으로 호출되기 때문에, 실행 제어가 어떤 경위로 블록을 떠나는가에 상관없이 자원 해제가

 제대로 이루어지게 된다.

 

auto_ptr은 자신이 소멸될 때 자신이 가리키고 있는 대상에 대해 자동으로 delete를 하기 때문에, auto_ptr은 오직 1개의 객체만

가리키는 것이 가능하다. 그래서 auto_ptr을 이용해서 객체를 복사하면 원본 객체는 null이 되버린다.

 

1
2
3
4
5
6
7
    std::auto_ptr<Investment> pInv1(createInvestment()); //팩토리 함수를 통해 객체 생성.
 
    std::auto_ptr<Investment> pInv2(pInv1); //pInv2는 pInv1이 가리키던 객체를 가리키고
                                            //pInv1은 null이 된다.
 
    pInv1 = pInv2;    //pInv1은 pInv2가 가리키던 객체를 가리키고
                    //pInv2는 null이 된다.
cs

 

이러한 특성때문에, STL 컨테이너의 경우에는 원소들이 '정상적인' 복사 동작을 가져야 하기 때문에, auto_ptr은 STL 컨테이너들의

원소로 허용되지 않는다.

 

auto_ptr을 쓸 수 없는 상황이라면, 그 대안으로 참조 카운팅 방식 스마트 포인터(RCSP)를 사용하면 좋다.

RCSP는 특정 자원을 가리키는 외부 객체의 개수를 유지하고 있다가 그 개수가 0이 되면 해당 자원을 자동으로 해제시킨다.
마치 가비지 컬렉션과 비슷해보이지만, 참조 상태가 고리를 이루는 경우(예로 서로 다른 두 객체가 서로를 가리키는 경우)에는

자원을 해제시킬수 없다는 점은 가비지 컬렉션과 다르다.

 

1
2
3
4
5
6
7
8
void Func()
{
    std::tr1::shared_ptr<Investment> pInv1(createInvestment());
 
    std::tr1::shared_ptr<Investment> pInv2(pInv1);
 
    pInv2 = pInv1;
}
cs

 

tr1::shared_ptr을 사용하기 위해서는 VS2008 sp1 이상이여야 하며, #include <Memory>를 선언해줘야 한다.

 

auto_ptr, tr1::shared_ptr은 자원 관리를 하는 몇 가지 방법들 중 하나일 뿐 중요한 점은 자원 관리 객체를 사용해서 자원을

관리하는 것이 중요하다는 것이다.

 

알아둬야 할 점이 한 가지 더 있다. auto_ptr 및 tr1:shared_ptr은 소멸자 내부에서 delete 연산자를 사용한다.

delete[] 연산자가 아니다!! 즉, 동적으로 할당된 배열에 대해 auto_ptr이나 tr1::shared_ptr을 사용하면 절대 안된다는 것이다.

문제는 컴파일 에러도 발생하지 않는다는 점이다.

C++ 표준 라이브러리에서는 동적 할당된 배열을 위한 스마트 포인터가 제공되지 않는다.

이유는 동적으로 할당된 배열은 이제 vertor 및 string으로 거의 대체할 수 있기 때문이다.

 

2줄 요약

 

 - 자원 누출을 막기 위해, 생성자 안에서 자원을 획득하고 소멸자에서 그것을 해제하는 RAII 객체를 사용하자.

 

 - 일반적으로 RAIi 클래스는 tr1::shared_ptr과 auto_ptr이 있다. tr1::shared_ptr이 복사 시의 동작이 직관적이기 때문에

   사용하기 더 좋다. auto_ptr은 복사되는 객체를 null로 만들어 버린다.

 

 

 

 

캡슐화한 객체 지향 시스템 중 설계가 잘 된 것들을 보면, 객체를 복사하는 함수가 딱 둘만 있는 것을 알 수 있다.

복사 생성자와 복사 대입 연산자인데 이 둘을 통틀어 복사 함수라고 한다.

 

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
void logCall(const std::string& funcName);
 
class Customer
{
public:
    ...
    Customer(const Customer& rhs);              //복사 생성자.
    Customer& operator=(const Customer& rhs); //복사 대입 연산자.
    ...
 
private:
    std::string name;
};
 
Customer::Customer(const Customer& rhs) : name(rhs.name)
{
    logCall("Customer copy construction");
}
 
Customer& Customer::operator=(const Customer& rhs)
{
    logCall("Customer copy assignment operator");
 
    name = rhs.name;
 
    return *this;
}
cs

 

문제될 것이 없는 코드이지만 멤버 변수 하나를 추가하면서 문제가 발생한다.
기존에 작성된 코드에서 멤버 변수가 추가됨에 따라 생성자, 복사생성자, 복사 대입 연산자 모두를 수정해야한다.
혹여나 여기서 하나라도 빼먹게되면 부분 복사가 되버리게 된다.
특히, 상속 관계에서는 더욱 더 신경써줘야 한다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class priorityCustomer : public Customer
{
public:
    ...
    priorityCustomer(const priorityCustomer& rhs);
    priorityCustomer& operator=(const priorityCustomer& rhs);
    ...
private:
    int priority;
};
 
priorityCustomer::priorityCustomer(const priorityCustomer& rhs) : priority(rhs.priority)
{
    logCall("priorityCustomer copy constructor");
}
 
priorityCustomer& priorityCustomer::operator =(const priorityCustomer& rhs)
{
    logCall("priorityCustomer copy assignment operator");
 
    priority = rhs.priority;
 
    return *this;
}
cs

 

겉으로 보기에는 모든 정보를 복사하고 있는 것처럼 보이지만, 그렇지 않다.
PriorityCustomer에 선언된 데이터는 모두 복사하고 있는 것은 맞지만, 부모 class인 Customer로부터 상속한 데이터 멤버들은

복사가 되지 않고 있다.
Customer의 데이터들도 엄연히 파생클래스인 PriorityCustomer에 들어있기 때문에 Customer의 데이터도 반드시 모두 복사해줘야 한다.
PriorityCustomer의 복사 생성자에는 기본 클래스 생성자에 넘길 인자들도 명시되어 있지 않아서 PriorityCustomer형 객체의 Customer 부분은

인자없이 실행되는 Customer 기본 생성자에 의해 기본적인 초기화만 된다.


또한 PriorityCustomer의 복사 대입 연산자의 경우에는 기본 클래스의 데이터 멤버를 건드릴 시도도 하지 않기 때문에,

기본 클래스의 데이터 멤버는 변경되지 않고 그래로 있게 된다.


즉, 파생 클래스에 대한 복사 함수를 직접 만든다면 기본 클래스 부분을 복사에서 빠뜨리지 않도록 주의해야 한다.
기본 클래스의 멤버 변수들은 private일 가능성이 높으므로 직접 접근하기 어렵다.


그러므로 파생 클래스의 복사 함수 안에서 기본 클래스의 복사 함수를 호출하도록 만들어서 사용하면 된다.

 

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
class priorityCustomer : public Customer
{
public:
    ...
    priorityCustomer(const priorityCustomer& rhs);
    priorityCustomer& operator=(const priorityCustomer& rhs);
    ...
private:
    int priority;
};
 
priorityCustomer::priorityCustomer(const priorityCustomer& rhs)
Customer(rhs),    //기본 클래스의 복사 생성자를 호출한다.
  priority(rhs.priority)
{
    logCall("priorityCustomer copy constructor");
}
 
priorityCustomer& priorityCustomer::operator =(const priorityCustomer& rhs)
{
    Customer::operator=(rhs);  //기본 클래스 부분을 대입한다.
 
    logCall("priorityCustomer copy assignment operator");
 
    priority = rhs.priority;
 
    return *this;
}
cs

 

또한 복사 생성자와 복사 대입 연산자의 동작은 비슷하므로 함수 본문 또한 비슷하다.
그래서 아마도 코드 중복을 피하려고 한쪽에서 다른 한쪽을 호출하는 것을 생각할 수 있는데…. 절대 그래선 안된다.
(대입 연산자에서 복사생성자를 호출한다던지 복사생성자에서 대입 연사자를 호출한다던지)

복사 대입 연산자는 이미 생성된 객체에서만 사용이 가능한데 복사 대입 연산자에서 복사 생성자를 호출하면 이미 존재하는

객체를 또 만들려는 동작이 되버린다. (이미 태어난 아이에게 엄마 뱃속으로 들어가라는 꼴임)

헷갈릴까봐 적는거지만 생성과 동시에 = 를 이용해서 대입하면 복사 대입 연산자가 아닌 복사 생성자가 호출된다.

즉, 복사 대입 연산자는 이미 생성된 객체에서만 사용 가능하다는 의미이다.

 

거꾸로 복사 생성자에서 복사 대입 연산자를 호출한다는 것 또한 말이 되지 않는다.

대입 연산자의 의미는 이미 초기화가 끝난 객체에게 값을 주는 것인데 아직 초기화가 완료되지 않은 객체에 대입을 하게되므로

프로그램이 죽을 수도 있다.

 

하고 싶다면 별도의 private 함수를 만들어서 각 함수에서 호출하도록 하자.

 

2줄 요약

 

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

 

 - 클래스의 복사 함수 두 개를 구현할 때, 한쪽을 이용해서 다른 쪽을 구현하려는 시도는 절대하지 마라.

 

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

 

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줄 요약

 

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

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

+ Recent posts