Interfaces en tant que types de données abstraits dans Go

Il n'y a pas si longtemps, un collègue a retweeté un excellent article Comment utiliser les interfaces Go . Il discute de quelques bogues lors de l'utilisation des interfaces dans Go, et donne également quelques recommandations sur la façon dont ils doivent être utilisés.

Dans l'article mentionné ci-dessus, l'auteur cite l'interface du package de tri de la bibliothèque standard comme exemple d'un type de données abstrait. Cependant, il me semble qu'un tel exemple ne révèle pas très bien l'idée quand il s'agit de vraies applications. En particulier sur les applications qui implémentent la logique d'un domaine d'activité ou résolvent des problèmes du monde réel.

De plus, lorsque vous utilisez des interfaces dans Go, il y a souvent un débat sur la suringénierie. Et il arrive également qu'après avoir lu ce genre de recommandations, les gens non seulement cessent d'abuser des interfaces, ils essaient de les abandonner presque complètement, se privant ainsi d'utiliser l'un des concepts de programmation les plus solides en principe (et l'une des forces de Go in particulier). Au sujet des erreurs typiques de Go, au fait, il y a un bon rapport de Stive Francia de Docker. Là, en particulier, les interfaces sont mentionnées plusieurs fois.

En général, je suis d'accord avec l'auteur de l'article. Néanmoins, il m'a semblé que le sujet de l'utilisation des interfaces en tant que types de données abstraits s'y révélait plutôt superficiellement, donc je voudrais le développer un peu et réfléchir à ce sujet avec vous.

Se référer à l'original


Au début de l'article, l'auteur donne un petit exemple de code, à l'aide duquel il signale des erreurs lors de l'utilisation des interfaces que les développeurs font souvent. Voici le code.

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

 package circus import "animal" func Perform(a animal.Animal) string { return a.Speaks() } 

L'auteur appelle cette approche «utilisation d'interface de style Java» . Lorsque nous déclarons une interface, nous implémentons le seul type et les seules méthodes qui satisferont cette interface. Je suis d'accord avec l'auteur, l'approche est telle quelle. Le code le plus idiomatique de l'article original est le suivant:

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

 package circus type Speaker interface { Speaks() string } func Perform(a Speaker) string { return a.Speaks() } 

Ici, en général, tout est clair et compréhensible. L'idée de base: "Déclarez d'abord les types, et ensuite seulement déclarez les interfaces au point d'utilisation . " C'est correct. Mais développons maintenant une petite idée de la façon dont vous pouvez utiliser les interfaces comme types de données abstraits. L'auteur souligne d'ailleurs que dans une telle situation, il n'y a rien de mal à déclarer l'interface «en amont» . Nous travaillerons avec le même code.

Jouons avec les abstractions


Nous avons donc un cirque et il y a des animaux. À l'intérieur du cirque, il existe une méthode plutôt abstraite appelée «Perform» , qui prend l'interface « Speaker» et fait émettre des sons à l'animal. Par exemple, il fera aboyer le chien à partir de l'exemple ci-dessus. Créez un dompteur d'animaux. Puisqu'il n'est pas idiot ici, on peut aussi généralement le faire émettre des sons. Notre interface est assez abstraite. :)

 package circus type Tamer struct{} func (t *Tamer) Speaks() string { return "WAT?" } 

Jusqu'à présent, tout va bien. Nous allons plus loin. Apprenons à notre dompteur à donner des ordres aux animaux de compagnie? Jusqu'à présent, nous aurons une commande vocale . :)

 package circus const ( ActVoice = iota ) func (t *Tamer) Command(action int, a Speaker) string { switch action { case ActVoice: return a.Speaks() } return "" } 

 package main import ( "animal" "circus" ) func main() { d := &animal.Dog{} t := &circus.Tamer{} t2 := &circus.Tamer{} t.Command(circus.ActVoice, d) // woof t.Command(circus.ActVoice, t2) // WAT? } 

Mmmm, intéressant n'est-ce pas? Il semble que notre collègue n'est pas content qu'il soit devenu un animal de compagnie dans ce contexte? : D Que faire? Le haut-parleur semble qu'une abstraction n'est pas très appropriée ici. Nous en créerons une plus appropriée (ou plutôt, nous retournerons en quelque sorte la première version du «mauvais exemple» ), après quoi nous changerons la notation de la méthode.

 package circus type Animal interface { Speaker } func (t *Tamer) Command(action int, a Animal) string { /* ... */ } 

Cela ne change rien, dites-vous, le code sera toujours exécuté, car les deux interfaces implémentent une méthode, et vous aurez raison en général.

Cependant, cet exemple capture une idée importante. Lorsque nous parlons de types de données abstraits, le contexte est crucial. L'introduction d'une nouvelle interface, au moins, a rendu le code d'un ordre de grandeur plus évident et plus lisible.

Soit dit en passant, l'une des façons de forcer le dompteur à ne pas exécuter la commande «vocale» consiste simplement à ajouter une méthode qu'il ne devrait pas avoir. Ajoutons une telle méthode, elle donnera des informations sur la capacité de l'animal à s'entraîner.

 package circus type Animal interface { Speaker IsTrained() bool } 

Désormais, le dompteur ne peut pas être glissé à la place d'un animal de compagnie.

Développer le comportement


Nous obligerons nos animaux de compagnie, pour un changement, à exécuter d'autres commandes, en plus, ajoutons un chat.

 package animal type Dog struct{} func (d Dog) IsTrained() bool { return true } func (d Dog) Speaks() string { return "woof" } func (d Dog) Jump() string { return "jumps" } func (d Dog) Sit() string { return "sit" } type Cat struct{} func (c Cat) IsTrained() bool { return false } func (c Cat) Speaks() string { return "meow!" } func (c Cat) Jump() string { return "meow!!" } func (c Cat) Sit() string { return "meow!!!" } 

 package circus const ( ActVoice = iota ActSit ActJump ) type Animal interface { Speaker IsTrained() bool Jump() string Sit() string } func (t *Tamer) Command(action int, a Animal) string { switch action { case ActVoice: return a.Speaks() case ActSit: return a.Sit() case ActJump: return a.Jump() } return "" } 

Génial, maintenant nous pouvons donner différentes commandes à nos animaux, et ils les exécuteront. À un degré ou un autre ...: D

 package main import ( "animal" "circus" ) func main() { t := &circus.Tamer{} d := &animal.Dog{} t.Command(circus.ActVoice, d) // "woof" t.Command(circus.ActJump, d) // "jumps" t.Command(circus.ActSit, d) // "sit" t2 := &circus.Tamer{} c := &animal.Cat{} t2.Command(circus.ActVoice, c) // "meow" t2.Command(circus.ActJump, c) // "meow!!" t2.Command(circus.ActSit, c) // "meow!!!" } 

Nos chats domestiques ne se prêtent pas particulièrement à l'entraînement. Par conséquent, nous allons aider le dompteur et nous assurer qu'il ne souffre pas avec eux.

 package circus func (t *Tamer) Command(action int, a Animal) string { if !a.IsTrained() { panic("Sorry but this animal doesn't understand your commands") } // ... } 

C'est mieux. Contrairement à l'interface animale initiale, qui duplique le locuteur , nous avons maintenant l'interface `Animal` (qui est essentiellement un type de données abstrait) qui implémente un comportement assez significatif.

Discutons des tailles d'interface


Réfléchissons maintenant à un problème comme l'utilisation d'interfaces larges.

Il s'agit d'une situation dans laquelle nous utilisons des interfaces avec un grand nombre de méthodes. Dans ce cas, la recommandation ressemble à ceci: «Les fonctions doivent accepter les interfaces contenant les méthodes dont elles ont besoin . »

En général, je conviens que les interfaces doivent être petites, mais dans ce cas, le contexte importe à nouveau. Revenons à notre code et apprenons à notre dompteur à «louer» son animal de compagnie.

En réponse aux louanges, l'animal familier émettra une voix.

 package circus func (t *Tamer) Praise(a Speaker) string { return a.Speaks() } 

Il semblerait que tout va bien, nous utilisons l'interface minimale nécessaire. Il n'y a rien de superflu. Mais là encore le problème. Bon sang, maintenant nous pouvons «louer» l' autre entraîneur et il va «donner une voix» . : D Saisissez-le? .. Le contexte compte toujours.

 package main import ( "animal" "circus" ) func main() { t := &circus.Tamer{} t2 := &circus.Tamer{} d := &animal.Dog{} c := &animal.Cat{} t.Praise(d) // woof t.Praise(c) // meow! t.Praise(t2) // WAT? } 

Pourquoi suis-je? Dans ce cas, la meilleure solution serait encore d'utiliser une interface plus large (représentant le type de données abstrait «animal» ). Puisque nous voulons apprendre à louer un animal de compagnie, pas une créature capable de faire des sons.

 package circus // Now we are using Animal interface here. func (t *Tamer) Praise(a Animal) string { return a.Speaks() } 

Tellement mieux. Nous pouvons faire l'éloge de l'animal, mais nous ne pouvons pas louer le dompteur. Le code est redevenu plus simple et plus évident.

Maintenant un peu sur la loi du lit


Le dernier point que je voudrais aborder est la recommandation selon laquelle nous devrions accepter un type abstrait et renvoyer une structure spécifique. Dans l'article d'origine, cette mention est donnée dans la section décrivant la loi dite de Postel .

L'auteur cite la loi elle-même:.
"Soyez conservateur avec ce que vous faites, soyez libéral avec vous acceptez"

Et l'interprète par rapport à la langue Go
"Go": "Accepter les interfaces, retourner les structures"
func funcName(a INTERFACETYPE) CONCRETETYPE

Vous savez, en général, je suis d'accord, c'est une bonne pratique. Cependant, je tiens à souligner à nouveau. Ne le prenez pas au pied de la lettre. Le diable est dans les détails. Comme toujours, le contexte est important.
Une fonction ne doit pas toujours renvoyer un type spécifique. C'est-à-dire si vous avez besoin d'un type abstrait, renvoyez-le. Pas besoin d'essayer de réécrire le code tout en évitant l'abstraction.

Voici un petit exemple. Un éléphant est apparu dans un cirque «africain» voisin, et vous avez demandé aux propriétaires du cirque de prêter un éléphant à un nouveau spectacle. Pour vous, dans ce cas, il est important que l'éléphant puisse exécuter toutes les mêmes commandes que les autres animaux. La taille d'un éléphant ou la présence d'un tronc dans ce contexte n'a pas d'importance.

 package african import "circus" type Elephant struct{} func (e Elephant) Speaks() string { return "pawoo!" } func (e Elephant) Jump() string { return "o_O" } func (e Elephant) Sit() string { return "sit" } func (e Elephant) IsTrained() bool { return true } func GetElephant() circus.Animal { return &Elephant{} } 

 package main import ( "african" "circus" ) func main() { t := &circus.Tamer{} e := african.GetElephant() t.Command(circus.ActVoice, e) // "pawoo!" t.Command(circus.ActJump, e) // "o_O" t.Command(circus.ActSit, e) // "sit" } 

Comme vous pouvez le voir, puisque les paramètres spécifiques de l'éléphant qui le distinguent des autres animaux de compagnie ne sont pas importants pour nous, nous pouvons très bien utiliser l'abstraction, et le retour de l'interface dans ce cas sera tout à fait approprié.

Pour résumer


Le contexte est crucial lorsqu'il s'agit d'abstractions. Ne négligez pas les abstractions et ayez peur d'eux, tout comme vous ne devriez pas en abuser. Vous ne devez pas considérer les recommandations comme des règles. Il y a des approches qui ont été testées par le temps, il y a des approches qui doivent encore être testées. J'espère avoir pu ouvrir un peu plus en profondeur le sujet de l'utilisation des interfaces comme types de données abstraits et m'éloigner des exemples habituels de la bibliothèque standard.

Bien sûr, pour certaines personnes, ce message peut sembler trop évident, et des exemples sont aspirés du doigt. Pour d'autres, mes pensées peuvent être controversées et les arguments peu convaincants. Néanmoins, quelqu'un peut être inspiré et commencer à réfléchir un peu plus profondément non seulement au code, mais aussi à l'essence des choses, ainsi qu'aux abstractions en général.

L'essentiel, mes amis, c'est que vous développez et recevez constamment un vrai plaisir de travailler. Bon à tous!

PS. Un exemple de code et la version finale peuvent être trouvés sur GitHub .

Source: https://habr.com/ru/post/fr450386/


All Articles