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

 

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

 

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

 

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

 

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

 

Const

const 키워드는 해당 변수를 상수로 만들어준다. (상수는 선언과 동시에 초기화를 해줘야하며 변경이 불가능하다.)

어떤 값(객체의 내용)이 불변이어야 한다는 제작자의 의도를 컴파일러 및 다른 프로그래머와 나눌 수 있는 수단.

 

- 클래스 외부에서는 전역 혹은 네임스페이스 유효 범위의 상수를 선언하는데 사용 가능.

- 파일, 함수, 블록 유효 범위에서 static으로 선언한 객체에도 const를 붙일 수 있음.

- 클래스 내부의 경우에는, 정적 멤버 및 비정적 데이터 멤버 모두를 상수로 선언 가능.

- 포인터에도 const 키워드 사용 가능.

 

const (포인터)

 

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
    char greeting[] = "Hello";
    char greeting2[] = "Nice";
 
    char* p = greeting;
 
    //쉽게 생각해서 const가 * 왼쪽에 있으면 const char*를 한 묶음으로 보면된다.
    //*은 해당 포인터가 가리키고 있는 주소값의 Value니까 (여기서는 *p는 'H')
    //결론적으로 p가 가리키고 있는 주소값의 Value를 변경하지 못한다고 생각하면 된다.
 
    const char* p1 = greeting;
 
    p1 = greeting2;    //가능.
 
    *(p1) = 'S';    //Error.
    *(p1+1= 'C';    //Error.
 
    //const가 * 오른쪽에 있으면 const p를 한묶으로 보면 된다.
    //p는 문자 'H'의 주소값이니까 p의 주소값을 변경하지 못한다고 생각하면 된다.
    //즉, p가 다른 주소값을 가리키도록 하지 못하므로
 
    char* const p2 = greeting;
 
    p2 = greeting2;    //Error.
 
    *(p2) = 'S';    //가능.
    *(p2+1= 'C';    //가능.
 
    //위에 두가지 case 모두 불가능.
    const char* const p3 = greeting;
 
cs

 

const (함수)

 

1. 함수 반환에 const.

 - 안전성이나 효율을 포기하지 않고도 사용자측의 에러 돌발 상황을 줄이는 효과를 볼 수 있다.

 - 예를 들어, 연산자 오버로딩 사용 시 if ((a*b) == c)를 if ((a*b) = c)와 같은 실수를 방지할 수 있다.

 

2. 함수의 매개 변수에 const

 - 함수 내에서 객체를 수정할 일이 없으면 무조건 const로 선언하라.

 

3. 멤버 함수 뒤에 const

 - "해당 멤버 함수가 상수 객체에 대해 호출될 함수이다"라는 사실을 알려주는 것이다.

 - 이 기능이 중요한 이유

   1) 클래스의 인터페이스를 이해하기 좋게 하기 위함.

   2) 상수 객체에서만 호출할 수 있도록 하기 위함.

     (c++ 프로그램의 실행 성능을 높이는 핵심 기법 중 하나는 객체 전달을 '상수 객체에 대한 참조자'로 전달하는 것.)

     (전달된 '상수 객체'에서 사용할 멤버 함수가 있어야 하기 때문이다.)

 - const 키워드가 있고 없고의 차이만으로도 함수 오버로딩이 가능하다.

 

const (객체)

 

const 객체는 객체의 멤버 변수를 어떠한 경우에도 수정할 수 없으며 const 멤버 함수만 호출 할 수 있다.

위에서 말했 듯이 상수 객체에 대한 참조자로 전달할 때 프로그램 실행 성능이 높아진다.

 

비트수준 상수성

 - 어떤 멤버 함수가 그 객체의 어떤 데이터 멤버도 건드리지 않아야(정적 멤버 제외) 그 멤버 함수가 'const'임을 인정하는 개념.

 - C++에서 정의하고 있는 상수성이 비트 수준 상수성.

 - 그러나 하기와 같이 const 객체임에도 불구하고 멤버 변수의 주소값을 얻어 멤버 변수의 값을 바꿀 수 있는 결함이 있다.

 - 컴파일러는 비트수준 상수성을 기준으로 하기 때문에 멤버 함수 내에서 값을 변경한 것이 아니기 때문에 컴파일러 단에서는

   모든 규칙이 지켜졌다고 보게 된다.

 

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
class CMyString
{
public:
    CMyString(char* pString)
    {
        int nLength = strlen(pString);
        m_pString = new char[nLength];
        
        strcpy(m_pString, pString);
    }
 
private:
    char* m_pString;
 
public:
    char& operator[](int nIndex) const
    {
        return m_pString[nIndex];
    }
};
 
 
int _tmain(int argc, _TCHAR* argv[])
{
 
    const CMyString cstrVal("Hello");
 
    char* pVal = &cstrVal[0];
 
    *pVal = 'J'//문자열이 "Jello"으로 변경.
}
cs

 

 - 위와 같은 상황이 발생하지 않기 위해서는 반환형으로 const char&를 해줘야함.

 

논리적 상수성

 - 비트수준 상수성을 보완하는 대체 개념.

 - 컴파일러는 비트수준 상수성을 기준으로 동작하기 때문에 프로그래머는 논리적 상수성을 기준으로 프로그램을

   작성해야 한다.

 - 상수 멤버 함수라고 해서 객체의 한 비트도 수정할 수 없는 것이 아니라 일부 몇 비트 정도는 바꿀순 있되,

   그것을 사용자 측에서 알아채지 못하게만 하면 상수 멤버 자격이 있다는 것.

 - 상수 객체지만 mutable 키워드를 사용하면 멤버 변수의 값을 변경할 수 있게 된다.

 

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
class CMyString
{
public:
    CMyString(char* pString)
    {
        m_nLength = strlen(pString);
        m_pString = new char[m_nLength];
        
        strcpy(m_pString, pString);
    }
 
private:
    char* m_pString;
    mutable int m_nLength; //mutable 키워드로 멤버 변수 선언.
 
public:
    const char& operator[](int nIndex) const
    {
        return m_pString[nIndex];
    }
};
 
 
int _tmain(int argc, _TCHAR* argv[])
{
    const CMyString cstrVal("Hello");
 
    return 0;
}
cs

 

 

코드 중복 피하기

 

만약 위와 같은 코드에서 operator[] 함수 내에 코드가 엄청나게 길었다고 가정해보자.

상수 객체는 반드시 const char& operator[](int nIndex) const를 호출해야 하고

비상수 객체는 char& operator[](int nIndex) 함수를 호출하게 될 것이므로 두 함수는 모두 있어야 한다.

그런데 만약 이 두 함수의 내용이 같다면, 이러한 함수가 여러 개라면 코드의 크기는 엄청나게 될 것이다.

(컴파일 시간, 유지보수, 코드 크기 등 문제 발생..)

 

그렇다고 반환값을 통일시켜버리면 위의 비트수준 상수성 예시처럼 const의 안정성이 지켜지지 않는다.

 

해결 방법.

 - 두 함수의 차이점은 단지 반환 타입에 const가 붙어 있냐 없냐만 차이가 있다. ( const char&, char& )

 - 비상수 operator[] version 함수에서 상수 operator[] 함수를 호출하는 것!! (casting을 이용)

   (상수 함수에서 비상수 함수를 호출하는 것은 애초에 비상수 함수에서 무슨 값을 변경할지 모르기 때문에

    안정성이 보장되지 않는다.)

 - 실제 동작 소스 코드는 상수 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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class CMyString
{
public:
    CMyString(char* pString)
    {
        m_nLength = strlen(pString);
        m_pString = new char[m_nLength];
        
        strcpy(m_pString, pString);
    }
 
private:
    char* m_pString;
    mutable int m_nLength; //mutable 키워드로 멤버 변수 선언.
 
public:
    //상수 version operator[]
    const char& operator[](int nIndex) const
    {
        //엄청난 크기의 코드가 들어있다고 가정.
 
        return m_pString[nIndex];
    }
 
    //비상수 version operator[]
    char& operator[](int nIndex)
    {
        return 
            const_cast<char&>(
                    static_cast<const CMyString&>
                        (*this)[nIndex]
            );
    }
};
 
int _tmain(int argc, _TCHAR* argv[])
{
    const CMyString cstrVal("Hello");
    CMyString strVal("Nice");
 
    cstrVal[0= 'R';  //Error C3892
    strVal[0= 'F';   //가능
 
    return 0;
}
cs

 

 1) 상수 operator[] 함수에 코드를 작성한다.

    - 상수 객체는 상수 operator[] 함수를 그대로 사용하게 되므로 문제 없다.

 

 2-1) 비상수 operator[] 함수에서는 캐스팅을 통해 상수 operator[]를 호출한다.

 2-2) 상수 operator[]로부터 받은 상수형 인자를 비상수 형으로 변환하여 최종 반환한다.

   - 애초에 상수 버젼 함수에 소스 코드가 들어가져 있으므로 안정성 보장.

   - 그러나 만약 반대의 경우라면 코드 소스가 비상수 함수에 들어가게 되고 이는 코드의 변경이 가능해지기 때문에

     안정성이 보장되지 않는다. (애초에 비트수준 상수성에 위배되기도 함.)

   - 그리고 상수 함수를 비상수 함수로 바꾸게 되면 모든 재앙의 씨앗이 되므로 절대로 해선 안된다. (중요)

 

 2-3) 소스 검토

   - static_cast<const CMyString&>(*this)[nIndex]   (비상수 객체에서 상수 객체로 변환)

    · 만약 그냥 operator[]를 사용했으면 재귀함수에 빠지게 되므로 casting을 통해 상수 operator[]를 호출한다.

    · const를 붙이는 캐스팅은 안전한 타입 변환이므로 static_cast만 써도 좋다.

    · 변환 형을 static_cast<const CMyString> 으로만 한다면 상수형 operator[] 함수를 호출하는 객체는 strVal 객체가 아닌

      임시 객체가 되버리므로 상수형 operator[] 함수에서 동작되는 부분들은 실제 객체와 아무 연관이 없어져버리게 된다.
      그러므로 반드시 형변환시에는 const CMyString& 참조형으로 변환해야만 한다.

 

3줄 요약

 

 - const를 붙여 선언하면 컴파일러가 사용상의 에러를 잡아내는데 도움을 준다.

 

 - 컴파일러는 비트수준 상수성만 지키기 때문에, 프로그래머는 논리적 상수성을 사용해서 코드를 작성해야 한다.

 

 - 상수 멤버 및 비상수 멤버 함수가 동일한 코드라면 코드 중복을 피하기 위해, 비상수 버젼이 상수 버젼을 호출하도록

   만드는게 좋다.

* 상수 사용 시에는 #define 보다 const 또는 enum을 우선 생각해라.

 

#difine ASPECT_RATIO 1.653 을 선언한 경우 우리에겐 ASPECT_RATIO라는 기호식 이름으로 보이지만

컴파일러에겐 보이지 않는다.

컴파일러에게 넘어가기 전에 선행 처리자가 이름을 밀어버리고 1.653 숫자 상수로 바꾸어 버리기 때문이다.

 

문제점

 - 숫자 상수로 대체된 코드에서 컴파일 에러가 발생하게 되면 에러 메시지에는 ASPECT_RATIO가 아닌 1.635이 적혀있기 때문에

   에러 부분을 찾기가 어려워 진다.

 

해결법

 - 매크로 대신 상수를 선언하라.

 - const double AspectRatio = 1.653;

 

이점

 - 언어 차원에서 지원하는 상수 타입의 데이터이기 때문에 컴파이러 눈에도 보이고 기호 테이블에도 들어가진다.

 - 최종 코드의 크기가 작아질 수 있다.

   (#define을 사용했을 경우 코드에 ASPECT_RATIO가 나올때마다 1.653으로 바뀌면서 코드 안에 1.653 사본이 등장 횟수만큼 들어가지만,

    상수 타입의 AspectRatio는 사본이 딱 한 개만 생기기 때문이다.)

 

상수 사용 시 주의점.

 1. 상수 포인터 (constant pointer)

  - 포인터는 꼭 const로 선언해주어야 하고, 포인터가 가리키는 대상까지 const로 선언해라.

  - const char* const strName - "Scott" 보다는 const std::string strName("Scott")이 더 좋다.

 

 2. 클래스 멤버로 상수를 정의 하는 경우.

  - 정수류(각종 정수 type, char, bool)로 선언된 정적(static) 클래스 멤버 상수 변수는 클래스 내부에서 초기화 가능.

  - 실수류 float, double의 static 멤버의 경우는 정의 시점에 초기화 가능.

  - const 멤버 변수는 이니셜라이저에서만 초기화 가능.

 

  - static 정수류 멤버 변수는 멤버 배열 변수의 인자로 사용 가능.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//Player.h File의 선언 부
class Player{
public:
    Player();
    virtual ~Player();
private:
    static const bool m_bool = true;
    static const int m_int = 5;
    static const short m_short = 11;
    static const double m_double;
 
    const int m_int2;
public:
    int dScores[m_int];
};
 
//Player.cpp File의 정의 부
const double Player::m_double = 1.35;
 
Player::Player() : m_int2(10)
{
 
}
cs

 

* 매크로 함수를 만드려면 Inline 함수를 우선 생각해라.

 

문제점

 - 괄호로 떡칠해야 된다.

 - 하기와 같은 소스 코드일 경우 전혀 엉뚱한 결과가 나온다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#define CALL_WITH_MAX(a,b) (a)>(b)? (a) : (b)
 
int _tmain(int argc, _TCHAR* argv[])
{
    int a = 10, b =5;
 
    CALL_WITH_MAX(++a, b);
 
    printf("a = %d, b = %d\n", a, b);
    
    a = 10;
    b = 5;
    int bbb = CALL_WITH_MAX(++a, b+10);
 
    printf("a = %d, b = %d\n", a, b);
}
cs

 

 - 첫 번째 CALL_WITH_MAX 호출 시 a가 2번 증가하게 된다. (비교되는 부문에서 1번 증가 반환하는 부문에서 1번 증가)

 - 두 번째 CALL_WITH_MAX 호출 시 a가 1번 증가하게 된다. (비교되는 부문에서 1번 증가)

 

해결법

 

 - 매크로 함수 대신 Inlnie 함수를 사용하라.

 

 - Inline 함수는 일반 함수보다 처리 속도가 빠른 이점이 매크로 함수와 동일하다.

 

 - 매크로 함수처럼 괄호로 분칠할 필요없고, 괴현상이 발생하지 않는다.

+ Recent posts