Journal Une 20-aine de lignes de code pour le defer de Go en C++

Posté par  (site web personnel) . Licence CC By‑SA.
Étiquettes :
21
7
fév.
2022

Bonjour Nal,

Si tu es comme moi, tu détestes Go malgré quelques fonctionnalités géniales et un écosystème vaste et grandissant, qui en font un choix à considérer malgré tes préférences personnelles.

Si tu es comme moi, tu préfères certainement le C++ surtout depuis le C++11. Tu fais d'ailleurs surement du C++20 histoire d'être moderne.

Parmi les fonctionnalités de Go que tu apprécies, il y a le mot clé defer :

func example() error {
  rsrc1, err := CreateResource()
  if err != nil {
    return err
  }
  defer rsrc1.Destroy()

  rsrc2, err := CreateResource()
  if err != nil {
    return err
  }
  defer rsrc2.Destroy()

  DoSomething(rsrc1, rsrc2)

  return nil
}

TL;DR : Il permet d'exécuter du code lorsque l'on quitte cette dernière.

Je veux la même chose en C++. Et en vrai, cela se fait assez simplement. On va juste profiter du concept de destructeur :

#include <functional>
#include <vector>
#include <algorithm>

class defer_frame {
  public:
    using function = std::function<void(void)>;

  private:
    std::vector<function> m_funcs;

  public:
    ~defer_frame() {
      std::for_each(m_funcs.rbegin(), m_funcs.rend(), [](auto &f) {
        f();
      });
    }

    void defer(function fn) {
      m_funcs.push_back(fn);
    }
};

Mais que fait cette classe ? Elle stocke tout simplement un ensemble de fonctions, et lorsque l'instance de la classe est détruite, elle appelle ces fonctions dans l'ordre inverse de leur déclaration.

Quand est-ce qu'est appelé le destructeur d'une classe ? Lorsque l'on quitte la scope actuelle, c'est à dire dans l'un des cas suivants :

  • un a atteint la fin du scope avec }
  • on quitte la boucle avec break
  • on quitte la fonction avec return
  • on lève une exception avec throw

Ce qui donne :

void example() {
  defer_frame _;

  auto rsrc1 = create_resource(); // throws on failure
  _.defer([&]() { destroy_resource(rsrc1); });

  auto rsrc2 = create_resource();
  _.defer([&]() { destroy_resource(rsrc2); });

  do_something(rsrc1, rsrc2);
}

La gestion d'erreur est tout de suite un peu plus propre :)

Histoire de taper un peu moins de code, on peut se créer 2 petites macros :

#define new_defer_frame() defer_frame _
#define defer(code_block) _.defer([&]() code_block)

Un petit exemple avec la SDL pour la route :

#include <SDL.h>
#include <stdexcept>

void abort_sdl() {
  throw std::runtime_error(SDL_GetError());
}

void run_sdl_program() {
  new_defer_frame();

  if (SDL_Init(SDL_INIT_EVERYTHING) < 0) {
    abort_sdl();
  }
  defer({ SDL_Quit(); });

  auto win = SDL_CreateWindow(
    "example",
    SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
    800, 600,
    SDL_WINDOW_SHOWN
  );
  if (win == nullptr) {
    abort_sdl();
  }
  defer({ SDL_DestroyWindow(win); });

  auto renderer = SDL_CreateRenderer(win, -1, SDL_RENDERER_ACCELERATED);
  if (renderer == nullptr) {
    abort_sdl();
  }
  defer({ SDL_DestroyRenderer(renderer); });

  bool running = true;
  while (running) {
    SDL_Event evt;

    while (SDL_PollEvent(&evt)) {
      switch (evt.type) {
        case:
          running = false;
          break;

        default:
          break;
      }
    }
  }
}
  • # Coquille

    Posté par  (site web personnel) . Évalué à 3.

    Et voilà, après 5 relectures, je laisse ensemble de fonction au lieu de ensemble de fonctions.

    Quand est-ce que je le vois ? Juste après avoir cliqué sur "Poster".

    Si les modos passent par là…

    https://link-society.com - https://kubirds.com - https://github.com/link-society/flowg

    • [^] # Re: Coquille

      Posté par  (Mastodon) . Évalué à 3.

      Corrigé, merci

      En théorie, la théorie et la pratique c'est pareil. En pratique c'est pas vrai.

  • # le defer est pavé de bonnes exceptions

    Posté par  (site web personnel) . Évalué à 10.

    Pourquoi vouloir defer quand on a du RAII ?

    Le post ci-dessus est une grosse connerie, ne le lisez pas sérieusement.

    • [^] # Re: le defer est pavé de bonnes exceptions

      Posté par  . Évalué à 5.

      Quand tu veux pas écrire une classe avec un destructeur dédié à un boulot de nettoyage très particulier ?

    • [^] # Re: le defer est pavé de bonnes exceptions

      Posté par  (site web personnel) . Évalué à 7.

      Prend l'exemple de la SDL.

      Tu serais obligé de créer des unique_ptr<SDL_Window> et unique_ptr<SDL_Renderer> avec les deleters appropriés. Tu devras aussi encapsuler dans une classe l'appel à SDL_Init avec son destructeur qui appelle SDL_Quit. Tout plein de code qui complexifie une fonction pourtant toute bête.

      Je t'avouerai en plus ne pas connaître suffisamment la spec pour te garantir dans quel ordre les destructeurs seront appelés. Ici, au moins, c'est explicite.

      RAII c'est un design pattern parmi d'autres. Si tu utilises une lib C en C++ (SDL, lua, …), c'est parfois plus simple et plus clair d'avoir un simple defer.

      https://link-society.com - https://kubirds.com - https://github.com/link-society/flowg

      • [^] # Re: le defer est pavé de bonnes exceptions

        Posté par  (site web personnel) . Évalué à 2.

        J'aurais fait une classe Game avec les pointeurs SDL_Window et SDL_Renderer comme membres :-)

        Le post ci-dessus est une grosse connerie, ne le lisez pas sérieusement.

        • [^] # Re: le defer est pavé de bonnes exceptions

          Posté par  (site web personnel) . Évalué à 4.

          Et donc tu appelles quand le SDL_DestroyRenderer/Window et SQL_Quit ?

          Si tu me dis que tu les appelles pas et que tu quittes tout simplement le programme sans t'en préoccuper, je te répondrais :

          Et tu fais quoi si tu as plusieurs fenêtres (cf ceci) ?

          https://link-society.com - https://kubirds.com - https://github.com/link-society/flowg

          • [^] # Re: le defer est pavé de bonnes exceptions

            Posté par  (site web personnel) . Évalué à 4.

            Un autre exemple qui vaut le coup d'oeil :

            class texture_manager {
              private:
                new_defer_frame();
                SDL_Renderer *m_renderer;
            
              public:
                texture_manager(SDL_Renderer *renderer) : m_renderer(renderer) {}
            
                SDL_Texture *load(const char *asset_path) {
                  defer_frame __; // pour pas shadow celle définie au niveau de la classe
            
                  auto surface = IMG_Load(asset_path);
                  if (surface == nullptr) {
                    throw std::runtime_error(IMG_GetError());
                  }
                  __.defer([&]() { SDL_FreeSurface(surface); });
            
                  auto texture = SDL_CreateTextureFromSurface(m_renderer, surface);
                  if (texture == nullptr) {
                    throw std::runtime_error(SDL_GetError());
                  }
                  defer({ SDL_DestroyTexture(texture); });
            
                  return texture;
                }
            };

            Dans cet exemple, j'ai 2 frames, une au niveau de la classe qui sera appelée par le destructeur, m'évitant ainsi de devoir maintenir une liste des textures créées. Et une au niveau de la fonction pour détruire la surface qui n'est que temporaire.

            Dans ma fonction run_sdl_program(), j'ai plus qu'à faire un new et "defer" le delete de mon texture_manager.

            https://link-society.com - https://kubirds.com - https://github.com/link-society/flowg

            • [^] # Re: le defer est pavé de bonnes exceptions

              Posté par  (Mastodon) . Évalué à 8.

              Moi, un étudiant me rend un code comme ça, je le pends par les pieds. Parce que là, ça ne demande qu'à péter à la gueule de celui qui va l'utiliser.

              Il suffit de faire une copie du texture_manager et là, je vais avoir des doubles appels à DestroyTexture, ça va être très fun. Et le problème vient du fait qu'à la base, on peut copier le defer_frame alors que ça n'a aucun sens.

              Moi, je suis comme devnewton, je suis partisan du RAII, c'est pas fait pour les chiens et au moins, c'est safe.

      • [^] # Re: le defer est pavé de bonnes exceptions

        Posté par  (site web personnel) . Évalué à 9.

        J'aime beaucoup le defer, mais j'aime aussi beaucoup le unique_ptr, et je le préfère pour gérer la création et la destruction de ressources à travers des API C, comme pour SDL, justement.

          std::unique_ptr<SDL_Window, decltype(&SDL_DestroyWindow)>
            window(SDL_CreateWindow("Viewer",
                                    SDL_WINDOWPOS_UNDEFINED,
                                    SDL_WINDOWPOS_UNDEFINED,
                                    1280,
                                    1024,
                                    0),
                   &SDL_DestroyWindow);

        est pour moi plus clair et plus sûr, car dans la même commande on gère à la fois la création de la ressource, et on prépare sa destruction. Et impossible de s'emmêler les pinceaux et de détruire la mauvaise ressource. Cerise sur le compilo, il est même possible d'utiliser le std::move et donc d'expliciter un transfert de la ressource.

        Je vois donc le defer comme un plan B, lorsque la sémantique est différente: afficher un message de log en fin de fonction, par exemple, ou encore aller écrire un fichier.

        Notons que ce dernier cas est assez mal géré par la RAII au cas où l'opération jette une exception (je n'entre pas dans les détails, mais en gros lancer une exception depuis un destructeur, c'est le mal). Ici, le defer nous donne une opportunité pour gérer ce cas d'erreur plus proprement.

    • [^] # Re: le defer est pavé de bonnes exceptions

      Posté par  (site web personnel) . Évalué à 10.

      les RAII defer, c'est en effet plus solide, c'est pour ça que les programmes roulent plus vite ?

    • [^] # Re: le defer est pavé de bonnes exceptions

      Posté par  . Évalué à 8.

      Parce que le block du defer n’est pas forcément lié à la destruction d’un objet.
      Tu peux vouloir logger quelque chose, émettre un événement analytics, fermer une fenêtre/modal, arrêter un activity indicator etc.

      Le concept c’est “qu’importe comment cette function termine, je veux toujours faire ceci”, pas “quand cet objet mort, fait ceci”.

      Linuxfr, le portail francais du logiciel libre et du neo nazisme.

  • # Le diable est l'ennemi du détail (ou un truc comme ça)

    Posté par  . Évalué à 3.

        ~defer_frame() {
          std::for_each(m_funcs.rbegin(), m_funcs.rend(), [](auto &f) {
            f();
          });
        }
    
        void defer(function fn) {
          m_funcs.push_back(fn);
        }

    Je ne fais ni de c++ ni de go, mais il me semble que l'ordre de libération est inverse. Ce serait plutôt une pile qu'il faut utiliser.

    D'ailleurs il me semble que la gestion des scopes doit pouvoir devenir pigeux :

    En go, j'imagine que ça :

    func example() error {
      rsrc1 := CreateResource()
      defer rsrc1.Destroy()
      if foo {
        bar := bazz()
        defer bar.Destroy()
        // some stuff
      }
    
      return nil
    }

    Devra se traduire par :

    void example() {
      new_defer_frame();
      auto rsrc1 = CreateResource();
      defer({ rsrc1.Destroy(); });
      if (foo) {
        new_defer_frame();
        auto bar = bazz();
        defer({ bar.Destroy(); });
        // some stuff
      }
    }

    C'est piégeux de devoir gérer manuellement le contexte (la frame).

    https://linuxfr.org/users/barmic/journaux/y-en-a-marre-de-ce-gros-troll

    • [^] # Re: Le diable est l'ennemi du détail (ou un truc comme ça)

      Posté par  (site web personnel) . Évalué à 3.

      il me semble que l'ordre de libération est inverse

      C'est le cas, j'ai utilisé rbegin et rend, qui itère sur le std::vector depuis la fin vers le début.

      En go, j'imagine que ça :

      En Go defer exécute du code à la fin de la fonction, pas à la fin du scope, donc ton defer à l'intérieur du if ne sera pas exécuté au bon moment.

      Donc ma version C++ permet une gestion plus fine. Le fait de déclarer une seconde frame dans le if (qui repose sur le variable shadowing) va justement te permettre d'avoir le comportement souhaité.

      https://link-society.com - https://kubirds.com - https://github.com/link-society/flowg

      • [^] # Re: Le diable est l'ennemi du détail (ou un truc comme ça)

        Posté par  . Évalué à 3.

        C'est le cas, j'ai utilisé rbegin et rend

        Ah oui effectivement je suis allé trop vite.

        En Go defer exécute du code à la fin de la fonction, pas à la fin du scope, donc ton defer à l'intérieur du if ne sera pas exécuté au bon moment.

        Ok je n'étais pas sûr.

        https://linuxfr.org/users/barmic/journaux/y-en-a-marre-de-ce-gros-troll

  • # Les scope guards en D

    Posté par  . Évalué à 10. Dernière modification le 07 février 2022 à 19:58.

    En D (pour celles et ceux qui ne connaissent pas, c'est un langage à ramasse-miettes compilé nativement qui se veut un remplaçant de C++ avec 90% de sa puissance et 10% de sa complexité), cette fonctionnalité existe sous la forme de scope guards : il suffit de préfixer une instruction (ou un bloc d'instructions) par scope(exit) et elle sera exécutée à la fin du bloc de code actuel (pas de la fonction, sauf s'il s'agit du bloc principal de la fonction bien sûr).

    Il y a aussi scope(success) : l'instruction ne sera exécutée que s'il n'y a pas eu d'exception levée, et scope(failure) que s'il y a eu une exception levée avant la fin de l'exécution du bloc courant.

    La doc vend cette fonctionnalité comme un remplacement de RAII en C++ :

    The D scope feature effectively replaces the RAII idiom used in C++ which often leads to special scope guard objects for special resources.

    Scope guards are called in the reverse order they are defined.

  • # Allocs

    Posté par  (site web personnel) . Évalué à 5.

    C'est bien mais le premier truc que je relèverai est que les std::function vont faire des allocations dynamiques, de même pour le std::vector.

    Exercices pour le lecteur, et l'auteur :

    1. dans quels cas std::function ne fera-t-elle pas d'allocation dynamique ? Est-ce garanti ?
    2. proposer une implémentation qui ne fait jamais d'allocations dynamiques.

    Et sur un autre sujet, tu ne devrais pas utiliser __ pour tes variables, c'est réservé pour le compilateur :)

    • [^] # Re: Allocs

      Posté par  (site web personnel) . Évalué à 1.

      dans quels cas std::function ne fera-t-elle pas d'allocation dynamique ? Est-ce garanti ?

      Quand elle est initialisée à partir d'un pointeur de fonction, et oui c'est garanti.

      Pour les lambdas, je suis pas sûr.

      proposer une implémentation qui ne fait jamais d'allocations dynamiques

      A partir du moment ou je veux pouvoir "defer" des fonctions, lambdas, des méthodes d'objet, et pouvoir jouer avec std::bind. Je pense pas que cela soit possible.

      https://link-society.com - https://kubirds.com - https://github.com/link-society/flowg

      • [^] # Re: Allocs

        Posté par  . Évalué à 7.

        Les lambdas "decay" en simple pointeur de fonction quand elles n'ont pas d'état. Je crois que la plupart du temps on souhaite que ces fonctions de nettoyage capturent tout par référence. Donc ça risque de pas être tip-top.

        Voilà ma tentative d'implémentation, il faut au minimum C++14, et ça nécessite simplement d'avoir accès à std::move.

        #include <utility>
        
        template<typename TCallback>
        class Defer {
        private:
            TCallback mCallback;
        
        public:
            Defer(TCallback&& callback)
                : mCallback(std::move(callback)) {}
        
            ~Defer() noexcept {
                mCallback();
            }
        };
        
        struct detail_defer_tag {};
        
        template <typename TCallback>
        auto operator+(const detail_defer_tag&, TCallback&& callback) {
            return Defer<TCallback>(std::move(callback));
        }
        
        #define CONCAT_IMPL( x, y ) x##y
        #define MACRO_CONCAT( x, y ) CONCAT_IMPL( x, y )
        #define defer const auto MACRO_CONCAT(d_, __COUNTER__) = detail_defer_tag{} + [&]()
        
        static volatile bool hasResource = false;
        
        int main(int argc, const char**) {
            int retVal = argc;
        
            hasResource = true;
            defer {
                hasResource = false;
            };
        
            if (hasResource) {
                retVal--;
            }
        
            return retVal;
        }

        Avec le lien godbolt qui va bien pour montrer que ça optimise pas trop mal: https://godbolt.org/z/7bK7q8Phe

        • [^] # Re: Allocs

          Posté par  (site web personnel, Mastodon) . Évalué à 4. Dernière modification le 08 février 2022 à 16:07.

          Je suis pas au fait du C++.

          Est-ce que tu pourrais m’expliquer :
          - d’où vient __COUNTER__, c’est le compilo qui te garanti que pour chaque appel de macro du as un compteur ?
          - j’ai compris l’astuce de surcharge de l’opérateur + qui permet d’éviter d’avoir une macro où on construit l’objet avec les parenthèse avant et après la fonction. Ça permet également de planquer la fonction en ayant l’impression d’avoir juste un block. Très beau. Mais je ne comprends pas à quoi sert le template (je suis rouillé du C++)
          - à quoi sert le std::move ?

          Désolé si c’est trivial…

          • [^] # Re: Allocs

            Posté par  . Évalué à 4.

            Il n'y a pas de mauvaises questions ;)

            • __COUNTER__ c'est une macro qui s'incrémente toute seule à chaque fois que qu'elle est utilisé. C'est souvent utilisé comme __LINE__ pour générer des noms uniques. Mais attention ça n'est pas standard, c'est une extension implémentée par MSVC, Clang et GCC.
            • le template<typename TCallback> est nécessaire pour pouvoir passer la lambda. Tu ne peux pas préciser le type de la lambda, et l'objectif est d'éviter l'usage de std::function, donc template.
            • à rien :D, je l'ai utilisé par habitude. Dans ce cas précis on s'attend à ce que la lambda capture son environnement par référence, donc le move n'apporte rien. En revanche si la lambda capturait par copie le move pourrait être bénéfique si les objets capturés contiennent des allocations sur le tas.

Suivre le flux des commentaires

Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.