Journal Golang, oops you did it again

Posté par  (site web personnel) . Licence CC By‑SA.
Étiquettes :
23
15
mar.
2022

C'est donc aujourd'hui que sort officiellement Go 1.18, avec le support tant attendu des Generics.

Naïf que je suis, je me dis :

Cool! On va enfin pouvoir implémenter des types Option et Result pour avoir enfin une gestion d'erreur potable.

Allez, je me lance. On commence par le type Option (ou Maybe Monad pour les intimes).

Tout d'abord, on se créé 2 structures, None et Some[T] :

type None struct {}
type Some[T any] {
  value T
}

On crée ensuite l'interface qui encapsule ces 2 types :

type Option[T any] interface {
  None | Some[T]
}

Et on crée les constructeurs :

func Nothing() None {
  return None {}
}

func Just[T any](val T) Some[T] {
  return Some[T] { value: val }
}

Puis, on créé une petite fonction HasValue qui retourne vrai ou faux selon le type de la monade :

func (opt None) HasValue() bool {
  return false
}

func (opt Some[T]) HasValue() bool {
  return true
}

Jusque là, tout va bien. Maintenant, on va créer la fonction Map qui transforme un Option[T] en Option[U] grâce à une fonction qui transforme T en U.

Le principe est ultra simple :

  • Si la monade est de type None on retourne None
  • Si la monade est de type Some[T] on applique la fonction f : T -> U sur la valeur contenue, et on retourne Some[U]

Voici l'implémentation :

func (opt None) Map[U any](f func(T) U) Option[U] {
  return Nothing()
}

func (opt Some[T]) Map[U any](f func(T) U) Option[U] {
  return Just(f(opt.value))
}

Plutôt simple non ?

C'est dommage, ça marchera pas :

method must have no type parameter

Et c'est un choix volontaire --> https://go.googlesource.com/proposal/+/refs/heads/master/design/43651-type-parameters.md#No-parameterized-methods

Alors, oui, je pourrais faire :

func MapOption[T any, U any](opt Option[T], f func(T) U) Option[U] {
 // ...
}

Mais non, Option[T] ne peut être utilisé que comme type constraint.

Ok, alors :

func MapOption[T any, U any, OT Option[T], OU Option[U])(opt OT, f func(T) U) OU {
  switch opt.(type) {
    // ...
  }
}

Toujours pas, on ne peut pas faire de switch sur le type sous-jacent d'un générique.

Ok, donc :

func MapOption[T any, U any](opt interface{}, f func(T) U) interface{} {
  switch opt.(type) {
  case Some[T]:
    val := opt.(Some[T]).value
    return Just[U](f(val))

  default:
    return Nothing()
  }
}

Oui, ça marche. Mais on perd tout l'intérêt des generics. Et il va falloir s'amuser à cast les interface{} dans le bon type à chaque appel.

Donc pour l'instant, AMHA, les generics ne servent pas à grand chose si ce n'est composer des interfaces.

  • # Kamoulox !

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

    Si je lis bien la justification initiale, l'ajout de la généricité dans Go est surtout fait pour les structures de données et les algos qui les manipulent, pas pour gérer les erreurs de façon gruik élégante.

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

    • [^] # Re: Kamoulox !

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

      Je n'ai pas bien compris ce qu'était une gestion élégante d'erreur avec un union une interface encapsulant 2 types…

      Ce n'est pas plus propre de séparer le résultat et la gestion d'erreur ?

      • [^] # Re: Kamoulox !

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

        L'avantage du type Result<Data, Error> c'est les opérations que tu peux chaîner.

        Result<T, E> -> Result<U, E> -> Result<V, E> -> ...

        Tu peux donc découpler la gestion d'erreur de ton algorithme et ce sans avoir besoin d'exception et de "jump" dans le code.

        Un exemple en Elixir avec ma lib rustic_result :

        K8s.Client.get("example.com/v1", :example, selector)
          |> then(&K8s.Client.run(conn, &1))
          |> Result.or_else(fn _ -> Result.err(:not_found) end)
          |> Result.and_then(fn resource ->
            case resource["status"]["phase"] do
              "Accepted" -> Result.ok(resource)
              "Rejected" -> Result.err(:rejected)
              nil -> Result.err(:rejected)
            end
          end)

        En gros :

        • map : Result<T, E> -> Result<U, E> avec f : T -> U
        • map_err : Result<T, E> -> Result<T, E2> avec f : E -> E2
        • and_then : Result<T, E> -> Result<U, E2> avec f : T -> Result<U, E2>
        • or_else : Result<T, E> -> Result<U, E2> avec f : E -> Result<U, E2>

        Le principe est de te permettre, grâce à ce type et ses opérations, de composer des "computations" et d'en récupérer un type final cohérent que tu peux traiter correctement.

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

      • [^] # Re: Kamoulox !

        Posté par  . Évalué à 3.

        Je suis en phase d'apprentissage de Rust, et ce système est nouveau pour moi.
        Mais de cette expérience encore fraiche, je dirais que cette façon de rassembler les données valide et d'erreur permet de plus facilement et proprement séparer le code logique du code de gestion d'erreurs.

      • [^] # Re: Kamoulox !

        Posté par  . Évalué à 6.

        Le principe en F# de railway programming.

        • [^] # Re: Kamoulox !

          Posté par  . Évalué à 2.

          Merci pour l'article, j'utilisais ce pattern sans en connaître le nom.
          De façon générale, les publications de ce site ont l'air super intéressantes ; ça me donnerait presque envie de me mettre à F# ou OCaml.

        • [^] # Re: Kamoulox !

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

          C'est inspirant cette façon de faire des présentations… 1h pour ne pas faire de try catch.

          Ce qui est intéressant c'est la volonté de formuler tous les cas de figure pour la gestion d'erreurs et d'y associer une syntaxe.

          Ce qui me gène avec cette approche :

          • l'aspect performance, on ne retourne pas directement un résultat, mais un objet virtuel, qui contient soit le résultat, soit une erreur. C'est un inconvénient par rapport au fait de dissocier le résultat et l'erreur ;
          • "do or do not, there is no try", j'ai besoin de savoir pourquoi ;
          • Au final je ne trouve pas forcément la syntax simple ;
          • Enfin et surtout, la validation des données N'EST PAS une erreur.

          En ce qui me concerne, nous utilisons groovy et Java, qui propose avec les enum des possibilités proches. Pour le cas présenter (validation d'un objet, sequence de traitement, gestion des erreurs associées) :

          • La validation des données nécessite un validator dans la classe implémentant l'objet, aucun cas de gestion de cette aspect n'apparait dans le code métier, c'est le FW qui décore les actions pour retourner l'information à l'utilisateur ;
          • Le reste sont des exceptions.

          Les exceptions constituent les erreurs non prévisibles, comme un problème réseau, disque plein, et d'autres aspect, comme la sécurité. Je comprends mal l'intérêt de ne pas utiliser les exceptions..

          • [^] # Re: Kamoulox !

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

            Quand tu lis la documentation d'une fonction, si elle retourne un Result, tu peux tout de suite savoir les cas d'échec possibles. Et le système de type du langage peut vérifier que tout les cas sont gérés.

            C'est pas le cas avec les exceptions. Les throws que tu fais ne font pas partie de la signature de la fonction.

            Les exceptions sont une forme de early return, souvent comparé à goto. Pour ma part je trouve les 2 concepts complémentaire, cf Elixir ou on utilise {:ok, val} ou {:error, reason} pour ce qui est prévisible et raise / rescue pour ce qui ne l'est pas.

            Dans certains cas je peux considérer qu'une requête HTTP qui retourne une 404 est prévisible mais qu'un network unreachable ne l'est pas : est-ce que la ressource existe ?

            Dans d'autres cas, je considère que toute erreur est imprévisible : j'ai besoin de tel document

            Dans le premier cas, si je reçois un {:error, 404} je peux retourner false et laisser l'exception network unreachable se propager.

            Dans le second cas je transforme le {:error, reason} en exception (unwrap).

            Les Result et les exceptions permettent tout deux de séparer le code et la gestion d'erreur, mais la sémantique, les performances, et l'intégration au système de type sont radicalement différent.

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

          • [^] # Re: Kamoulox !

            Posté par  . Évalué à 4.

            Les exceptions constituent les erreurs non prévisibles, comme un problème réseau, disque plein, et d'autres aspects, comme la sécurité. Je comprends mal l'intérêt de ne pas utiliser les exceptions..

            Je ne suis pas d’accord. Si les interfaces que tu utilises sont correctement définies, tu dois pouvoir connaître la liste des exceptions. Donc, des erreurs. Récupérer une exception networkUnreachable ou une erreur NetworkUnreachable, je ne vois pas trop la différence.

            Le principal intérêt des exceptions, àmha, dans les langages impératifs, c’est de pouvoir balancer l’erreur et la récupérer à l’endroit le plus propice, sans faire de cascade d’erreur, de cas particulier juste dans l’optique de la rebalancer à l’appelant. Le pendant de ça, c’est que rien ne garantit que quelqu’un récupère l’erreur.

            Avec les types permettant le Railway programming, dans les langages fonctionnels, si tu ajoutes un cas d’erreur, tous les case, match où le type est utilisé doit être complet. Donc le compilateur te le dira si tu oublies de gérer un cas d’erreur.

            • [^] # Re: Kamoulox !

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

              c’est que rien ne garantit que quelqu’un récupère l’erreur

              En Java, tu es obligé d'attraper la plupart des exceptions.

              Malheureusement il y a des exceptions "unchecked".

              On retrouve le même horrible principe en Go avec error versus panic :(

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

              • [^] # Re: Kamoulox !

                Posté par  (site web personnel) . Évalué à 4. Dernière modification le 17 mars 2022 à 15:05.

                Sauf que tu as recover() en Go pour "catch" le panic.

                Ce qui veut dire que panic/recover c'est le try/catch de Go, mais en plus moche.

                https://go.dev/blog/defer-panic-and-recover

                EDIT: A moins que c'est justement ce qui tu voulais dire, auquel cas, ignore mon message.

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

                • [^] # Re: Kamoulox !

                  Posté par  . Évalué à 3.

                  Et oui, en beaucoup plus moche, car si rien ne récupère ton panic (via le defer recover()) et bien ton programme s’arrête immédiatement avec une belle stacktrace. Ça peut être très gênant :)

                  • [^] # Re: Kamoulox !

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

                    Pas sûr de comprendre.

                    Si t'as rien qui catch ton exception, ça remonte aussi en haut de la stack et fini par quitter le programme anormalement.

                    Je disais que le panic/recovery était plus moche car au moins avec de vrai exceptions tu peux contrôler précisément ou et quand le recovery code s'exécute.

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

                    • [^] # Re: Kamoulox !

                      Posté par  . Évalué à 4.

                      On est d'accord sur la mocheté des panic/recover mais avec cette petite joyeuseté supplémentaire.

                      Bon, en Go, je ne panique pratiquement jamais, sauf si c'est une erreur fatale au démarrage (ex: impossible de binder un port ou de se connecter à une BDD ou autres choses). Sinon je gère le cas non passant via des erreurs (le célèbre if err != nil) avec le cas passant sur le côté gauche (https://medium.com/@matryer/line-of-sight-in-code-186dd7cdea88). Ca m'autorise en relecture de folder facilement le code pour me concentrer sur le cas passant.

              • [^] # Re: Kamoulox !

                Posté par  . Évalué à 2.

                En Java, tu es obligé d'attraper la plupart des exceptions.

                C'est pas plutôt uniquement pour les checked exceptions ?

                "Quand certains râlent contre systemd, d'autres s'attaquent aux vrais problèmes." (merci Sinma !)

                • [^] # Re: Kamoulox !

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

                  C'est justement pour ça qu'il précise juste après l'existance des exceptions "unchecked".

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

                  • [^] # Re: Kamoulox !

                    Posté par  . Évalué à 2.

                    Bah oui mais c'est loin d'être la majorité des exceptions en Java.

                    "Quand certains râlent contre systemd, d'autres s'attaquent aux vrais problèmes." (merci Sinma !)

                    • [^] # Re: Kamoulox !

                      Posté par  (site web personnel) . Évalué à 2. Dernière modification le 17 mars 2022 à 23:32.

                      Premièrement, c'est pas le sujet, le concept de checked exception existe, qu'elles soient utilisées ou non en pratique est un autre débat.

                      Deuxièmement, c'est principalement les RuntimeException (et classes filles) qui sont unchecked.

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

                      • [^] # Re: Kamoulox !

                        Posté par  . Évalué à 1. Dernière modification le 20 mars 2022 à 11:45.

                        Ouais ben je suis bien content de pas avoir des checked exceptions, ça n'a aucun sens 99% du temps.

                        "Quand certains râlent contre systemd, d'autres s'attaquent aux vrais problèmes." (merci Sinma !)

              • [^] # Re: Kamoulox !

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

                En Java, tu es obligé d'attraper la plupart des exceptions.

                Pas en Groovy. Dans le cadre d'un serveur d'application qui intègre un connecteur FreeCAD, Solr, LibreOffice, sans compter la gestion des données persistantes, la problématique de la sécurité, la validation des données… c'est peut-être un avantage. Tu n'as certainement pas envie de traiter tous les cas de figure si le formulaire contient un fichier à uploader et peu lever un nombre d'exception conséquent lors de la sauvegarde de ce formulaire.

                D'ailleurs, vu par l'utilisateur, le cas ou une exception est non catchée sur un serveur d'application c'est que la requête retourne un code 501 et que les données persistantes ne sont pas modifiées. C'est donc "limité". L'application continue de tourner. C'est difficile d'anticiper un comportement correcte suite à certaines erreurs. Moi, c'est mon tél ou ma boite mail qui vont recevoir les insultes, personne ne va en mourir dans mon cas (à moins d'être susceptible ou dépressif). Mais je ne vois pas comment on peut faire mieux.

                Il n'y a quasiment pas de try … catch dans notre code, excepté pour ne pas interrompre l'initialisation du serveur si l'erreur est tolérable, lors de l'initialisation des services, lorsque le service est initialisé AVANT son utilisation (pas de lazy-init). Bref, au final, je n'ai pas quasiment pas de code de gestion d'erreur, mais nombre d'erreur soit sont traitées par le framework lui-même, soit on ne peut rien en faire et seulement prévenir l'administrateur…

                • [^] # Re: Kamoulox !

                  Posté par  . Évalué à 4.

                  Si ça marche pour toi, tant mieux, mais je doute que ça soit le cas pour beaucoup d’applis.

                  Sur les erreurs dures, que tu ne peux pas corriger, t’as probablement envie de mapper les erreurs en fonction du contexte. Y’a une différence entre « tu m’as passer un uuid quand je m’attend à un int », auquel cas tu veux retourne un 400 bad request, et un « je me suis pété une NPE, et je veux retourner un 500 », avec tout un spectre entre les deux (404 not found, 503, 406, 429, que sais je encore), et tu peux vouloir remonter un code d’erreur applicatif a ton client aussi.

                  Et en fonction du besoin, certaine partie du traitement peuvent être optionnelles, et la requête peut être complétée sans forcément tout faire. Typiquement, sur un read only qui agrège des données de multiples sources, ce qui est somme toute très courant dans un monde soa. Et la croit moi, tu veux les catcher ces exceptions la.

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

          • [^] # Re: Kamoulox !

            Posté par  . Évalué à 5.

            l'aspect performance, on ne retourne pas directement un résultat, mais un objet virtuel, qui contient soit le résultat, soit une erreur. C'est un inconvénient par rapport au fait de dissocier le résultat et l'erreur ;

            Je sais pas trop ce que tu veux dire par « aspect performance », mais lancer une exception c’est loin d’être cheap, le stack unwinding, capturer la stack trace etc a un coût certain. En pratique, pour beaucoup d’application, ça va pas faire une différence notable, mais c’est pas gratuit, que ce soit en c++ ou en java.

            A l’inverse, retourner un struct avec erreur xor résultat ne coute presque rien, c’est traité comme un return de base, que tu doit bien faire à un moment ou un autre.

            Le coût de performance se transfère surtout sur la façon d’écrire le code, et la, t’as 2 approches, grosse modo:

            • ne pas abstraire le mécanisme, et laisser le développeur gérer ça « à la mano », avec un switch/case ou un if (result.error) (ce qui peut vite devenir assez moche en fonction du code que t’écrit),
            • abstraire le mécanisme, en prétendant que c’est une vraie exception, mais laisser le compilo gérer le sucre syntaxique pour traduire ça en if (result.error) goto exceptionHandlingBlock. C’est ce que fait Swift notamment, avec un try/catch qui n’est qu’une façon de différencier le cas d’erreur du cas normal au niveau syntaxique, mais en utilisant un Result sous le capot.

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

            • [^] # Re: Kamoulox !

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

              lancer une exception c’est loin d’être cheap

              Totalement d'accord. Et c'est aussi plus complexe à implémenter correctement dans un interpréteur / compilateur.

              ne pas abstraire le mécanisme, et laisser le développeur gérer ça

              C'est la méthode Erlang/Elixir. Mais ça génère pas trop de boilerplate grâce au pattern matching.

              abstraire le mécanisme, en prétendant que c’est une vraie exception, mais laisser le compilo gérer le sucre syntaxique

              C'est plus ou moins la méthode de Rust avec le ? que tu mets après les appels de fonction qui retournent cette structure.

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

            • [^] # Re: Kamoulox !

              Posté par  . Évalué à 4.

              ce qui peut vite devenir assez moche en fonction du code que t’écrit

              Pour utiliser abondamment les autres approches (try/catch, résultat genre optional monadique sur le quel tu fais des map/filter/flatmap/etc), je trouve le pattern matching très élégant.

              Il est d'une part bien plus souple, tu ne discrimine pas forcément les erreurs/résulats, mais les résultats vides par exemple.

              En terme de performance ça peut devenir génial avec de l'inline de code et si le compilateur a le droit de supprimer cette structure et tu peux te retrouver avec des appels sans gestion d'erreur si le compilateur a pu déduire que l'erreur n'était pas possible (ou simplement que le cas n'est pas possible) dans cet appel là. Dans le pire cas c'est une if très simple.

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

            • [^] # Re: Kamoulox !

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

              Je sais pas trop ce que tu veux dire par « aspect performance », mais lancer une exception c’est loin d’être cheap, le stack unwinding, capturer la stack trace etc a un coût certain. En pratique, pour beaucoup d’application, ça va pas faire une différence notable, mais c’est pas gratuit, que ce soit en c++ ou en java.

              Il y a 2 aspects liés à la gestion des exceptions :

              • le coût d'une levée d'exception, comme tu le signales,
              • le coût de gestion de celles-ci dans le binaire (binaire plus gros).

              En C++, le coût de gestion était élevé à mon époque, tout le monde n'activait pas la gestion de celles-ci (d'où des incompatibilités sur certaines plateformes). En gros, ça revient à ajouter des return spéciaux supplémentaires, et l'une des règles de codage en C++ était de minimiser le nombre de return dans le corps des méthodes. Je crois qu'en Java, le coût de gestion est plus négligeable car il peut y avoir des optimisations au runtime, et la Jvm est stack-based (un peu comme du C++ sur du x86), donc peu d'impact d'un return.

              Le lancement d'une exception dans le cas normal arrive rarement. Donc même si le coût est élevé, tu perds au final peu de performances. C'est différent de systématiquement encapsuler ton résultat. Mais oui, c'est pas forcément significatif en terme d'impacte sur la plupart du code que l'on rencontre.

  • # Go ou golang ?

    Posté par  . Évalué à 6.

    J'ai pas bien compris, le nom du langage c'est go ou golang ? Les gens disent comment dans la vraie vie ?

    • [^] # Re: Go ou golang ?

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

      Le nom c'est Go.

      Mais quand je fais des recherches google, les résultats sont plus pertinents en utilisant le terme "golang". Du coup je mélange un peu les deux.

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

    • [^] # Re: Go ou golang ?

      Posté par  (site web personnel, Mastodon) . Évalué à 6.

      Le nom du langage est bien « Go » mais le site officiel s'appelle « golang » et on fait référence au langage comme ça (en fait « go-lang » qui en devenant très commun perd son tiret de mot composé, comme « e-mail » et autres) dans beaucoup d'endroits. Ça rend la recherche plus facile dans les moteurs de recherche …parce-que s'appeler d'un mot (verbe) courant et si court, c'est le risque de noyade dans la flopée de page (dont l'emploie du mot est fort légitime d'ailleurs.)
      La faute à un choix de nom peu heureux, mais c'est pas le pire (n'est-ce pas C, D, Eiffel, J, Julia, R, et j'en passe ?)

      “It is seldom that liberty of any kind is lost all at once.” ― David Hume

  • # Approche prudente ?

    Posté par  . Évalué à 7.

    Dans la référence que tu as donnée à savoir https://go.googlesource.com/proposal/+/refs/heads/master/design/43651-type-parameters.md#No-parameterized-methods, on peut y lire :

    So while parameterized methods seem clearly useful at first glance, we would have to decide what they mean and how to implement that.

    Donc, j'ai l'impression qu'ils se laissent le temps pour identifier les effets de bord éventuels et/ou comment l’implémenter.

  • # Conteneurs

    Posté par  . Évalué à 5.

    Donc pour l'instant, AMHA, les generics ne servent pas à grand chose si ce n'est composer des interfaces.

    Je dirais qu'ils servent surtout à gérer les conteneurs et les fonctions qui opèrent dessus en ignorant le contenu de façon simple, type-safe et sans codegen.

    Par exemple, le package sort permet de trier un slice en utilisant la fonction https://pkg.go.dev/sort#Slice qui n'est pas vraiment ergonomique (je dirais même pas sûre).

    Avec les génériques on pourrait avoir func Slice[T any](x []T, less func(xi, xj *T) bool) à la place.

    Pour prendre un exemple plus compliqué, l'outil dataloaden permet de générer du code pour un pattern "dataloader", ie. permettre d'avoir une fonction fn(T): U dans l'interface publique, et d'avoir le traitement implémenté dans une fonction fn2(T[]): U[], qui est appelée beaucoup moins de fois que fn (et donc optimiser plus facilement le traitement). Aujourd'hui c'est fait avec de la codegen, et ce serait beaucoup plus ergonomique avec des génériques (compilation plus rapide que la codegen, pas besoin de déclarer la liste des variantes à générer, facile d'adapter le code, etc.)

    • [^] # Re: Conteneurs

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

      Sauf que je peux déjà plus ou moins le faire sans Generics ça.

      type Comparable interface {
        Compare(other Comparable) (int, error)
      }
      
      type Foo struct {}
      
      func (Foo) Compare(other Comparable) (int, error) {
        switch other.(type) {
        case Foo:
          // do stuff
          return 0, nil
      
        default:
          return 0, errors.New("wrong type")
      }
      
      func Sort(items []Comparable) ([]Comparable, error) {
        // do stuff
      }

      Alors oui, ça fait un peu de boilerplate. Mais c'est possible.

      Donc je persiste et signe, les generics servent surtout a faire de la composition d'interface justement pour retirer le boilerplate de ces fonctions.

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

      • [^] # Re: Conteneurs

        Posté par  . Évalué à 4.

        C'est pas un boilerplate c'est in cas d'erreur supprimé car vérifié par le compilateur. Ce n'est pas une question de verbosité mais de fiabilité.

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

Suivre le flux des commentaires

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