Skip to content
C++
Library
since C++23
Intermediate

std::out_ptr and std::inout_ptr

C++23 RAII proxy adapters that let smart pointers be passed directly to C-style T** output-parameter APIs without unsafe casts or manual resets.

std::out_ptr / std::inout_ptrsince C++23

RAII proxy adapters in <memory> that allow a smart pointer to be passed to a C-style function expecting a raw T** output parameter, automatically forwarding the written pointer back into the smart pointer when the proxy is destroyed.

Overview

C APIs frequently allocate resources and hand them back through a pointer-to-pointer output parameter:

cpp
// Typical C allocation pattern
int create_connection(Connection** out, const Config* cfg);
void destroy_connection(Connection* conn);

Before C++23, using std::unique_ptr with such APIs required tedious manual bookkeeping:

cpp
// Pre-C++23 approach β€” error-prone
Connection* raw = nullptr;
if (create_connection(&raw, &cfg) != 0)
    throw std::runtime_error("failed");
std::unique_ptr<Connection, decltype(&destroy_connection)>
    conn(raw, destroy_connection);

The problem compounds when the smart pointer already holds a value that needs to be released before re-initialising, or when you have multiple early-return paths.

C++23 introduces two function templates in <memory> to solve this:

  • std::out_ptr(smart, args...) β€” for output-only parameters. The C function writes a fresh pointer; the proxy resets the smart pointer with it on destruction. The smart pointer's current value is not passed to the function.
  • std::inout_ptr(smart, args...) β€” for in/out parameters. The smart pointer's current raw value is release()d and placed into the proxy so the C function can see (and typically free) the existing resource before writing a new one.

Both return a temporary proxy object (std::out_ptr_t / std::inout_ptr_t) whose destructor calls smart.reset(p, args...) with whatever the C function deposited into it.

Syntax

cpp
#include <memory>                              // C++23

namespace std {
    template<class Pointer = void, class Smart, class... Args>
    auto out_ptr(Smart& s, Args&&... args);    // C++23

    template<class Pointer = void, class Smart, class... Args>
    auto inout_ptr(Smart& s, Args&&... args);  // C++23
}

Pointer is the raw pointer type passed to the C function. It defaults to void so the implementation can deduce it from Smart::pointer or Smart::element_type*. Specify it explicitly when you need to pass void** to a type-erased C API and manually cast later.

The args... pack is forwarded to reset() on destruction β€” essential for unique_ptr with a custom deleter that must be re-supplied on each reset:

cpp
auto conn = std::unique_ptr<Connection, decltype(&destroy_connection)>(
    nullptr, destroy_connection);

create_connection(std::out_ptr(conn, destroy_connection), &cfg);
//                                    ^^^^^^^^^^^^^^^^
//                          forwarded to unique_ptr::reset(p, deleter)

Examples

Basic output parameter

cpp
#include <memory>
#include <stdexcept>

extern "C" {
    struct AVFormatContext;
    int  avformat_open_input(AVFormatContext**, const char*, void*, void*);
    void avformat_close_input(AVFormatContext**);
}

struct AVFormatDeleter {
    void operator()(AVFormatContext* ctx) const noexcept {
        avformat_close_input(&ctx);
    }
};

std::unique_ptr<AVFormatContext, AVFormatDeleter> open_media(const char* path) {
    std::unique_ptr<AVFormatContext, AVFormatDeleter> ctx;

    int rc = avformat_open_input(
        std::out_ptr(ctx),   // C++23: proxy writes into ctx on destruction
        path, nullptr, nullptr);

    if (rc < 0)
        throw std::runtime_error("avformat_open_input failed");

    return ctx;
}

In/out parameter β€” resource reuse or reallocation

Some APIs take the existing handle, free it internally, then write a new one:

cpp
extern "C" {
    // Takes ownership of *handle, replaces it with a resized version
    int resize_buffer(Buffer** handle, size_t new_capacity);
}

void grow(std::unique_ptr<Buffer, decltype(&free_buffer)>& buf, size_t cap) {
    // inout_ptr releases buf's raw pointer into the proxy so the C function
    // can observe (and free) the current Buffer before writing the new one.
    int rc = resize_buffer(std::inout_ptr(buf, free_buffer), cap);
    if (rc != 0)
        throw std::bad_alloc{};
    // buf now owns the resized Buffer; free_buffer is the deleter
}

Using out_ptr here would be incorrect: the C function would receive an uninitialised pointer instead of the current buffer, leading to a double-free or memory leak.

Interoperability with shared_ptr

out_ptr works with std::shared_ptr (C++11); inout_ptr does not, because shared_ptr has no release().

cpp
std::shared_ptr<Connection> shared_conn;

// OK β€” shared_ptr supports reset()
create_connection(std::out_ptr(shared_conn), &cfg);  // C++23

// Compile error β€” shared_ptr has no release()
// resize_buffer(std::inout_ptr(shared_conn), cap);

Void-pointer C APIs

When a C function expects void**, specify the Pointer template argument explicitly:

cpp
extern "C" int dlopen_typed(void** handle, const char* path, int flags);

std::unique_ptr<void, decltype(&dlclose)> lib(nullptr, dlclose);

dlopen_typed(
    std::out_ptr<void*>(lib),   // Pointer = void*; raw is void**
    "libfoo.so", RTLD_LAZY);

Best Practices

Use out_ptr as the default. Reach for inout_ptr only when the C function explicitly documents that it takes ownership of the incoming pointer and/or needs to observe the existing value.

Always pass the custom deleter in args for unique_ptr with stateful or non-default deleters. Calling reset(p) alone on a unique_ptr<T, D> with a stateful deleter leaves the deleter in an unknown state; passing it explicitly via out_ptr(sp, deleter) ensures reset(p, deleter) is called.

Avoid mixing out_ptr with raw-pointer aliases. Taking the address of the smart pointer's underlying raw pointer directly (via a cast or std::addressof hack) bypasses the RAII handoff β€” the whole point of these adapters.

Do not use the proxy object beyond its statement. The proxy is designed to be a temporary: create it in the call expression, let the statement end, and let the destructor fire. Storing the proxy in a variable and calling the C function later introduces subtle ordering bugs.

Common Pitfalls

Confusing out_ptr and inout_ptr leads to resource leaks or double-frees. If the C function frees the pointer you pass in and you used out_ptr, the smart pointer's existing resource is leaked (never released before the call). If the C function does not free the old value and you used inout_ptr, the smart pointer releases ownership before the call, leaving the resource unowned until the proxy destructs β€” any exception in between causes a leak.

Missing deleter args causes undefined behaviour with non-default deleters. std::unique_ptr<T, D>::reset(p) is only well-formed when D is default-constructible. If it isn't β€” for instance a lambda stored as the deleter β€” you must supply the deleter via the variadic args to out_ptr/inout_ptr.

The proxy must not outlive the smart pointer. Both adapters store a reference to the smart pointer. If the smart pointer is destroyed before the proxy (e.g. it goes out of scope in a coroutine suspension), the proxy's destructor writes through a dangling reference.

Transient raw-pointer window during inout_ptr. Between construction of the inout_ptr_t proxy (which calls release()) and its destruction (which calls reset()), the resource is unowned by the smart pointer. Any exception thrown by the C function itself in that window leaks the resource β€” the same hazard that exists with any manual release() / reset() pair.

How the Adapters Work Internally

Understanding the exact sequence of operations prevents misuse and makes the compiler errors easier to diagnose.

std::out_ptr_t β€” construction and destruction sequence

cpp
1. out_ptr(smart, args...) called
   └─ out_ptr_t constructed
      └─ stores reference to smart
      └─ stores args... pack
      └─ sets internal raw_ = nullptr

2. operator T**() invoked by C function call
   └─ returns &raw_   (the address of the internal raw pointer)

3. C API runs β€” writes new pointer into *(&raw_)
   └─ raw_ now holds the freshly allocated value

4. Temporary proxy destroyed (end of full expression)
   └─ smart.reset(raw_, args...)
      └─ smart now owns the new pointer
      └─ any old value already owned by smart was destroyed
         by reset() before adopting the new pointer

Key: out_ptr_t does not call release() on construction. The smart pointer retains its current value right up until the destructor calls reset(), which destroys the old resource then adopts the new one. This is safe for the common case of an initially-null smart pointer; it also means that if the C function returns an error and writes nothing (leaving raw_ null), reset(nullptr) simply destroys whatever the smart pointer was previously holding β€” which may or may not be what you want (see pitfall below).

std::inout_ptr_t β€” construction and destruction sequence

cpp
1. inout_ptr(smart, args...) called
   └─ inout_ptr_t constructed
      └─ raw_ = smart.release()   ← ownership transferred OUT
      └─ stores reference to smart
      └─ stores args... pack

2. operator T**() invoked by C function call
   └─ returns &raw_   (carries the old pointer value in)

3. C API runs β€” frees *(&raw_) if non-null, then writes new pointer
   └─ raw_ now holds the replacement value (or nullptr on failure)

4. Temporary proxy destroyed (end of full expression)
   └─ smart.reset(raw_, args...)
      └─ smart now owns whatever the C function left in raw_

Key: inout_ptr_t calls release() on construction, so there is a window (steps 2–3) during which the resource is not owned by the smart pointer. Any exception thrown in that window by other code running concurrently, or by the C function itself, loses the resource unless the C function guarantees it was already freed internally.

operator void**() β€” type-erased APIs

Both adapters also expose operator void**() when the smart pointer's element type is void, or when the Pointer template argument is void*:

cpp
extern "C" int plugin_load(void** handle, const char* path);

std::unique_ptr<void, decltype(&plugin_unload)> plug(nullptr, plugin_unload);

// Explicit Pointer = void* because plugin_load takes void**
plugin_load(std::out_ptr<void*>(plug, plugin_unload), "myplugin.so");

The returned proxy exposes operator void**(), and the raw void* is stored in the smart pointer on destruction. If you later need a typed pointer, cast via static_cast on the result of plug.get().

std::out_ptr vs std::inout_ptr β€” Decision Guide

SituationUse
Smart pointer starts null; C function writes a fresh resourceout_ptr
Smart pointer holds an existing resource; C function frees the old one and writes a new oneinout_ptr
C function only reads the existing resource and writes an updated version (the old one is conceptually "replaced")inout_ptr
Smart pointer is a std::shared_ptrout_ptr only (inout_ptr does not compile)
C function returns an error and writes nothingout_ptr β€” the old resource is preserved until reset(nullptr) destroys it; with inout_ptr the resource has already been release()d and is now unowned

A concrete example showing why the choice matters:

cpp
// C API contract: "If *buf is non-null, it is freed before a new buffer
// is allocated into *buf."
extern "C" int buf_realloc(uint8_t** buf, size_t new_size);

std::unique_ptr<uint8_t[], decltype(&free)> data(nullptr, free);

// First call: data is null β€” either adapter would work here,
// but inout_ptr communicates intent and is correct for subsequent calls.
buf_realloc(std::inout_ptr<uint8_t*>(data, free), 1024);

// Second call: data is non-null β€” the C function MUST receive the old
// pointer so it can free it. Using out_ptr here would leak the 1 KB buffer.
buf_realloc(std::inout_ptr<uint8_t*>(data, free), 2048);

Custom Deleter Forwarding

When unique_ptr is parameterised with a non-default deleter, that deleter must be forwarded through out_ptr / inout_ptr so that reset(raw, deleter) is called correctly on destruction:

cpp
#include <cstdio>
#include <memory>

// unique_ptr with FILE* and fclose as the deleter
using FilePtr = std::unique_ptr<FILE, decltype(&fclose)>;

FilePtr open_config(const char* path) {
    FilePtr fp(nullptr, fclose);  // deleter captured at construction

    // Legacy API that opens a file and writes the FILE* into an out-param
    extern int legacy_fopen(FILE** out, const char* path, const char* mode);

    int rc = legacy_fopen(
        std::out_ptr(fp, fclose),  // fclose forwarded to reset(raw, fclose)
        path, "r");

    if (rc != 0)
        throw std::runtime_error("legacy_fopen failed");

    return fp;
}

If you omit fclose from the out_ptr call, the implementation calls fp.reset(raw) β€” a form that requires the deleter to be default-constructible. decltype(&fclose) is a plain function pointer type, so its default-constructed value is nullptr, and calling fclose(nullptr) is undefined behaviour.

For lambdas as deleters the situation is even clearer: lambdas are never default-constructible (before C++20 captureless lambda types are, but captured-variable lambdas are not), so omitting the deleter arg is a compile error, which is exactly the right outcome.

cpp
auto deleter = [](Connection* c) { connection_close(c); };
std::unique_ptr<Connection, decltype(deleter)> conn(nullptr, deleter);

// Correct: deleter forwarded through out_ptr
create_connection(std::out_ptr(conn, deleter), &cfg);

// Wrong (compile error for non-default-constructible deleter):
// create_connection(std::out_ptr(conn), &cfg);

std::shared_ptr and the inout_ptr Constraint

std::out_ptr works with std::shared_ptr because shared_ptr has a reset() member. std::inout_ptr does not compile with shared_ptr, and this is an intentional, compile-time constraint rather than a runtime check.

The reason: shared_ptr::release() does not exist. Shared ownership means the same resource may be pointed to by multiple shared_ptr instances. Calling something analogous to release() would abandon the other owners, causing them to later free a resource they no longer own β€” a guaranteed use-after-free. The standard therefore provides no release() on shared_ptr, and inout_ptr_t requires it.

cpp
std::shared_ptr<Connection> conn;

// OK: shared_ptr::reset() exists
create_connection(std::out_ptr(conn), &cfg);

// Compile error: shared_ptr has no release()
// resize_buffer(std::inout_ptr(conn), new_cap);

When you do use out_ptr with shared_ptr, be aware that reset() on a shared_ptr with use_count() > 1 detaches only that one owner β€” the other owners continue to hold the old resource. This is rarely the intention in C-API scenarios, so prefer unique_ptr for C-API interop and reserve shared_ptr for cases where shared ownership is genuinely required at the C++ level.

Before C++23 β€” The Manual Pattern and WIL

Before C++23, interoperating with C output-parameter APIs required one of several manual approaches.

The raw reset pattern

cpp
// Pre-C++23 β€” verbose and error-prone
Connection* raw = nullptr;
int rc = create_connection(&raw, &cfg);
if (rc != 0) throw std::runtime_error("failed");
// smart ptr not involved during the call β€” must construct or reset manually
std::unique_ptr<Connection, decltype(&destroy_connection)>
    conn(raw, destroy_connection);

// Re-initialising an existing smart pointer is even more verbose:
raw = nullptr;
rc = create_connection(&raw, &cfg);
if (rc != 0) { /* existing conn still valid if we haven't reset yet */ }
conn.reset(raw);

Every early-return path has to ensure the raw pointer either finds an owner or is freed, and every re-init must remember to call reset rather than reconstruct.

Windows Implementation Library (WIL) wil::out_ptr

Microsoft's open-source WIL (Windows Implementation Library) shipped wil::out_ptr and wil::inout_ptr as backports well before C++23. Many COM-heavy codebases still use these:

cpp
// WIL (pre-C++23 backport, available via NuGet / vcpkg)
#include <wil/com.h>
#include <wil/resource.h>

wil::com_ptr<IUnknown> punk;

// wil::out_ptr mirrors the C++23 design
CoCreateInstance(CLSID_Foo, nullptr, CLSCTX_ALL,
    IID_PPV_ARGS(wil::out_ptr(punk)));

// wil::unique_any<HANDLE, ...> + wil::out_ptr for Win32
wil::unique_handle file_handle;
HRESULT hr = SomeWin32Api(wil::out_ptr(file_handle));

The C++23 standard adapters were designed to be source-compatible with the WIL pattern, making migration straightforward: replace wil::out_ptr with std::out_ptr and update the include once your toolchain supports C++23.

The P0468 proposal history

std::out_ptr and std::inout_ptr were standardised via proposal P0468 (Nina Ranns, JeanHeyd Meneide). The design deliberately mirrors WIL's wil::out_ptr to ease the transition and validate the design against years of real-world COM and Win32 usage.

Pitfall β€” Never Call out_ptr Twice on the Same Smart Pointer

This is the most dangerous misuse pattern:

cpp
// BUG: do NOT do this
std::unique_ptr<Connection, decltype(&destroy_connection)>
    conn(nullptr, destroy_connection);

// First call β€” fine: out_ptr_t is created, C function writes into proxy,
// destructor calls conn.reset(raw, destroy_connection).
create_connection(std::out_ptr(conn, destroy_connection), &cfg1);

// Hypothetical second call on same conn β€” this is fine too:
// out_ptr destroys the first Connection via reset() before setting the new one.
create_connection(std::out_ptr(conn, destroy_connection), &cfg2);
// ^ This is actually correct β€” reset() in the destructor handles cleanup.

// THE REAL BUG: saving the proxy across multiple statements
auto proxy = std::out_ptr(conn, destroy_connection);  // conn.release() NOT called here (out_ptr)
//                                                     // but inout_ptr WOULD call release() here
create_connection(proxy, &cfg1);   // proxy destructor NOT yet called
create_connection(proxy, &cfg2);   // UB: proxy writes raw_ again on second destruction
// proxy destructor fires ONCE β€” only last written value is reset into conn

For inout_ptr the hazard is even sharper: construction calls release(), so the smart pointer is already empty. A second creation of an inout_ptr_t on the same smart pointer (even in separate statements) will release() a null pointer and pass that to the C function, which typically treats an incoming non-null pointer as a resource to free β€” freeing a null is either a no-op or undefined behaviour depending on the API.

Rule: create the out_ptr / inout_ptr proxy, make exactly one C API call, and let the proxy die at the semicolon. One proxy lifetime = one C API call.

std::out_ptr with Custom Deleters and C Allocator Conventions

Passing a unique_ptr with a custom deleter through a C API requires careful deleter forwarding. The most common mismatch occurs when the C library allocates with its own allocator (e.g., av_malloc, curl_easy_init, sqlite3_open) but the caller's unique_ptr is parameterised with the corresponding library-specific destructor rather than ::free. Without explicit forwarding, reset(raw) would attempt to default-construct the deleter, producing a null function pointer and undefined behaviour on the next destructor call.

Consider a library that manages its own heap and exposes a typed destroy function:

cpp
// C++23
#include <memory>
#include <stdexcept>

// Hypothetical C library with its own allocator
extern "C" {
    struct LibHandle;
    int  lib_open(LibHandle** out, const char* endpoint);
    void lib_close(LibHandle* h);       // must be used instead of free()
}

using LibHandlePtr = std::unique_ptr<LibHandle, decltype(&lib_close)>;

LibHandlePtr connect(const char* endpoint) {
    LibHandlePtr handle(nullptr, lib_close);  // deleter = lib_close

    int rc = lib_open(
        std::out_ptr(handle, lib_close),  // forward deleter β†’ reset(raw, lib_close)
        endpoint);

    if (rc != 0)
        throw std::runtime_error("lib_open failed");

    return handle;
}

The lib_close argument in std::out_ptr(handle, lib_close) is forwarded verbatim to handle.reset(raw, lib_close) inside the proxy destructor. Omitting it would call handle.reset(raw), which requires decltype(&lib_close) to be default-constructible to a meaningful value β€” for a plain function-pointer type the default is nullptr, so destruction would silently call nullptr(handle.get()), invoking undefined behaviour.

When the C API uses ::free as its deallocation function β€” for example, POSIX wordexp(3) or many OpenSSL getter functions that document "caller must free with free()" β€” the deleter is simply ::free and the same rule applies:

cpp
// C++23
#include <cstdlib>
#include <memory>

extern "C" int c_alloc_name(char** out);   // caller frees with free()

using CStrPtr = std::unique_ptr<char, decltype(&std::free)>;

CStrPtr get_name() {
    CStrPtr name(nullptr, std::free);

    if (c_alloc_name(std::out_ptr(name, std::free)) != 0)
        throw std::runtime_error("c_alloc_name failed");

    return name;
}

decltype(&std::free) is a plain function-pointer type whose default value is nullptr, making the explicit std::free argument to out_ptr mandatory for correctness β€” even though it might seem redundant at first glance.

std::inout_ptr for Realloc-Style Interfaces

std::inout_ptr is the correct adapter whenever the C function both inspects the current pointer and replaces it β€” a contract common in grow-or-replace, connection-pool refresh, and reallocation APIs. The proxy calls release() during construction so the C function receives the existing raw pointer; after the call the proxy calls reset() with whatever the function deposited, fully transferring ownership back into the smart pointer.

A realloc-style interface is the canonical example:

cpp
// C++23
#include <cstdlib>
#include <memory>
#include <new>

// Contract: if *buf is non-null, lib_realloc frees it before allocating
// a new block of `new_size` bytes and writing it into *buf.
extern "C" int lib_realloc(void** buf, size_t new_size);

using BytesBuf = std::unique_ptr<void, decltype(&std::free)>;

void grow_buffer(BytesBuf& buf, size_t new_size) {
    // inout_ptr releases current raw pointer into the proxy (step 1)
    // lib_realloc sees the old pointer, frees it, writes new one (step 2)
    // proxy destructor calls buf.reset(new_raw, std::free) (step 3)
    if (lib_realloc(std::inout_ptr<void*>(buf, std::free), new_size) != 0)
        throw std::bad_alloc{};
}

Using std::out_ptr here instead would be a silent bug: the proxy would pass an uninitialised nullptr to lib_realloc, which would skip the free and allocate a fresh block β€” leaking the memory that buf previously owned.

The same pattern applies to connection-refresh APIs where the server-side session token embedded in the handle must be exchanged:

cpp
// C++23
#include <memory>
#include <stdexcept>

extern "C" {
    struct Session;
    // Takes existing *s (may be null), closes it, opens a fresh session into *s
    int session_refresh(Session** s, const char* auth_token);
    void session_destroy(Session* s);
}

using SessionPtr = std::unique_ptr<Session, decltype(&session_destroy)>;

void refresh(SessionPtr& session, const char* token) {
    // session_refresh owns the old Session* and writes the new one
    if (session_refresh(std::inout_ptr(session, session_destroy), token) != 0)
        throw std::runtime_error("session_refresh failed");
    // session now holds the refreshed handle; old handle was freed by the C API
}

Error-path behaviour differs from out_ptr. With inout_ptr, if the C function returns an error it may have already freed the old pointer but written nullptr (or left *s indeterminate). The proxy destructor always calls reset() with whatever is in the internal raw storage β€” so after a failed session_refresh the smart pointer may be null or hold an indeterminate value. Consult the C API's documentation for its error-path contract before relying on the smart pointer after a failed inout_ptr call.

See Also

  • reference/library/memory/unique-ptr β€” the primary target smart pointer for both adapters
  • reference/library/memory/shared-ptr β€” compatible with out_ptr; not compatible with inout_ptr