C++ 값 초기화: int x{}와 int()가 0이 되는 이유와 {}·()의 차이
이 글의 핵심
지역 변수와 멤버를 초기화하지 않아 생기는 쓰레기 값 버그는 대부분 기본 초기화와 값 초기화를 헷갈린 데서 나옵니다. 이 글은 0을 보장하려면 어떤 문법을 써야 하는지, 사용자 정의 생성자가 있을 때 값 초기화가 어떻게 달라지는지, 0 초기화·기본 초기화와 어떤 관계인지를 비교표와 예제로 정리합니다.
값 초기화란?
값 초기화(value initialization)는 빈 괄호 ()나 빈 중괄호 {}로 객체를 만들 때 적용되는 규칙입니다. 기본 타입은 0으로 초기화되고, 클래스 타입은 기본 생성자가 호출됩니다(사용자가 본문을 작성한 기본 생성자가 없으면 그 전에 0으로 채워집니다).
int x{}; // 0
int* ptr{}; // nullptr
double d{}; // 0.0
이 규칙이 필요한 이유는 기본 초기화와 비교하면 분명해집니다. 기본 초기화는 지역 변수에 쓰레기 값을 남길 수 있지만, 값 초기화는 정해진 값을 보장합니다.
// 기본 초기화: 값이 정해지지 않음
int x;
// 값 초기화: 0
int y{};
“쓰레기 값”이라는 표현은 편의상 쓰는 말이고, 표준 용어로는 미확정 값(indeterminate value)입니다. 이 차이는 중요합니다. 미확정 값을 읽는 것 자체가 미정의 동작이라, 컴파일러는 “이 값은 읽히지 않는다”고 가정하고 최적화할 수 있습니다. 그래서 초기화하지 않은 변수는 단순히 “스택에 남아 있던 이전 값”이 아니라, 빌드 설정에 따라 다른 값이 나오거나 if (x)와 if (!x)가 둘 다 참인 것처럼 동작하는 기묘한 결과를 낼 수 있습니다. 디버그 빌드에서 잘 돌던 코드가 릴리스에서만 이상해지는 흔한 원인이 이것입니다. MSVC 디버그 빌드(/RTC)는 지역 변수를 0xCC 패턴으로 채워서 초기화 누락이 눈에 띄게 해 주고, GCC·Clang의 -Wuninitialized(-Wall에 포함)와 GCC의 -Wmaybe-uninitialized는 많은 경우를 경고로 잡아 줍니다. C++26은 이런 읽기를 “잘못된 동작(erroneous behavior)“으로 다시 분류했지만, 기존 컴파일러에서는 여전히 미정의 동작으로 다루는 것이 안전합니다.
참고로 전역 변수와 static 변수는 초기화자가 없어도 0으로 초기화되므로 이 문제가 없습니다. 쓰레기 값은 자동(지역) 변수, new로 만든 객체, 그리고 멤버를 초기화하지 않은 생성자에서 생깁니다.
언제 값 초기화가 일어나는가
값 초기화는 문맥에 따라 다음처럼 나타납니다.
- 빈 괄호
T()로 임시 객체를 만들거나new T()를 쓰면 값 초기화입니다. - 빈 중괄호
T{}는 리스트 초기화 문법이지만, 기본 생성자가 있는 클래스나 기본 타입에서는 값 초기화로 이어집니다. 단,T가 집합체이면 값 초기화가 아니라 집합체 초기화가 적용되고, 초기화자가 없는 멤버는 각각 빈 초기화자로 초기화되어 결과적으로 0이 됩니다(리스트 초기화 참고). T t;처럼 초기화자가 없으면 기본 초기화이지, 값 초기화가 아닙니다. 지역 기본 타입은 미확정 값이 됩니다.
즉 0을 보장하고 싶다면 기본 초기화가 아니라 값 초기화(또는 결과적으로 0 초기화를 거치는 경로)를 선택해야 합니다.
{}와 ()가 둘 다 값 초기화일 때의 차이
int()와 int{}는 같은 결과(정수 0)를 줍니다. 차이는 문법적인 함정과 리스트 초기화 규칙에 있습니다.
| 항목 | () | {} |
|---|---|---|
값 초기화 (예: int) | int() → 0 | int{} → 0 |
| 좁히기(narrowing) | int(3.14)는 허용(잘림) | int{3.14}는 컴파일 오류 |
| 가장 성가신 파싱 | T f();는 함수 선언 | T f{};는 객체 한 개 |
std::vector | vector<int>(10, 0)는 크기 10, 값 0 | vector<int>{10, 0}은 요소 두 개 |
int a = int(); // 값 초기화, 0
int b{}; // 값 초기화, 0
// int c(3.14); // 좁히기 허용 → 3
// int d{3.14}; // 오류: narrowing
// 가장 성가신 파싱 회피
Widget w1; // 기본 초기화(Widget이 어떻게 정의됐는지에 따름)
Widget w2{}; // 값 초기화(기본 생성자)
// Widget w3(); // 객체가 아니라 함수 선언
auto p1 = new double; // 기본 초기화(미확정 값)
auto p2 = new double(); // 값 초기화 → 0.0
auto p3 = new double{}; // 값 초기화 → 0.0
기본값 0이나 빈 객체가 목적이라면 지역 변수와 멤버에는 {}를 우선 고려할 만합니다. 쓰레기 값을 막으면서 좁히기 실수도 컴파일 단계에서 잡아 주기 때문입니다. 다만 std::vector처럼 std::initializer_list 생성자가 있는 타입에서는 {} 안에 인자를 넣으면 그 생성자가 우선 선택된다는 점을 기억해야 합니다.
클래스와 기본 타입에서의 값 초기화
기본 타입(int, char, 포인터 등)의 값 초기화 결과는 0, false, nullptr처럼 0에 해당하는 값입니다. 클래스 타입은 기본 생성자가 호출되며, 기본 생성자가 없거나 접근할 수 없으면 컴파일 오류입니다. 집합체에 {}를 쓰면 앞서 말한 대로 집합체 초기화가 되어 모든 멤버가 0이 됩니다(집합체 초기화 참고).
struct Pod { int x; int y; };
Pod p{}; // x, y 모두 0 (집합체 초기화, 각 멤버를 빈 초기화자로 초기화)
class NonDefault {
public:
NonDefault(int) {}
};
// NonDefault nd{}; // 기본 생성자 없음 → 오류
사용자가 작성한 기본 생성자가 있으면 달라진다
값 초기화에서 가장 놓치기 쉬운 규칙은 기본 생성자를 누가 만들었는가에 따라 결과가 갈린다는 점입니다.
struct A { int x; }; // 암시적 기본 생성자
struct B { int x; B() = default; }; // 첫 선언에서 = default (사용자 제공 아님)
struct C { int x; C() {} }; // 본문을 직접 작성 (사용자 제공)
A a{}; // x == 0
B b{}; // x == 0
C c{}; // x는 미확정 값. 생성자가 x를 초기화하지 않았기 때문
표준 규칙은 이렇습니다. 기본 생성자가 사용자 제공(user-provided)이 아니면(암시적이거나 첫 선언에서 = default) 값 초기화는 객체를 먼저 0으로 채우고 그다음 기본 생성자를 호출합니다. 반면 사용자가 본문을 작성한 기본 생성자가 있으면 값 초기화는 그 생성자를 호출하는 것으로 끝나고, 0 채우기는 일어나지 않습니다. (A와 C++17까지의 B는 집합체라 {}가 집합체 초기화로 처리되지만, 결과는 똑같이 0입니다.)
그래서 C() {}처럼 빈 생성자를 습관적으로 추가했을 뿐인데 C c{};의 멤버가 0에서 미확정 값으로 바뀔 수 있습니다. 기존 구조체에 디버그 로그를 찍는 생성자를 하나 추가하는 정도의 변경이 그동안 {} 덕분에 0이던 멤버를 초기화되지 않은 상태로 만드는 식입니다. 이 함정을 가장 확실히 피하는 방법은 아래 “멤버 초기화” 예시처럼 멤버 선언에 직접 기본값(int x{};)을 적는 것입니다. 그러면 어떤 생성자를 쓰든, 어떤 초기화 문법을 쓰든 멤버가 초기화됩니다.
기본 타입
int x{}; // 0
double d{}; // 0.0
bool b{}; // false
char c{}; // '\0'
int* ptr{}; // nullptr
예시
배열 초기화
int arr1[5]; // 지역 변수라면 미확정 값
int arr2[5]{}; // 모두 0
struct Point {
int x, y;
};
Point points[3]{}; // 모두 {0, 0}
배열에 초기화자를 일부만 주면 나머지 요소는 0으로 채워집니다. int arr[5] = {1, 2};는 {1, 2, 0, 0, 0}이므로 쓰레기 값 걱정은 없습니다. 다만 “모두 1로 채우겠다”는 의도로 int arr[5] = {1};이라고 쓰면 첫 요소만 1이고 나머지는 0이 되는 실수가 흔합니다.
클래스
class Widget {
int value;
public:
Widget() : value{} {} // value = 0
};
int main() {
Widget w{}; // 사용자 제공 기본 생성자 호출
}
new 연산자
// 기본 초기화
int* p1 = new int; // 미확정 값
// 값 초기화
int* p2 = new int(); // 0
int* p3 = new int{}; // 0
// 배열
int* arr1 = new int[5]; // 미확정 값
int* arr2 = new int[5](); // 모두 0
int* arr3 = new int[5]{}; // 모두 0
괄호 한 쌍의 차이라 코드 리뷰에서 놓치기 쉽습니다. 스마트 포인터를 쓸 때도 같은 구분이 있습니다. std::make_unique<int[]>(n)은 값 초기화라 모든 요소가 0이고, 곧 덮어쓸 버퍼라서 0 채우기 비용을 피하고 싶다면 C++20의 std::make_unique_for_overwrite<int[]>(n)을 쓰면 기본 초기화가 됩니다. 이름에 “for_overwrite”가 들어간 것은 “초기화되지 않았으니 반드시 먼저 쓰고 나서 읽으라”는 경고의 의미입니다.
멤버 초기화
class Data {
int x{}; // 0
double y{}; // 0.0
int* ptr{}; // nullptr
public:
Data() = default;
};
int main() {
Data d;
// d.x = 0, d.y = 0.0, d.ptr = nullptr
}
멤버 기본값(default member initializer, C++11)은 엄밀히 말하면 값 초기화가 아니라 “생성자가 멤버 초기화 목록에서 그 멤버를 언급하지 않으면 이 초기화자를 쓴다”는 규칙입니다. 덕분에 Data d;처럼 기본 초기화 문법으로 만들어도 멤버가 초기화되고, 생성자를 여러 개 추가해도 각 생성자에서 멤버 초기화를 반복할 필요가 없습니다. 참고로 모든 멤버가 public이고 생성자를 선언하지 않은 구조체라면, C++11에서는 멤버 기본값을 두는 순간 집합체가 아니게 되어 {1, 2.0, nullptr} 같은 집합체 초기화를 쓸 수 없었고, C++14부터 이 제약이 풀렸습니다.
초기화 방식 비교
| 초기화 방법 | 문법 | 결과 (기본 타입) | 결과 (클래스) |
|---|---|---|---|
| 기본 초기화 | int x; | 지역 변수는 미확정 값 | 기본 생성자 호출 |
| 값 초기화 | int x{}; | 0 | 기본 생성자 호출(사용자 제공이 아니면 먼저 0 채움) |
| 직접 초기화 | int x(10); | 10 | 인자에 맞는 생성자 호출 |
| 복사 초기화 | int x = 10; | 10 | 변환 생성자 (explicit 생성자는 사용 불가) |
안전한 시작값이 필요한 변수에는 {}를 쓰고, 의미 있는 초기값이 있다면 = value나 (value), {value}로 그 값을 직접 적는 것이 읽기에도 명확합니다.
int counter{}; // 0으로 시작
int* ptr{}; // nullptr로 시작
int max_size = 100; // 의미 있는 초기값
std::string name("Alice");
자주 발생하는 문제
초기화하지 않은 지역 변수
int x;
std::cout << x << '\n'; // 미정의 동작: 미확정 값 읽기
int x{};
std::cout << x << '\n'; // 0
초기화하지 않은 포인터
int* ptr;
if (ptr) {} // 미정의 동작: 미확정 값 읽기
int* ptr{};
if (ptr) {} // OK, nullptr이므로 거짓
초기화하지 않은 클래스 멤버
class Bad {
int value; // 생성자가 초기화하지 않으면 미확정 값
};
class Good {
int value{}; // 항상 0으로 시작
};
성능 고려사항
값 초기화는 메모리를 0으로 채우는 비용이 있습니다. 작은 변수에서는 무시할 수준이지만, 큰 버퍼에서는 눈에 띌 수 있습니다.
#include <chrono>
#include <iostream>
using namespace std::chrono;
int main() {
// 값 초기화 (모두 0) — 40MB라 스택이 아닌 힙에 할당
auto start = steady_clock::now();
int* arr1 = new int[10000000]{};
auto elapsed1 = steady_clock::now() - start;
// 기본 초기화 (미확정 값)
start = steady_clock::now();
int* arr2 = new int[10000000];
auto elapsed2 = steady_clock::now() - start;
std::cout << "값 초기화: " << duration_cast<milliseconds>(elapsed1).count() << "ms\n";
std::cout << "기본 초기화: " << duration_cast<milliseconds>(elapsed2).count() << "ms\n";
delete[] arr1;
delete[] arr2;
}
이런 예제는 int arr[10000000]{};처럼 지역 배열로 쓰는 경우가 많은데, 4천만 바이트는 Linux의 흔한 기본 스택 크기(8MB)와 Windows 기본 스택(1MB)을 훨씬 넘어서 측정 전에 스택 오버플로로 죽습니다. 그래서 위처럼 힙에 할당해야 합니다.
측정 결과를 해석할 때도 주의가 필요합니다. 운영체제는 새로 받은 메모리 페이지를 처음 접근할 때 0으로 채워서 넘겨주므로, 기본 초기화 쪽은 “할당만 하고 아무것도 안 한” 상태라 거의 0ms가 나오고 실제 페이지 할당 비용은 처음 쓸 때로 미뤄집니다. 즉 두 숫자의 차이는 “0 채우기 비용”과 “페이지를 실제로 건드리는 비용”이 섞인 값입니다. 또 최적화 빌드에서는 사용하지 않는 배열 연산을 컴파일러가 통째로 없앨 수 있으므로, 결과를 읽어서 출력하는 등 사용하는 코드가 있어야 측정이 의미 있습니다.
그래서 기본 초기화를 일부러 고를 만한 경우는 큰 버퍼를 곧바로 전부 덮어쓸 것이 확실할 때 정도이고, 그 외에는 값 초기화를 기본으로 두는 편이 낫습니다.
// 곧바로 덮어쓸 큰 버퍼: 0 채우기 생략 (C++20)
auto buffer = std::make_unique_for_overwrite<int[]>(1000000);
read_from_file(buffer.get(), 1000000 * sizeof(int)); // 즉시 덮어씀
// 일부만 쓸 수도 있는 캐시: 0으로 초기화해 두는 편이 안전
std::vector<int> cache(1000000);
활용 패턴
카운터
class Counter {
int count_{}; // 0으로 초기화
public:
void increment() { ++count_; }
int get() const { return count_; }
};
아직 할당하지 않은 포인터 멤버
class Resource {
int* data_{}; // nullptr로 초기화
public:
void allocate(int size) {
data_ = new int[size]{}; // 배열도 0으로 초기화
}
~Resource() {
delete[] data_; // nullptr 삭제는 안전
}
};
data_{}로 nullptr을 보장해 두면 allocate()를 호출하지 않은 객체가 소멸할 때도 delete[] nullptr이 되어 안전합니다. 초기화하지 않았다면 소멸자가 미확정 주소를 해제하려다 크래시할 수 있습니다. 다만 이 클래스는 복사 생성자를 정의하지 않아서 Resource r2 = r1;처럼 복사하면 두 객체가 같은 포인터를 갖고 둘 다 delete[]하는 이중 해제가 생깁니다(Rule of Three). allocate()를 두 번 호출하면 이전 메모리가 누수되는 문제도 있습니다. 실제 코드라면 std::unique_ptr<int[]>나 std::vector<int>로 바꾸는 것이 근본적인 해결입니다.
통계 구조체
struct Statistics {
int count{};
double sum{};
double min{};
double max{};
void add(double value) {
if (count == 0) {
min = max = value;
} else {
if (value < min) min = value;
if (value > max) max = value;
}
sum += value;
++count;
}
double average() const {
return count > 0 ? sum / count : 0.0;
}
};
min과 max를 0으로 시작하면 음수만 들어올 때 max가 0으로 남는 버그가 생기기 쉬운데, 여기서는 첫 값이 들어올 때 둘 다 덮어쓰므로 문제가 없습니다.
auto x = T{};와 T x{};
두 형태의 결과는 같습니다. C++17부터 auto x = T{};는 임시 객체를 만든 뒤 복사하는 것이 아니라 복사 생략이 보장되어 x를 직접 초기화합니다. 그래서 복사·이동 생성자가 삭제된 타입(std::mutex 등)에도 쓸 수 있습니다. 규칙 자체는 cppreference의 Value initialization 문서에 사례별로 정리되어 있습니다.
직접 실행한 초기화 결과
Apple clang 21.0.0(libc++), macOS arm64, -std=c++20 -O0에서 struct A{int x; double y;}로 확인했습니다. 앞에서 스택을 0xAB로 채우는 함수를 한 번 호출했습니다.
| 초기화 | 결과 |
|---|---|
스택 A a1; (기본 초기화) | 13680640, 4.1119e-314 (두 번 실행에서 47054848, 4.1119e-314로 달라짐) |
스택 A a2{}; (값 초기화) | 0, 0 |
D(int x = 7; 기본 멤버 초기화자) | D d1;과 new D 모두 7 |
int i2{}, new int(), new int{} | 모두 0 |
std::vector<int>(5) | 00000 |
static int g; | 0 |
std::string s; | size() == 0 |
한 가지 주의할 점이 있습니다. 같은 프로그램에서 new A(괄호 없음)를 해제된 0xAB 블록을 일부러 재사용하게 만든 뒤에도 0, 0이 출력됐습니다. 이 환경의 힙은 새로 받은 메모리나 재사용 메모리가 0으로 보일 수 있어서, “new A에서 값이 0이었다”는 결과로 값 초기화가 된다고 결론 내리면 안 됩니다. 초기화되지 않은 값을 읽는 것은 정의되지 않은 동작이라, 값이 0이든 쓰레기든 결과를 믿을 수 없습니다. 스택에서는 위처럼 쓰레기가 그대로 보이고 실행마다 달랐습니다.
그래서 값이 필요한 변수는 A a{};나 new A{}처럼 중괄호로 항상 명시하세요. B처럼 생성자가 멤버를 초기화하지 않는 타입에서 new B()와 new B가 같은 결과를 낼 수 있는지는 이 환경에서는 구별하지 못했습니다.
같이 보면 좋은 글
- C++ 기본 초기화: 지역 변수가 쓰레기 값을 갖는 이유와 전역·정적 변수와의 차이
- C++11 중괄호 초기화: narrowing 방지, Most Vexing Parse, vector
{10}과 (10)의 차이 - C++ 동적 초기화와 정적 초기화 순서 문제: Meyers 싱글톤·constinit으로 해결하기
- C++ explicit 키워드: 암시적 변환 막기, explicit operator bool, C++20 explicit(bool)
- C++ default와 delete