🔝 Retour au Sommaire
Par défaut, std::unique_ptr<T> appelle delete (ou delete[] pour unique_ptr<T[]>) lors de la destruction. C'est le comportement attendu pour les objets alloués avec new. Mais dans la réalité — surtout en programmation système sur Linux — vous manipulez régulièrement des ressources qui ne proviennent pas de new et qui ne se libèrent pas avec delete :
- Un fichier ouvert avec
fopen()doit être fermé avecfclose(). - Un descripteur de fichier obtenu via
open()doit être fermé avecclose(). - Une connexion réseau ou une socket doit être fermée avec
close()ou une fonction spécifique de la bibliothèque. - Un buffer alloué par
malloc()doit être libéré avecfree(), pasdelete. - Un handle retourné par une bibliothèque C (OpenSSL, SQLite, libcurl…) doit être libéré par la fonction prévue par cette bibliothèque.
Appeler delete sur ces ressources serait un comportement indéfini. Pourtant, le besoin est le même : garantir la libération automatique, même en cas d'exception ou de sortie anticipée. C'est exactement ce que permettent les custom deleters.
Un custom deleter est un objet callable (fonction, lambda, foncteur) que vous passez au unique_ptr pour remplacer l'appel à delete. Le unique_ptr appellera votre deleter au lieu de delete lors de sa destruction.
La signature générale est :
std::unique_ptr<T, Deleter> ptr(raw_pointer, deleter_instance);Le type du deleter fait partie du type du unique_ptr — c'est un paramètre template. C'est une différence importante avec std::shared_ptr, où le deleter est effacé par type erasure (section 9.2).
L'exemple le plus classique en programmation système. Sans custom deleter, le code est fragile :
// ⚠️ Code fragile — fuite si une exception survient
void traiter_fichier(const std::string& chemin) {
FILE* f = fopen(chemin.c_str(), "r");
if (!f) return;
// ... traitement qui peut lever une exception ...
lire_et_analyser(f); // 💥 Si exception → fclose jamais appelé
fclose(f);
}Avec un unique_ptr et un custom deleter, la fermeture est garantie :
#include <memory>
#include <cstdio>
void traiter_fichier(const std::string& chemin) {
auto deleter = [](FILE* f) {
if (f) fclose(f);
};
std::unique_ptr<FILE, decltype(deleter)> fichier(
fopen(chemin.c_str(), "r"),
deleter
);
if (!fichier) {
std::print("Impossible d'ouvrir {}\n", chemin);
return;
}
// Utilisation normale via get()
char buffer[256];
while (fgets(buffer, sizeof(buffer), fichier.get())) {
std::print("{}", buffer);
}
} // ✅ fclose() est appelé automatiquement ici
// même si une exception a été levée plus hautLe decltype(deleter) extrait le type de la lambda pour l'utiliser comme paramètre template. C'est la syntaxe standard — un peu verbeuse, mais correcte.
Les lambdas sont la forme la plus concise et la plus lisible pour les deleters simples :
// Lambda sans capture — taille nulle (zero overhead)
auto fichier = std::unique_ptr<FILE, decltype([](FILE* f) { fclose(f); })>(
fopen("data.txt", "r"),
[](FILE* f) { fclose(f); }
);Depuis C++20, les lambdas sans capture sont utilisables directement dans les paramètres template, ce qui simplifie l'écriture :
// C++20 : lambda stateless dans le type directement
using FilePtr = std::unique_ptr<FILE, decltype([](FILE* f) { fclose(f); })>;
FilePtr ouvrir(const char* chemin, const char* mode) {
return FilePtr(fopen(chemin, mode));
}
// Utilisation propre
auto f = ouvrir("config.yaml", "r");💡 Point clé : une lambda sans capture n'ajoute aucun octet à la taille du
unique_ptr. Le compilateur peut la stocker comme un type vide grâce à l'empty base optimization. Lesizeofreste identique à celui d'un pointeur brut.
Vous pouvez utiliser directement un pointeur vers une fonction existante :
// fclose a la signature : int fclose(FILE*)
// Compatible avec un deleter qui prend FILE*
std::unique_ptr<FILE, int(*)(FILE*)> fichier(
fopen("data.txt", "r"),
fclose
);Cette forme est simple mais a un coût : le pointeur de fonction occupe 8 octets supplémentaires dans le unique_ptr. La taille passe de 8 à 16 octets sur x86_64 :
// Comparaison des tailles
std::print("Deleter par défaut : {}\n",
sizeof(std::unique_ptr<FILE>)); // 8
std::print("Pointeur de fonction : {}\n",
sizeof(std::unique_ptr<FILE, int(*)(FILE*)>)); // 16
std::print("Lambda sans capture : {}\n",
sizeof(std::unique_ptr<FILE,
decltype([](FILE* f){ fclose(f); })>)); // 8Pour les cas plus complexes ou quand vous voulez un type nommé réutilisable, un foncteur est la solution la plus propre :
struct FermerFichier {
void operator()(FILE* f) const noexcept {
if (f) {
std::print("[debug] Fermeture du fichier\n");
fclose(f);
}
}
};
// Type lisible et réutilisable
using FilePtr = std::unique_ptr<FILE, FermerFichier>;
FilePtr ouvrir_fichier(const char* chemin, const char* mode) {
return FilePtr(fopen(chemin, mode));
}Un foncteur sans membre (comme ci-dessus) bénéficie de l'empty base optimization : le unique_ptr garde une taille de 8 octets, identique au défaut. C'est la combinaison idéale entre lisibilité, réutilisabilité et performance.
| Forme | Taille du unique_ptr | Réutilisable | Verbosité |
|---|---|---|---|
| Lambda sans capture | sizeof(T*) — aucun surcoût |
Non (sauf via alias) | Faible |
| Lambda avec capture | sizeof(T*) + sizeof(captures) |
Non | Faible |
| Pointeur de fonction | sizeof(T*) + sizeof(void*) — +8 octets |
Oui | Faible |
| Foncteur sans membre | sizeof(T*) — aucun surcoût (EBO) |
Oui | Moyenne |
| Foncteur avec membres | sizeof(T*) + sizeof(membres) |
Oui | Moyenne |
Les descripteurs de fichiers Unix (int fd) sont omniprésents en programmation système. Le problème est que unique_ptr attend un pointeur, pas un entier. L'approche classique est d'encapsuler le descripteur dans une petite structure RAII dédiée plutôt que de forcer unique_ptr :
class FileDescriptor {
int fd_ = -1;
public:
explicit FileDescriptor(int fd) : fd_(fd) {}
~FileDescriptor() { if (fd_ >= 0) ::close(fd_); }
// Non copiable, déplaçable
FileDescriptor(const FileDescriptor&) = delete;
FileDescriptor& operator=(const FileDescriptor&) = delete;
FileDescriptor(FileDescriptor&& other) noexcept
: fd_(other.fd_) { other.fd_ = -1; }
FileDescriptor& operator=(FileDescriptor&& other) noexcept {
if (this != &other) {
if (fd_ >= 0) ::close(fd_);
fd_ = other.fd_;
other.fd_ = -1;
}
return *this;
}
int get() const noexcept { return fd_; }
explicit operator bool() const noexcept { return fd_ >= 0; }
};💡 Cette approche — écrire un petit wrapper RAII — est souvent préférable à un
unique_ptravec custom deleter quand la ressource n'est pas un pointeur. C'est le principe RAII dans sa forme la plus directe (section 6.3).
Cependant, si vous préférez rester avec unique_ptr, une astuce consiste à utiliser un type opaque :
struct FdCloser {
// Utilise un "tag type" pour wrapper l'int dans un pointeur
struct FdTag { int fd; };
void operator()(FdTag* tag) const noexcept {
if (tag && tag->fd >= 0) ::close(tag->fd);
delete tag;
}
};
// Lourd et artificiel — préférez le wrapper RAII dédié ci-dessusLes bibliothèques C fournissent généralement des fonctions de création et de destruction en paire. Les custom deleters permettent de les intégrer proprement dans du C++ moderne.
// API C typique — header de la bibliothèque
extern "C" {
typedef struct DbConnection DbConnection;
DbConnection* db_connect(const char* url);
void db_disconnect(DbConnection* conn);
int db_execute(DbConnection* conn, const char* query);
}
// Wrapper C++ avec custom deleter
struct DbDisconnect {
void operator()(DbConnection* conn) const noexcept {
if (conn) db_disconnect(conn);
}
};
using DbPtr = std::unique_ptr<DbConnection, DbDisconnect>;
DbPtr ouvrir_connexion(const char* url) {
DbPtr conn(db_connect(url));
if (!conn) {
throw std::runtime_error("Échec de connexion à la base");
}
return conn;
}
void executer_requetes() {
auto conn = ouvrir_connexion("localhost:5432/mydb");
db_execute(conn.get(), "CREATE TABLE IF NOT EXISTS logs (...)");
db_execute(conn.get(), "INSERT INTO logs VALUES (...)");
} // ✅ db_disconnect() appelé automatiquementCe pattern est identique quelle que soit la bibliothèque C. Il suffit d'adapter le foncteur à la fonction de libération appropriée :
// OpenSSL
struct SslFree {
void operator()(SSL_CTX* ctx) const noexcept { SSL_CTX_free(ctx); }
};
using SslCtxPtr = std::unique_ptr<SSL_CTX, SslFree>;
// SQLite
struct SqliteClose {
void operator()(sqlite3* db) const noexcept { sqlite3_close(db); }
};
using SqlitePtr = std::unique_ptr<sqlite3, SqliteClose>;
// libcurl
struct CurlCleanup {
void operator()(CURL* curl) const noexcept { curl_easy_cleanup(curl); }
};
using CurlPtr = std::unique_ptr<CURL, CurlCleanup>;Quand vous interfacez du code C qui utilise malloc, le buffer doit être libéré avec free, pas delete :
#include <cstdlib>
// API C qui retourne un buffer alloué par malloc
extern "C" char* c_library_get_data();
void traiter() {
auto deleter = [](char* p) { std::free(p); };
std::unique_ptr<char, decltype(deleter)> data(
c_library_get_data(),
deleter
);
if (data) {
std::print("Données reçues : {}\n", data.get());
}
} // ✅ free() appelé automatiquementUn deleter ne devrait jamais lever d'exception. Si le destructeur d'un unique_ptr appelle un deleter qui lance une exception alors qu'une autre exception est déjà en cours de propagation (déroulement de la pile), le programme appelle std::terminate — un crash immédiat.
Marquez toujours vos deleters noexcept :
// ✅ Foncteur noexcept
struct FermerFichier {
void operator()(FILE* f) const noexcept {
if (f) fclose(f);
}
};
// ✅ Lambda noexcept (C++17)
auto deleter = [](FILE* f) noexcept {
if (f) fclose(f);
};Si la fonction de libération sous-jacente peut échouer (certaines API retournent un code d'erreur), la bonne pratique est de loguer l'erreur sans la propager :
struct SafeDbDisconnect {
void operator()(DbConnection* conn) const noexcept {
if (conn) {
int rc = db_disconnect(conn);
if (rc != 0) {
// Logger l'erreur, mais ne JAMAIS throw
std::print(stderr, "Erreur déconnexion: code {}\n", rc);
}
}
}
};Comme mentionné dans la comparaison des formes, un custom deleter avec état augmente la taille du unique_ptr. C'est le cas des lambdas avec capture et des foncteurs avec des membres :
// Lambda avec capture — stocke le logger capturé
Logger* logger = get_logger();
auto deleter = [logger](FILE* f) {
logger->info("Fermeture fichier");
if (f) fclose(f);
};
// sizeof augmente de la taille de la capture
std::unique_ptr<FILE, decltype(deleter)> fichier(
fopen("data.txt", "r"), deleter
);
// sizeof(fichier) == sizeof(FILE*) + sizeof(Logger*) == 16 octetsDans la grande majorité des cas, cette augmentation est négligeable. Mais si vous stockez des millions de unique_ptr dans un conteneur, la différence de taille peut impacter la consommation mémoire et la performance des parcours (moins de unique_ptr par cache line). Dans ces situations, préférez un foncteur sans membre ou une lambda sans capture.
Pour éviter de répéter la syntaxe verbeuse des custom deleters, il est courant de centraliser la création dans une fonction factory avec un alias de type :
// Header : resource_handles.h
#pragma once
#include <memory>
#include <cstdio>
// ---- FILE* ----
struct FCloser {
void operator()(FILE* f) const noexcept { if (f) fclose(f); }
};
using UniqueFile = std::unique_ptr<FILE, FCloser>;
inline UniqueFile ouvrir(const char* chemin, const char* mode) {
return UniqueFile(fopen(chemin, mode));
}
// ---- pipe (popen/pclose) ----
struct PCloser {
void operator()(FILE* f) const noexcept { if (f) pclose(f); }
};
using UniquePipe = std::unique_ptr<FILE, PCloser>;
inline UniquePipe ouvrir_pipe(const char* cmd, const char* mode) {
return UniquePipe(popen(cmd, mode));
}Le code client devient alors parfaitement lisible :
#include "resource_handles.h"
void exemple() {
auto fichier = ouvrir("/etc/hostname", "r");
if (!fichier) return;
char buf[256];
if (fgets(buf, sizeof(buf), fichier.get())) {
std::print("Hostname : {}", buf);
}
auto pipe = ouvrir_pipe("ls -la /tmp", "r");
if (!pipe) return;
while (fgets(buf, sizeof(buf), pipe.get())) {
std::print("{}", buf);
}
}
// ✅ fclose et pclose appelés automatiquement| Aspect | Détail |
|---|---|
| Quand utiliser | Ressources non-new : fichiers, handles C, malloc, sockets, bibliothèques tierces |
| Forme recommandée | Foncteur noexcept sans membre + alias de type (using) |
| Alternative concise | Lambda sans capture (zero overhead, C++20 pour le type inline) |
| Surcoût | Nul si le deleter est stateless (lambda sans capture ou foncteur vide) |
| Piège principal | Un deleter qui lève une exception → std::terminate potentiel |
| Alternative | Pour les ressources non-pointeur (int fd, handles), préférez un wrapper RAII dédié |
Règle pratique — Si vous vous retrouvez à écrire
delete,fclose,freeou toute autre fonction de libération manuellement dans du code C++ moderne, c'est le signal qu'ununique_ptravec custom deleter (ou un petit wrapper RAII) devrait prendre le relais.