정적 및 전역 변수 (Static and Global Variables)¶
정적 저장 기간이 있는 객체는 쉽게 파괴할 수 없는 경우를 제외하고는 금지됩니다. 비공식적으로 이는 소멸자가 멤버 및 기본 소멸자를 고려하더라도 아무 작업도 수행하지 않음을 의미합니다. 보다 공식적으로 이는 형식에 사용자 정의 또는 가상 소멸자가 없으며 모든 기본 및 비정적 멤버가 쉽게 소멸될 수 있음을 의미합니다. 정적 함수-지역 변수는 동적 초기화를 사용할 수 있습니다. 정적 클래스 멤버 변수 또는 네임스페이스 범위의 변수에 대한 동적 초기화 사용은 권장되지 않지만 제한된 상황에서는 허용됩니다. 자세한 내용은 아래를 참조하세요.
경험상 전역 변수는 단독으로 고려되는 선언이 constexpr 일 수 있는 경우 이러한 요구 사항을 충족합니다.
모든 객체에는 수명과 관련된 저장 기간이 있습니다. 정적 저장 기간이 있는 객체는 초기화 시점부터 프로그램이 끝날 때까지 유지됩니다. 이러한 개체는 네임스페이스 범위("전역 변수")의 변수, 클래스의 정적 데이터 멤버 또는 static 지정자로 선언된 함수 지역 변수로 나타납니다. 함수 지역 정적 변수는 제어가 처음 선언을 통과할 때 초기화됩니다. 정적 저장 기간이 있는 다른 모든 개체는 프로그램 시작의 일부로 초기화됩니다. 정적 저장 기간이 있는 모든 개체는 프로그램 종료 시(조인되지 않은 스레드가 종료되기 전에 발생함) 삭제됩니다.
초기화는 동적일 수 있습니다. 즉, 초기화 중에 사소한 일이 발생한다는 의미입니다. (예를 들어 메모리를 할당하는 생성자 또는 현재 프로세스 ID로 초기화되는 변수를 고려하십시오.) 다른 종류의 초기화는 정적 초기화입니다. 하지만 둘이 정반대는 아닙니다. 정적 초기화는 항상 정적 저장 기간이 있는 객체에 발생합니다(주어진 상수 또는 모든 바이트가 0으로 설정된 표현으로 객체를 초기화함). 반면 동적 초기화는 필요한 경우 그 후에 발생합니다.
전역 및 정적 변수는 명명된 상수, 일부 번역 단위 내부의 보조 데이터 구조, 명령줄 플래그, 로깅, 등록 메커니즘, 백그라운드 인프라 등 다양한 응용 프로그램에 매우 유용합니다.
동적 초기화를 사용하거나 사소하지 않은 소멸자가 있는 전역 및 정적 변수는 찾기 어려운 버그로 쉽게 이어질 수 있는 복잡성을 생성합니다. 동적 초기화는 변환 단위 전체에 걸쳐 순서가 지정되지 않으며 소멸도 마찬가지입니다(초기화의 역순으로 소멸이 발생한다는 점 제외). 하나의 초기화가 정적 저장 기간을 가진 다른 변수를 참조하는 경우 수명이 시작되기 전에(또는 수명이 끝난 후에) 개체에 액세스할 수 있습니다. 또한 프로그램이 종료 시 조인되지 않은 스레드를 시작하는 경우 소멸자가 이미 실행된 경우 해당 스레드는 수명이 끝난 후 개체에 액세스하려고 시도할 수 있습니다.
소멸자가 사소한 경우 해당 실행은 순서에 전혀 영향을 받지 않습니다(효과적으로 "실행"되지 않습니다). 그렇지 않으면 수명이 끝난 후 객체에 액세스할 위험에 노출됩니다. 따라서 우리는 사소하게 파괴할 수 있는 경우에만 정적 저장 기간을 가진 객체를 허용합니다. 기본 유형(포인터 및 int 와 같은)은 쉽게 파괴할 수 있는 유형의 배열과 마찬가지로 쉽게 파괴할 수 있습니다. constexpr로 표시된 변수는 쉽게 파괴될 수 있습니다.
const int kNum = 10; // Allowed
struct X { int n; };
const X kX[] = {{1}, {2}, {3}}; // Allowed
void foo() {
static const char* const kMessages[] = {"hello", "world"}; // Allowed
}
// Allowed: constexpr guarantees trivial destructor.
constexpr std::array<int, 3> kArray = {1, 2, 3};
// bad: non-trivial destructor
const std::string kFoo = "foo";
// Bad for the same reason, even though kBar is a reference (the
// rule also applies to lifetime-extended temporary objects).
const std::string& kBar = StrCat("a", "b", "c");
void bar() {
// Bad: non-trivial destructor.
static std::map<int, int> kData = {{1, 0}, {2, 0}, {3, 0}};
}
참조는 객체가 아니므로 파괴 가능성에 대한 제약이 적용되지 않습니다. 그러나 동적 초기화에 대한 제약은 여전히 적용됩니다. 특히 static T& t = *new T; 형식의 함수-로컬 정적 참조가 허용됩니다.
초기화는 더 복잡한 주제입니다. 이는 클래스 생성자가 실행되는지 여부뿐만 아니라 초기화 프로그램의 평가도 고려해야 하기 때문입니다.
int n = 5; // Fine
int m = f(); // ? (Depends on f)
Foo x; // ? (Depends on Foo::Foo)
Bar y = g(); // ? (Depends on g and on Bar::Bar)
첫 번째 문을 제외한 모든 문은 불확실한 초기화 순서에 노출됩니다.
우리가 찾고 있는 개념은 C++ 표준의 공식 언어에서 상수 초기화라고 합니다. 이는 초기화 표현식이 상수 표현식이고 객체가 생성자 호출에 의해 초기화되는 경우 생성자도 constexpr로 지정되어야 함을 의미합니다.
struct Foo { constexpr Foo(int) {} };
int n = 5; // Fine, 5 is a constant expression.
Foo x(2); // Fine, 2 is a constant expression and the chosen constructor is constexpr.
Foo a[] = { Foo(1), Foo(2), Foo(3) }; // Fine
상수 초기화는 항상 허용됩니다. 정적 저장 기간 변수의 지속적인 초기화는 constexpr 또는 constinit 로 표시되어야 합니다. 그렇게 표시되지 않은 모든 비로컬 정적 저장 기간 변수는 동적 초기화가 있는 것으로 가정하고 매우 신중하게 검토해야 합니다.
대조적으로 다음 초기화는 문제가 있습니다.
// Some declarations used below.
time_t time(time_t*); // Not constexpr!
int f(); // Not constexpr!
struct Bar { Bar() {} };
// Problematic initializations.
time_t m = time(nullptr); // Initializing expression not a constant expression.
Foo y(f()); // Ditto
Bar b; // Chosen constructor Bar::Bar() not constexpr.
비지역 변수의 동적 초기화는 권장되지 않으며 일반적으로 금지됩니다. 그러나 프로그램의 어떤 측면도 다른 모든 초기화와 관련하여 이 초기화 순서에 의존하지 않는 경우에는 이를 허용합니다. 이러한 제한 사항에 따라 초기화 순서는 눈에 띄는 차이를 만들지 않습니다. 예를 들어:
int p = getpid(); // Allowed, as long as no other static variable
// uses p in its own initialization.
정적 지역 변수의 동적 초기화가 허용됩니다(그리고 일반적입니다).
- 전역 문자열: 명명된 전역 또는 정적 문자열 상수가 필요한 경우 string_view의 constexpr 변수, 문자 배열 또는 문자열 리터럴을 가리키는 문자 포인터를 사용하는 것이 좋습니다. 문자열 리터럴에는 이미 정적 저장 기간이 있으며 일반적으로 충분합니다. TotW #140을 참조하세요.
- 맵, 세트 및 기타 동적 컨테이너: 검색할 세트나 조회 테이블과 같은 정적 고정 컬렉션이 필요한 경우 표준 라이브러리의 동적 컨테이너를 정적 변수로 사용할 수 없습니다. 왜냐하면 여기에는 중요한 소멸자가 있기 때문입니다. 대신, int 배열의 배열("int에서 int로 매핑"의 경우) 또는 쌍의 배열(예: int 및 const char* 쌍)과 같은 사소한 유형의 간단한 배열을 고려하세요. 소규모 컬렉션의 경우 선형 검색으로 충분합니다(메모리 지역성으로 인해 효율적입니다). 표준 작업을 위해 absl/algorithm/container.h의 기능을 사용하는 것을 고려해보세요. 필요한 경우 컬렉션을 정렬된 순서로 유지하고 이진 검색 알고리즘을 사용합니다. 표준 라이브러리의 동적 컨테이너를 정말로 선호한다면 아래 설명된 대로 함수 로컬 정적 포인터 사용을 고려해 보세요.
- 스마트 포인터( std::unique_ptr , std::shared_ptr ): 스마트 포인터는 소멸 중에 정리를 실행하므로 금지됩니다. 귀하의 사용 사례가 이 섹션에 설명된 다른 패턴 중 하나에 적합한지 고려하세요. 한 가지 간단한 해결책은 동적으로 할당된 객체에 대한 일반 포인터를 사용하고 절대 삭제하지 않는 것입니다(마지막 항목 참조).
- 사용자 정의 유형의 정적 변수: 직접 정의해야 하는 유형의 정적 상수 데이터가 필요한 경우 유형에 간단한 소멸자와 constexpr 생성자를 제공하십시오.
- 다른 모든 방법이 실패하면 동적으로 객체를 생성하고 함수 로컬 정적 포인터나 참조(예: static const auto& impl = *new T(args...); )를 사용하여 객체를 삭제하지 않을 수 있습니다.
이해하기 쉽게 설명하기¶
두 개의 축: 소멸(destruction)과 초기화(initialization)¶
정적 저장 기간(static storage duration) 객체 — 전역 변수, 클래스의 정적 멤버, static 지역 변수 — 는 프로그램이 끝날 때까지 살아 있습니다. 이들이 문제를 일으키는 지점은 딱 두 가지입니다.
- 소멸 순서: 번역 단위 간 소멸 순서가 정해져 있지 않아, 종료 시 이미 파괴된 객체를 다른 객체가 참조하면 use-after-free가 발생합니다.
- 초기화 순서: 동적 초기화(dynamic initialization)는 번역 단위 간 순서가 보장되지 않아, 아직 초기화되지 않은 객체를 참조할 수 있습니다.
규칙 1: 소멸은 "사소하게 소멸 가능"한 것만 허용¶
정적 저장 기간 객체는 사소하게 소멸 가능(trivially destructible)할 때만 허용됩니다. 즉 소멸자가 실질적으로 아무 일도 하지 않아야 합니다(사용자 정의·가상 소멸자가 없고, 모든 멤버도 사소하게 소멸 가능). 이러면 소멸 순서가 아예 문제되지 않습니다.
const int kNum = 10; // 허용 (기본 타입)
struct X { int n; };
const X kX[] = {{1}, {2}, {3}}; // 허용
constexpr std::array<int, 3> kArray = {1, 2, 3}; // 허용
const std::string kFoo = "foo"; // 금지: std::string은 사소하지 않은 소멸자
const std::string& kBar = StrCat("a", "b", "c"); // 금지: 수명 연장된 임시도 동일
std::string, std::map, std::unique_ptr 같은 표준 컨테이너·스마트 포인터는 사소하지 않은 소멸자를 가지므로 전역/정적으로 둘 수 없습니다.
규칙 2: 초기화는 "상수 초기화"를 선호¶
가능하면 상수 초기화(constant initialization), 즉 컴파일 타임에 끝나는 초기화를 쓰세요. 정적 저장 기간 변수의 상수 초기화는 constexpr 또는 constinit로 표시해야 합니다. 그렇게 표시되지 않은 비지역 정적 변수는 동적 초기화로 간주되어 엄격한 검토 대상입니다.
struct Foo { constexpr Foo(int) {} };
int n = 5; // OK: 5는 상수 표현식
Foo x(2); // OK: 2는 상수 표현식, 선택된 생성자가 constexpr
time_t m = time(nullptr); // 문제: 초기화 표현식이 상수가 아님 (동적 초기화)
예외적으로 동적 초기화가 허용되는 경우¶
- 함수 지역
static변수: 동적 초기화가 허용되며 흔히 쓰입니다(제어가 선언을 처음 지날 때 한 번 초기화). - 순서에 의존하지 않는 비지역 변수: 다른 어떤 초기화도 이 변수에 의존하지 않는다면 허용됩니다(예:
int p = getpid();).
전역 컨테이너·문자열이 필요할 때의 대안¶
- 전역 문자열 →
constexprstring_view, 문자 배열, 또는 문자열 리터럴을 가리키는 문자 포인터 - 맵·세트 → 사소한 타입의 정렬된 배열 + 이진 탐색(소규모는 선형 탐색으로 충분)
- 정말 동적 컨테이너가 필요하면 → 함수 지역
static포인터(static const auto& impl = *new T(...);)로 의도적으로 누수