9 분 소요

모던 C++

개요

생성 후 대입하는 건, 생성과 대입의 2개 과정을 거쳐서 낭비입니다. 또한 예외 보증에도 좋지 않습니다. 생성 후 대입 과정에서 예외가 발생하면 난감해지니까요.(swap을 이용한 예외 보증 복사 대입 연산자 참고)

또한, 개체 생성시 초기화를 하지 않으면, 초기화되지 않은 개체를 사용하는 실수를 할 수 있습니다. 그러니 항상 생성과 동시에 초기화 하는 것이 좋습니다.

하지만 초기화 방법은 다른 언어에 비해 난해합니다.

T obj = val;과 같은 직관적이고 단순한 표현이, 조금 파보면 숨은 의미들이 생겨납니다.(사실 이런게 C++의 재미죠.)

이모든 것들을 이해하고 복사 부하 없이 효율적으로 초기화 할 수 있다면, C++ 마스터입니다.

초기화에는 다음과 같은 방법들이 있습니다.

항목 내용
기본 초기화 T obj;
값 초기화 T obj();
복사 초기화 T obj = other; 또는
T obj(other);
생성 후 대입(불필요한 부하) T obj;
obj = other;
배열 초기화 T arr[] = {};,
char str[] = "abc";
구조체 초기화 struct T {
       int x;
       int y;
};
T t = {0, 10};

다음은 초기화 테스트용 클래스입니다. 기본 생성자, int를 전달받는 값 생성자(형변환이 되지 않게 explicit로 정의했습니다. 명시적 변환 생성 지정자(explicit) 참고하세요.), 복사 생성자, 복사 대입 연산자를 정의하였습니다.

1
2
3
4
5
6
7
8
9
class T {
public:
    T() {} // 기본 생성자
    explicit T(int t) {} // int를 전달받는 생성자. explicit를 주어 암시적 형변환을 막음
    T(int x, int y) {} // 값 2개를 전달받는 생성자
    T(const T& other) {} // 복사 생성자

    void operator =(const T& other) {} // 복사 대입 연산자
};

기본 초기화

T obj; 와 같이 생성하면, 기본 생성자가 호출되어 기본 초기화를 수행합니다.

1
T obj; // (O) 기본 생성자 호출

T obj();와 같이 괄호로 정의하면, 개체 생성이 아니라 T를 리턴하는 obj() 함수 선언으로 인식되니 주의하세요. (초기화 파싱 오류 참고)

1
T obj(); // (△) 비권장. 초기화 아님. T를 리턴하는 obj 함수 선언임

(C++11~) 중괄호 초기화가 추가되어 T obj{};와 같이 기본 생성자를 호출할 수 있습니다.

값 초기화

값 초기화는 값 생성자를 이용하여 특정한 값으로 생성할 때 사용합니다.

1
2
T obj(1); // (O) int를 전달받는 생성자 호출
T obj(1, 2); // (O) 값 2개를 전달받는 생성자 호출

특별히 값이 1개이면, 형변환 생성자라고 하는데요, 복사 대입 연산자(=) 형태로 표현할 수 있습니다. 하지만 이게 프로그래머의 의도인지, 실수인지 헷갈릴때가 있습니다.

1
T obj = 1; // (△) 비권장. T t(int x); 를 호출을 위한 것인지, 정수 1을 T에 잘못 대입한 것인지 헷갈립니다.

그래서 복사 대입 연산자(=) 형태로 사용하는 것은 형변환이 되지 않도록 explicit를 사용하여 막아주는게 좋습니다. 컴파일 오류로 알려 주거든요.

대신 T obj = T(1);와 같이 명시적으로 생성한 개체를 대입하는 형태로 표현할 수도 있는데요, 이 방식은 T(1)로 생성한 개체를 T obj복사 생성자로 생성하는 구문이어서 생성자를 2회 호출합니다. 개체 1개를 생성하기 위해 생성자를 2회 호출하니 비효율적이죠.(사실 컴파일러 최적화에 의해 생성자를 1회 호출하기는 합니다. 생성자 호출 및 함수 인수 전달 최적화 참고)

1
T obj = T(1); // (△) 비권장. T(1)로 생성한 개체를 T obj 의 복사 생성자를 호출하여 생성합니다.

그리고, T obj(T(1));와 같이 명시적으로 값 생성자복사 생성자를 호출하는 표현이 있을 수 있습니다. 이 또한 T obj = T(1); 와 동일하게 생성자가 2회 호출하므로 비효율적입니다.(사실 컴파일러 최적화에 의해 생성자를 1회 호출하기는 합니다. 생성자 호출 및 함수 인수 전달 최적화 참고)

1
T obj(T(1)); // (△) 비권장. T(1)로 생성한 개체를 T obj 의 복사 생성자를 호출하여 생성합니다.

상기와 여러가지 가능한 표현 방식이 있습니다만, 다음과 같이 생성자를 직접 호출하는게 암시적 형변환에 안전하고 생성자를 1회만 호출하므로 효율적입니다.(자꾸 쓰다보면 가독성도 좋아지더라구요.)

1
2
T obj(1); // (O) int를 전달받는 생성자 호출
T obj(1, 2); // (O) 값 2개를 전달받는 생성자 호출

(C++17~) 임시 구체화와 복사 생략 보증을 통해 컴파일러 의존적이었던 생성자 호출 및 함수 인수 전달 최적화, 리턴값 최적화등이 표준화 되었습니다.

복사 초기화

값 초기화에서 특별히 같은 타입을 전달한다면 복사 생성자를 호출해 주며, 복사 초기화 라고 합니다. 복사 생성자복사 대입 연산자(=) 형태로 사용하는 것을 explicit를 사용하여 막을 수 있습니다만, 형변환이 되는게 아니므로, 굳이 막을 필요는 없습니다.

1
2
3
4
T other;

T obj1 = other; // (O) 타입이 같다면 복사 생성자가 호출됨
T obj2(other); // (O) 명시적으로 복사 생성자 호출됨

초기화 파싱 오류

안타깝게도 함수 선언의 문법과 생성자 호출의 문법이 유사하여 컴파일러가 이 둘을 구분하지 못합니다. 따라서, C언어와의 호환성을 위해 그냥 함수 선언으로 해석합니다.

1
2
T f(); // T 타입을 리턴하는 함수 f()를 선언합니다.
T obj(); // T 타입의 obj 개체를 기본 생성자로 생성하고 싶지만, 사실은 T 타입을 리턴하는 함수 obj()를 선언합니다.
항목 의도 컴파일러 해석
T obj(); T 타입의 obj 개체를 기본 생성자로 생성하고 싶습니다. T 타입을 리턴하는 함수 obj()를 선언합니다.
T obj(T()); T 타입의 기본 생성자로 생성한 것을 obj복사 생성하고 싶습니다. T 타입을 리턴하고, T(*)() 함수 포인터인자로 전달받은 함수 obj()를 선언합니다.

따라서 상기 의도에 따른 표현은 다음과 같이 해야 합니다.

항목 바른 표현 내용
T obj(); T obj; 단, 자동 제로 초기화가 안됩니다.
T obj(); 또는
T obj(T());
T obj = T(); 자동 제로 초기화가 됩니다. 다만 기본 생성자로 생성한 것을 복사 생성자로 한번더 생성하는 것이므로, 생성자를 2회 호출하는 표현인데요, 다행스럽게도 컴파일러에 따라 1회로 최적화 해줍니다.(생성자 호출 및 함수 인수 전달 최적화 참고)

(C++11~) 중괄호 초기화가 추가되어 T obj{};와 같이 기본 생성자를 호출할 수 있습니다.
(C++17~) 임시 구체화와 복사 생략 보증을 통해 컴파일러 의존적이었던 생성자 호출 및 함수 인수 전달 최적화, 리턴값 최적화등이 표준화 되었습니다.

생성자 호출 및 함수 인수 전달 최적화

생성자 호출이나 함수 인자 전달시 다음 초기화 사례들은 컴파일러에 따라 1회 생성자 호출로 최적화 해줍니다.(상황에 따라 최적화가 안되면 비효율적인 코드가 될 수 있습니다.)

항목 의도 컴파일러 최적화
T obj = T(); 기본 생성자로 생성한 것을 복사 생성자를 호출하여 전달합니다. obj의 메모리 위치에 기본 생성자를 호출합니다.
T obj = T(arg); T(arg)로 생성한 것을 복사 생성자를 호출하여 전달합니다. obj의 메모리 위치에 T(arg) 생성자를 호출합니다.
T obj(T(arg)); T(arg)로 생성한 것을 복사 생성자를 호출하여 전달합니다. T obj = T(arg);과 동일합니다.

(C++17~) 임시 구체화와 복사 생략 보증을 통해 컴파일러 의존적이었던 생성자 호출 및 함수 인수 전달 최적화, 리턴값 최적화등이 표준화 되었습니다.

생성 후 대입 : 하지 마라.

생성 후 대입을 할 수 있습니다만, 서두에 말씀드렸듯이 하지 마십시요.

1
2
3
4
T other;

T obj; // 기본 생성자
obj = other; // (△) 비권장. 생성하고 대입하지 말고, 완전하게 생성하세요. T obj(other); 가 낫습니다.

배열 초기화

배열 정의시 배열 요소의 갯수가 유추될 수 있도록 중괄호 집합 초기화를 사용할 수 있으며, 문자열 상수로 초기화 할 수 있습니다.(더 자세한 내용은 배열 초기화를 참고하세요.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int arr1[3]; // (△) 비권장. int 형 3개 정의. 초기화 되지 않아 비권장 
// int arr1[]; // (X) 컴파일 오류. 갯수가 지정되지 않음. 오류
// int arr1[] = {}; // (X) 컴파일 오류. 갯수가 지정되지 않음. 오류
int arr2[] = {0, 1, 2}; // (O) 갯수만큼 초기화
int arr3[3] = {}; // (O) 3개 모두 0으로 초기화
int arr4[3] = {0, 1, }; // (O) 갯수가 적거나 같아야 함. 모자라면 0

EXPECT_TRUE(arr2[2] == 2);
EXPECT_TRUE(arr3[0] == 0 && arr3[1] == 0 && arr3[2] == 0);
EXPECT_TRUE(arr4[2] == 0);

char str1[] = "abc"; // (O) {'a', `b`, 'c', '\0'};
EXPECT_TRUE(str1[0] == 'a');
EXPECT_TRUE(str1[1] == 'b');
EXPECT_TRUE(str1[2] == 'c');
EXPECT_TRUE(str1[3] == '\0'); // 널문자가 추가됨

wchar_t str2[] = L"abc"; // (O) {L'a', L`b`, L'c', L'\0'};
EXPECT_TRUE(str2[0] == L'a');
EXPECT_TRUE(str2[1] == L'b');
EXPECT_TRUE(str2[2] == L'c');
EXPECT_TRUE(str2[3] == L'\0'); // 널문자가 추가됨

(C++11~) 중괄호 집합 초기화가 개선되어 = 없이 사용 가능합니다.
(C++20~) 지명 초기화가 추가되어 중괄호 집합 초기화시 변수명을 지명하여 값을 초기화 할 수 있습니다.

구조체 초기화

구조체는 별도로 값 초기화를 위한 값 생성자를 구현하지 않더라도 중괄호 집합 초기화를 사용하여 초기화 할 수 있습니다.

단, 모든 멤버 변수접근 지정자public이어야 하며, 선언된 순서와 초기화 순서가 일치하여야 합니다.(더 세부적인 제약 조건은 집합 타입을 참고 하세요.)

1
2
3
4
struct T {int x; int y;}; // 멤버 변수가 public 입니다.
T t = {10, 20}; // (O) 중괄호로 초기화. 선언된 순서와 일치해야 합니다.

EXPECT_TRUE(t.x == 10 && t.y == 20);

(C++11~) 중괄호 집합 초기화가 개선되어 = 없이 사용 가능합니다.
(C++20~) 지명 초기화가 추가되어 중괄호 집합 초기화시 변수명을 지명하여 값을 초기화 할 수 있습니다.

자동 제로 초기화

기본적으로 변수는 생성시 초기화하지 않으면, 기존 메모리에 있는 쓰레기 값을 갖게 되지만, 하기의 경우는 메모리 영역을 제로(0)로 만들어 초기화 합니다.

  1. 전역 변수, 정적 전역 변수, 정적 멤버 변수, 함수내 정적 지역 변수

  2. 배열 요소의 갯수보다 초기화 값을 적게 제공한 경우 나머지 요소

  3. 클래스, 구조체, 공용체의 멤버 변수

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
T t;
T* ptr1 = new T; // 괄호 없이 생성합니다. 자동 초기화가 안됩니다.
T* ptr2 = new T(); // 괄호로 생성합니다. 자동 초기화가 됩니다.

EXPECT_TRUE(g_Val == 0); // 전역 변수는 0으로 자동 초기화
EXPECT_TRUE(s_Val == 0); // 정적 전역 변수는 0으로 자동 초기화
EXPECT_TRUE(T::s_m_Val == 0); // 정적 멤버 변수는 0으로 자동 초기화
EXPECT_TRUE(T::s_c_m_Val == 0); // 정적 상수 멤버 변수는 명시적 초기화
EXPECT_TRUE(ptr1->m_Val == 0 || ptr1->m_Val != 0); // new T; 로 생성하면 자동 초기화 되지 않습니다.
EXPECT_TRUE(ptr2->m_Val == 0); // new T(); 로 생성하면 자동 초기화됩니다.
EXPECT_TRUE(t.f1() == 0); // 정적 지역 변수는 0으로 자동 초기화
// EXPECT_TRUE(t.f2() != 0); // 지역 변수는 쓰레기값이 될 수도 있음

int arr[3] = {1, }; 
EXPECT_TRUE(arr[0] == 1 && arr[1] == 0 && arr[2] == 0); // 배열 갯수 보다 초기화 갯수가 적을때 나머지 요소는 0으로 자동 초기화

delete ptr1;
delete ptr2;

뭐는 자동으로 0이 되고, 어떻게 호출하느냐에 따라 안될 수도 있고, 복잡하죠?

복잡하니, 모든 변수를 초기화 한다는 대원칙을 가지고 작성하세요. 생성자 없이 자동 제로 초기화에 의존해서 개발했는데, 다른 누군가가 T* ptr1 = new T;와 같이 사용한다면 낭패니까요.

항목 자동 제로 초기화 지원 선언에서 명시적 초기화 지원 권장 초기화 방법
전역 변수 O O 선언시 초기화
정적 전역 변수 O O 선언시 초기화
정적 멤버 변수 O X 사용 비권장. 함수내의 정적 지역 변수 사용
정적 상수 멤버 변수 X O 선언시 초기화
개체의 멤버 변수 X 기본 생성자초기화 리스트. (new T;new T();가 달라서 △로 표기했습니다. 실수할 소지가 많으니 명시적으로 초기화 하세요.)
함수내 정적 지역 변수 O O 선언시 초기화
지역 변수 X O 선언시 초기화

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

댓글남기기