11 분 소요

모던 C++

개요

멤버 변수들의 권장 초기화 방법은 다음과 같습니다.

항목 내용 초기화 권장 초기화 방법 수명
비정적 멤버 변수 특정 개체에 속하는 변수입니다.
특별히 정적 멤버 변수로 만들지 않는한 멤버 변수비정적 멤버 변수 입니다.
선택 초기화 리스트 개체 생성시 생성자 실행전 초기화 리스트에서 생성 ~ 소멸자 실행 후 종료
참조자 멤버 변수 비정적 멤버 변수와 동일하며, 참조자를 저장합니다. 필수 초기화 리스트 비정적 멤버 변수와 동일
포인터 멤버 변수 비정적 멤버 변수와 동일하며, 포인터를 저장합니다.
포인터 이므로 널 값을 지원합니다.
선택 초기화 리스트 비정적 멤버 변수와 동일
상수 멤버 변수 비정적 멤버 변수와 동일하며, 수정할 수 없습니다. 필수 초기화 리스트
선언부 초기화
비정적 멤버 변수와 동일
정적 멤버 변수 특정 개체에 속하지 않는 변수로서, 클래스에 응집되어 있는 전역 변수라고 할 수 있습니다. 선택 별도 정의 main 호출 ~ main 종료
정적 상수 멤버 변수 정적 멤버 변수와 동일하며, 수정할 수 없습니다. 필수 선언부 초기화
별도 정의
정적 멤버 변수와 동일

멤버 변수는 개체의 데이터를 저장 및 관리합니다. 주로 private로 은닉하고, Getter 함수Setter 함수를 통해 외부에 노출합니다.

참조자 멤버 변수와 상수 멤버 변수는 초기화가 필수입니다. 반드시 값 생성자를 만든뒤 초기화 리스트를 이용해서 초기화해야 합니다.

1
2
3
4
5
6
7
8
9
10
class T {
    int& m_Val2; // 참조자 멤버 변수. 참조자는 초기화 되어야 함
    const int m_Val4; // 상수 멤버 변수. 초기값이 세팅되어야 함
    ```
public: 
    // 참조자 멤버 변수와 상수 멤버 변수는 초기화 필수
    T(int& val2, int val4) :
        m_Val2(val2), // 참조자는 반드시 초기화 리스트에서 세팅되어야 함
        m_Val4(val4) {} // 상수 멤버 변수는 초기값이 세팅되어야 함
}; 

상수 멤버 변수정적 상수 멤버 변수는 선언부에서 초기화 할 수 있습니다.

1
2
const int m_Val4 = 0; // (O) 선언부 초기화 지원
static const int s_c_m_Val6 = 0; // (O) 선언부 초기화 지원

정적 멤버 변수는 선언과 정의를 분리해서 작성해야 합니다.(정적 멤버 변수 보다는 생성 시점을 명시적으로 알 수 있는 함수내 정적 지역 변수 사용을 권장합니다.)

1
2
3
4
5
class T {
public:
    static int s_m_Val5; // 정적 멤버 변수. 별도 정의 필요
};
int T::s_m_Val5; // 별도 정의 필요

(C++17~) 인라인 변수가 추가되어 헤더 파일에 정의된 변수를 여러개의 cpp에서 #include 하더라도 중복 정의 없이 사용할 수 있습니다. 또한, 클래스 정적 멤버 변수를 선언부에서 초기화 할 수 있습니다.

전체적인 선언과 정의 방법은 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class T {
    int m_Val1; // 멤버 변수
    int& m_Val2; // 참조자 멤버 변수. 참조자는 초기화 되어야 함
    int* m_Val3; // 포인터 멤버 변수
    const int m_Val4; // 상수 멤버 변수. 초기값이 세팅되어야 함
    // const int m_Val4 = 0; // (O) 선언부 초기화 지원
public:    
    static int s_m_Val5; // 정적 멤버 변수. 선언만 했기에 별도 정의 필요
    static const int s_c_m_Val6; // 정적 상수 멤버 변수
    // static const int s_c_m_Val6 = 0; // (O) 선언부 초기화 지원
public: 
    // 참조자 멤버 변수와 상수 멤버 변수는 초기화 필수
    T(int& val2, int val4) :
        m_Val2(val2), // 참조자는 반드시 초기화 리스트에서 세팅되어야 함
        m_Val4(val4) {} // 상수 멤버 변수는 초기값이 세팅되어야 함
};

int T::s_m_Val5; // 선언만 했기에 별도 정의 필요
const int T::s_c_m_Val6 = 0; // 선언만 했다면 정의 필요. 선언부 초기화를 했다면 별도 정의 불필요

초기화 리스트

멤버 변수 초기화시 생성 후 대입하면, 불필요한 생성자가 여러번 호출되고 대입의 오버헤드가 생깁니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class T {
public:
    T() {} // 기본 생성자
    explicit T(int val) {} // 값 생성자
};

class U {
    T m_X;
    T m_Y;
public:
    U(int x, int y) { // (△) 비권장. 초기화 리스트가 없어서 m_X, m_Y를 기본 생성자로 생성합니다.  
        m_X = T(x); // (△) 비권장. 값 생성자로 임시 개체를 생성 후, 복사 대입 연산자로 대입합니다.
        m_Y = T(y);
    }
};

따라서, 멤버 변수 초기화시, 생성후 대입하지 말고 초기화 리스트를 사용하는게 불필요한 생성이나 대입이 없어 좋습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class T {
public:
    T() {} // 기본 생성자
    explicit T(int val) {} // 값 생성자
};

class U {
    T m_X;
    T m_Y;
public:
    U(int x, int y) : 
        m_X(x), 
        m_Y(y) {} // (O) 값 생성자로 멤버 변수를 초기화 합니다. 
};

(C++11~) 생성자 위임이 추가되어 생성자초기화 리스트 코드가 좀더 간결해 졌습니다.
(C++11~) 멤버 선언부 초기화가 추가되어 비정적 멤버 변수의 초기화가 쉬워졌습니다.

필요한 인자를 모두 나열하고 초기화

값 생성자인자 작성시에는 명시적 의존성 원칙에 따라 필요한 모든 요소를 나열하고 초기화 하는게 코딩 계약상 좋습니다.

다음처럼 일부 멤버 변수만 초기화 하고, 나중에 별도 함수를 호출하여 초기화를 마무리하면,

  • 사용자가 실수로 빼먹을 수도 있고,
  • 예외 보증에도 좋지 않습니다.

    불완전하게 생성된 개체에 별도 Setter 함수를 호출하여 완전하게 만드는 중에 또다시 예외가 발생하면, 예외 보증 처리를 위해 이미 생성된 개체를 소멸시켜야 합니다.

    또한 이미 이 개체를 참조하는 곳이 있다면, 힘겹게 찾아야 하며, 찾았더라도 처리를 어찌해야 할지 난감해 집니다.(예외와 생성자 참고)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class T {
    int m_X;
    int m_Y;
public:
    explicit T(int x) : 
        m_X(x) {} // (△) 비권장. 멤버 변수 중 m_Y는 초기화하지 않았습니다.
    void SetX(int x) {m_X = x;}
    void SetY(int y) {m_Y = y;}
};

// (△) 비권장. 멤버 변수 중 m_Y는 초기화하지 않았습니다. 
// 초기화를 위해 추가로 필요한 요소가 뭔지 T 클래스를 파악해 봐야 합니다.
T t(10); 

// (△) 비권장. m_Y를 함수를 별도로 호출해야 합니다.
t.SetY(20); 

보다는

1
2
3
4
5
6
7
8
9
10
11
12
class T {
    int m_X;
    int m_Y;
public:
    T(int x, int y) : 
        m_X(x), // (O) 생성자에서 모든 멤버 변수를 초기화 합니다.
        m_Y(y) {} 
};

// (O) 생성자에서 모든 멤버 변수를 초기화 합니다.
// 필요한 요소가 뭔지 생성자만 봐도 알 수 있습니다. 
T t(10, 20);  

가 낫습니다.

멤버 변수 선언 순서와 초기화 리스트 순서

초기화 리스트에 기재된 순서가 아닌, 멤버 변수 선언의 순서로 초기화 됩니다. 따라서 헷갈리지 않도록 멤버 변수 선언 순서에 따라 초기화 리스트를 작성하세요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class T {
    int m_A; // m_A, m_B, m_C의 순서로 초기화 됩니다.
    int m_B;
    int m_C;
public:
    // 초기화 리스트 순서가 아닌 멤버 변수 선언 순서로 초기화 됩니다.
    T(int a, int b, int c) :
        m_C(c + m_B), // (△) 비권장. 3
        m_B(b + m_A), // (△) 비권장. 2
        m_A(a) {} // (△) 비권장. 1
    int GetA() const {return m_A;}
    int GetB() const {return m_B;}
    int GetC() const {return m_C;}
};
T t(10, 20, 30);
EXPECT_TRUE(t.GetA() == 10 && t.GetB() == 30 && t.GetC() == 60);

멤버 변수명과 인자명이 같은 경우

생성자초기화 리스트에서는 멤버 변수명과 인자명이 같더라도 함께 사용할 수 있습니다.

  1. a(a)로 사용한 경우, 멤버 변수 a복사 생성자인자 a를 전달합니다.

  2. 함수 본문에서는 인자멤버 변수를 가리므로 this->멤버 변수명으로 사용해야 합니다.

저는 헷갈려서 m_XXXX와 같이 m_ 접두어를 선호합니다만, XXXX_와 같이 뒤에 _를 사용하는 방법도 있습니다.(다만 앞에 _를 사용하진 마세요. _ 선택적 금지 참고)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class T {
public:
    int a;
    int b;
    int c;
    T(int a, int b, int c) : // 멤버 변수명과 인자명이 같더라도 초기화 리스트에서 사용 가능합니다.
        a(a), 
        b(b),
        c(c) {
        // 함수 본문에서 멤버 변수명과 인자명이 같으면, 멤버 변수는 this를 써서 접근합니다.
        this->a += 1; // 멤버 변수 a를 수정함
        a += 2; // 인자 a를 수정함       
    }
};
T t(10, 20, 30); 
EXPECT_TRUE(t.a == 11 && t.b == 20 && t.c == 30);

개체 크기와 메모리 정렬

일반적으로 개체의 크기는 멤버 변수의 합입니다.

1
2
3
4
5
class T1 {
    int m_X;
    int m_Y;
};
EXPECT_TRUE(sizeof(T1) == sizeof(int) * 2); // 8

하지만 다음의 경우를 보면, char(1byte) + int(4byte) 여서 5byte 일것 같지만, 사실은 8byte 입니다.

1
2
3
4
5
class T2 { // 멤버 변수중 가장 큰 int 에 맞춰 정렬
    char m_X; // 1byte. 3 byte 패딩
    int m_Y;
};
EXPECT_TRUE(sizeof(T2) == sizeof(int) * 2); // 8

이는 메모리에서 멤버 변수의 데이터를 좀 더 빠른 속도로 읽기 위해, 멤버 변수 중 가장 크기가 큰 값으로 메모리 정렬을 하기 때문입니다. 메모리 정렬은 1byte이거나 2의 배수 byte(2, 4, 6, 8…) 일 수 있습니다.

CPU는 메모리(RAM)에 접근하여 처리할 데이터를 읽어오는데, 이 접근 횟수가 많을 수록 속도가 느려집니다.

CPU가 한번에 데이터를 가져올 수 있는 크기가 4byte나 8byte로 정해져 있는데요, 4byte로 가져온다고 가정하고, 메모리를 정렬하지 않고 1byte 단위로 배치된 멤버 변수를 읽는다면, 상기 T2의 경우 int값(m_Y)을 읽으려면 2번 접근해야 합니다.

image

하지만, 메모리를 4byte 단위로 정렬해 두었다면 1번 접근하면 됩니다.

image

이러한 이점 때문에 메모리 정렬을 수행하며, 메모리 정렬을 위해 추가된 byte를 패딩(Padding) 이라 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
class T3 { // 멤버 변수중 가장 큰 double에 맞춰 정렬
    char m_X; // 1byte. 7byte 패딩
    double m_Y;
};
EXPECT_TRUE(sizeof(T3) == sizeof(double) * 2); // 16

struct T4 { // 멤버 변수중 가장 큰 double에 맞춰 정렬.
    char m_X; // 1byte. 3byte 패딩
    int m_Y; // 4byte. 
    double m_Z;
};
EXPECT_TRUE(sizeof(T3) == sizeof(double) * 2); // 16

강제로 메모리 정렬 byte 크기를 변경하는 방법은 #pragma pack을 이용하면 됩니다. #pragma pack을 이용하면, 메모리 정렬 byte 크기를 줄여 메모리 낭비는 줄일 수 있으나, 메모리 접근 속도는 저하됩니다.

(C++11~) alignas() 와 alignof()가 추가되어 메모리 정렬 방식을 표준화 됐습니다.

빈 클래스와 자식 개체의 크기

멤버 변수가 없는 빈 클래스도 자료형이므로 최소 1byte의 크기를 가집니다.

1
2
class Empty {}; // 빈 클래스는 강제로 1byte
EXPECT_TRUE(sizeof(Empty) == 1);

Empty는 다른 개체에 포함될 경우 메모리 정렬에 따라 공간을 차지합니다.

1
2
3
4
5
6
7
class Empty {}; // 빈 클래스는 강제로 1byte
EXPECT_TRUE(sizeof(Empty) == 1);
class Composite {
    int m_X;
    Empty m_Empty; // 1byte 이지만 3byte 패딩됨
};
EXPECT_TRUE(sizeof(Composite) == sizeof(int) + sizeof(int));

(C++20~) [[no_unique_address]]가 추가되어 아무 멤버 변수가 없는 개체의 크기를 최적화합니다.

하지만, 이를 상속하고, 상속한 개체에서 멤버 변수가 구현되었다면, 강제로 추가된 1byte는 빼고 크기가 설정됩니다.

1
2
3
4
5
6
7
class Empty {}; // 빈 클래스는 강제로 1byte
EXPECT_TRUE(sizeof(Empty) == 1);

class EmptyDerived : public Empty { // 빈 클래스를 상속받으면, 강제 1byte는 빼고 크기가 설정됨
    int m_X;
};
EXPECT_TRUE(sizeof(EmptyDerived) == sizeof(int));

빈 클래스라도 가상 함수가 있다면 가상 함수 테이블이 생성됩니다. 컴파일러에 따라 다를 수도 있으나 대부분 8byte 입니다.

1
2
3
4
5
6
7
8
9
10
11
class Base { // 멤버 변수는 없지만 virtual 이 있어 가상 함수 테이블이 생성됨
public:
    virtual ~Base() {}
};
EXPECT_TRUE(sizeof(Base) == 8);

class Derived : public Base { // 가상 함수 테이블 크기로 정렬됨
    char m_X;
};
EXPECT_TRUE(sizeof(Derived) == 8 + 8); 
std::cout << sizeof(Derived) << std::endl;    

메모리 할당에 따른 멤버 변수 선언 순서

멤버 변수를 선언할 때에는 메모리 정렬을 고려하여 선언하는게 좋습니다. 특히, char와 같이 4byte 이하인 멤버 변수들은 몰아서 선언하는게 좋습니다.

다음 코드는 패딩 작업에 의해 빈공간이 생겨 16byte 크기이지만,

1
2
3
4
5
6
7
class T {
    char m_Char1; // 1byte, 3byte 패딩 
    int m_Int1; // 4byte
    char m_Char2; // 1byte, 3byte 패딩
    int m_Int2; // 4byte
};
EXPECT_TRUE(sizeof(T) == 16);

다음 코드는 패딩이 최소화되어 12byte입니다.

1
2
3
4
5
6
7
class T {
    char m_Char1; // 1byte
    char m_Char2; // 1byte, 2byte 패딩
    int m_Int1; // 4byte
    int m_Int2; // 4byte
};
EXPECT_TRUE(sizeof(T) == 12);   

포인터 멤버 변수

포인터 멤버 변수복사 생성이나 복사 대입 연산시 복사 되면서 소유권 분쟁을 하게 됩니다. 이럴때 어떤 것을 delete 해야 할지 상당히 고민되죠.

다음 코드에서 Tnew로 생성한 포인터도 전달 받고, 지역 변수 val의 주소도 전달받습니다. T가 알아서 이를 판단하기 어려워 호출하는 쪽에서 delete하는데요,

1
2
3
4
5
6
7
8
9
10
11
12
13
class T {
    int* m_Ptr;
public:
    T(int* ptr) : 
        m_Ptr(ptr) {}
};
int* ptr = new int;
int val = 10;

T t1(ptr); // (△) 비권장. ptr는 new 한 것이기 때문에 m_Ptr은 delete 되어야 합니다.
delete ptr; // (△) 비권장. 그냥 밖에서 지워버렸습니다.

T t2(&val); // 요것은 delete하면 안됩니다.

이렇게 외부에서 일일이 포인터를 delete하면, 다음처럼 이미 deletet1t2에 대입하는 실수도 빈번해 집니다. 아무리 꼼꼼히 검토하더라도요.

1
2
3
4
5
6
T t1(ptr);
delete ptr; // delete 했습니다.

T t2(&val); 

t2 = t1; // (△) 비권장. 이미 지워버린 ptr을 가진 t1을 t2에 복사했습니다. 이런 실수 많이 하게 됩니다.

이런 고민을 하면서 코딩하다 보면 논리 자체에 대한 고민보다 포인터 처리에 대한 고민만 많아지므로, 포인터를 관리하는 스택 개체를 이용하는게 좋습니다.

항목 내용
스마트 포인터(auto_ptr, unique_ptr, shared_ptr) 소유권 분쟁시 소유권 이전을 할 것인지, 깊은 복제를 할 것인지, 자원을 공유할 것인지, 유일한 자원을 사용할 것인지에 따라 용도에 맞는 스마트 포인터를 사용합니다.(복사 대입 연산자까지 지원하는 스마트 포인터 참고, 스마트 포인터(auto_ptr, unique_ptr, shared_ptr 등) 참고)
Holder 소유권 분쟁이 없도록 복사 생성이나 복사 대입 연산을 원천 차단하고, 획득된 자원을 해제하는 역할을 합니다.(Holder 참고)

스마트 포인터(auto_ptr, unique_ptr, shared_ptr)나 Holder멤버 변수로 사용시 다음 조건을 충족해야 합니다.

항목 내용
암시적 복사 생성자와 호환 개체간 복사 생성포인터 멤버 변수소유권 분쟁이 없어야 합니다.(복사 대입 연산자까지 지원하는 스마트 포인터 참고, 스마트 포인터(shared_ptr 등) 참고)
암시적 복사 대입 연산자와 호환 개체간 복사 대입포인터 멤버 변수소유권 분쟁이 없어야 합니다.(복사 대입 연산자까지 지원하는 스마트 포인터 참고, 스마트 포인터(shared_ptr 등) 참고)
예외 발생예외 보증을 위해 이전 상태를 유지해야 합니다. 멤버 변수가 1개면 스마트 포인터로 가능하지만, 멤버 변수가 2개 이상이면 명시적으로 swap을 이용한 복사 대입 연산자를 구현하거나 PImpl 이디엄으로 클래스를 구성합니다.(멤버 변수가 2개 이상인 경우 스마트 포인터와 복사 대입 연산자와의 호환성 참고)
암시적 소멸자와 호환 소멸자에서 별다른 delete를 작성하지 않더라도 유효 범위를 벗어나면 자동 소멸되도록 만듭니다.(복사 생성자만 지원하는 스마트 포인터 참고, 스마트 포인터(auto_ptr, unique_ptr, shared_ptr 등) 참고, Holder 참고)

상기 조건을 충족하는 예제는 멤버 변수가 2개 이상인 경우 스마트 포인터와 복사 대입 연산자와의 호환성을 참고하시기 바랍니다.

댓글남기기