RAII Pattern

tbowan

21/06/2017

Permettant d'éviter les problèmes de gestion des resources (aquisition et libération), le patron RAII est un must known de la programmation orientée objet. On vous dit pourquoi.

La gestion des ressources

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.

La gestion d'une ressources, quelle qu'elle soit, 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éréz 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.

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

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 fontion 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, sinon, ce sera l'équipe support parce qu'un client pas content a trouvé un bug.

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 pase 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 debug en perspective.

La solution : RAII

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

Le RAII (Ressource Aquisition Is Initialization) 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 ce nom barbare et ces 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 illustratif2.

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 garbage collector qui détruit les objets lorsqu'ils ne sont plus référencés3, ce qui revient au même la plupart du temps.

<?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 languages supportent le RAII ?

Cette technique fonctionne avec tous les languages 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 languages objets comme le C++ et le PHP dont on vient de voir deux exemples.

Pour les autres languages, 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 languages 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 quitera le bloc particulier correspondant.

D'autres languages 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.

L'exemple suivant, en python illustre le problème, la classe File fournit le même genre de méthode que précédement. 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 file descriptor dans la fonction __enter__ et ajouter des exceptions dans les autres méthodes si le file descriptor 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 debug 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.

Conclusion

Si vos languages 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 rendres plus sûrs en évitant les problèmes de gestions d'erreur et de libération.

Pour aller plus loin, voici un profiler PHP qui tire parti du RAII pattern.


  1. Ressource Acquisition Is Initialization, wikipedia.org.

  2. On pourrait même utiliser les templates pour implémenter le mutex sous forme de politique mais on sort du cadre du RAII.

  3. Constructors and destructors, php.net.