Interface non exportée

En Go

Le problème exposé dans ce sujet a été résolu.

Bonjour,

Je lis cet article et je suis plutôt sceptique à certains conseils comme:

  1. Define the interface at point of use.
  2. L’utilisation des interfaces scellée.

J’ai vraiment du mal à voir l’apport. C’est moi qui passe à coté d’une pratique courante où un gain est clairement identifié ?

Qu’en pensez vous ? Quel est votre expérience à ce sujet ?

Merci

tl;dr / Résumé de l’article

L’auteur recommande de faire:

package animals

type Dog struct{}
func (a Dog) Speaks() string { return "woof" }

Puis:

package circus

type Speaker interface {
	Speaks() string
}

func Perform(a Speaker) string { return a.Speaks() }

Plutôt que :

package animals 

type Animal interface {
	Speaks() string
}

// implementation of Animal
type Dog struct{}
func (a Dog) Speaks() string { return "woof" }

Et ça:

package circus

import "animals"

func Perform(a animal.Animal) string { return a.Speaks() }
+0 -0

Salut !

Alors, pour le premier conseil (définir les interfaces là où elles sont utilisées), c’est exactement ce que fait la lib standard avec io.Writer, par exemple. C’est une façon très simple de faire de la programmation par contrat.

Typiquement, toutes les fonctions du package io prennent en paramètre des interfaces définies dans ce package : là où elles sont utilisées, donc. Tu vas donc avoir :

type Writer interface {
	Write(p []byte) (n int, err error)
}

func WriteString(w Writer, s string) (n int, err error) {
   // implem de la fonction WriteString
}

Autrement dit, le contrat sur le premier argument est défini dans le même package que la fonction, si tu veux utiliser io.WriteString, tu as juste à lui passer une structure possédant une méthode Write telle que définie par cette interface, puisque Go te dispense d’avoir à faire des déclarations du style type MachinWriter implements io.Writer.

Dans le cas général, on n’a vraiment pas besoin de plus que ça pour faire du polymorphisme. Au lieu de définir des grosses interfaces communes à plein de composants, on définit l’interface minimale très précise dont on a besoin pour fonctionner.

En y réfléchissant, ça rapproche beaucoup l’utilisation des interfaces de celle de fonctions de callback :

type EventCallback = func(*Event) error

func WatchEvents(ctx context.Context, callback EventCallback) error {
   // ...
}

Dans les faits c’est une sorte de "duck-typing typé statiquement".


Pour ce qui est des interfaces scellées, c’est une simple façon de protéger l’implémentation : la structure, ses champs, son implémentation est privée, et tu la retournes "abstraite" par l’interface publique. Tu peux changer l’implémentation privée de la struct, renommer ses champs, retourner des structures complètement différentes en fonction d’un paramètre passé au constructeur etc. sans jamais risquer de casser le code client, puisque ce dernier ne dépend que de ton interface publique qui, elle, reste fixe, "scellée".

C’est bêtement de l’encapsulation.

+3 -0

Typiquement, toutes les fonctions du package io prennent en paramètre des interfaces définies dans ce package : là où elles sont utilisées, donc.

Intéressant ! Ça a du sens sur un bout de code qui peut être importé. Par-contre, ça n’a pas de sens de faire ça dans le package main ou avec une interface non exportée (en minuscule).

Pour ce qui est des interfaces […] ton interface publique qui, elle, reste fixe, "scellée".

Là par-contre, je ne te suis pas du tout.
Ce que tu décris c’est juste l’utilisation d’une interface publique, non ? Mais une interface scellée publique ne laisse plus que la possibilité d’étendre les types déjà définis (puisqu’on ne peut pas créer de nouveau type qui implémente cette interface). Je ne vois pas l’apport par rapport à une interface publique simple.

+0 -0

Ce que tu décris c’est juste l’utilisation d’une interface publique, non ?

Oui… C’est la façon classique de faire ça dans les langages OO habituels comme Java. Mais en Go on ne fait pas ce genre de choses par défaut. Puisque ses interfaces sont très malléables, la plupart du temps on se contente de retourner des pointeurs vers des structs bien concrètes et libre à l’utilisateur de définir une interface s’il en a besoin.

Retourner une interface, on le fait seulement quand on a une bonne raison concrète de le faire. Typiquement si un fonction peut retourner des types différents.

L’extension de type est toujours possible, il suffit d’embarquer l’interface que tu souhaites étendre dans une structure et surcharger les méthodes qui vont bien.

type MyInterface interface {
    Foo()
    Bar()
}

type Extended struct {
    MyInterface // Extended implémente MyInterface par construction
} 

Mais tu me mets le doute, je ne suis pas certain de comprendre la même chose que toi sur ce qu’est une interface scellée.

Cela dit, je n’ai jamais vu ni eu besoin d’étendre un type d’un package étranger en Go. Edit: en fait, si, une fois, pour abstraire un mécanisme de discovery et de failover derrière l’interface de grpc.ClientConnection tout en wrappant ce dernier, ce qui m’a permis d’injecter facilement ce comportement dans mon code existant.

+1 -0

Mais tu me mets le doute, je ne suis pas certain de comprendre la même chose que toi sur ce qu’est une interface scellée.

J’ai l’impression oui. ^^
Pour moi, de ce que je comprends de la définition d’interface scellée de Go. C’est que c’est une interface avec une méthode privée.

Un exemple avec des bonbons 🍬 🍭:

package sweets

type SweetCandy interface {
	SweetLevel() int
	seal()
}

type Flavor string

const (
	Chocolate = Flavor("chocolate")
	Vanilla   = Flavor("Vanilla")
)

type Berlingo struct {
	SugarQuantity float32
	Flavor        Flavor
}

func (x Berlingo) SweetLevel() int {
	switch x.Flavor {
	case Chocolate:
		return int(x.SugarQuantity * 2)
	case Vanilla:
		return int(x.SugarQuantity + 10)
	default:
		return int(x.SugarQuantity)
	}
}

func (x Berlingo) seal() {
}

func GetSweetLevel(x SweetCandy) int {
	switch candy := x.(type) {
	case Berlingo:
		return candy.SweetLevel()
	default:
		return 1
	}
}

Et du coup, on ne peut plus créer de types qui remplisse l’interface SweetCandy. Ici, j’essaye de créer un nouveau bonbon :

package main

import (
	"fmt"
	"pudding-maker/sweets"
)

type Pantteri struct {
	SugarQuantity int
}

func (x Pantteri) SweetLevel() int {
	return x.SugarQuantity
}

// Ici, on essaye mais ça ne marche pas.
func (x Pantteri) seal() {}

func main() {
	candy := sweets.Berlingo{10.5, sweets.Chocolate}
	finishCandy := Pantteri{5}

	fmt.Println(sweets.GetSweetLevel(candy))
	fmt.Println(sweets.GetSweetLevel(finishCandy))
}

La ligne 23 j’obtiens l’erreur cannot use finishCandy (variable of type Pantteri) as type sweets.SweetCandy in argument to sweets.GetSweetLevel: Pantteri does not implement sweets.SweetCandy (missing seal method).

Ça permet de faire un switch exhaustif switch candy := x.(type) car le code client ne pourra pas créer d’autres bonbons.

PS: J’ai trouvé ça sur Wikipédia, ça à l’air trop bon ! https://fr.wikipedia.org/wiki/Pantteri

+0 -0

Ok, je comprends l’idée.

Je n’ai jamais eu à utiliser ce genre de trucs du coup je n’ai pas d’opinion tranchée sur le sujet. J’imagine qu’il y a des cas où l’on ne veut vraiment pas laisser n’importe qui implémenter une interface en considérant qu’elle n’est utile que pour nous à cause de détails d’implémentations qu’on serait obligé de leaker vers les utilisateurs si on voulait qu’ils soient capable de l’implémenter…

Mais à première vue ça ressemble à un smell.

+1 -0

Ok, donc c’est à peu prêt la même réaction que j’ai eu face à cette pratique. ^^
Je demande à voir un case d’utilisation intéressant.

+0 -0

Je vois deux cas d’utilisations.

  • Celui mentionné avant de ne pas faire fuiter une interface parce que ses détails internes sont compliqués. Un cas très légitime de ça est lorsqu’on refactorise un vieux code et qu’on n’est pas encore sur d’avoir compris quelle est la bonne interface à avoir mais qu’on en a trouvé une qui rend service pour le refactoring. Si on la scelle, on évite qu’elle se répande dans la codebase avant d’être propre (mais effectivement, c’est un code smell à corriger plus tard).
  • C’est juste une façon de construire un truc qui ressemble à un sum type, au sens algébrique du terme. C’est à dire comme une enum si les variantes pouvaient contenir autre chose que le type unitaire. Du coup, ça me rend un peu curieux, @nohar c’est quoi la façon canonique d’imiter un sum type en Go si c’est pas en trichant avec une interface scellée ?
+1 -0

@nohar c’est quoi la façon canonique d’imiter un sum type en Go si c’est pas en trichant avec une interface scellée ?

Excellente question !

Ma réponse va être décevante : "je sais pas trop, je n’ai jamais ou presque croisé ce genre de use-case dans la pratique, donc pour moi ça va se faire au cas par cas suivant le contexte".

L’intérêt des sum types c’est que l’on peut faire des switches dessus pour émuler une certaine forme de pattern matching. Le seul truc dont je suis à peu près sûr, c’est qu’il est en principe plus efficace de faire un switch sur la valeur d’un enum (sur des constantes d’un type custom qui est un alias de uint8, par exemple), qu’un type switch où l’on essayee downcaster une interface vers les implems possibles pour trouver le bon cas (le genre de chose que je répugne intuitivement à faire).

Du coup je ne sais pas trop. J’imagine que devant un tel cas, je ferais d’abord quelque chose de très bas du front avec un enum, mais pour répondre avec précision il faudrait que je fasse des tests sur un cas concret.

Ce qui me fait peur avec cette approche, c’est de faire intervenir la réflexivité dans le processus, parce que c’est le genre de truc qui plombe presque systématiquement les perfs, du coup la réponse la plus honnête que j’ai à te proposer est "je n’ai pas (encore) de façon canonique de faire ça".

+3 -0

Je viens de tilter qu’en fait, j’ai un cas de sealed interface que je croise tous les jours au boulot depuis 3 ans sans le réaliser… gRPC en génère !

Voilà le cas que j’ai trouvé ce matin. Je veux faire un microservice qui choisit la bonne faction pour un nouveau joueur qui rejoint un monde dans notre jeu (vocabulaire business à nous, inutile de m’étendre sur le sujet), ça donne le protobuf suivant.

syntax = "proto3";

package faction.v1;

option csharp_namespace = "Gabsee.Backend.FactionAPI.V1";
option go_package = "gitlab.com/gabsee/backend/grpc/factionpb/v1";

message PickFactionRequest {
    string world = 1;
}

message PickFactionResponse {
    string faction = 1;
}

service Picker {
    rpc PickFaction(PickFactionRequest) returns (PickFactionResponse) {}
}

À partir de cette définition, pour implémenter ce service en Go, on doit générer les packages qui vont bien, puis créer un serveur comme celui-ci (qui délègue absolument tout à un service de la couche métier interne, et se contente de rajouter une ligne de log) :

package pickerv1

import (
	"context"

	v1 "gitlab.com/gabsee/backend/grpc/factionpb/v1"
	"gitlab.com/gabsee/backend/pkg/domain/faction"
	"gitlab.com/gabsee/backend/pkg/log"
	"go.uber.org/zap"
	"google.golang.org/grpc"
)

func NewGRPC(svc faction.PickerService) *server {
	return &server{svc: svc}
}

type server struct {
	v1.UnimplementedPickerServer
	svc faction.PickerService
}

func (s *server) Register(srv *grpc.Server) {
	v1.RegisterPickerServer(srv, s)
}

func (s *server) PickFaction(ctx context.Context, in *v1.PickFactionRequest) (*v1.PickFactionResponse, error) {
	log.WithContext(ctx).Info("pick faction",
		zap.String("world", in.World),
	)
	faction, err := s.svc.PickFaction(ctx, in.World)
	return &v1.PickFactionResponse{Faction: faction}, err
}

On voit ici ligne 18 que le serveur est obligé d’embarquer la struct v1.UnimplementedPickerServer. Ceci est enforcé par l’interface v1.PickerServer qui est également générée par protobuf :

package v1

// ...

// PickerServer is the server API for Picker service.
// All implementations must embed UnimplementedPickerServer
// for forward compatibility
type PickerServer interface {
	PickFaction(context.Context, *PickFactionRequest) (*PickFactionResponse, error)
	mustEmbedUnimplementedPickerServer()
}

Cette sealed interface sert à s’assurer que l’on embarque bien l’implémentation "de base" du service, qui retourne une erreur avec le code Unimplemented à chaque appel. Tous les implémenteurs de ce service doivent forcément embarquer cette implem de base.

C’est une façon de sécuriser l’utilisation que les gens font du code généré et, comme l’indique le commentaire du fichier généré, d’assurer la forward compatibility : si on rajoute une rpc à ce service dans la déclaration protobuf et que l’on régénère le code, les implémentations existantes ne seront pas cassées et pourront continuer à compiler sans problème, elle se contenteront de renvoyer Unimplemented lorsqu’on appellera la nouvelle méthode.

Au passage : pour des raisons évidentes (si on veut au contraire que ça pète à build-time quand on oublie d’adapter les implems), ce comportement de grpc est optionnel et on peut bien sûr le désactiver.

+1 -0
Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte