std::intializer_list는 어떻게 생성 및 파괴될까?

2022. 9. 16. 18:48C++

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::vectorinitializer_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

[2] https://eel.is/c++draft

 

 

기본적인 동작을 보기 위해 최적화 옵션을 제일 낮은 단계로 설정하였으며, clang x86-64 컴파일러를 사용하였습니다. 컴파일러 및 최적화 옵션에 따라서 생성되는 어셈블리는 달라질 수 있습니다.