Placement New로 인해 발생할 수 있는 Undefined Behavior 및 솔루션

2022. 3. 9. 08:03C++

C/C++에서는 갖가지 상황에서 Undefined Behavior가 발생할 수 있다.

그 중 대다수에 포인터가 연관되어 있는데, 오늘은 특정 상황에서 Undefined Behavior를 우회하는 방법을 살펴 볼 것이다.

 

1. Reusing dynamic storage occupied by a const complete object

상수로 선언된 변수의 값을 수정할 수 없다는 것은 알고 있을 것이다.

하지만, 놀랍게도 다음과 같이 placement new를 통해 상수의 값을 강제로 덮어씌울 수 있다.

struct X { int n; };
const X *p = new const X{3};
const int a = p->n;
new (const_cast<X*>(p)) const X{5};
const int b = p->n;                 // undefined behavior

포인터 변수 p가 가리키던 주소에 있는 상수 X 오브젝트에 placement new를 통해 새로운 상수 X 오브젝트를 생성했다.

하지만, b와 같이 포인터 변수 p를 통해 다시 X::n에 접근하는 것은 undefined behaviour이다.

 

위 코드에서의 문제는 무엇일까?

 

[dcl.type.cv#4]

Any attempt to modify a const object during its lifetime results in undefined behavior.

[basic.life#10]

Creating a new object within the storage that a const complete object with static, thread, or automatic storage duration occupies, or within the storage that such a const object used to occupy before its lifetime ended, results in undefined behavior.

위에서 말하는 것과 같이, 상수 오브젝트가 살아있는 동안 값을 변경하거나, static, thread, automatic storage duration의 상수 오브젝트 위에 새로운 오브젝트를 생성하는 것은 undefined behaviour로 정의 돼 있지만, 우리는 기존 오브젝트의 값을 변경한 것도 아니며, dynamic storage duration (heap)의 상수 오브젝트 위에 새로운 상수 오브젝트를 생성했으므로 해당되지 않는다.

 

하지만, 위 코드는 다음 항목을 위반한다.

 

[basic.life#8]

If, after the lifetime of an object has ended and before the storage which the object occupied is reused or released, a new object is created at the storage location which the original object occupied, a pointer that pointed to the original object, a reference that referred to the original object, or the name of the original object will automatically refer to the new object and, once the lifetime of the new object has started, can be used to manipulate the new object, if the original object is transparently replaceable (see below) by the new object. An object o1 is transparently replaceable by an object o2 if:

- ...
- o1 is not a complete const object, and
- ...

 

자, 줄이 긴데 강조된 문장만 해석해보자.

만약 원본 오브젝트가 새로운 오브젝트로 transparently replaceable 하다면, 원본 오브젝트를 가리키던 포인터를 새로운 오브젝트를 조작하는데 사용할 수 있다. 다음 경우에 오브젝트 o1은 오브젝트 o2로 transparently replaceable 하다.

- ...
- o1이 완전한 상수 오브젝트가 아니다.
- ...

즉, (다른 조건들과 함께) 위 조건을 만족시키면, 기존에 생성된 포인터를 통해 새로운 오브젝트를 조작할 수 있다는 것이다. 하지만,여기서 o1p이고, p는 상수로 선언되어 있으므로 위 조건을 만족하지 못한다.

(여기서 '완전한(complete)' 이라는 단어는 다른 오브젝트의 (멤버 변수와 같은)서브 오브젝트가 아니라는 것을 말한다.)

 

따라서 포인터 p를 새로운 오브젝트를 조작하는데 사용할 수 없으며, 이것이 변수 b를 초기화 할 때 undefined behaviour가 발생하는 이유이다.

 

그럼 새로운 오브젝트는 만들었는데.. 어떻게 접근해야 할까?

 

2번에서 Undefined Behavior가 발생하는 다른 경우와 함께 2가지 해결 방법을 제시하겠다.

 

2. Using old pointers/references/the name of the original object after placement new

1번 예제 처럼 상수 오브젝트가 아니더라도 Undefined Behavior가 발생하는 경우를 확인해보자.

 

C++에서 메모리를 할당 해 놓고 placement new를 통해 오브젝트를 생성하는 기법은 흔한 기법이다.

다음 예제를 보자. (간결한 설명을 위해 예외 체크 코드는 생략)

template<class T, std::size_t N>
class static_vector
{
    std::aligned_storage_t<sizeof(T), alignof(T)> data[N]; #a
    std::size_t m_size = 0;
 
public:
    template<typename ...Args> void emplace_back(Args&&... args)
    {
        ::new(&data[m_size]) T(std::forward<Args>(args)...); #b
        ++m_size;
    }
 
    const T& operator[](std::size_t pos) const
    {
        return *reinterpret_cast<const T*>(&data[pos]); #c
    }
 
    ~static_vector() 
    {
        for(std::size_t pos = 0; pos < m_size; ++pos)
        {
            std::destroy_at(reinterpret_cast<T*>(&data[pos])); #d
        }
    }
};

a. std::aligned_storage_t 타입을 가진 N개의 배열 생성

이 배열 메모리에 placement new를 수행하게 되는데, placement new를 사용해서 오브젝트의 lifetime을 시작하기 위해서는 최소한 오브젝트를 담을 수 있는 크기의 정렬된 메모리가 있어야 한다. 따라서 char, unsigned char, std::byte 대신 std::aligned_storage_t를 사용한다.

 

b. emplace_back 함수에서 placement new를 통해 오브젝트 생성

인자들은 perfect forwarding을 통해 전달하고 있다.

 

c. operator[] 함수에서 reinterpret_cast를 통해 aligned_storage_t* -> const T* 캐스팅 및 역참조

 

d. ~static_vector 소멸자에서 reinterpret_cast를 통해 aligned_storage_t* -> T* 캐스팅 및 std::destroy_at 함수를 통해 소멸자 호출

 

정렬된 메모리 만들었고, 오브젝트도 만들었고, T* 타입으로 캐스팅 해서 사용했고, ... 보기에는 문제가 전혀 없어보인다.

 

ab는 전혀 문제가 없다.

문제는 cd에서 reinterpret_cast를 통한 캐스팅에 있다.

 

1번에서 placement new를 수행했을 때 원본 오브젝트를 가리키던 포인터/레퍼런스 또는 원본 오브젝트의 이름을 사용하기 위해서는 조건이 필요하다고 했다. 이 예제에서 해당되지 않는 조건들은 제외하고, 나머지 조건을 추가해서 다시보자.

 

[basic.life#8]

If, after the lifetime of an object has ended and before the storage which the object occupied is reused or released, a new object is created at the storage location which the original object occupied, a pointer that pointed to the original object, a reference that referred to the original object, or the name of the original object will automatically refer to the new object and, once the lifetime of the new object has started, can be used to manipulate the new object, if the original object is transparently replaceable (see below) by the new object. An object o1 is transparently replaceable by an object o2 if:

- the storage that o2 occupies exactly overlays the storage that o1 occupied, and
- o1 and o2 are of the same type (ignoring the top-level cv-qualifiers), and 
- ...
만약 원본 오브젝트가 새로운 오브젝트로 transparently replaceable 하다면, 원본 오브젝트의 이름을 새로운 오브젝트를 조작하는데 사용할 수 있다. 다음 경우에 오브젝트 o1은 오브젝트 o2로 transparently replaceable 하다.

- o2가 차지한 메모리가 o1이 차지했던 메모리와 완전히 겹친다.
- (top-level const/volatile qualifier를 무시하고) o1o2가 동일한 타입이다.

 

위 예제에서 placement new는 위에 명시된 두 조건을 만족하는가?

 

- &data[m_size]의 크기는 sizeof(T)이므로 새로운 오브젝트의 크기와 같다.

(하지만, aligned_storage는 aligned_storage_t 타입의 크기가 sizeof(T)인 것을 보장하지 않는다.  [링크]에서 aligned_storage_t 타입을 a trivial and standard-layout type of at least size Len with alignment requirement Align와 같이 적어도 Len 크기라고 표현하고 있는데, 이게 표기 상 결함인지, 실제로 sizeof(T) 보다 클 수가 있는지는 모르겠지만, 이 외에도 여러가지 이유로 C++23에서 aligned_storage와 aligned_union은 deprecated 되었다.)

 

- &data[m_size]의 타입은 aligned_storage_t이므로 T와 타입이 다르다.

 

따라서 두 번째 조건을 만족하지 못하므로, 역시 1번의 const 예제 처럼 원본 오브젝트의 이름을 통해 새로운 오브젝트를 조작할 수 없다.

 

Solution

그럼 새로운 오브젝트를 만들어놓고 어떻게 접근할 수 있단 말인가?

 

a. placement new가 반환하는 포인터를 이용한다.

[expr.new#10]

let T be the allocated type; the new-expression is a prvalue of type “pointer to T” that points to the object created.

위에서 말하는 바와 같이, new가 반환하는 포인터는 생성된 오브젝트를 가리키게 보장되어 있다.

 

그럼 이 방법을 이용하여 1번의 const 예제를 수정 해 보자.

struct X { int n; };
const X *p = new const X{3};
const int a = p->n;
const X* p2 = new (const_cast<X*>(p)) const X{5};
const int b = p2->n;                 // defined behavior

 

 

placement new가 반환하는 포인터를 저장했으며, 이 포인터는 생성된 오브젝트를 가리키게 보장 돼 있으므로 b와 같이 사용하는 것은 정의된 동작이다.

 

하지만, 2번의 경우는 좀 다르다.

2번은 placement new를 통해 생성된 오브젝트만 접근이 가능한 것이 아니고 랜덤 액세스가 가능하기 때문에 placement new가 반환된 모든 포인터를 저장해 놓는 비효율적인 방법을 사용 할 것이 아니면 이 방법은 해결책이 되지 못한다.

 

b. std::launder가 반환하는 포인터를 이용한다.

위에서 본 예제들과 같이 placement new를 통해 오브젝트를 생성했지만 원본 오브젝트를 가리키는 포인터/레퍼런스 또는 원본 오브젝트의 이름을 사용할 수 있는 조건을 만족하지 못 한 경우, placement new가 반환한 포인터를 사용하는 것이 아니면 새로운 오브젝트에 undefined behaviour가 아닌 방식으로 접근 할 방법이 없다.

 

이 문제를 해결하기 위해 C++17에 std::launder 함수가 도입되었다.

template <class T>
[[nodiscard]] constexpr T* launder(T* p) noexcept;

이 함수는 파라미터로 포인터를 받아서 그 포인터를 그대로 반환하며, 인자로 전달된 주소에 오브젝트가 있으면 반환된 포인터가 해당 오브젝트를 가리키도록 한다.

 

1번의 예제를 수정해보자.

struct X { int n; };
const X *p = new const X{3};
const int a = p->n;
new (const_cast<X*>(p)) const X{5};
const int b = std::launder(p)->n;

 

2번의 예제를 수정해보자.

template<class T, std::size_t N>
class static_vector
{
    std::aligned_storage_t<sizeof(T), alignof(T)> data[N]; #a
    std::size_t m_size = 0;
 
public:
    template<typename ...Args> void emplace_back(Args&&... args)
    {
        ::new(&data[m_size]) T(std::forward<Args>(args)...); #b
        ++m_size;
    }
 
    const T& operator[](std::size_t pos) const
    {
        return *std::launder(reinterpret_cast<const T*>(&data[pos])); #c
    }
 
    ~static_vector() 
    {
        for(std::size_t pos = 0; pos < m_size; ++pos)
        {
            std::destroy_at(std::launder(reinterpret_cast<T*>(&data[pos]))); #d
        }
    }
};