2022. 9. 16. 18:48ㆍC++
std::initializer_list는 단순히 특정 타입 오브젝트의 배열이 아니다. 정확히 말하면 initializer_list는 컴파일러가 생성한 임시 배열(temporary array)의 포인터와 크기를 들고있는 오브젝트이다. 다음 코드를 보자.

6번 라인을 보면 0~9까지 총 10개의 원소로 initializer_list를 초기화했다. 그냥 척 보기에는 list 변수가 10개의 int를 들고있는 배열로 보이는데, 6번 라인의 어셈블리를 확인해보자.


먼저 read-only 메모리에 우리가 입력한 0~9까지의 숫자들이 .Lconstinit 레이블로 저장된 것을 볼 수 있는데, 어셈블리 5~8번 라인을 보면 .Lconstinit 레이블에 있는 0~9까지의 40byte를 스택 메모리 [rbp - 56]에 memcpy하고 있는 것을 알 수 있다. 9~11번 라인을 통해 0~9까지의 숫자들이 복사된 메모리 주소(rbp - 56)와 원소 개수(10)를 각각 [rbp - 16], [rbp - 8]에 저장하고 있는 것을 알 수 있다. 스택을 그림으로 그려보면 다음과 같다.

위 어셈블리와 스택 그림을 통해 [rbp - 56]은 컴파일러가 임시로 생성한 배열이 시작되는 메모리 주소이며, [rbp - 8]와 [rbp - 16]은 initializer_list가 가지고 있는 해당 배열 크기와 포인터임을 유추할 수 있다. 이 사실이 맞는지 확인해보려면 initializer_list를 복사해서 배열 전체가 복사되는지, 아니면 배열 포인터와 크기만 복사되는지 확인하면 된다. 복사한 후 어셈블리를 확인해보자.


어셈블리만 딱 봐도 [rbp - 8], [rbp - 16]에 저장되어 있던 배열 크기와 포인터만 복사되는 것을 알 수 있다.
생성되는 임시 배열의 타입은 const T[N]이며, 이 배열의 각 원소는 원래의 initializer_list 각 원소로 부터 copy-initialized 된다. 아까 어셈블리에서 .Lconstinit 레이블 이라는 것을 보았는데, const T[N]의 각 원소가 이 레이블에 있는 각 원소로 부터 copy-initialized 된다는 것이다.
결과적으로 컴파일러는 다음과 유사한 코드를 생성할 것이다.
#include <initializer_list>
#include <utility>
const int* begin(const int(&arr)[10])
{
return &arr[0];
}
const int* end(const int(&arr)[10])
{
return &arr[10];
}
int main()
{
const int __temp[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
std::initializer_list<int> list{ begin(__temp), end(__temp) };
}
그럼 initializer_list의 원소들을 복사가 아닌 이동 생성자를 통해 컨테이너에 삽입할 수 있으면 효율적일텐데 왜 임시 배열은 왜 const로 지정되었을까?
명확한 답변은 찾지 못했지만, C++ 표준에 다음과 같은 문구가 있다.
[Note 6: The implementation is free to allocate the array in read-only memory if an explicit array with the same initializer can be so allocated. — end note]
배열의 원소들이 같으면 (아마 배열의 재생성을 줄이기 위해서) 컴파일러가 자율적으로 임시 배열을 read-only 메모리에 할당해도 된다는 내용인데, read-only 메모리에 있는 데이터들은 복사만 가능하므로 const로 지정되는 것 같다는 추측이다.
마지막으로 임시 배열과 initializer_list 오브젝트의 lifetime에 대해 알아보자.
임시 배열은 대부분 initializer_list 오브젝트가 파괴될 때 같이 파괴되는데, C++ 표준에 나와있는 다음 예제를 살펴보자.
typedef std::complex<double> cmplx;
std::vector<cmplx> v1 = { 1, 2, 3 };
void f() {
std::vector<cmplx> v2{ 1, 2, 3 };
std::initializer_list<int> i3 = { 1, 2, 3 };
}
struct A {
std::initializer_list<int> i4;
A() : i4{ 1, 2, 3 } {}
};
v1, v2는 std::vector의 initializer_list 생성자를 호출하며, 생성자 호출이 끝나면 인자 initializer_list가 파괴되면서 임시 배열도 같이 파괴된다. i3는 함수 f가 끝나면 임시 배열과 같이 파괴된다. i4는 멤버 변수로 선언이 되어있으므로 A의 인스턴스가 파괴될 때 까지 살아있는다. 하지만, member-initializer-list에서 생성한 임시 배열은 모든 멤버가 초기화 된 후(생성자 바디로 들어가기 전에) 파괴되기 때문에[1][2] 생성자 호출이 끝난 후 i4는 내부적으로 파괴된 배열의 주소를 가리키게 된다(즉, dangling pointer가 된다).
[참고]
[1] https://en.cppreference.com/w/cpp/utility/initializer_list
기본적인 동작을 보기 위해 최적화 옵션을 제일 낮은 단계로 설정하였으며, clang x86-64 컴파일러를 사용하였습니다. 컴파일러 및 최적화 옵션에 따라서 생성되는 어셈블리는 달라질 수 있습니다.
'C++' 카테고리의 다른 글
std::chrono - 1. Duration (1) | 2022.09.22 |
---|---|
std::chrono - 0. chrono가 왜 필요한가? (1) | 2022.09.21 |
std::string_view를 값으로 전달해야 하는 3가지 이유 (번역) (1) | 2022.09.13 |
Empty Base Optimization (1) | 2022.09.13 |
문자열 리터럴을 템플릿 인자로 전달하는 방법 (0) | 2022.04.02 |