Skip to content

Latest commit

 

History

History
452 lines (334 loc) · 15 KB

File metadata and controls

452 lines (334 loc) · 15 KB

🔝 Retour au Sommaire

9.1.3 Custom deleters

Le problème : toutes les ressources ne se libèrent pas avec delete

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é avec fclose().
  • Un descripteur de fichier obtenu via open() doit être fermé avec close().
  • 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é avec free(), pas delete.
  • 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.


Principe : fournir sa propre logique de destruction

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).


Cas concret n°1 : gestion de FILE* (fopen/fclose)

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 haut

Le 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 trois formes de custom deleters

1. Lambda (la plus courante)

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. Le sizeof reste identique à celui d'un pointeur brut.

2. Pointeur de fonction

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); })>));        // 8

3. Foncteur (struct avec operator())

Pour 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.

Comparaison des trois formes

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

Cas concret n°2 : descripteurs de fichiers POSIX

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_ptr avec 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-dessus

Cas concret n°3 : bibliothèques C (libcurl, OpenSSL, SQLite…)

Les 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.

Exemple avec une API C fictive (pattern universel)

// 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é automatiquement

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

Cas concret n°4 : malloc/free

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é automatiquement

Le deleter et noexcept

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

Impact sur la taille : quand le zero overhead disparaît

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 octets

Dans 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.


Créer une fonction factory générique

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

Résumé

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, free ou toute autre fonction de libération manuellement dans du code C++ moderne, c'est le signal qu'un unique_ptr avec custom deleter (ou un petit wrapper RAII) devrait prendre le relais.

⏭️ std::shared_ptr et std::weak_ptr