Every C library hands you a pointer and a function to free it. FILE* and fclose. SDL_Window* and SDL_DestroyWindow. The first thing most people write is this:
std::unique_ptr<FILE, int (*)(FILE*)> file{std::fopen(path, "r"), fclose};
It works. It is also twice the size of the pointer it wraps, because the function pointer is stored next to the FILE* in every object. The function never changes. There is no reason to carry it around at runtime.
The trick
Put the function into the deleter’s type:
template <typename T, auto DeleteFn>
using unique_ptr_deleter =
std::unique_ptr<T, decltype([](T* p) static noexcept { DeleteFn(p); })>;
using file_ptr = unique_ptr_deleter<FILE, fclose>;
auto file = file_ptr{std::tmpfile()};
DeleteFn is a non-type template parameter, so fclose is baked into the lambda’s body at compile time. The lambda captures nothing, so its closure type is an empty class. unique_ptr stores an empty deleter for free.
Sizes
I compiled the same file with four compilers. Each size is enforced by a static_assert, so a compile with exit code 0 means the size holds. GCC and Clang also ran the program and printed the numbers.
| type | GCC 14.2 | GCC 16.2 | Clang 22.1 | MSVC 19.44 x64 |
|---|---|---|---|---|
FILE* | 8 | 8 | 8 | 8 |
unique_ptr<FILE, lambda> | 8 | 8 | 8 | 8 |
unique_ptr<FILE, int(*)(FILE*)> | 16 | 16 | 16 | 16 |
unique_ptr<FILE, stateful functor> | 16 | 16 | 16 | 16 |
The stateful functor is a struct holding the function pointer as a member. It shows that the gain comes from the deleter being empty, not from it being a functor.
unique_ptr<FILE, int(*)(FILE*)> 16 bytes
+--------------------+--------------------+
| FILE* ptr | int(*)(FILE*) del |
| 8 bytes | 8 bytes |
+--------------------+--------------------+
unique_ptr<FILE, decltype([](FILE*) static {...})> 8 bytes
+--------------------+
| FILE* ptr | deleter: empty class, no storage
| 8 bytes |
+--------------------+
MSVC can’t execute on Godbolt, so it is compile-only. The static_asserts still prove the layout: godbolt.org/z/s4bb8G67f.
Why the empty deleter is free
unique_ptr has to store a pointer and a deleter. A plain struct with an empty member still pays at least one byte for it, and then padding rounds that up to 8. The standard libraries avoid that.
libstdc++ (GCC 14) keeps a tuple<pointer, _Dp> inside __uniq_ptr_impl. Its tuple marks empty, non-final element types [[no_unique_address]] (see _Head_base in <tuple>), so the deleter takes no space. MSVC’s STL uses its own compressed pair, which gets the same result through the empty base optimization. Different mechanism, same 8 bytes.
So the whole point is to make the deleter an empty type. A function pointer can never be one. A capture-less lambda always is.
Which standard each piece needs
- Lambda inside
decltypeneeds C++20. Lambdas in unevaluated operands were not allowed before. GCC 14 with-std=c++17says:lambda-expression in unevaluated context only available with '-std=c++20'. staticon the lambda needs C++23. With-std=c++20 -pedantic-errors, GCC 14 rejects it:'static' only valid in lambda with '-std=c++23'.
The static doesn’t change the size. A capture-less lambda is empty either way. What it does is make operator() a static member function, so the call has no implicit this. At -O2 the call inlines anyway, so I would not expect a measurable difference. I keep it because it says what I mean: this deleter has no state.
If you are stuck on C++20, drop static and you still get 8 bytes: godbolt.org/z/YqnEj13Y5.
One caveat about fclose
I include <stdio.h> and use ::fclose, not std::fclose. The standard only lets you take the address of functions in namespace std that are explicitly marked addressable, and std::fclose is not one of them. Passing it as a template argument is technically unspecified. The global C function from <stdio.h> doesn’t have that problem. GCC 14.2, Clang 22.1 and MSVC 19.44 all accepted std::fclose too, but I’d rather not depend on it.
The full program
#include <cstdio>
#include <memory>
#include <stdio.h>
template <typename T, auto DeleteFn>
using unique_ptr_deleter =
std::unique_ptr<T, decltype([](T* p) static noexcept { DeleteFn(p); })>;
struct stateful_closer {
int (*close)(FILE*);
void operator()(FILE* f) const noexcept { close(f); }
};
using file_lambda = unique_ptr_deleter<FILE, fclose>;
using file_fnptr = std::unique_ptr<FILE, int (*)(FILE*)>;
using file_functor = std::unique_ptr<FILE, stateful_closer>;
static_assert(sizeof(file_lambda) == sizeof(FILE*));
static_assert(sizeof(file_fnptr) == 2 * sizeof(FILE*));
static_assert(sizeof(file_functor) == 2 * sizeof(FILE*));
int main() {
std::printf("FILE* %zu\n", sizeof(FILE*));
std::printf("unique_ptr<FILE, lambda> %zu\n", sizeof(file_lambda));
std::printf("unique_ptr<FILE, int(*)(...)> %zu\n", sizeof(file_fnptr));
std::printf("unique_ptr<FILE, functor> %zu\n", sizeof(file_functor));
auto file = file_lambda{std::tmpfile()};
if (!file) {
std::puts("tmpfile failed");
return 1;
}
std::fputs("closed by the empty deleter\n", file.get());
std::puts("file open, closes at scope exit");
}
Output, identical on GCC 14.2, GCC 16.2 and Clang 22.1 (x86-64, -std=c++23 -O2):
FILE* 8
unique_ptr<FILE, lambda> 8
unique_ptr<FILE, int(*)(...)> 16
unique_ptr<FILE, functor> 16
file open, closes at scope exit