Logique bien structurée : un didacticiel Golang OOP
Publié: 2022-03-11Go est-il orienté objet ? Peut-il être? Go (ou « Golang ») est un langage de programmation post-POO qui emprunte sa structure (packages, types, fonctions) à la famille des langages Algol/Pascal/Modula. Néanmoins, en Go, les modèles orientés objet sont toujours utiles pour structurer un programme de manière claire et compréhensible. Ce didacticiel Golang prendra un exemple simple et montrera comment appliquer les concepts de fonctions de liaison aux types (ou classes), aux constructeurs, au sous-typage, au polymorphisme, à l'injection de dépendances et aux tests avec des simulations.
Étude de cas dans Golang OOP : Lecture du code constructeur à partir d'un numéro d'identification de véhicule (VIN)
Le numéro d'identification de véhicule unique de chaque voiture comprend, en plus d'un numéro "en cours" (c'est-à-dire de série), des informations sur la voiture, telles que le fabricant, l'usine de production, le modèle de voiture et si elle est conduite à gauche ou à droite. du côté de la main droite.
Une fonction pour déterminer le code fabricant pourrait ressembler à ceci :
package vin func Manufacturer(vin string) string { manufacturer := vin[: 3] // if the last digit of the manufacturer ID is a 9 // the digits 12 to 14 are the second part of the ID if manufacturer[2] == '9' { manufacturer += vin[11: 14] } return manufacturer }
Et voici un test qui prouve qu'un exemple de VIN fonctionne :
package vin_test import ( "vin-stages/1" "testing" ) const testVIN = "W09000051T2123456" func TestVIN_Manufacturer(t *testing.T) { manufacturer := vin.Manufacturer(testVIN) if manufacturer != "W09123" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } }
Donc, cette fonction fonctionne correctement lorsqu'elle reçoit la bonne entrée, mais elle a quelques problèmes :
- Il n'y a aucune garantie que la chaîne d'entrée soit un VIN.
- Pour les chaînes de moins de trois caractères, la fonction provoque une
panic
. - La deuxième partie facultative de l'ID est une caractéristique des VIN européens uniquement. La fonction renverrait des identifiants erronés pour les voitures américaines ayant un 9 comme troisième chiffre du code du fabricant.
Pour résoudre ces problèmes, nous allons le refactoriser en utilisant des modèles orientés objet.
Go OOP : lier des fonctions à un type
La première refactorisation consiste à faire des VIN leur propre type et à y lier la fonction Manufacturer()
. Cela rend le but de la fonction plus clair et empêche une utilisation irréfléchie.
package vin type VIN string func (v VIN) Manufacturer() string { manufacturer := v[: 3] if manufacturer[2] == '9' { manufacturer += v[11: 14] } return string(manufacturer) }
Nous adaptons alors le test et introduisons le problème des VIN invalides :
package vin_test import( "vin-stages/2" "testing" ) const ( validVIN = vin.VIN("W0L000051T2123456") invalidVIN = vin.VIN("W0") ) func TestVIN_Manufacturer(t * testing.T) { manufacturer := validVIN.Manufacturer() if manufacturer != "W0L" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, validVIN) } invalidVIN.Manufacturer() // panic! }
La dernière ligne a été insérée pour montrer comment déclencher une panic
lors de l'utilisation de la fonction Manufacturer()
. En dehors d'un test, cela planterait le programme en cours d'exécution.
POO dans Golang : utiliser des constructeurs
Pour éviter la panic
lors de la gestion d'un VIN invalide, il est possible d'ajouter des contrôles de validité à la fonction Manufacturer()
elle-même. Les inconvénients sont que les vérifications seraient effectuées à chaque appel à la fonction Manufacturer()
, et qu'il faudrait introduire une valeur de retour d'erreur, ce qui rendrait impossible l'utilisation directe de la valeur de retour sans variable intermédiaire (par exemple, comme une clé de carte).
Une méthode plus élégante consiste à placer les vérifications de validité dans un constructeur pour le type de VIN
, de sorte que la fonction Manufacturer()
soit appelée uniquement pour les VIN valides et n'ait pas besoin de vérifications ni de gestion des erreurs :
package vin import "fmt" type VIN string // it is debatable if this func should be named New or NewVIN // but NewVIN is better for greping and leaves room for other // NewXY funcs in the same package func NewVIN(code string)(VIN, error) { if len(code) != 17 { return "", fmt.Errorf("invalid VIN %s: more or less than 17 characters", code) } // ... check for disallowed characters ... return VIN(code), nil } func (v VIN) Manufacturer() string { manufacturer := v[: 3] if manufacturer[2] == '9' { manufacturer += v[11: 14] } return string(manufacturer) }
Bien sûr, nous ajoutons un test pour la fonction NewVIN
. Les VIN invalides sont désormais rejetés par le constructeur :
package vin_test import ( "vin-stages/3" "testing" ) const ( validVIN = "W0L000051T2123456" invalidVIN = "W0" ) func TestVIN_New(t *testing.T) { _, err := vin.NewVIN(validVIN) if err != nil { t.Errorf("creating valid VIN returned an error: %s", err.Error()) } _, err = vin.NewVIN(invalidVIN) if err == nil { t.Error("creating invalid VIN did not return an error") } } func TestVIN_Manufacturer(t *testing.T) { testVIN, _ := vin.NewVIN(validVIN) manufacturer := testVIN.Manufacturer() if manufacturer != "W0L" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } }
Le test de la fonction Manufacturer()
peut désormais omettre de tester un VIN invalide puisqu'il aurait déjà été rejeté par le constructeur NewVIN
.
Go OOP Piège : Le polymorphisme dans le mauvais sens
Ensuite, nous voulons différencier les VIN européens et non européens. Une approche consisterait à étendre le type
VIN à une struct
et à stocker si le VIN est européen ou non, en améliorant le constructeur en conséquence :
type VIN struct { code string european bool } func NewVIN(code string, european bool)(*VIN, error) { // ... checks ... return &VIN { code, european }, nil }
La solution la plus élégante consiste à créer un sous-type de VIN
pour les VIN européens. Ici, le drapeau est implicitement stocké dans les informations de type, et la fonction Manufacturer()
pour les VIN non européens devient agréable et concise :
package vin import "fmt" type VIN string func NewVIN(code string)(VIN, error) { if len(code) != 17 { return "", fmt.Errorf("invalid VIN %s: more or less than 17 characters", code) } // ... check for disallowed characters ... return VIN(code), nil } func (v VIN) Manufacturer() string { return string(v[: 3]) } type EUVIN VIN func NewEUVIN(code string)(EUVIN, error) { // call super constructor v, err := NewVIN(code) // and cast to subtype return EUVIN(v), err } func (v EUVIN) Manufacturer() string { // call manufacturer on supertype manufacturer := VIN(v).Manufacturer() // add EU specific postfix if appropriate if manufacturer[2] == '9' { manufacturer += string(v[11: 14]) } return manufacturer }
Dans les langages OOP comme Java, nous nous attendrions à ce que le sous-type EUVIN
soit utilisable partout où le type VIN
est spécifié. Malheureusement, cela ne fonctionne pas dans Golang OOP.
package vin_test import ( "vin-stages/4" "testing" ) const euSmallVIN = "W09000051T2123456" // this works! func TestVIN_EU_SmallManufacturer(t *testing.T) { testVIN, _ := vin.NewEUVIN(euSmallVIN) manufacturer := testVIN.Manufacturer() if manufacturer != "W09123" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } } // this fails with an error func TestVIN_EU_SmallManufacturer_Polymorphism(t *testing.T) { var testVINs[] vin.VIN testVIN, _ := vin.NewEUVIN(euSmallVIN) // having to cast testVIN already hints something is odd testVINs = append(testVINs, vin.VIN(testVIN)) for _, vin := range testVINs { manufacturer := vin.Manufacturer() if manufacturer != "W09123" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } } }
Ce comportement peut s'expliquer par le choix délibéré de l'équipe de développement Go de ne pas prendre en charge la liaison dynamique pour les types non-interface. Il permet au compilateur de savoir quelle fonction sera appelée au moment de la compilation et évite la surcharge de l'envoi de méthode dynamique. Ce choix décourage également l'utilisation de l'héritage comme modèle de composition général. Au lieu de cela, les interfaces sont la voie à suivre (pardonnez le jeu de mots).

Succès de Golang OOP : le polymorphisme dans le bon sens
Le compilateur Go traite un type comme une implémentation d'une interface lorsqu'il implémente les fonctions déclarées (duck typing). Par conséquent, pour utiliser le polymorphisme, le type VIN
est converti en une interface qui est mise en œuvre par un type général et un type VIN européen. Notez qu'il n'est pas nécessaire que le type de VIN européen soit un sous-type du type général.
package vin import "fmt" type VIN interface { Manufacturer() string } type vin string func NewVIN(code string)(vin, error) { if len(code) != 17 { return "", fmt.Errorf("invalid VIN %s: more or less than 17 characters", code) } // ... check for disallowed characters ... return vin(code), nil } func (v vin) Manufacturer() string { return string(v[: 3]) } type vinEU vin func NewEUVIN(code string)(vinEU, error) { // call super constructor v, err := NewVIN(code) // and cast to own type return vinEU(v), err } func (v vinEU) Manufacturer() string { // call manufacturer on supertype manufacturer := vin(v).Manufacturer() // add EU specific postfix if appropriate if manufacturer[2] == '9' { manufacturer += string(v[11: 14]) } return manufacturer }
Le test de polymorphisme passe maintenant avec une légère modification :
// this works! func TestVIN_EU_SmallManufacturer_Polymorphism(t *testing.T) { var testVINs[] vin.VIN testVIN, _ := vin.NewEUVIN(euSmallVIN) // now there is no need to cast! testVINs = append(testVINs, testVIN) for _, vin := range testVINs { manufacturer := vin.Manufacturer() if manufacturer != "W09123" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } } }
En fait, les deux types de VIN peuvent désormais être utilisés partout où l'interface VIN
est spécifiée, car les deux types sont conformes à la définition de l'interface VIN
.
Golang orienté objet : Comment utiliser l'injection de dépendances
Enfin et surtout, nous devons décider si un VIN est européen ou non. Supposons que nous ayons trouvé une API externe qui nous donne ces informations et que nous ayons construit un client pour cela :
package vin type VINAPIClient struct { apiURL string apiKey string // ... internals go here ... } func NewVINAPIClient(apiURL, apiKey string) *VINAPIClient { return &VINAPIClient {apiURL, apiKey} } func (client *VINAPIClient) IsEuropean(code string) bool { // calls external API and returns correct value return true }
Nous avons également construit un service qui gère les VIN et peut notamment les créer :
package vin type VINService struct { client *VINAPIClient } type VINServiceConfig struct { APIURL string APIKey string // more configuration values } func NewVINService(config *VINServiceConfig) *VINService { // use config to create the API client apiClient := NewVINAPIClient(config.APIURL, config.APIKey) return &VINService {apiClient} } func (s *VINService) CreateFromCode(code string)(VIN, error) { if s.client.IsEuropean(code) { return NewEUVIN(code) } return NewVIN(code) }
Cela fonctionne bien comme le montre le test modifié :
func TestVIN_EU_SmallManufacturer(t *testing.T) { service := vin.NewVINService( & vin.VINServiceConfig {}) testVIN, _ := service.CreateFromCode(euSmallVIN) manufacturer := testVIN.Manufacturer() if manufacturer != "W09123" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } }
Le seul problème ici est que le test nécessite une connexion en direct à l'API externe. C'est malheureux, car l'API pourrait être hors ligne ou tout simplement inaccessible. De plus, appeler une API externe prend du temps et peut coûter de l'argent.
Comme le résultat de l'appel API est connu, il devrait être possible de le remplacer par un mock. Malheureusement, dans le code ci-dessus, le VINService
lui-même crée le client API, il n'y a donc pas de moyen facile de le remplacer. Pour rendre cela possible, la dépendance du client API doit être injectée dans le VINService
. Autrement dit, il doit être créé avant d'appeler le constructeur VINService
.
La directive Golang OOP ici est qu'aucun constructeur ne doit appeler un autre constructeur . Si cela est bien appliqué, chaque singleton utilisé dans une application sera créé au niveau le plus élevé. En règle générale, il s'agira d'une fonction d'amorçage qui crée tous les objets nécessaires en appelant leurs constructeurs dans l'ordre approprié, en choisissant une implémentation appropriée pour la fonctionnalité prévue du programme.
La première étape consiste à faire du VINAPIClient
une interface :
package vin type VINAPIClient interface { IsEuropean(code string) bool } type vinAPIClient struct { apiURL string apiKey string // .. internals go here ... } func NewVINAPIClient(apiURL, apiKey string) *VINAPIClient { return &vinAPIClient {apiURL, apiKey} } func (client *VINAPIClient) IsEuropean(code string) bool { // calls external API and returns something more useful return true }
Ensuite, le nouveau client peut être injecté dans le VINService
:
package vin type VINService struct { client VINAPIClient } type VINServiceConfig struct { // more configuration values } func NewVINService(config *VINServiceConfig, apiClient VINAPIClient) *VINService { // apiClient is created elsewhere and injected here return &VINService {apiClient} } func (s *VINService) CreateFromCode(code string)(VIN, error) { if s.client.IsEuropean(code) { return NewEUVIN(code) } return NewVIN(code) }
Avec cela, il est maintenant possible d'utiliser une simulation de client API pour le test. En plus d'éviter les appels à une API externe pendant les tests, la simulation peut également servir de sonde pour collecter des données sur l'utilisation de l'API. Dans l'exemple ci-dessous, nous vérifions simplement si la fonction IsEuropean
est effectivement appelée.
package vin_test import ( "vin-stages/5" "testing" ) const euSmallVIN = "W09000051T2123456" type mockAPIClient struct { apiCalls int } func NewMockAPIClient() *mockAPIClient { return &mockAPIClient {} } func (client *mockAPIClient) IsEuropean(code string) bool { client.apiCalls++ return true } func TestVIN_EU_SmallManufacturer(t *testing.T) { apiClient := NewMockAPIClient() service := vin.NewVINService( & vin.VINServiceConfig {}, apiClient) testVIN, _ := service.CreateFromCode(euSmallVIN) manufacturer := testVIN.Manufacturer() if manufacturer != "W09123" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } if apiClient.apiCalls != 1 { t.Errorf("unexpected number of API calls: %d", apiClient.apiCalls) } }
Ce test réussit, car notre sonde IsEuropean
s'exécute une fois lors de l'appel à CreateFromCode
.
Programmation orientée objet en Go : une combinaison gagnante (lorsqu'elle est bien faite)
Les critiques pourraient dire : "Pourquoi ne pas utiliser Java si vous faites de la POO de toute façon ?" Eh bien, parce que vous bénéficiez de tous les autres avantages astucieux de Go tout en évitant une VM/JIT gourmande en ressources, des frameworks maudits avec un vaudou d'annotation, une gestion des exceptions et des pauses-café pendant l'exécution des tests (ce dernier peut être un problème pour certains).
Avec l'exemple ci-dessus, il est clair que la programmation orientée objet dans Go peut produire un code plus compréhensible et plus rapide par rapport à une implémentation simple et impérative. Bien que Go ne soit pas censé être un langage POO, il fournit les outils nécessaires pour structurer une application de manière orientée objet. Avec les fonctionnalités de regroupement dans des packages, la POO dans Golang peut être exploitée pour fournir des modules réutilisables en tant que blocs de construction pour de grandes applications.
En tant que partenaire Google Cloud, les experts certifiés Google de Toptal sont à la disposition des entreprises à la demande pour leurs projets les plus importants.