使用 C++ 移动语义管理指向外部分配内存的指针






4.97/5 (19投票s)
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 包装器,原因有两个。第一个是使用 std::wstring
而不是 PWCHAR
数据类型处理字符串要容易得多。第二个是使用异常和 RAII,您的整体代码会更简洁、更易读。这时 C 风格 API 和 C++ 之间的冲突就显而易见了。
如果您查看代码,会发现我们在使用返回的指针初始化 wstring
变量后,就释放了该指针。如果由于某种原因,wstring
构造函数抛出异常,sidString
将永远不会被释放,从而导致内存泄漏。
当然,wstring
构造函数抛出异常的几率几乎为零。在这种情况下,我们可能可以通过使用基于堆栈的数组和内存复制来轻松解决。但问题原则仍然存在。如果 API 返回一个我们需要在其他函数调用中用作参数的指针,情况会糟糕得多。在这种情况下,我们根本无法预测何时会发生异常。最重要的是,生成的代码将是一堆嵌套的 if
语句。
我们真正想要的是一种处理这些指针的方式,这种方式能够
- 保证释放指针,并且
- 异常安全
- 能够轻松地将指针传递给调用者/子例程
简而言之,我们需要实现一个智能指针。
重用 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
需要构造函数接受一个函数指针,该函数最终将负责清理指针。我们的 CLocalAllocPtr
100% 是 unique_ptr
,只是具有不同的清理函数,因为我们指针指向的内存的分配方式需要 LocalFree
,而不是其他堆管理函数。
我们可以这样使用它:
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
实现移动语义,所以我们也可以这样做:
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
之上,这在设计上很棒。然而,它仍然依赖于程序员立即将原始指针包装到智能指针中。对于我们这样简单的例子,这是微不足道的。但如果您处理许多指针,如果您未能立即这样做,仍然可能产生问题。此外,从简洁性的角度来看,这还多了一个我想要消除的步骤。
我真正想要的是类似 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
不可能做到这一点。它的全部前提是它是唯一的,并且全权负责管理生命周期。为了做出这种保证,它将该成员保持为 private
。这意味着即使在我们的派生类中,我们也无法访问它。
正如他们所说:这个实现很接近,但功亏一篑。我们必须回到绘图板,如果想结合 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;
}
}
}
我们有三种构造函数。默认构造函数仅初始化一个空智能指针。接受原始指针的构造函数假定拥有该指针的所有权。然后,还有一个移动构造函数。当使用 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;
}
在这两种情况下,我们都接管了原始指针的所有权,并且在这两种情况下,我们都需要预见到,如果实例已经包含另一个指针,则需要将其释放。
在移动构造函数/赋值中,我们需要检查自赋值。这通常通过类似 if (&other != this)
的比较来完成。在这种情况下,这并不可行,因为(在下一节中显示)我重写了 &
运算符,以便能够将该类用作智能指针。但这并不重要,因为检查的目的是确定对象是否指向同一内容。为此,我们还可以比较对象中 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 的方式来实现 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; //????
有一个显式的原始指针转换,还有一个接受原始指针的赋值运算符。如果编译器会自动将它们作为最佳匹配来规避我们没有复制构造函数或复制赋值的事实,我们将陷入困境,因为这可能导致有两个智能指针都认为它们拥有相同的指针。
结果是,编译器会正确地拒绝编译此代码,并显示以下消息:
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
当您在类中实现移动语义时,编译器会保留复制构造函数和复制赋值的隐式声明,但会删除它们的实现。这是一种合乎逻辑的做法,因为如果您实现了移动语义,那么可以安全地假设您不希望自动复制。如果您想要复制,则需要显式实现它。
最终结果是,由于声明仍然存在,当我们尝试编译 pSid1 = pSid2
时,编译器将选择复制赋值,而不是转换为指针和指针赋值,因为这是更正确的匹配。这将导致编译错误,告知您正在发生您可能想重新考虑的事情。
仍然可以这样做:
CLocalAllocPtr<SID> pSid1;
CLocalAllocPtr<SID> pSid2;
//... the SIDs come from somewhere ...
pSid1 = (PSID)pSid2; //????
这将强制编译器选择转换为原始指针,然后使用赋值运算符的路径。结果将是灾难性的,但公平地说,如果您像这样自残,至少您是知情的,并且只能责怪自己。
结论
使用 CLocalAllocPtr
类,您可以安全地接收原始指针并在代码中使用它们,而无需担心内存泄漏或其他因传递原始指针引起的问题。如果您以这种方式处理 win32 API,请随时使用它。
就个人而言,我更喜欢我自己的实现,因为它很方便。然而,从软件管理的角度来看,我可以看到为什么其他人会更喜欢重用 unique_ptr
的实现。我在源代码下载中也包含了该版本。
我还包括了使用目标类型而不是指针类型作为引用的实现。
所有内容均根据 MIT 许可证获得许可,请随意使用。
历史
- 2022 年 11 月 10 日:文章将
malloc
替换为LocalAlloc
- 2022 年 11 月 7 日:在用户 riki_p 指出拼写错误后更新了代码和文章
- 2022 年 11 月 4 日:首次发布