C++23的out_ptr和inout_ptr
c++23新增了一些智能指針適配器,用來擴(kuò)展和簡化智能指針的使用。
這次主要介紹的是std::out_ptr和std::inout_ptr。這兩個適配器用法和實(shí)現(xiàn)都很簡單,但網(wǎng)上的文檔都比較抱歉,還缺少一些比較重要的部分,因此單開一篇文章記錄一下。
out_ptr
首先從功能最簡單的out_ptr講起。
std::out_ptr其實(shí)是一個函數(shù),返回一個類型為std::out_ptr_t的智能指針適配器,函數(shù)簽名如下:
#include <memory>
template< class Pointer = void, class Smart, class... Args >
auto out_ptr( Smart& s, Args&&... args );
這個函數(shù)主要是把各種智能指針包裝成output parameter,以方便現(xiàn)有的接口使用,尤其是一些用c語言寫的函數(shù)。
在繼續(xù)之前我們先來復(fù)習(xí)一下output parameter是什么。這東西又叫傳出參數(shù),一次就是函數(shù)會把一部分?jǐn)?shù)據(jù)寫進(jìn)自己的參數(shù)里返回給調(diào)用者。
通過參數(shù)返回是因為c語言和c++11之前的c++不支持多值返回也沒有類似tuple這樣方便的數(shù)據(jù)結(jié)構(gòu),導(dǎo)致函數(shù)無法直接返回兩個以上的值,所以需要用一種額外的傳遞數(shù)據(jù)的方式。
比如我在以前的博客中提到的hsearch:int hsearch_r(ENTRY item, ACTION action, ENTRY **retval, struct hsearch_data *htab)。這個函數(shù)用來在哈希表里創(chuàng)建或者查找數(shù)據(jù),查找失敗的時候會返回錯誤碼,而查找成功的時候函數(shù)返回0并把找到的數(shù)據(jù)設(shè)置給retval。這個retval就是output parameter,承載了函數(shù)除了錯誤碼之外的返回數(shù)據(jù)。
c++里現(xiàn)在很少用指針類型作為output parameter了,但還有更本地化的做法——引用:int func(const char *name, Data &retval)。
這類函數(shù)有幾個特點(diǎn):
- 不在乎output parameter里有什么值
- 函數(shù)調(diào)用期間完全享有output parameter和其資源的所有權(quán)
- 函數(shù)返回后output parameter通常被設(shè)置為新值
在c++提倡少用裸指針的今天,我們越來越習(xí)慣使用shared_ptr和unique_ptr,但不管哪種智能指針都很難直接適配上面這些函數(shù),看個例子就明白了:
int get_data(const std::string &name, Data **retval)
{
if (!check_name(name)) {
return ErrCheckFailed;
}
*retval = make_data(name);
return 0;
}
// 使用裸指針
Data *data_ptr = nullptr;
if (auto err = get_data("name", &data_ptr); err != 0) {
錯誤處理
} else {
這里可以使用data_ptr
}
使用裸指針的時候代碼比較簡單,我們再來看看使用智能指針的時候:
std::unique_ptr<Data> resource;
Data *data_ptr = nullptr;
if (auto err = get_data("name", &data_ptr); err != 0) {
錯誤處理
} else {
resource.reset(data_ptr);
這里可以使用resource
}
代碼會變得啰嗦,而且如果我們忘記了調(diào)用reset,那么資源就可能泄漏了;還有最重要的一點(diǎn),我們主動使用了裸指針,而這正是我們想避免的。
這時候就需要out_ptr了。out_ptr生成的適配器會先放棄智能指針持有資源的所有權(quán)并將舊資源釋放,因為如前面所說我們要調(diào)用的函數(shù)會接管資源的所有權(quán),接著構(gòu)造出的std::out_ptr_t有自動的類型轉(zhuǎn)換方法,可以把智能指針轉(zhuǎn)換成我們需要的T**交給函數(shù)使用,最后在函數(shù)調(diào)用結(jié)束之后再把新的資源設(shè)置回智能指針。
所以上面的例子可以改成:
std::unique_ptr<Data> resource;
if (auto err = get_data("name", std::out_ptr(resource)); err != 0) {
錯誤處理
} else {
這里可以使用resource,無需reset
}
除了代碼更簡潔,out_ptr還保證異常安全,即使在調(diào)用get_data的過程中拋出了異常,也不會出現(xiàn)資源泄漏。
利用out_ptr我們可以在使用智能指針的同時兼容老舊接口。
out_ptr和shared_ptr
如果只看函數(shù)簽名,很多人會覺得out_ptr也可以直接配合std::shared_ptr使用,然而現(xiàn)實(shí)是多變的:
struct Data {
std::string name;
};
int get_data(const std::string &name, Data **retval)
{
if (name == "")
return 1;
*retval = new Data{name};
return 0;
}
int main()
{
std::shared_ptr<Data> resource;
if (auto err = get_data("apocelipes", std::out_ptr(resource)); err != 0)
std::cerr << "error\n";
else
std::cout << "success, name: " << resource->name << "\n";
}
上面的代碼無法通過編譯:
$ clang++ -std=c++23 test.cpp
In file included from test.cpp:2:
In file included from /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/memory:948:
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__memory/out_ptr.h:38:17: error: static assertion failed due to requirement '!__is_specialization_v<std::shared_ptr<Data>, shared_ptr> || sizeof...(_Args) > 0': Using std::shared_ptr<> without a deleter in std::out_ptr is not supported.
38 | static_assert(!__is_specialization_v<_Smart, shared_ptr> || sizeof...(_Args) > 0,
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__memory/out_ptr.h:93:10: note: in instantiation of template class 'std::out_ptr_t<std::shared_ptr<Data>, Data *>' requested here
93 | return std::out_ptr_t<_Smart, _Ptr, _Args&&...>(__s, std::forward<_Args>(__args)...);
| ^
test.cpp:19:48: note: in instantiation of function template specialization 'std::out_ptr<void, std::shared_ptr<Data>>' requested here
19 | if (auto err = get_data("apocelipes", std::out_ptr(resource)); err != 0)
| ^
1 error generated.
報錯雖然很長但只要關(guān)注前幾行就行了,錯誤的原因很明顯,std::shared_ptr要配合out_ptr使用就必須顯示提供deleter。
這是因為對于std::shared_ptr,deleter并不是類型的一部分,通常是我們通過構(gòu)造函數(shù)或者reset方法穿進(jìn)去的,為了能100%正確釋放資源,我們需要手動把合適的deleter傳進(jìn)去;相對地deleter是std::unique_ptr類型的一部分,out_ptr可以直接從類型參數(shù)里得到合適的deleter從而正確釋放資源。
這也是為什么out_ptr還有變長參數(shù),這些參數(shù)就是為了std::shared_ptr或者其他有特殊要求的類似智能指針準(zhǔn)備的。
好在上面的代碼稍作修改就能正常使用:
int main()
{
std::shared_ptr<Data> resource;
- if (auto err = get_data("apocelipes", std::out_ptr(resource)); err != 0)
+ if (auto err = get_data("apocelipes", std::out_ptr(resource, std::default_delete<Data>{})); err != 0)
std::cerr << "error\n";
else
std::cout << "success, name: " << resource->name << "\n";
}
std::default_delete<T>會調(diào)用delete或者delete[]來釋放資源,正好我們這里可以利用它。shared_ptr平時也默認(rèn)使用的這個。
修改很簡單,但網(wǎng)上講這點(diǎn)的文檔不多,因此多記一筆。另外基于out_ptr會臨時轉(zhuǎn)移所有權(quán)這點(diǎn)來看,共享所有權(quán)模型的std::shared_ptr其實(shí)并不適合使用out_ptr,雖然標(biāo)準(zhǔn)沒有禁止甚至還要求額外做檢測(用于初始化shared_ptr),但我仍然建議把std::shared_ptr和std::out_ptr一起使用看做一種壞味道,盡量避免這種用例。
inout_ptr
inout_ptr的名字比較抽象,但只是在out_ptr的基礎(chǔ)上加了個“in”而已。它會返回一個std::inout_ptr_t類型的對象,函數(shù)簽名如下:
#include <memory>
template< class Pointer = void, class Smart, class... Args >
auto inout_ptr( Smart& s, Args&&... args );
這個“in”是指使用output parameter的函數(shù)在重新設(shè)置參數(shù)的值之前會先使用他們,因此這些函數(shù)的特點(diǎn)是:
- 非常在乎output parameter里有什么值,根據(jù)這些值執(zhí)行不同的操作
- 函數(shù)調(diào)用期間完全享有output parameter和其資源的所有權(quán)
- 函數(shù)返回后output parameter不變或者被設(shè)置為新值
還是看例子,我們對Data增加一個update_data函數(shù),如果name是recreate則刪除原來的對象重新創(chuàng)建一個:
int update_data(Data **data)
{
if (data == nullptr || *data == nullptr)
return 1;
if ((*data)->name == "recreate") {
delete *data;
*data = new Data{"apocelipes"};
return 2; // 代表已修改
}
return 0;
}
現(xiàn)實(shí)中沒人這么寫代碼,但存在很多類似的c接口,而且我們也很難控制第三方庫的代碼質(zhì)量,難免不會遇上類似的東西。如果想在這種接口上用智能指針,那只能說有福了:
auto resource = std::make_unique<Data>("recreate");
Data *ptr = resource.get();
resource.release(); // 釋放所有權(quán),但不釋放資源
if (auto code = update_data(&ptr); code == 1)
std::cerr << "error\n";
else if (code == 2) {
resource.reset(ptr);
std::cout << "updated, name: " << resource->name << "\n";
} else {
resource.reset(ptr);
std::cout << "updated, name: " << resource->name << "\n";
}
可以看到代碼會變得很復(fù)雜,而且一但忘記使用reset就會內(nèi)存錯誤。這時候我們就需要inout_ptr幫忙了。
inout_ptr整體上和out_ptr差不多,都是讓出資源的所有權(quán)然后重新把函數(shù)返回的值設(shè)置回去,但還有幾個差異:
- 前面說過需要
inout_ptr的函數(shù)是需要參數(shù)的值的,因此構(gòu)造inout_ptr_t時之后放棄資源的所有權(quán),不會像out_ptr那樣釋放資源本身 - 資源的釋放是調(diào)用的函數(shù)的責(zé)任,
inout_ptr只會把函數(shù)返回出來的值重新設(shè)置回智能指針
用inout_ptr改寫后的代碼如下:
auto resource = std::make_unique<Data>("recreate");
if (auto code = update_data(std::inout_ptr(resource)); code == 1)
std::cerr << "error\n";
else if (code == 2) {
std::cout << "updated, name: " << resource->name << "\n";
} else {
std::cout << "updated, name: " << resource->name << "\n";
}
代碼看起來清爽多了。
另外雖然inout_ptr也有變長參數(shù),但標(biāo)準(zhǔn)明確規(guī)定它不能配合std::shared_ptr使用,這些參數(shù)std::unique_ptr用不上,是預(yù)留給其他的第三方的類似指針對象使用的。
注意事項
除了std::shared_ptr配合out_ptr使用時需要傳入deleter,還有一個注意事項。
兩個適配器都不建議這么用:
auto out = std::out_ptr(resource);
func(out);
因為他們都是在析構(gòu)函數(shù)里重新設(shè)置智能指針的值,如果綁定到一個局部變量或者其他存儲器的變量上,函數(shù)調(diào)用結(jié)束就無法把正確的值重新設(shè)置回智能指針,這會導(dǎo)致嚴(yán)重的內(nèi)存錯誤。
唯一建議的用法是直接使用out_ptr和inout_ptr的返回值:func(std::out_ptr(resource)),這樣函數(shù)調(diào)用結(jié)束后表達(dá)式結(jié)束,返回值作為表達(dá)式中創(chuàng)建的臨時變量會被析構(gòu),這樣智能指針的值就被正常設(shè)置了。
盡管只要在轉(zhuǎn)換操作符上加上一點(diǎn)限制就能避免誤用,但標(biāo)準(zhǔn)考慮到了各種邊緣情形,最終沒有添加限制,所以我們只能牢記這條注意事項避免踩坑了。
總結(jié)
說實(shí)話這兩個適配器有很濃的給c庫函數(shù)擦屁股的意味,甚至標(biāo)準(zhǔn)文檔上直接拿fopen_s做例子了,我們看下它的函數(shù)聲明就能秒懂:errno_t fopen_s( FILE *restrict *restrict streamptr, const char *restrict filename, const char *restrict mode );。
另外這兩個適配器雖然叫智能指針適配器,但也可以對普通裸指針使用,不過我不推薦這種用法。
最后雖然它們的用法都比較偏,但真要用的時候還都有用,所以了解一下總是沒壞處的。而且它們的源代碼也很簡單,有興趣可以看看libcxx的實(shí)現(xiàn),雖然相比其他家的有點(diǎn)啰嗦,但可讀性很強(qiáng):
out_ptr: https://github.com/llvm/llvm-project/blob/main/libcxx/include/__memory/out_ptr.h
inout_ptr: https://github.com/llvm/llvm-project/blob/main/libcxx/include/__memory/inout_ptr.h
參考資料
https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2023/n4950.pdf p.643


浙公網(wǎng)安備 33010602011771號