動態

詳情 返回 返回

C++23的out_ptr和inout_ptr - 動態 詳情

c++23新增了一些智能指針適配器,用來擴展和簡化智能指針的使用。

這次主要介紹的是std::out_ptrstd::inout_ptr。這兩個適配器用法和實現都很簡單,但網上的文檔都比較抱歉,還缺少一些比較重要的部分,因此單開一篇文章記錄一下。

out_ptr

首先從功能最簡單的out_ptr講起。

std::out_ptr其實是一個函數,返回一個類型為std::out_ptr_t的智能指針適配器,函數簽名如下:

#include <memory>

template< class Pointer = void, class Smart, class... Args >
auto out_ptr( Smart& s, Args&&... args );

這個函數主要是把各種智能指針包裝成output parameter,以方便現有的接口使用,尤其是一些用c語言寫的函數。

在繼續之前我們先來複習一下output parameter是什麼。這東西又叫傳出參數,一次就是函數會把一部分數據寫進自己的參數裏返回給調用者。

通過參數返回是因為c語言和c++11之前的c++不支持多值返回也沒有類似tuple這樣方便的數據結構,導致函數無法直接返回兩個以上的值,所以需要用一種額外的傳遞數據的方式。

比如我在以前的博客中提到的hsearch:int hsearch_r(ENTRY item, ACTION action, ENTRY **retval, struct hsearch_data *htab)。這個函數用來在哈希表裏創建或者查找數據,查找失敗的時候會返回錯誤碼,而查找成功的時候函數返回0並把找到的數據設置給retval。這個retval就是output parameter,承載了函數除了錯誤碼之外的返回數據。

c++裏現在很少用指針類型作為output parameter了,但還有更本地化的做法——引用:int func(const char *name, Data &retval)

這類函數有幾個特點:

  1. 不在乎output parameter裏有什麼值
  2. 函數調用期間完全享有output parameter和其資源的所有權
  3. 函數返回後output parameter通常被設置為新值

在c++提倡少用裸指針的今天,我們越來越習慣使用shared_ptr和unique_ptr,但不管哪種智能指針都很難直接適配上面這些函數,看個例子就明白了:

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
}

代碼會變得囉嗦,而且如果我們忘記了調用reset,那麼資源就可能泄漏了;還有最重要的一點,我們主動使用了裸指針,而這正是我們想避免的。

這時候就需要out_ptr了。out_ptr生成的適配器會先放棄智能指針持有資源的所有權並將舊資源釋放,因為如前面所説我們要調用的函數會接管資源的所有權,接着構造出的std::out_ptr_t有自動的類型轉換方法,可以把智能指針轉換成我們需要的T**交給函數使用,最後在函數調用結束之後再把新的資源設置回智能指針。

所以上面的例子可以改成:

std::unique_ptr<Data> resource;
if (auto err = get_data("name", std::out_ptr(resource)); err != 0) {
    錯誤處理
} else {
    這裏可以使用resource,無需reset
}

除了代碼更簡潔,out_ptr還保證異常安全,即使在調用get_data的過程中拋出了異常,也不會出現資源泄漏。

利用out_ptr我們可以在使用智能指針的同時兼容老舊接口。

out_ptr和shared_ptr

如果只看函數簽名,很多人會覺得out_ptr也可以直接配合std::shared_ptr使用,然而現實是多變的:

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.

報錯雖然很長但只要關注前幾行就行了,錯誤的原因很明顯,std::shared_ptr要配合out_ptr使用就必須顯示提供deleter。

這是因為對於std::shared_ptr,deleter並不是類型的一部分,通常是我們通過構造函數或者reset方法穿進去的,為了能100%正確釋放資源,我們需要手動把合適的deleter傳進去;相對地deleter是std::unique_ptr類型的一部分,out_ptr可以直接從類型參數裏得到合適的deleter從而正確釋放資源。

這也是為什麼out_ptr還有變長參數,這些參數就是為了std::shared_ptr或者其他有特殊要求的類似智能指針準備的。

好在上面的代碼稍作修改就能正常使用:

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>會調用delete或者delete[]來釋放資源,正好我們這裏可以利用它。shared_ptr平時也默認使用的這個。

修改很簡單,但網上講這點的文檔不多,因此多記一筆。另外基於out_ptr會臨時轉移所有權這點來看,共享所有權模型的std::shared_ptr其實並不適合使用out_ptr,雖然標準沒有禁止甚至還要求額外做檢測(用於初始化shared_ptr),但我仍然建議把std::shared_ptrstd::out_ptr一起使用看做一種壞味道,儘量避免這種用例。

inout_ptr

inout_ptr的名字比較抽象,但只是在out_ptr的基礎上加了個“in”而已。它會返回一個std::inout_ptr_t類型的對象,函數簽名如下:

#include <memory>

template< class Pointer = void, class Smart, class... Args >
auto inout_ptr( Smart& s, Args&&... args );

這個“in”是指使用output parameter的函數在重新設置參數的值之前會先使用他們,因此這些函數的特點是:

  1. 非常在乎output parameter裏有什麼值,根據這些值執行不同的操作
  2. 函數調用期間完全享有output parameter和其資源的所有權
  3. 函數返回後output parameter不變或者被設置為新值

還是看例子,我們對Data增加一個update_data函數,如果name是recreate則刪除原來的對象重新創建一個:

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;
}

現實中沒人這麼寫代碼,但存在很多類似的c接口,而且我們也很難控制第三方庫的代碼質量,難免不會遇上類似的東西。如果想在這種接口上用智能指針,那隻能説有福了:

auto resource = std::make_unique<Data>("recreate");

Data *ptr = resource.get();
resource.release(); // 釋放所有權,但不釋放資源
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";
}

可以看到代碼會變得很複雜,而且一但忘記使用reset就會內存錯誤。這時候我們就需要inout_ptr幫忙了。

inout_ptr整體上和out_ptr差不多,都是讓出資源的所有權然後重新把函數返回的值設置回去,但還有幾個差異:

  1. 前面説過需要inout_ptr的函數是需要參數的值的,因此構造inout_ptr_t時之後放棄資源的所有權,不會像out_ptr那樣釋放資源本身
  2. 資源的釋放是調用的函數的責任,inout_ptr只會把函數返回出來的值重新設置回智能指針

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也有變長參數,但標準明確規定它不能配合std::shared_ptr使用,這些參數std::unique_ptr用不上,是預留給其他的第三方的類似指針對象使用的。

注意事項

除了std::shared_ptr配合out_ptr使用時需要傳入deleter,還有一個注意事項。

兩個適配器都不建議這麼用:

auto out = std::out_ptr(resource);
func(out);

因為他們都是在析構函數裏重新設置智能指針的值,如果綁定到一個局部變量或者其他存儲器的變量上,函數調用結束就無法把正確的值重新設置回智能指針,這會導致嚴重的內存錯誤。

唯一建議的用法是直接使用out_ptrinout_ptr的返回值:func(std::out_ptr(resource)),這樣函數調用結束後表達式結束,返回值作為表達式中創建的臨時變量會被析構,這樣智能指針的值就被正常設置了。

儘管只要在轉換操作符上加上一點限制就能避免誤用,但標準考慮到了各種邊緣情形,最終沒有添加限制,所以我們只能牢記這條注意事項避免踩坑了。

總結

説實話這兩個適配器有很濃的給c庫函數擦屁股的意味,甚至標準文檔上直接拿fopen_s做例子了,我們看下它的函數聲明就能秒懂:errno_t fopen_s( FILE *restrict *restrict streamptr, const char *restrict filename, const char *restrict mode );

另外這兩個適配器雖然叫智能指針適配器,但也可以對普通裸指針使用,不過我不推薦這種用法。

最後雖然它們的用法都比較偏,但真要用的時候還都有用,所以瞭解一下總是沒壞處的。而且它們的源代碼也很簡單,有興趣可以看看libcxx的實現,雖然相比其他家的有點囉嗦,但可讀性很強:

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

user avatar shumile_5f6954c414184 頭像 daqianduan 頭像 runyubingxue 頭像 dalideshoushudao 頭像 aitibao_shichangyingxiao 頭像 zbooksea 頭像 emanjusaka 頭像 java_3y 頭像 mangrandechangjinglu 頭像 meiyoufujideyidongdianyuan 頭像 fanjiapeng 頭像 yejianfeixue 頭像
點贊 37 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.