RAII Pattern

Divulgâchage : Permettant d’éviter les problèmes de gestion des ressources (acquisition et libération), le patron RAII est un must known de la programmation orientée objet. Pour éviter les problèmes (bogues et maintenance), et si votre langage le permet, initialisez dans les constructeurs, libérez dans les destructeurs, et gérez les erreurs avec des exceptions.

S’il y a bien un problème que tout développeur rencontre, c’est la gestion des ressources. La mémoire, les fichiers, les connexions réseaux, les bases de données, les librairies à initialiser (openssl, opengl, …), les mutex, … La liste est longue car si on veut être exact, toute structure est en réalité une ressource.

rihaij @ pixabay

Dans une zone du code où on a besoin d’une ressource (sa portée, qu’on peut voir comme une petite bulle d’univers), la gestion d’une ressources se résume aux trois phases suivantes :

  1. L’acquisition : avez-vous obtenu une ressource valide ?
  2. La manipulation : la ressource reste-t-elle valide ?
  3. La libération : que se passe-t-il si vous ne libérez pas la ressource ?

Premier (mauvais) exemple

Prenons un exemple simple qui consiste à incrémenter la valeur enregistrée dans un fichier. Le code suivant, en C, va effectuer les opérations nécessaires :

Pour simplifier, on considère que les fonctions de lecture et d’écriture sont fournies ailleurs et n’allons pas nous y attarder.

// Fonctions de lecture / écriture
void read(int fd, int * value) ;
void write(int fd, int value) ;

// Manipulation d'une ressource
void increment(const char * filename)
{
    int fd = open(filename, O_RDWR | O_CREAT, 0666);
    flock(fd, LOCK_EX) ;
    int value ;
    read(fd, &value) ;
    write(fd, value + 1) ;
    close(fd) ;
}

Le problème, dans cette version du code, c’est qu’aucune vérification n’est faite sur la validité de la ressource (le descripteur de fichier). Que se passera-t-il si le fichier n’existe pas ? Si le mutex n’est pas acquis ? En cas d’erreur, l’exécution peut quitter la fonction sans que le fichier soit fermé…

Vous pouvez me croire, c’est toujours lorsqu’on oublie une de ces vérification que le cas se produit. Avec de la chance, c’est le département qualité qui vous le dira (et vous pourrez prendre votre temps pour corriger), sinon, ce sera l’équipe support parce qu’un client pas content a trouvé un bug (et votre temps sera fonction de la patience du client).

Deuxième (meilleur) exemple

Dans cette nouvelle version, toujours en C, nous allons donc effectuer les vérifications nécessaires et être prudent à bien libérer la ressource en cas de problème. Cette fois, les fonctions retournent un code d’erreur (-1) lorsque quelque chose se passe mal.

int read(int * fd, int * value) ;
int write(int * fd, int value) ;

int increment(const char * filename)
{
    int fd = open(filename, O_RDWR | O_CREAT, 0666);
    if (fd == -1) {
        // Error while opening the file
        return -1 ;
    }

    if (! flock(fd, LOCK_EX)) {
        // Error while trying to acquire a lock
        fclose(fd) ;
        return -1 ;
    }

    int value ;
    if (! read(fd, &value)) {
        // Error while reading the file content
        fclose(fd) ;
        return -1 ;
    }

    if (! write(fd, value + 1)) {
        // Error while writing the new value
        flose(fd) ;
        return -1 ;
    }
    
    if (! close(fd)) {
        // Error while closing the file
        return -1 ;
    }
    
    return 0 ;
}

Ce qui était clair est maintenant compliqué. De moins de 10 lignes, on passe à une trentaine, la complexité cyclomatique est multipliée par 6. Et d’un problème de gestion de ressource, on arrive à celui de la maintenabilité car pour tout changement sur le code, il faudra vérifier les codes de retour, libérer la ressource et retourner un code d’erreur.

Vous pouvez en être sûr, quelqu’un finira par modifier votre code et oubliera une vérification ou une libération, ce sera le drame et des heures de débogage en perspective.

La solution : RAII

Démontrant encore une fois la supériorité de la programmation orientée objet, le patron RAII résout ces problèmes de gestion de manière très élégante.

Le RAII est un idiome de programmation qui nous vient du C++ et dont le but est de garantir qu’une ressource acquise est valide et que sa libération sera automatiquement effectuée lorsqu’elle ne sera plus à portée (retour, arrêt, exception, …).

Derrière son nom barbare et ses concepts se cache en fait une technique toute simple et plutôt classique en POO :

Exemple RAII en C++

En reprenant l’exemple précédent, nous allons maintenant encapsuler la gestion du fichier dans une classe File qui gérera l’ouverture dans le constructeur, la fermeture dans le destructeur et fournira des méthodes pour les autres opérations. Le tout avec des exceptions pour que ça soit plus propre.

! Le code suivant, en C++, est un exemple d’implémentation minimale d’une telle classe. On pourrait améliorer cette classe, ou utiliser les classes de la STD voir de boost en lieu et place mais le but est purement illustratif. Pour faire les choses vraiment bien, on pourrait même utiliser les templates pour implémenter le mutex sous forme de politique mais on déborderait carrément du sujet.

class File
{
private:
    int fd ;

    // Make File not copyable
    File( const File& other );
    File& operator=( const File& );

public:

    File(std::string const & filename)
    {
        fd = open(filename.c_str(), O_RDWR | O_CREAT, 0666);
        if (fd == -1) {
            throw std::exception("Opening file failed") ;
        }
    }

    ~File()
    {
        close(fd) ;
        // No check since one can not throw in destructor
    }

    void lock()
    {
        if (flock(fd, LOCK_EX) == -1) {
            throw std::exception("Locking file failed") ;
        }
    }

    int read() { ... }

    void write(int value) { ... }
}

Grâce à cette abstraction, la fonction d’incrément devient bien plus simple puisqu’elle n’a plus besoin de gérer les différents cas d’erreur mais uniquement de définir un fichier et d’utiliser ses méthodes.

void increment(std::string const & filename)
{
    File f(filename) ;
    f.lock() ;
    f.write(f.read() + 1) ;
}

Exemple RAII en PHP

L’implémentation en PHP est similaire et ne diffère que par la syntaxe, les fonctions utilisées sont presque les mêmes et le principe ne change donc pas.

La subtile différence réside dans le fait que là où le C++ détruit les objets d’un bloc lorsque l’exécution en sort, le PHP utilise un ramasse miette qui détruit les objets lorsqu’ils ne sont plus référencés (cf. la documentation sur les Constructeurs et destructeurs). Ça revient au même la plupart du temps mais dans certains cas subtils, ça peut poser problème.

<?php
class File
{
    private $fd ;

    public function __construct($filename)
    {
        $this->fd = fopen($filename, "rw") ;
        if ($this->fd === false) {
            throw new Exception("Opening file failed") ;
        }
    }

    public function __destruct()
    {
        if (fclose($this->fd) === false) {
            throw new Exception("Closing file failed") ;
        }
    }

    public function lock()
    {
        if (! flock($this->fd, LOCK_EX)) {
            throw new Exception("Locking file failed") ;
        }
    }

    public function read() { ... }

    public function write($value) { ... }
}

function increment($filename)
{
    $f = new File($filename) ;
    $f->lock() ;
    $f->write($f->read() + 1) ;
}

Quels langages supportent le RAII ?

Cette technique fonctionne avec tous les langages supportant les exceptions et qui garantisse que vos destructeurs soient appelés lorsque l’exécution quitte la portée de vos objets. C’est le cas des vrais langages objets comme le C++ et le PHP dont on vient de voir deux exemples.

Pour les autres langages, c’est un peu plus compliqués. Ils fournissent souvent une méthode finalize appelée lors de la libération mais comme ils ne peuvent vous garantir de libérer vos objets dès que vous sortez des blocs, vous n’êtes pas plus avancés.

Certains langages ont choisi de bricoler un truc syntaxique pour combler cette lacune. Avec cette syntaxe particulière, vous pouvez définir une méthode qui sera appelée lorsque l’exécution quittera le bloc particulier correspondant.

D’autres langages ont carrément snobé le principe et considéré qu’il n’était pas nécessaire. Comme en node.js ou vous devrez définir vos macros vous-même et espérer ne pas oublier un cas d’erreur.

Exemple (dangereux) de faux RAII

Le problème de ces méthodes, en dehors de l’incohérence syntaxique, est qu’elle n’est pas garantie par la classe qui fournit la ressource mais par celle qui l’utilise.

Transposé au contrôle d’accès d’un site web, c’est comme laisser les visiteurs déterminer eux-même s’ils peuvent ou non voir une page. Tant qu’ils jouent le jeux, tout marche très bien.

L’exemple suivant, en python illustre le problème, la classe File fournit le même genre de méthode que précédemment. La fonction goodOne montre l’utilisation du with. Le problème est que rien n’oblige à utiliser cette construction, comme le montre la fonction evilOne.

class File:

    def __init__(self, filename):
        self.fd = open(filename, "rw")

    def __enter__(self):
        return self

    def __exit__(self, type, value, traceback):
        self.fd.close()

    def lock(self):
        fcntl.flock(self.fd, LOCK_EX)

    def read(self):
        # ...

    def write(self, value):
        # ...

def goodOne(filename):
    with File(filename) as f:
        f.write(f.read() + 1)

def evilOne(filename):
    f = File(filename)
    f.write(f.read() + 1)

Alors oui, on pourra me répondre qu’on pourrait déléguer l’ouverture du descripteur de fichier dans la fonction __enter__ et ajouter des exceptions dans les autres méthodes si le descripteur n’est pas disponible et donc forcer l’utilisation du with :

class File:

    def __init__(self, filename):
        self.filename = filename
        self.fd       = None
        
    def __enter__(self):
        self.fd = open(filename, "rw")
        return self

    def __exit__(self, type, value, traceback):
        self.check()
        self.fd.close()

    def check(self):
        if (self.fd is None):
            raise Exception("File have not been correctly created")

    def lock(self):
        self.check()
        fcntl.flock(self.fd, LOCK_EX)

    def read(self):
        self.check()
        # ...

    def write(self, value):
        self.check()
        # ...

Mais cette solution est loin d’être aussi pratique qu’un vrai RAII :

  1. elle complexifie la classe File en imposant un appel à check() systématique, qu’un seul soit oublié/supprimé/… et ce sont des heures de débogage en perspective,
  2. elle ne détecte l’oubli du with qu’à l’exécution, donc, à moins d’avoir une couverture du code à 100% par les tests automatiques, ça arrivera en production chez un client…

Bref, vous l’aurez compris, ça ne reste que du bricolage pour imiter les pros.

Et après ?

Si vos langages permettent l’utilisation de cette technique, je ne peux que vous conseiller d’en abuser car elle permet à la fois de simplifier les codes métiers mais surtout de les rendre plus sûrs en évitant les problèmes de gestions d’erreur et de libération.