13 분 소요

모던 C++

개요

생성자는 개체가 생성될 때 제일 먼저 호출되는 특수 멤버 함수입니다. 개체가 메모리에 할당된 뒤에 호출되고, 초기값을 설정하는 역할을 합니다.

좋은 생성자캡슐화에 언급되었듯, 잘못 사용하기엔 어렵게, 바르게 사용하기엔 쉽게 구현해야 하는데요, 그러기 위해선 명시적 의존성 원칙에 따라 필요한 인자는 모두 나열하는게 좋습니다.

또한, 암시적으로 은근슬쩍 동작하는건 사용하지 않는다면 차단하고(암시적 정의 차단 참고), 복사 생성이 올바르게 작동하도록 신경써줘야 합니다.

항목 내용
T() {} 기본 생성자
T(int, int) {} 값 생성자
T(int) {} 인자가 1개인 값 생성자(형변환 생성자)
T(const T& other) {} 복사 생성자

기본 생성자

인수없는 생성자기본 생성자라고 합니다. 아무런 인자없이 생성할때 기본적인 초기값으로 개체를 생성해 줍니다.

T t;로 호출할 수 있습니다.(T t(); 와 같이 하면 T를 리턴하는 함수 t()선언입니다. 초기화 파싱 오류 참고)

1
2
3
4
5
6
class T {
public:
    T() {}
};
T t; // (O) 개체 정의(인스턴스화)
T t(); // (X) T를 리턴하는 함수 t() 선언

(C++11~) 중괄호 초기화를 제공하여 클래스인지, 배열인지, 구조체인지 구분없이 중괄호({})를 이용하여 일관성 있게 초기화 할 수 있으며, 초기화 파싱 오류도 해결했습니다.

암시적 기본 생성자

컴파일러는 다른 생성자(값 생성자던, 복사 생성자)가 정의되지 않으면, 암시적으로 기본 생성자를 정의해 줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class T1 {
};
T1 t1; // (O) 암시적 기본 생성자 사용

class T2 {
public:
    T2() {} // 사용자 정의 기본 생성자가 있음
};
T2 t2; // (O) 사용자가 정의한 기본 생성자 사용

class T3 {
public:
    T3(int, int) {} // 값 생성자가 있어 암시적 기본 생성자가 정의되지 않음
};
T3 t3; // (X) 컴파일 오류. 기본 생성자 정의 안됨

class T4 {
public:
    T4(const T4& other) {*this = other;} // 복사 생성자가 있어 암시적 기본 생성자가 정의되지 않음
};
T4 t4; // (X) 컴파일 오류. 기본 생성자 정의 안됨

암시적 기본 생성자에서는 자동 제로 초기화를 수행하기 때문에 멤버 변수의 메모리 영역이 제로(0)로 초기화 됩니다.

하지만 안타깝게도 기본 생성자를 어떻게 호출하느냐에 따라 자동 제로 초기화 적용 여부가 달라 집니다.

항목 내용
T t;T* ptr = new T; 괄호 없이 기본 생성자를 호출하면 자동 제로 초기화를 하지 않습니다.
T* ptr = new T(); 괄호를 사용하여 기본 생성자를 호출하면 자동 제로 초기화를 합니다.
1
2
3
4
5
6
7
8
9
10
11
class T {
    int m_Val; // (△) 암시적으로 기본 생성자가 자동 제로 초기화되거나 안되거나 할 수 있습니다.
public:
    int GetVal() const {return m_Val;}
};
T t1; // (O) 컴파일러가 암시적으로 정의한 기본 생성자

EXPECT_TRUE(t1.GetVal() == 0 || t1.GetVal() != 0); // 0으로 자동 초기화 되거나 안될 수 있습니다.

T t2 = T(); // 자동 제로 초기화 됩니다. T t2();는 T를 리턴하는 함수 선언이어서(초기화 파싱 오류) T t2 = T();와 같이 초기화 합니다.
EXPECT_TRUE(t2.GetVal() == 0);

그리고, 참조자 형식이나 const로 선언된 상수 멤버 변수는 생성시 초기값이 전달되야 하기 때문에, 암시적 기본 생성자로 초기화 할 수 없으며, 명시적으로 생성자를 구현해야 합니다.

1
2
3
4
5
6
7
8
9
10
class T1 {
private:
    const int& m_Val; // 멤버 변수에 참조자가 있어, 암시적으로 생성한 기본 생성자에서 초기화 할 수 없음
};
T1 t1; // (X) 컴파일 오류. 기본 생성자에서 멤버 변수 초기화 안됨

class T2 {
    const int m_Val; // 멤버 변수에 상수형 개체가 있어, 암시적으로 생성한 기본 생성자에서 초기화 할 수 없음
};
T2 t2; // (X) 컴파일 오류. 기본 생성자에서 멤버 변수 초기화 안됨

명시적 기본 생성자

기본 생성자가 필요하다면, 암시적인 기본 생성자를 활용하기 보다는 명시적으로 정의해서 사용하는 편이 유지보수 측면에서 좋습니다. 명시적 의존성 원칙에 따라 필요한 모든 요소를 나열하고 초기화 하는게 코딩 계약상 좋거든요.

1
2
3
4
class T {
    int m_Val;
}
T t; // (△) 비권장. 암시적 기본 생성자 사용

보다는, 기본값 인자를 사용하여,

1
2
3
4
5
6
7
8
9
class T {
    int m_Val;
public:
    explicit T(int val = 0) : // 값 생성자의 기본값을 이용해 암시적 기본 생성자를 없앴습니다.
        m_Val(val) {}
};
T t1; // (O) 기본값으로 값 생성자 호출
T t2(0); // (O) 기본값과 동일한 값으로 값 생성자 호출
T t3(10); // (O) 임의 값으로 값 생성자 호출

가 낫습니다.

값 생성자

값 생성자는 전달한 인수들로 개체를 생성시킵니다.

값 생성자 구현은

  1. 명시적 의존성 원칙에 따라 필요한 모든 요소를 나열하고 초기화하는게 코딩 계약상 좋고,
  2. 불필요한 대입의 오버헤드를 줄이기 위해 초기화 리스트를 이용하는게 좋습니다.(초기화 리스트 참고)
1
2
3
4
5
6
7
8
9
10
class T {
private:
    int m_X;
    int m_Y;
public:
    T(int x, int y) : // 필요한 모든 인자를 나열
        m_X(x), // 초기화 리스트로 모든 멤버 변수 초기화
        m_Y(y) {}
};
T t(10, 20); // (O) 

형변환 생성자

특별히 값 생성자인자가 1개만 있으면, 암시적인 형변환을 하므로 형변환 생성자라고도 합니다. 암시적 형변환은 코딩 계약을 망쳐버리고, 코드 분석도 어렵게 만드니 명시적 변환 생성 지정자(explicit)를 사용해서 차단하는게 좋습니다.(명시적 변환 생성 지정자(explicit) 참고)

1
2
3
4
5
class T {
    int m_Val;
public:
    explicit T(int val) {} // 꼭 explicit를 사용하세요.  
};

복사 생성자

동일한 타입 1개를 인자로 전달 받는 생성자복사 생성자라고 합니다. 복사 생성자는 전달받은 개체를 this에 복제하는 역할을 합니다.

1
2
3
4
5
6
class T {
public:
    T(const T& other) { // other는 수정되지 않으므로 const 형입니다. 불필요한 복제가 없도록 &를 사용합니다.
        // other를 this에 복제해야 합니다.
    }
};

암시적 복사 생성자

복사 생성자를 정의하지 않으면, 컴파일러는 암시적으로 복사 생성자를 정의해 줍니다. 기본 동작은 멤버별 복사 생성자 호출입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class T {
    int m_X;
    int m_Y;
public:
    T(int x, int y) : 
        m_X(x), 
        m_Y(y) {} 
    // 암시적 복사 생성자의 기본 동작은 멤버별 복사 생성자 호출입니다.    
    // T(const T& other) :
    //     m_X(other.m_X),
    //     m_Y(other.m_Y) {}
    int GetX() const {return m_X;}
    int GetY() const {return m_Y;}
};
T t1(10, 20);
T t2 = t1; // (O) 타입이 같다면 복사 생성자 호출
T t3(t1); // (O) 명시적으로 복사 생성자 호출

EXPECT_TRUE(t2.GetX() == 10 && t2.GetY() == 20);
EXPECT_TRUE(t3.GetX() == 10 && t3.GetY() == 20);

포인터 멤버 변수의 소유권 분쟁

new로 생성한 것은 delete로 소멸( 참고) 시켜야 합니다. 그렇지 않으면 메모리 릭이 발생합니다. 그렇다고 여러 차례 delete 한다면 예외가 발생합니다.(delete 여러번 호출 금지 참고) new로 생성한 것은 단 한번만 delete 해야 합니다.

예를 들어 생성자에서 new로 생성한 개체를 전달받고, 안전한 소멸을 보장하기 위해 소멸자에서 delete로 소멸시키는 T개체를 정의한다고 합시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
class T {
    int* m_Val;
public:
    // val : new 로 생성된 것을 전달하세요.
    explicit T(int* val) :
        m_Val(val) {}
    // 암시적 복사 생성자의 기본 동작은 멤버별 복사 생성자 호출입니다.    
    // T(const T& other) : 
    //     m_Val(other.m_Val) {} // (△) 비권장. 동일한 힙 개체를 참조합니다.        
    
    // 힙 개체를 메모리에서 제거 합니다.
    ~T() {delete m_Val;} 
};        

실행을 시켜보면 예외가 발생합니다.

1
2
3
4
5
// (X) 예외 발생. t1이 delete 한 것을 t2도 delete 합니다.
{
    T t1(new int(10));
    T t2(t1); // 복사 생성의 결과 t1과 t2가 동일한 힙 개체를 참조합니다.
}

T t2(t1);암시적 복사 생성자를 호출하는데요, other.m_Val 값이 m_Val에 그대로 복사되어 같은 곳을 가리키게 됩니다. 따라서 다음 그림처럼 t1, t2 가 동일한 개체를 참조하게 되죠.

image

t1t2유효 범위가 끝나서 각자의 소멸자가 실행되면, 동일한 개체를 각자 delete 하다가 두번 delete하게 되고, 결국 예외가 발생합니다.

이렇게 포인터 멤버 변수의 소유권을 서로 가지고 있고, 서로 소멸시키는 현상을 소유권 분쟁이라 합니다.

소유권 분쟁을 해결하는 방법은

  1. 소유권 이전을 하거나(auto_ptr, unique_ptr),
  2. 깊은 복제를 하거나,
  3. 자원을 공유하거나(shared_ptr),
  4. 유일한 자원으로 대체해서 사용하는

방법이 있습니다.

여기서는 깊은 복제 방법으로 구현해 보도록 하겠습니다.

깊은 복제를 하기 위해서는 암시적 복사 생성자 대신, 다음처럼 복사 생성자를 명시적으로 구현하여, 개체의 복제본을 만들면 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class T {
    int* m_Val;
public:
    // val : new 로 생성된 것을 전달하세요.
    explicit T(int* val) :
        m_Val(val) {}

    // (O) NULL 포인터가 아니라면 복제합니다.
    T(const T& other) :
        m_Val(other.m_Val != NULL ? new int(*other.m_Val) : NULL) {}

    // 힙 개체를 메모리에서 제거 합니다.
    ~T() {delete m_Val;} 
};
// (O) 힙 개체를 복제하여 소유권 분쟁이 없습니다.
{
    T t1(new int(10));
    T t2(t1); // 새로운 int형 개체를 만들고 10을 복제합니다.
} 

이제, t1, t2는 다른 메모리 영역을 가리키므로, 각자 delete 해도 됩니다.

image

복사 생성자만 지원하는 스마트 포인터

개체의 복제본을 만들기 위해 클래스마다 일일이 명시적으로 복사 생성자를 개발하는 것 보다는, 암시적 복사 생성자를 그대로 사용할 수 있도록 스마트 포인터(shared_ptr)를 만들어 사용하는게 코드도 간결하고 분석하기 좋습니다. 여기서는 int형을 지원하는 것만 구현해 보도록 하겠습니다.(복사 대입 연산까지 지원하는 것은 복사 대입 연산자까지 지원하는 스마트 포인터를 참고하세요. 그리고, 모든 타입을 지원하는 일반화된 스마트 포인터의 구현 예는 auto_ptr을 참고하세요.)

스마트 포인터는 다음 단계를 통해 포인터 복제를 대행하고, 유효 범위를 벗어나 자동 소멸될 때(소멸자 호출 시점 참고) 포인터를 delete합니다.

  1. #1 : 스마트 포인터를 클래스 멤버 변수로 정의해 둡니다.
  2. #2 : 암시적 복사 생성자가 호출되면, 내부적으로 멤버 변수들의 복사 생성자를 호출합니다. 이때 스마트 포인터의 복사 생성자가 호출됩니다.
  3. #3 : 스마트 포인터의 복사 생성자에서 포인터 복제를 합니다.
  4. #4 : 개체의 소멸자 호출뒤 멤버 변수들이 소멸됩니다.(개체 소멸 순서 참고)
  5. #5 : 멤버 변수 소멸시 스마트 포인터의 소멸자에서 포인터를 delete합니다.
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
46
47
48
49
50
51
// 복사 생성시 m_Ptr을 복제하고, 소멸시 delete 합니다.(복사 대입 연산은 지원하지 않습니다.)
class IntPtr {
private:
    int* m_Ptr; // new로 생성된 개체입니다.
public: 
    explicit IntPtr(int* ptr) :
        m_Ptr(ptr) {}

    // (O) NULL 포인터가 아니라면 복제합니다.    
    IntPtr(const IntPtr& other) : // #2
        m_Ptr(other.IsValid() ? new int(*other.m_Ptr) : NULL) {} // #3

    // 힙 개체를 메모리에서 제거 합니다.
    ~IntPtr() { // #4
        delete m_Ptr; // #5
    }

    // 포인터 연산자 호출시 m_Ptr에 접근할 수 있게 합니다.
    const int* operator ->() const {return m_Ptr;}
    int* operator ->() {return m_Ptr;}

    const int& operator *() const {return *m_Ptr;}
    int& operator *() {return *m_Ptr;}

    // 유효한지 검사합니다.
    bool IsValid() const {return m_Ptr != NULL ? true : false;}    
};

class T {
    // (O) IntPtr로 복사 생성시 포인터의 복제본을 만들고, 소멸시 IntPtr에서 delete 합니다.
    // (O) 암시적 복사 생성자에서 정상 동작하므로, 명시적으로 복사 생성자를 구현할 필요가 없습니다.
    IntPtr m_Val; // #1
public:
    // val : new 로 생성된 것을 전달하세요.
    explicit T(int* val) :
        m_Val(val) {}
    int GetVal() const {return *m_Val;}
};
// (O) 힙 개체를 복제하여 소유권 분쟁 없이 각자의 힙 개체를 delete 합니다.
{
    T t1(new int(10));
    T t2(t1); // 새로운 int형 개체를 만들고 10을 복제합니다.

    EXPECT_TRUE(t2.GetVal() == 10);
} 
// (X) 예외 발생. 2번 delete 합니다. 아직 복사 대입 연산은 지원하지 않습니다.
{
    T t1(new int(10));
    T t2(new int(20));
    t2 = t1; // 아직 복사 대입 연산은 지원하지 않습니다.
}

T t2(t1);t2암시적 복사 생성자를 호출하는데요, IntPtr복사 생성자에 의해 t1.m_Val.m_Ptr 값이 t2.m_Val.m_Ptr에 복제됩니다. 따라서 t1, t2는 서로 다른 개체를 참조하게 되고, t1, t2 소멸시 각자의 개체를 소멸하게 됩니다.

image

생성자에서 가상 함수 호출 금지

부모 클래스의 생성자에서 가상 함수를 호출하면, 아직 자식 클래스들이 완전히 생성되지 않은 상태이기에 부모 클래스의 가상 함수가 호출됩니다. 의도치 않은 동작이므로, 생성자에서는 가상 함수를 호출하지 마세요.

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
class Base {
protected:    
    int m_Val;
public:
    Base() : 
        m_Val(0) {
        // (X) 오동작. 가상 함수를 생성자에서 호출합니다.
        // Derived::SetVal()이 호출되길 기대하지만, 
        // Base::SetVal()이 호출됩니다.    
        SetVal(); 
    }
    virtual void SetVal() {
        m_Val = 1; // Base 에서는 1
    }
    int GetVal() const {return m_Val;}
};

class Derived : public Base {
public:
    Derived() :
        Base() {} // Base의 기본 생성자를 호출하면서 가상 함수 SetVal()이 호출됩니다.
    virtual void SetVal() {
        m_Val = 2; // Derived 에서는 2
    }
};

Derived d;

// (X) 오동작. Base 생성자에서 가상 함수인 SeVal()을 호출하면, 
// Derived::SetVal()이 호출되길 기대하나,
// 아직 Derived가 완전히 생성되지 않은 상태이기에,
// Base::SetVal()이 호출됨
EXPECT_TRUE(d.GetVal() == 1); 

기본 생성자, 복사 생성자 사용 제한

만약 기본 생성자복사 생성자가 필요없다면, 생성자를 사용할 수 없게 만드는게 좋습니다. 어짜피 사용하지 않을거라 내버려 뒀는데, 누군가가 유지보수 하면서 무심결에 사용하게 된다면, 오동작을 할 수 있거든요. 의도하지 않았다면 동작하지 않게 해야 합니다.

사용을 제한하는 방법은 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
class T {
public:
    T(int, int) {} // (O) 값 생성자를 정의하면 암시적 기본 생성자를 사용할 수 없습니다.
private:
    T(const T& other) {} // (O) private여서 외부에서 복사 생성자 사용할 수 없습니다.
};

T t1; // (X) 컴파일 오류. 기본 생성자 없음
T t2(0, 0); // (O)
T t3(t1); // (X) 컴파일 오류. 복사 생성자를 사용할 수 없게 private로 하여 단단히 코딩 계약을 했습니다.

(C++11~) default, delete가 추가되어 암시적으로 생성되는 멤버 함수의 사용 여부를 좀더 명시적으로 정의할 수 있습니다.

상속 전용 기반 클래스 - protected 생성자

상속해서만 사용할 수 있는 클래스는 protected 생성자로 만듭니다. 그러면 개체 정의(인스턴스화)에서는 사용할 수 없고, 상속해서만 사용할 수 있습니다.(상속 강제 참고)

1
2
3
4
5
6
7
8
9
10
11
12
class Base {
protected: // 개체 정의(인스턴스화)에서는 사용할 수 없고, 상속해서만 사용할 수 있습니다.
    Base() {} 
public:
    virtual void f() {}
};
class Derived : Base {
    virtual void f() {}
};

Base b; // (X) 컴파일 오류
Derived d; // (O) 상속하면 인스턴스화 가능

(C++11~) final이 추가되어 강제적으로 상속을 제한할 수 있습니다.

생성자 접근 차단 - private 생성자

외부에서 생성자 접근을 못하게 하려면 private 생성자로 만듭니다. 이때, 생성을 위한 Create()계열 함수를 별도로 만들 수 있습니다. 상속을 제한하거나, 다양한 생성 방식을 개체에서 통제하고 싶을때 사용합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class T {
private:
    T(int a, int b, int c) {} // 외부에서는 접근 불가
public:
    static T CreateFromA(int a) {return T(a, 0, 0);} // a값만 가지고 생성
    static T CreateFromB(int b) {return T(0, b, 0);} // b값만 가지고 생성
    static T CreateFromC(int c) {return T(0, 0, c);} // c값만 가지고 생성
};

// T t(10, 0, 0); // (X) 컴파일 오류
// T* p = new T(10, 0, 0); // (X) 컴파일 오류

class U : public T {};
// U u; // (X) 컴파일 오류. 상속해서 생성할 수 없음
// U* p = new u; // (X) 컴파일 오류  

T t(T::CreateFromA(10)); // (O) T를 복사 생성    

(C++11~) 생성자 위임이 추가되어 생성자초기화 리스트 코드가 좀더 간결해 졌습니다. 상기 예는 생성자 위임을 통해서도 구현할 수 있습니다.

혹은 생성자에서 모든 처리를 하기 힘든 개체를 생성할때도 유용합니다.(시스템 종속성이 높다던지, 일단 생성후 환경 정돈을 해야 한다던지 등이요.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class T {
private:
    T() {} // 외부에서 접근 불가
public:
    static T* Create() {
        T* result = new T; // 기본 생성자를 만들고,
        GlobalSetter.f(); // 생성후 사전에 해야할 전역 설정을 하고,
        T->Func(GlobalGetter.f()); // 전역 설정을 참조하여 Func()을 실행하고
        ... // 뭔가를 열심히 더하고...

        // 이제 T 개체 생성에 따른 주변 환경도 다 설정했으므로 리턴
        return result; 
    }
};

댓글남기기