Les lois de la réflexion

Cet article est une traduction de l'article The Laws of Reflection par Rob Pike (en anglais sur le site du projet Go).

Introduction

La réflexion, en informatique, est la capacité d'un programme à examiner sa propre structure notamment à travers les types. C'est une forme de métaprogrammation. C'est également une source importante de confusion.

Dans cet article, nous tenterons de clarifier les choses en expliquant comment fonctionne la réflexion dans le langage Go. Le modèle de réflexion de chaque langage est différent (et de nombreux langages ne le supportent pas du tout), mais cet article concerne Go, donc pour le reste de cet article le mot « réflexion » doit être compris comme « réflexion dans le langage Go ».

Types et interfaces

Comme la réflexion s'appuie sur le système de type, nous allons commencer par un rappel sur les types du langage Go.

Go est statiquement typé. Chaque variable a un type statique ; le type est donc connu et fixe au moment de la compilation: int, float32, *MyType, []byte, et ainsi de suite. Si nous déclarons :

type MyInt int

var i int
var j MyInt

Alors i est de type int et j est de type MyInt. Les variables i et j ont des types statiques distincts et, bien qu'elles aient le même type sous-jacent, elles ne peuvent être affectées l'une à l'autre sans une conversion au préalable.

Une catégorie importante de types est les types d'interface, types qui représentent des ensembles fixes de méthodes. Une variable d'interface peut stocker toute valeur concrète (tant qu'il ne s'agit pas d'une interface) à condition que cette valeur implémente les méthodes de l'interface. Deux exemples bien connus sont io.Reader et io.Writer, les types Reader et Writer du paquet io (en anglais) :

// Reader est l'interface qui enveloppe la méthode Read
type Reader interface {
    Read(p []byte) (n int, err error)
}

// Writer est l'interface qui enveloppe la méthode Write
type Writer interface {
    Write(p []byte) (n int, err error)
}

On dit que tout type qui implémente une méthode Read (ou Write) avec cette signature implémenteio.Reader (ou io.Writer). Pour le reste de notre raisonnement, on dira que cela signifie qu'une variable de type io.Reader peut contenir toute valeur dont le type a une méthode Read :

var r io.Reader
r = os.Stdin
r = bufio.NewReader(r)
r = new(bytes.Buffer)
// et ainsi de suite

Il est important d'être clair sur le fait que, quelle que soit la valeur concrète contenue dans la variable r, le type de r est toujours io.Reader: Go est statiquement typé et le type statique de r est io.Reader.

Un exemple extrêmement important d'un type d'interface est l'interface vide :

interface{}

Ce type d'interface représente l'ensemble vide de méthodes et cette interface n'est satisfaite par aucune valeur, car toute valeur a zéro ou plusieurs méthodes.

Certaines personnes disent que les interfaces de Go sont dynamiquement typées. Ce qui est inexact. Les interfaces sont statiquement typées : une variable de type interface a toujours le même type statique, et même si au moment de l'exécution, la valeur stockée dans la variable d'interface peut changer de type, cette valeur satisfera toujours l'interface.

Nous devons être précis sur tout cela parce que la réflexion et les interfaces sont étroitement liées.

La représentation d'une interface

Russ Cox a écrit un article détaillé sur son blog (en anglais) sur la représentation des valeurs d'une interface en Go. Il n'est pas nécessaire de le répéter ici en intégralité, mais voici un résumé simplifié.

Une variable de type interface stocke un doublet : la valeur concrète assignée à la variable, et le descripteur du type de cette valeur. Pour être plus précis, la valeur est la donnée concrète sous-jacente qui implémente l'interface et le type décrit le type complet de cette valeur. Par exemple, à l'issue de l'exécution de cet extrait de code :

var r io.Reader
tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
if err != nil {
    return nil, err
}
r = tty

r contient, schématiquement, le doublet {valeur, type} soit {tty, *os.File}. Notez que le type *os.File implémente des méthodes autres que Read; même si la valeur de l'interface fournit seulement l'accès à la méthode Read, la valeur intrinsèque porte toutes les informations du type de cette valeur. C'est la raison pour laquelle nous pouvons écrire ce genre de code à la suite du précédent :

var w io.Writer
w = r.(io.Writer)

L'expression, dans cette affectation, est une assertion de type. Et ce dont elle s'assure c'est que l'élément contenu dans r implémente également io.Writer, et que l'on peut l'affecter à w. Après cette affectation, w contiendra le doublet {tty, *os.File}. C'est le même doublet qui était contenu dans r. Le type statique de l'interface détermine quelles méthodes peuvent être appelées avec une variable d'interface, bien que la valeur concrète et intrinsèque puisse avoir un plus grand ensemble de méthodes.

Dans la continuité de nos exemples, nous pouvons coder ceci :

var empty interface{}
empty = w

Notre valeur d'interface vide empty contiendra encore le même doublet {tty, *os.File}. Ce qui est pratique : une interface vide peut contenir n'importe quelle valeur et stocke toutes les informations dont nous pourrions avoir besoin à propos de cette valeur.

(Nous n'avons pas besoin de vérifier le type ici, parce que l'on sait que le type statique de w satisfait l'interface vide. Dans l'exemple où nous avions transféré une valeur depuis un Reader vers un Writer, nous avions besoin d'être explicite et d'utiliser une assertion de type parce que les méthodes du type Writer ne sont pas un sous-ensemble des méthodes du type Reader.)

Un détail important est que le doublet, dans une interface, a toujours la forme {valeur, type concret} et qu'il ne peut pas avoir la forme {valeur, type d'interface}. Les interfaces ne stockent pas les valeurs d'interface.

Nous sommes maintenant prêts à utiliser la réflexion.

La première loi de la Réflexion

1. La réflexion part de la valeur d'interface vers l'objet de réflexion.

À un niveau basique, la réflexion est juste un mécanisme permettant d'examiner le doublet {type, valeur} stocké dans une variable d'interface. Pour commencer, nous avons besoin de connaitre deux types Type et Value dans le paquet reflect (NDT: tous ces liens pointent vers la documentation officielle en anglais). Ces deux types donnent accès aux contenus d'une variable d'interface. Deux fonctions simples, nommées reflect.TypeOf et reflect.ValueOf, retournent les parties reflect.Type et reflect.Value depuis une valeur d'interface. De même, il est facile d'obtenir le reflect.Type depuis la reflect.Value, mais gardons séparés les concepts de Value et de Type pour le moment.

Commençons avec TypeOf :

package main

import (
    "fmt"
    "reflect"
)

func main() {
    var x float64 = 3.4
    fmt.Println("type:", reflect.TypeOf(x))
}

Ce programme affiche

type: float64

Vous pourriez vous demander où est l'interface dans cet exemple de code, parce qu'il semble que le programme passe la variable x (de type float64) à la fonction reflect.TypeOf et pas une valeur d'interface. En fait, comme on peut le lire dans la documentation Go (en anglais), la signature reflect.TypeOf comporte une interface vide :

// TypeOf retourne le Type réflexion correspondant à la valeur contenue dans interface{}.
func TypeOf(i interface{}) Type

Lorsque nous appelons reflect.TypeOf(x), x est tout d'abord stocké dans une interface vide, laquelle est ensuite passée en tant que paramètre ; reflect.TypeOf décompose cette interface vide afin d'en retrouver le type.

En toute logique, la fonction reflect.ValueOf retrouve la valeur du paramètre qu'elle reçoit (à partir d'içi, nous ignorerons tous les détails sans importance afin de nous concentrer seulement sur l'exécution du code) :

var x float64 = 3.4
fmt.Println("value:", reflect.ValueOf(x))

Affiche

value: <float64 Value>

À la fois reflect.Type et reflect.Value ont beaucoup de méthodes nous permettant de les examiner et de les manipuler. Un exemple important est que Value a une méthode Type qui retourne le Type d'une reflect.Value. Un autre exemple intéressant est que Type et Value ont une méthode Kind qui retourne une constante indiquant quelle sorte d'élément est stocké : Uint, Float64, Slice, et ainsi de suite. On peut noter également, les méthodes de Value avec des noms tels que Int et Float nous permettent de récupérer les valeurs (en tant que int64 et float64) stockées à l'intérieur :

var x float64 = 3.4
v := reflect.ValueOf(x)
fmt.Println("type:", v.Type())
fmt.Println("kind is float64:", v.Kind() == reflect.Float64)
fmt.Println("value:", v.Float())

Affichera :

type: float64
kind is float64: true
value: 3.4

Il y a aussi des méthodes comme SetInt et SetFloat, mais pour les utiliser, nous devons comprendre la notion d'affectation, notion qui concerne la troisième loi de la réflexion et qui sera abordée plus loin.

La bibliothèque de réflexion a deux caractéristiques méritant d'être mises en exergue. Tout d'abord, afin de maintenir l'API simple, les méthodes « getter » et « setter » de Value fonctionnent sur le plus grand type que peut contenir la valeur : par exemple, int64 pour tous les entiers signés. Autrement dit, la méthode Int de Value renvoie un int64 et la valeur SetInt prend un int64, il peut être nécessaire de convertir le type réellement retourné :

var x uint8 = 'x'
v := reflect.ValueOf(x)
fmt.Println("type:", v.Type())                            // uint8.
fmt.Println("kind is uint8: ", v.Kind() == reflect.Uint8) // true.
x = uint8(v.Uint())                                       // v.Uint retourne un type uint64.

La seconde caractéristique est que le Kind d'un objet de réflexion décrit le type sous-jacent et pas le type statique. Si un objet de réflexion contient une valeur de type entier définit par l'utilisateur, comme dans :

type MyInt int
var x MyInt = 7
v := reflect.ValueOf(x)

Le Kind de v est reflect.Int, même si le type statique de x est MyInt et non int. En d'autres termes, le Kind ne peut pas discriminer un type int d'un MyInt, alors que Type le peut.

La deuxième loi de la réflexion

2. La réflexion va de l'objet de la réflexion à la valeur de l'interface.

Comme la réflexion physique, la réflexion en Go génère une image inverse.

Pour une reflect.Value, nous pouvons récupérer une valeur d'interface à l'aide de la méthode Interface, en effet la méthode associe les informations de type et de valeur qu'elle retourne dans une représentation de l'interface :

// Interface retourne la valeur de v en tant qu' interface{}.
func (v Value) Interface() interface{}

En conséquence, nous pouvons écrire le code suivant :

y := v.Interface().(float64) // y sera de type float64.
fmt.Println(y)

Afin d'afficher la valeur de type float64 représentée par l'objet de reflection v.

Nous pouvons faire encore mieux. Les paramètres de fmt.Println, fmt.Printf et ainsi de suite sont tous passés en tant que valeurs d'interface vides, qui sont ensuite décomposées en interne par le paquet fmt, tout comme nous l'avons fait dans les exemples précédents. Par conséquent tout ce qu'il faut pour imprimer correctement le contenu d'une reflect.Value c'est passer le résultat de la méthode Interface à la fonction d'impression formatée :

fmt.Println(v.Interface())

Pourquoi ne pas utiliser la fonction fmt.Println(v) ? Parce que v est une reflect.Value, alors que nous voulons la valeur concrète qu'elle contient. Comme notre valeur est de type float64, on peut même utiliser un format à virgule flottante si on le souhaite :

fmt.Printf("value is %7.1e\n", v.Interface())

Et nous obtiendrons dans ce cas :

3.4e+00

Encore une fois, il n'y a pas besoin de faire une assertion de type sur le résultat de v.Interface() pour float64, la valeur de l'interface vide contient les informations du type de la valeur concrète qu'elle stocke et Printf les récupérera.

En bref, la méthode Interface est l'inverse de la fonction ValueOf, sauf que son résultat est toujours du type statique interface{}.

C'est une boucle: la réflexion va des valeurs d'interface à des objets de réflexion puis revient.

La troisième loi de la réflexion

3. Pour modifier un objet de réflexion, la valeur doit être modifiable.

La troisième loi est la plus subtile et elle prête à confusion, mais elle est assez facile à comprendre si nous commençons à partir du début.

Voici un extrait de code qui ne fonctionne pas, mais qu'il est intéressant d'étudier.

var x float64 = 3.4
v := reflect.ValueOf(x)
v.SetFloat(7.1) // Erreur: le programme s'arrêtera en paniquant.

Si vous exécutez ce code, il va paniquer avec le message cryptique suivant :

panic: reflect.Value.SetFloat using unaddressable value

Le problème n'est pas que la valeur 7.1 n'est pas adressable, mais que v n'est pas modifiable. Le fait d'être ou non modifiable est une caractéristique d'une Value de réflexion, et toutes les Values de réflexion ne l'ont pas forcément.

La méthode CanSet d'une Value informe du fait qu'une Value soit modifiable ou pas. Dans notre cas, on écrira :

var x float64 = 3.4
v := reflect.ValueOf(x)
fmt.Println("settability of v:", v.CanSet())

Ce code affichera :

settability of v: false

C'est une erreur que d'appeler une méthode Set sur une Value non modifiable. Mais qu'est qui fait qu'une valeur soit modifiable ou pas ?

La possibilité de modification est un peu comme l'adressage, mais plus stricte. C'est la possibilité qu'a un objet de réflexion de modifier l'emplacement de stockage qui a été utilisé pour créer l'objet de réflexion. La possibilité de modification est déterminée par le fait que l'objet de réflexion contient l'élément d'origine. Quand on code :

var x float64 = 3.4
v := reflect.ValueOf(x)

on passe une copie de x à reflect.ValueOf, de sorte que la valeur de l'interface créée comme argument de reflect.ValueOf est une copie de x et pas x elle-même. Ainsi, si l'instruction :

v.SetFloat(7.1)

était autorisée, elle ne mettrait pas à jour x, même si v semble avoir été créé à partir de x. Au lieu de cela, elle mettrait à jour la copie de x stockée à l'intérieur de la valeur de réflexion et x ne serait pas affectée. Ce serait source de confusion et inutile, c'est donc illégal et la possibilité de modification (NDT: settability en anglais) est la caractéristique utilisée pour éviter ce problème.

Si cela semble bizarre, ce n'est pas le cas. Il s'agit en fait d'une situation familière, mais déguisée. Pensez au cas où l'on passe x à une fonction :

f(x)

Nous ne nous attendrions à ce que f soit capable de modifier x, car nous avons passé une copie de la valeur de x et pas x elle-même. Si nous voulons que f modifie x directement, nous devons passer à notre fonction l'adresse de x (un pointeur vers x) :

f(&x)

C'est simple et familier, et la réflexion fonctionne de la même manière. Si nous voulons modifier x par la réflexion, nous devons donner à la bibliothèque de réflexion un pointeur sur la valeur que nous voulons modifier.

Essayons de faire cela. Nous allons d'abord initialiser x comme d'habitude, puis créer une valeur de réflexion qui pointe vers x, appelée p.

var x float64 = 3.4
p := reflect.ValueOf(&x) // Note: utiliser l'adresse de x.
fmt.Println("type of p:", p.Type())
fmt.Println("settability of p:", p.CanSet())

Ce code affichera

type of p: *float64
settability of p: false

L'objet de réflexion p n'est pas modifiable, mais ce n'est pas p que nous voulons modifier, c'est *p. Pour atteindre ce vers quoi p pointe, nous appelons la méthode Elem de Value, qui permet d'atteindre l'élément à modifier via le pointeur, et de sauvegarder le résultat dans une Value de réflexion appelé v :

v := p.Elem()
fmt.Println("settability of v:", v.CanSet())

Maintenant v est un objet de réflexion modifiable, comme le montre l'affichage de notre programme :

settability of v: true

et puisqu'il représente x, nous sommes enfin en mesure d'utiliser v.SetFloat pour modifier la valeur de x :

v.SetFloat(7.1)
fmt.Println(v.Interface())
fmt.Println(x)

L'affichage est – comme attendu :

7.1
7.1

La réflexion peut être difficile à comprendre, mais elle fait exactement ce que le langage fait, bien que les Types et Values de réflexion peuvent dissimuler ce qui se passe. Il suffit de garder à l'esprit que les valeurs de réflexion ont besoin de l'adresse d'un objet afin de modifier ce qu'il représente.

Structures

Dans notre exemple précédent v n'est pas un pointeur en elle-même, elle est simplement dérivée d'un autre pointeur. Cette situation se présente généralement lorsque la réflexion est utilisée pour modifier les champs d'une structure. À partir du moment où nous avons l'adresse de la structure, nous pouvons modifier ses champs.

Voici un exemple simple qui analyse une valeur de structure, t. Nous créons l'objet de réflexion avec l'adresse de la structure parce que nous voulons la modifier plus tard. Ensuite, nous déclarons et initialisons la variable typeOfT avec le type de la structure et nous itérons sur les champs à l'aide d'appels à des méthodes simples (voir la documentation du paquet reflect – en anglais – pour plus de détails). Notez que nous extrayons les noms des champs de type struct, mais les champs eux-mêmes sont des objets ordinaires reflect.Value.

type T struct {
    A int
    B string
}
t := T{23, "skidoo"}
s := reflect.ValueOf(&t).Elem()
typeOfT := s.Type()
for i := 0; i < s.NumField(); i++ {
    f := s.Field(i)
    fmt.Printf("%d: %s %s = %v\n", i,
        typeOfT.Field(i).Name, f.Type(), f.Interface())
}

Ce programme affichera :

0: A int = 23
1: B string = skidoo

Il y a un autre point à propos de la capacité de modification qui est introduit incidemment dans cet exemple : les noms des champs de T sont en majuscules (exportés) parce que seuls les champs exportés d'une structure sont modifiables.

Nous pouvons modifier les champs de la structure, parce que s contient un objet de réflexion modifiable :

s.Field(0).SetInt(77)
s.Field(1).SetString("Sunset Strip")
fmt.Println("t is now", t)

Et voici le résultat :

t is now {77 Sunset Strip}

Si nous avions modifié le programme afin que s soit créé à partir de t et pas &t, les appels à SetInt et SetString échoueraient parce que les champs de t ne seraient pas modifiables.

Conclusion

Voici de nouveau les lois de la réflexion :

  • La réflexion va de la valeur de l'interface vers l'objet de réflexion.
  • La réflexion va de l'objet de la réflexion vers la valeur de l'interface.
  • Pour modifier un objet de réflexion, la valeur doit être modifiable.

Une fois que vous comprenez ces lois, la réflexion en Go devient beaucoup plus facile à utiliser, même elle reste subtile. C'est un outil puissant qui doit être utilisé avec précaution et évité, sauf si strictement nécessaire.

Il y a beaucoup de choses concernant la réflexion qui ne sont pas abordées dans cet article – comme l'envoi et la réception sur les canaux, l'allocation de mémoire, l'utilisation des types slices et map, l'appel de méthodes et de fonctions – mais cet article est déjà assez long. Nous aborderons certains de ces sujets dans un prochain article.


Étiquettes :   golang 
Portrait de Benjamin BALET
Benjamin BALET
Consultant APM

Retrouvez mes cooordonées

Benjamin BALET sur viadeo






Vous aimerez aussi

Gobs le format natif d'échange de données en Go

Traduction d'un article du blog officiel expliquant comment échanger des données entre deux programmes golang grâce à un format natif   Lire »

Comment gérer efficacement les erreurs en golang ?

Préconisations officielles pour la gestion des erreurs dans un programme golang. Cet article complète les explications sur panic, defer et recover   Lire »

Le modèle mémoire du runtime et du langage go

Traduction d'une partie des spécifications officielles du langage go, cet article explique comment go gère la mémoire.   Lire »

Comment écrire du code Go ?

Traduction d'une partie des spécifications officielles du langage Go, cet article explique comment développer en Go.   Lire »

Canaux et go routines avec ou sans état

Exemples d'utilisation du type chan et des go routines stateful et stateless   Lire »

Commentaires

Soyez le premier à commenter cet article

Publier un commentaires

Tous les commentaires sont soumis à modération. Les balises HTML sont pour la plupart autorisées. Les liens contextuels et intéressants sont en follow.

(requis)
(requis)
(requis, mais non publié)
(recommandé si vous souhaitez être publié)