C++ Move 문법을 이용한 외부 할당 메모리 포인터 관리

2022. 11. 14. 18:22프로그래밍

728x90

https://www.codeproject.com/Articles/5344758/Using-Cplusplus-Move-Semantics-to-Manage-Pointers

 

Using C++ Move Semantics to Manage Pointers to Externally Allocated Memory

The win32 subsystem often returns pointers to objects that need to be deallocated by the caller. In this article, I show a way to do this reliably and in an exception-safe manner.

www.codeproject.com

win32 하위 시스템은 호출자가 할당을 해제해야 하는 객체들에 대한 포인터를 반환하는 경우가 많습니다. 이 글에서 이를 안정적이고 예외로부터 안전한 방식으로 수행하는 방법을 보여 드리겠습니다.

소개

저는 최근에 보안 관련 프로그래밍을 많이 하고 있는 중입니다. Windows 보안 관련 API는 사용하기가 상당히 쉽지만 기본적으로 C 스타일의 인터페이스를 가지고 있습니다. 표면적으로는 C++이 C의 상위 집합이기 때문에 이것이 큰 문제가 되지 않는다고 당신은 생각할 것입니다.

이에 대한 문제는 API가 반환 값에 대한 포인터를 제공하고 당신이 LocalFree를 사용하여 해제할 것으로 예상하는 경우가 많다는 것입니다. 

예를 들어 다음과 같이 ConvertSidToSidString API의 경우:

BOOL ConvertSidToStringSidW( [in] PSID Sid, [out] LPWSTR *StringSid );

호출자가 해제해야 하는 포인터로 StringSid를 반환합니다. 
하지만 다음 코드를 생각해 봅시다:

wstring w32_ConvertSidToStringSid(PSID pSid)
{
    PWCHAR sidString;

    //Get a human readable version of the SID
    if (!ConvertSidToStringSid(pSid, &sidString)) {
        throw ExWin32Error();
    }

    wstring retval = sidString;
    LocalFree(sidString);

    return retval;
}


위 코드는 래퍼 함수를 구현한다고 한다면 생각 할 수 있는 구현입니다. 다음 두 가지 이유로 저는 이러한 유형의 코드를 가진 거대한 API 래퍼 콜랙션을 유지 관리합니다.

첫 번째는 PWCHAR 데이터 유형 대신 std::wstring을 사용할 때 문자열을 처리하는 것이 훨씬 쉽다는 것입니다. 두 번째는 예외와 RAII를 사용 해야 전체 코드가 훨씬 깨끗하고 읽기 쉽다는 것입니다. 그리고 여기서 C 스타일 API와 C++ 간의 충돌(부분들)이 명확해 집니다.
위 코드를 보면 반환된 포인터를 사용하여 wstring 변수를 초기화한 다음 포인터를 해제하는 것을 볼 수 있습니다. 만약, 어떤 이유로 wstring 생성자가 예외를 throw하면 sidString이 해제되지 않고 메모리 누수가 발생할 것입니다.

물론 확실히 wstring 생성자가 예외를 던질 가능성은 거의 없습니다. 그리고 이런 경우에는 스택 기반 배열과 메모리 복사본을 사용하여 쉽게 해결할 수 있습니다.
하지만 구조적인 문제는 여전히 존재하고 있습니다. 그리고 API가 만약 다른 함수 호출에서 매개변수로 사용해야 하는 포인터를 반환하면 상황이 훨씬 더 나빠집니다. 이 시나리오라면 예외가 언제 발생할지 예측할 수도 없습니다. 게다가 결과 코드는 중첩된 if 문의 스파게티코드가 될 수도 있을 것입니다.

우리가 정말로 원하는 것은 다음과 같은 방식으로 

이러한 포인터의 처리는

  1. 포인터를 확실히 Free 해야 하고
  2. exception에 안전해야 하며,
  3. 호출자/서브루틴에 포인터를 쉽게 전달할 수 

있어야 한다는 것입니다.

요컨대, 우리는 스마트 포인터 구현을 생각해봐야 할 필요가 있습니다.

unique_ptr을 재사용할까?


저는 재사용의 큰 지지자입니다. 수년간 사용하고 잘 만들어 논 클래스를 합리적으로 사용할 수 있다면 확실히 사용하겠습니다. 
COM의 경우, 저는 CComPtr을 사용합니다. Variant 구조체는 CComVariant 등을 하구요...

첫 번째 저의 생각은 unique_ptr을 사용하는 것이 었습니다. 이 녀석은 우리가 원하는 거의 모든 것을 할 수 있습니다. 

다음의 테스트로 제가 만든 구현도 매우 간단합니다:

using deleter = void(*)(void *);
void deleterfunc(void* ptr) { if (ptr) LocalFree(ptr); }

template<typename T>
struct CLocalAllocPtr : public std::unique_ptr < T, deleter>
{
public:
    CLocalAllocPtr(T* t) : std::unique_ptr < T, deleter>(t, deleterfunc) {}
};

간단하죠? unique_ptr은 생성자가 결국 포인터를 정리할 함수에 대한 함수 포인터를 취해야 합니다. 

포인터가 가리키는 메모리가 여타 힙 관리 함수를 사용하는 대신 LocalFree를 요구하는 방식으로 할당되었기 때문에 우리의 CLocalAllocPtr은 100% 다른 정리 함수를 가진 unique_ptr입니다.

우리는 다음과 같은 방식을 사용할 수 있습니다:

void foo(LPWSTR* arg) {
    *arg = (LPWSTR)LocalAlloc(LPTR,42);
}

int main()
{
    WCHAR* rawPtr = NULL;
    foo(&rawPtr);
    CLocalAllocPtr <WCHAR> smartPtr(rawPtr);
    return 0;
}

여기에서 foo는 우리가 제어할 수 없는 API 호출이라고 가정합시다. foo는 메모리를 할당하고 포인터를 반환합니다.
우리가 책임을 가지고 있기 때문에, CLocalAllocPtr에 대한 포인터 제어를 전달하여 수명주기을 관리하고 smartPtr가 범위를 벗어날 때 LocalFree가 실행되도록 합니다.

unique_ptr은 move 문법을 구현하므로 다음 작업도 수행할 수 있습니다:

void foo(LPWSTR* arg) {
    *arg = (LPWSTR)LocalAlloc(LPTR,42);
}

CLocalAllocPtr <WCHAR> Bar() {
    WCHAR* rawPtr = NULL;
    foo(&rawPtr);
    return CLocalAllocPtr <WCHAR> (rawPtr);
}

int main()
{
    CLocalAllocPtr <WCHAR> smartPtr2 = Bar();
    return 0;
}


포인터의 소유권을 호출자와 서브루틴으로 이전 될 수 있습니다. 표면적으로는 이 코드는 우리가 필요로 하는 모든 것을 수행합니다.

원시 포인터 액세스

누군가는 API 호출에 직접 포인터 값을 제공해야 하는 경우가 많다고 주장할 수 있습니다. 이 unique_ptr 클래스는 get() 메서드를 제공합니다.

void Baz(WCHAR* arg) { }

int main()
{
    CLocalAllocPtr <WCHAR> smartPtr2 = Bar();
    Baz(smartPtr2.get());

    return 0;
}


솔직히 저는 위의 방식은 마음에 들지 않습니다. 그렇죠, 이 수행 방식이 바로 'C++' 방식이라는 것을 알고 있지만 저는 이 방식이 자동적으로 변환되기를 원하는 것입니다. 이를 위해서 CLocalAllocPtr 클래스에 작은 추가 사항이 발생 할 수 있습니다.

template<typename T>
struct CLocalAllocPtr : public std::unique_ptr < T, deleter>
{
public:
    CLocalAllocPtr(T* t) : std::unique_ptr < T, deleter>(t, deleterfunc) {}

    operator T* () {
        return this->get();
    }
};

void Baz(WCHAR* arg) { }

int main()
{
    CLocalAllocPtr <WCHAR> smartPtr2 = Bar();
    Baz(smartPtr2); //automatic conversion to pointer
    return 0;
}

간단한 캐스팅 연산자를 추가하면 일반 포인터를 사용하는 것처럼 실제로 스마트 포인터를 사용할 수 있습니다. 끝났습니다, 문제는 해결 됐고, 잘한 것 같네요!

...

재미를 망치는 하나의 작은 세부 사항 하나가 빠졌네요.솔직히 말해서 위의 구현은 견고하고 디자인 관점에서 훌륭한 unique_ptr을 기반을 제공합니다.

하지만 원시 포인터를 스마트 포인터로 즉시(IMMEDIATELY) 래핑하는 것은 여전히 ​​프로그래머에게 의존해야만 합니다. 하지만 우리 작성한 코드와 같은 간단한 예의 경우 이 문제는 사소해 보입니다.
하지만 많은 포인터들을 다루는 경우라면, 이를 즉시 수행하지 않으면 여전히 문제가 발생할 수 있습니다. 정리 관점에서 더해보면, 이것이 코드에서 제거하고 싶은 추가 단계 1이 되겠네요.

내가 정말로 원하는 것은 CComPtr의 참조 연산자와 같은 동작으로 다음과 같은 작업을 수행할 수 있게 해주는 구현을 원한다는 것입니다:

CComPtr<IADs> rootDse = NULL;
hr = ADsOpenObject(L"LDAP://rootDSE",
    NULL,
    NULL,
    ADS_AUTHENTICATION_ENUM::ADS_SECURE_AUTHENTICATION, // Use Secure
                                                        // Authentication
    IID_IADs,
    (void**)&rootDse);

COM 스마트 포인터를 사용하면 내부 포인터에 대한 포인터를 얻기 위해 자신을 참조할 수 있습니다. 
이 의미는, ADsOpenObject에 대한 호출이 완료되면 스마트 포인터가 초기화됩니다. 추가 단계를 추가할 필요가 없습니다. 

슬프게도 unique_ptr로는 불가능합니다. 전체 전제는 고유하고 수명주기 관리에 대한 단독 책임이 있다는 것입니다. 그리고 그 보장을 위해 해당 멤버는 비공개로 유지됩니다. 이는 파생 클래스에서도 액세스할 수 없음을 의미합니다.

속담에서: 여태까지의 구현은 거의 정답에 가까웠지만, 정답이 아니었네요. 특정 unique_ptr 동작을 참조 연산자와 결합하려면 다시 처음부터 구현을 다시해야봐야 할 것 같네요...

 

처음부터 CLocalAllocPtr 구현

고맙게도 우리가 원하는 것은 범위가 상당히 제한되어 있으므로 unique_ptr을 다시 구현할 필요가 없습니다. 그럼 생성자 / 소멸자부터 다시 살펴 봅시다.

template<typename T>
struct CLocalAllocPtr
{
    T Ptr = NULL;

    void Release() {
        if (Ptr) {
            LocalFree(Ptr);
            Ptr = NULL;
        }
    }

    ~CLocalAllocPtr() {
        Release();
    }

    CLocalAllocPtr() {
        Ptr = NULL;
    }

    CLocalAllocPtr(T ptr) {
        Ptr = ptr;
    }

    CLocalAllocPtr(CLocalAllocPtr&& other) noexcept {
        if (&(other.Ptr) != &(this->Ptr)) {
            Ptr = other.Ptr;
            other.Ptr = NULL;
        }
    }
}

코드에서 세 가지 유형의 생성자를 가지고 있습니다. 기본 생성자는 초기화하고 스마트 포인터를 비웁니다. 원시 포인터를 사용하는 생성자는 포인터의 소유권을 가정하는 포인터입니다.

그리고는 move 생성자가 있습니다. move 생성자는 rvalue로 초기화될 때마다 사용됩니다.
이런 일이 발생되면 포함된 포인터의 소유권이라 가정하고 이중 소멸자 호출을 피하기 위해 rvalue에서 포인터를 지웁니다.

우리 시나리오에서는 복사 생성자는 의미가 없기 때문에 만들지 않았습니다.
다른부분에서 할당 된 포인터의 수명 주기를 관리하는 것이 이 클래스의 요점이 되겠습니다.

우리는 그 행동을 복사하거나 복제할 수 없으며 원하지도 않습니다. 다른 인스턴스를 원할 경우 올바른 접근 방식은 상대방에게 인스턴스를 할당하도록 요청하는 것입니다.

생성자 옆에 할당 연산자도 만들었습니다.

CLocalAllocPtr& operator = (CLocalAllocPtr&& other) noexcept {
    if (&(other.Ptr) != &(this->Ptr)) {
        Release();
        Ptr = other.Ptr;
        other.Ptr = NULL;
    }
    return *this;
}

CLocalAllocPtr& operator = (T t) noexcept {
    Release();
    Ptr = t;
    return *this;
}

두 경우 모두 원시 포인터의 소유권을 가져오며 두 경우 모두 인스턴스에 이미 다른 포인터에 포함되어 있으면 해제 해야 필요가 있습니다.move 생성자/할당에서 자체 할당 여부를 확인 해야 합니다. 이것은 일반적으로 if (&other != this)와 같은 비교로 수행됩니다. 

이 경우에는 (다음 섹션에 설명) 클래스를 #smartpointer 로 사용할 수 있도록 & 연산자를 재정의하므로 옵션이 아닙니다. 그러나 검사의 요점은 객체가 동일한 것을 가리키는지 여부를 결정하는 것이기 때문에 실제로는 중요하지 않습니다. 이를 위해 객체에 있는 'Ptr' 변수의 주소를 비교할 수도 있습니다. 모든 Ptr 값은 객체에 대해 로컬이므로 주소 위치가 다르면 개체도 다릅니다.

원시 포인터 액세스

포인터의 수명주기 관리를 방해하지 않고 포인터에 액세스하기 위한 코드를 구현할 수 있습니다.

//Get a reference to the pointer value
T* operator &() {
    return &Ptr;
}

//Cast to the pointertype
operator T () {
    return Ptr;
}

//access members of Ptr
T operator -> () {
    return Ptr;
}

참조 연산자는 COM 스마트 포인터의 동작과 유사하게 포함된 포인터에 대한 직접 액세스 권한을 서브루틴에 부여하려는 경우에 사용됩니다. 캐스팅 연산자를 사용하면 원시 포인터 값으로 암시적으로 변환할 수 있습니다. 이 방식은 포인터를 서브루틴으로 전달할 때 자주 사용됩니다.

첫 번째 구현에서는 포인터가 가리키는 유형이 T인 반면 이 구현에서는 '무언가에 대한 포인터' 유형으로 T가 만들어졌음을 발견했을 것입니다. 이 부분은 의도적인 코드입니다. 

unique_ptr 같은 CLocalAllocPtr을 구현하고 포인터 유형 대신 템플릿 인수로 대상 유형을 사용하는 것이 가능했을 것입니다. 기능적으로 완벽하게 작동할 것입니다. 

원시 포인터로의 자동 캐스트되는 것이 여전한 문제로 남았습니다.
사용 사례로 돌아가서 아래 함수를 생각해 봅시다.

BOOL ConvertSidToStringSidW( [in] PSID Sid, [out] LPWSTR *StringSid );

포인터가 가리키는 것이 무엇이든 T를 취하는 방식으로 CLocalAllocPtr을 구현한다고 가정합니다. 이렇게 사용하려고 하고 해당 API를 호출하려면 다음과 같은 코드가 구성 됩니다:

CLocalAllocPtr<SID> pSid;
CLocalAllocPtr<WCHAR> outStr;

//... the SID comes from somewhere ...

ConvertSidToStringSidW( pSid, &outStr );


그리고 이것도 동작 할 것입니다. 저는 비교를 위해 이 두 가지 구현을 만들었습니다. 그리고 마지막에 가장 의미가 있는 구현을 선택했습니다. 바로 포인터 유형을 사용하는 구현입니다. 이런 식으로 PSID를 래핑하고 스마트 포인터를 PSID에 똑같이 사용합니다. LPWSTR을 래핑하고 LPWSTR처럼 사용합니다.

대안 구현으로는 SID 유형을 래핑하고 이를 PSID처럼 사용하는 것입니다. WCHAR 유형을 래핑하고 LPWSTR처럼 사용합니다. 기능적으로는 동일하지만 이상하고 어울리지 않게 보입니다.

컴파일러는 함정을 피합니다

내 코드를 테스트하면서 이중 삭제로 이어질 수 있는 잠재적인 함정에 대해 궁금했습니다.

CLocalAllocPtr<SID> pSid1;
CLocalAllocPtr<SID> pSid2;

//... the SIDs come from somewhere ...

pSid1 = pSid2;  //????

원시 포인터에 사용할 수 있는 명시적 캐스트가 있으며 원시 포인터를 가지는 할당 연산자가 존재합니다.

그리고 복사 생성자나 복사 할당이 없다는 사실을 피하기 위해 컴파일러가 자동으로 이것들을 사용한다면, 우리는 컴파일러가 같은 포인터를 소유하고 있다고 생각 할 수 있는 각각 2개의 스마트 포인터가 있는 상황으로 이어질 수 있기 때문에 많은 문제에 직면할 것입니다. 

결과적으로 컴파일러는 다음 메시지와 함께 이를 컴파일하는 것을 정상적으로 거부했습니다.

error C2280: 'CLocalAllocPtr<PSID> &CLocalAllocPtr<PSID>::operator =
(const CLocalAllocPtr<PSID> &)': attempting to reference a deleted function
 message : compiler has generated 'CLocalAllocPtr<PSID>::operator =' here
 message : 'CLocalAllocPtr<PSID> &CLocalAllocPtr<PSID>::operator =
(const CLocalAllocPtr<PSID> &)': function was implicitly deleted 
because 'CLocalAllocPtr<PSID>' has a user-defined move constructor

클래스에서 move 문법을 구현하면 컴파일러는 복사 생성자 및 복사 할당에 대한 암시적 선언을 그대로 유지하지만 구현은 제거됩니다. move 문법를 구현하면 논리적으로도 자동 복사 되지 말아야 하기 때문입니다. 만약 복사를 원하면 명시적으로 구현해야할 필요가 있습니다.

최종 결과는 선언이 여전히 존재하기 때문에 우리가 pSid1 = pSid2를 컴파일하려고 할 때 컴파일러는 포인터로의 변환과 포인터 할당보다 복사 할당을 선택 할 것입니다. 

왜냐하면 그것이 더 정확한 맞춤이기 때문입니다. 그리고 이것은 당신이 재고하고 싶을 수도 있는 어떤 일이 일어나고 있다는 것을 알려주는 컴파일 오류를 일으킬 것입니다.

여전히 다음과 같은 작업이 가능합니다:

CLocalAllocPtr<SID> pSid1;
CLocalAllocPtr<SID> pSid2;

//... the SIDs come from somewhere ...

pSid1 = (PSID)pSid2;  //????

그러면 컴파일러가 원시 포인터로 캐스팅한 다음 할당 연산자를 사용하는 경로를 선택하도록 강제 합니다.

공평하게 말해서 결과는 치명적이겠지만 이렇게 자신의 발에 총을 쏘면 적어도 자신이 고의로 방아쇠를 당겼기 때문에 자신만 탓하면 되니까요...


결론

CLocalAllocPtr 클래스를 사용하면 원시 포인터를 안전하게 받아들이고 원시 포인터를 전달함으로써 발생하는 메모리 누수나 기타 문제에 대한 걱정 없이 코드에서 사용할 수 있습니다. 이러한 방식으로 win32 API를 처리하는 경우 자유롭게 사용하십시오.

개인적인 편의상 자체 구현을 선호합니다. 그러나 소프트웨어 관리 관점에서 볼 때 다른 사람들이 #unique_ptr 을 재사용하는 구현을 선호하는 이유를 알 수 있습니다. 소스 코드 다운로드에도 해당 버전을 포함했습니다.

참조용 포인터 유형 대신 대상 유형을 사용하는 구현도 포함했습니다.

모든 것이 MIT 라이선스에 따라 라이선스가 부여되므로 재미있게 사용하세요.

History
10th November, 2022: article replaced malloc with LocalAlloc.
7th November, 2022: Updated code and article after user riki_p pointed out a typo.
4th November, 2022: Initial release
License
This article, along with any associated source code and files, is licensed under The MIT License