Skip to content

Définition

Le type any permet de stocker n’importe quel type de valeur. C’est l’équivalent d’une interface vide interface{}.

Lorsqu’une valeur prends un type any, son type réel ne disparaît pas, il est caché dans l’interface. L’utilisation de &T permet de le voir.

Un type any ne peut pas être utilisé pour faire des opérations, il sera nécessaire de donner un type à la variable any.

Raison

Modele mental

func main() {
	var x any // variable qui accepte n'importe quel type de donnée

    // la variable x peut changer de type 
	x = 42
	fmt.Printf("value=%v type=%T\n", x, x) // value=42 type=int

	x = "go"
	fmt.Printf("value=%v type=%T\n", x, x) // value=go type=string
}

Quand l’utiliser ?

  • stocker un ensemble de données différentes
  • reception de valeur de type inconnu

Quand l’éviter ?

Pièges

  • Réduction de la lisibilité
  • Contrat caché
  • Propagation

Bonne pratique

Pattern

Journalisation et diagnostic

func debug(name string, v any) {
    fmt.Println("[debug] %s=%v (type=%T)\n", name, v, v)
}

func main(){
    debug("id", 7) // [debug] id=7 (type=int)
}

Conteneur universel

func main() {
// creaton d'un map qui prend en valeur any 
// ctx commence a vivre dans la logique metier, et c'est le debut des problemes
	ctx := map[string]any{
		"op":   "create_task",
		"id":   10,
		"done": false,
	}
	fmt.Println(ctx["op"], ctx["id"], ctx["done"]) // create_task 10 false
}

25 - 1: type assetion v, ok := x.(T)

Permet de tester que la valeur réel de l’instace est du type recherche. Permet de cette maniére d’appliquer des comportements spécifique selon le type.

Une type assertion permet de récupérer la valeur concrète contenue dans une interface, et de vérifier qu’elle est bien du type attendu.

v, ok := x.(T)

Essaie d’extraire du type d’interface x une valeur de type T. Si cela réussit, met la dans v et fixe ok a true, sinon ok = false

Généralement utiliser sur:

  • any == interface{}
  • error
  • une interface (io.Reader, fmt.Stringer)

T peut être:

  • un type concret (int, string, Task)
  • une interface (fmt.Stringer, error, une interface)

On vient vérifier la presence d’un comportement ou d’un ensemble de méthodes précise dans la valeur contenu dans x

Pattern d’utilisation

Porme sur

func printAsInt(x any) {
    // type assrtion -> on vérifie sur l'interface est de type int  
	n, ok := x.(int)
	if !ok {
		fmt.Printf("not an int: type=%T\n", x) // not an int: type=string
		return
	}
	fmt.Println("int:", n) // int: 42
}

func main() {
	printAsInt("42")
	printAsInt(42)
}

Forme non sur

On utilise pas le ok, ce qui empeche de vérifier si le type correspond. Dans ce cas, si le type ne correspond pas dans l’interface, on obtient une panic.

On l’utilise dans le cas où l’on souhaite vraiment que le programme s’arrête si le type ne correspond pas, car c’est une violation du contrat, et continuer n’a pas de sens.

func main() {
	var x any = "go"
	_ = x.(int) // panic: interface conversion: interface {} is string, not int
	fmt.Println("unreachable")
}

Cas pratique

Rendu du résultat sans type switch

Parfois, on veux qu’une commande renvoie une seule tâche, parfois une liste de tâches, parfois uniquement la chaîne “done”. On veux une seule fonction renderResult qui accepte n’importe quoi et l’affiche.

Création du modéle de donnée:

type Task struct {
	ID    int
	Title string
	Done  bool
}

La fonction renderResult():

func renderResult(x any) {

    //controle si la valeur est de type string 
    if s, ok := x.(string); ok {
		fmt.Println(s)
		return
	}

    //controle si de valeur task 
    if t, ok := x.(Task); ok {
		fmt.Printf("#%d %s (done=%v)\n", t.ID, t.Title, t.Done)
		return
	}

	fmt.Printf("unknown result: type=%T value=%v\n", x, x)
}

Utilisation:

func main() {
	renderResult("ok")                     // ok
	renderResult(Task{ID: 1, Title: "Go"}) // #1 Go (done=false)
	renderResult(123)                      // unknown result: type=int value=123
}

Assertion sur une interface: vérfiier le comportement

Le type assertion peut se faire sur une interface. Elle permet de vérifier un comportement.

La lib fmt connaît l’interface:

type Stringer interface {
	String() string
}

Si une valeur implémente String() string, alors fmt peut l’afficher. Si le résultat d’une commande sait se décrirem, on peut l’afficher avec String().

// Task implémente la fonction String()
func (t Task) String() string {
	status := " "
	if t.Done {
		status = "x"
	}
	return fmt.Sprintf("[%s] #%d %s", status, t.ID, t.Title)
}

Désormais renderResult() peut d’abord vérifier si on peut utiliser avec fmt.Stringer:

func renderResult(x any) {
    // implemente la methode, on check si c'est du type fmt.Stringer pour utiliser permettre l'utilisation de String() sur la valeur 
	if s, ok := x.(fmt.Stringer); ok {
		fmt.Println(s.String())
		return
	}
	fmt.Printf("fallback: %T %v\n", x, x)
}

func main() {
	renderResult(Task{ID: 2, Title: "Write code"}) // [ ] #2 Write code
}

Assertion sur interface error: reconnaître une erreur spécial

Les erreurs sont la raison la plus fréquente d’utiliser le type assertion. Dans la lib standard, on rencontre une approche: une erreur peut implémenter des méthodes supplémentaire, et alors on peut la traiter de maniére plus intelligente.

On as une fonction qui renvoie une error et parfois cette erreur avec un attribut supplementaire.

On créer l’interface erreur temporaire:

type temporary interface {
	error
	Temporary() bool
}

On viens créer le type d’erreur:

type TempError struct{ msg string }

func (e TempError) Error() string   { return e.msg }
func (e TempError) Temporary() bool { return true }

On implémente le traitement:

func handle(err error) {
	if err == nil {
		return
	}
    
    // si contient le type erreur temporaire, on ajoute le message
	if t, ok := err.(temporary); ok && t.Temporary() {
		fmt.Println("temporary problem, try again") // temporary problem, try again
		return
	}

	fmt.Println("fatal:", err.Error())
}

Ici, on retrouve une philosophie Go: au lieu d’une grosse hiérachie d’exceptions, on as de petites interfaces que l’on peut vérifier de manière ciblée via le type assertion.

Nuances: T vs *T, nil et porée

T & *T

Le type assertion reconnait une différence le type concret qui se trouve dans ‘interface’. Il fait une distinction entre T et *T.

Bonne pratique: si on conçoit une API, il faut rester cohérent: soit on stocke que des valeurs, soit des pointeurs. Si on travail avec une API tierce, il faut reprendre la façon de faire.

type User struct{ Name string }

func main() {
	u := User{Name: "Ann"} // T 
	p := &User{Name: "Bob"} // *T 

	var x any = u // on passe T
	_, ok1 := x.(User)
	_, ok2 := x.(*User)
	fmt.Println(ok1, ok2) // true false

	x = p // on passe *T
	_, ok3 := x.(User)
	_, ok4 := x.(*User)
	fmt.Println(ok3, ok4) // false true
}

Nuance nil

Si le type existe mais que la valeur est nil, l’interface elle même n’est pas nil.

func main() {
	var p *int = nil
	var x any = p

	q, ok := x.(*int)
	fmt.Println(ok)       // true, x contient bien un point *int 
	fmt.Println(q == nil) // true, la valeur du pointeur est nil 
}

L’assertion vers *int fonctionne, parce que le type a l’interieur est *int, même si la valeur est nil.

ok == true garantit que le type correspond.

Pattern bloc étroit: assertion dans un if, pour ne pas encombrer la portée

On peut utilser v, ok := ... directement dans le if, afin que v ne vive que la il est nécessaire.

func printID(x any) {
    // existe que dans la portée de if 
	if t, ok := x.(Task); ok {
		fmt.Println(t.ID) // print the task ID
		return
	}
	fmt.Println("no task here")
}

25-2: Type switch: choix de la branche

Le type switch permet de gérer proprement lorsque la valeur arrive sous forme d’interface, et qu’il faut choisir la logique selon le type concret.

Le type switch est appropier pour ces cas :

  • error et on souhaite savoir s’il s’agit d’un type d’erreur précis
  • construction d’un dispatcher de commande/event
  • commande différentes représenter par des structures différentes mais stockée derrière une interface

Go considère le type assertion et le type switch comme l’un des moyens de voir le type concret d’une erreur.

Switch ordinaire et type switch

Un switch ordinaire choisit une branche selon la valeur d’une expression.

Le type switch choisit une branche selon le type concret de la valeur d’une interface.

Lorsque l’on ecrit un switch ordinaire, le compilaire connait le type de l’exporession. Lorsque l’on écrit un type switch, le compilateur sait que c’est une interface, et on lui demande de vérifier le type concret de l’interface et de fournir une varaible de ce type.

L’écriture x.(type) ne peut pas être utilisée comme expression seule. Elle n’existe que dans la construction du type switch.

switch v := x.(type) {
case int:
    // v has type int
case string:
    // v has type string
default:
    // v is still available as x (the interface), and v does not exist
}
  • x: doit avoir un type d’interface
  • v: dans chaque branche obtenit le type concret de cette branche

On veut une fonction de debogage qui affiche une description de la valeur:

func describe(x any) {
    // switch type qui teste le type any de la valeur fournis 
	switch v := x.(type) {
    // le type est int 
	case int:
		fmt.Println("int:", v) // int: 7
    // le type est string 
	case string:
		fmt.Println("string:", v) // string: go
    // autre type 
	default:
		fmt.Printf("unknown: %T\n", x) // unknown: bool
	}
}

Dispatcher de commandes

Le parseur de commande renvoie any car la commande peut avoir une forme différente. Par exemple AddCmd(), DoneCmd(), ou ListCmd(). Le dispatcher fait une branche selon le type.

On viens définir les types de commande:

type AddCmd struct{ Title string }
type DoneCmd struct{ ID int }
type ListCmd struct{}

On créer ensuite un exécutant de commande. Dans un vrai projet, on réalise une interface avec une méthode Run(). Ici, l’objectif est de monter le mécanisme de dispatch

func runCommand(cmd any) error {
    // on viens vérifier le type de la commande fournis et exécuter une logique celon celui-ci 
	switch c := cmd.(type) {
	case AddCmd:
		fmt.Println("ADD:", c.Title) // ADD: Buy milk
	case DoneCmd:
		fmt.Println("DONE:", c.ID) // DONE: 3
	case ListCmd:
		fmt.Println("LIST") // LIST
	default:
		return fmt.Errorf("unknown command type %T", cmd)
	}
	return nil
}

On peut ensuite l’utiliser de cette maniere dans main():

func main() {
	_ = runCommand(AddCmd{Title: "Buy milk"}) // ADD: Buy milk
	_ = runCommand(DoneCmd{ID: 3})            // DONE: 3
	_ = runCommand(ListCmd{})                 // LIST
	fmt.Println("ok")                         // ok
}

Plusieurs types dans un case

Si plusieurs types sont énumérés dans un case, la variable v ne reçoit par un type unique précis. Le compilateur choisit une variante commune pour cette branche. En pratique, v aura le type de l’expression source du type switch.

func printNumber(x any) {
	switch v := x.(type) {
	case int, int64:
		fmt.Printf("number: %v\n", v) // number: 10
	default:
		fmt.Printf("not a number: %T\n", x)
	}
}

Branche d’interface

On peut venir faire une branche sur une interface. Cela permet de tester que l’interface possede les methodes attendu.

type Task struct{ Title string }

func (t Task) String() string { return "Task: " + t.Title }

func show(x any) {
	switch v := x.(type) {
    // verifie si le type de la valeur implemente les methode de l'interface => String() pour ce test 
	case fmt.Stringer:
		fmt.Println(v.String()) // Task: Read Go book
	default:
		fmt.Printf("%v\n", x)
	}
}

case nil & nil type

case nil ne catch que les interface nil. Si dans l’interface, on retrouve un nil type, cela ne match pas.

type Task struct{ Title string }

func main() {
	var t *Task = nil
	var x any = t

	switch v := x.(type) {
	case nil:
		fmt.Println("nil interface")
	case *Task:
		fmt.Println("typed nil pointer:", v == nil) // typed nil pointer: true
	}
}

Ordre des case: precis vers generique

Lorsque l’on utilise un type switch avec des type concrets et des interfaces, l’ordre des branches deviens important. On commence par tester l’interface, qui est plus precise, puis en remonte en testant les type concrets.

type Task struct{ Title string }

func (t Task) String() string { return "Task(" + t.Title + ")" }

func main() {
	var x any = Task{Title: "Learn type switch"}

	switch v := x.(type) {
	case fmt.Stringer:
		fmt.Println("stringer:", v.String()) // stringer: Task(Learn type switch)
	case Task:
		fmt.Println("task:", v.Title)
	}
}

25-3: Typed errors: structure d’erreur, Error() et Unwrap()

Le typed error permet de garder l’erreur comme valeur mais devient une valeur structuree. Elle permet de stocker le contexte dans des champs

Un typed error est une structure ordinaire qui possède une methode Error() string.

Par exemple, un user saisit un ID de tache sous forme de string et on souhaite valider cette entree.

type InvalidIDError struct {
	Input string // ce que l’utilisateur a saisi
}

// impléementation de la methode pour convenir a l'interface Error
func (e InvalidIDError) Error() string {
	return fmt.Sprintf("invalid id %q", e.Input)
}

On obtient un champ Input accessible au code permettant ensuite de dèfinir des message d’erreur plus précis. On peut ensuite l’utiliser comme une erreur normal.

func ParseID(s string) (int, error) {
	id, err := strconv.Atoi(s)
	if err != nil {
    // on utilise la typedError 
		return 0, InvalidIDError{Input: s}
	}
	return id, nil
}

Bonne pratique

Une Error() doit rester compacte, agreable a lire dans la console. Le reste est plus interessant a conserver dans des champs. Elle ne promet pas de format stable pour une analyse machine.

En Go, la responsabilité d’une erreur est de sommer le contexte afin que le message soit utile. Les types errors pernet de definir le contexte avec Error(), mais on conserve les details dans les champs

Exemple d’une table introuvable:

type TaskNotFoundError struct {
   ID int
}

func (e TaskNotFoundError) Error() string {
   return fmt.Sprintf("task %d not found", e.ID)
}

La valeur ID est disponible dans séparément dans le champ de la struct.

Typed errors pour la validation et les regles metiers

Dans le gestionnaire de taches, les erreurs appartiennent a trois classes diferentes au niveau du sens:

  • entree invalide => validation
  • tache introuvable => not found
  • imprevue => internal I/O, etc

On vient définir trois types

type ValidationError struct {
	Field string
	Msg   string
}

func (e ValidationError) Error() string {
	return "invalid " + e.Field + ": " + e.Msg
}

type TaskNotFoundError struct {
	ID int
}

func (e TaskNotFoundError) Error() string {
	return fmt.Sprintf("task %d not found", e.ID)
}

type OpError struct {
	Op  string
	Err error
}

func (e *OpError) Error() string {
	return e.Op + ": " + e.Err.Error()
}

Value receiver et pointer receiver dans Error()

On peut définir Error() comme méthode de valeur (fun(e T) Error() string) ou comme méthode de pointeur (fun(e *T) Error() string). Les deux variants sont possible et le comportement est différents.

Si Error() est définit sur *T, alors seul *T implémente l’interface error.

type MyError struct{}

func (e *MyError) Error() string { return "boom" }

func main() {
	// var err error = MyError{} // ne compilera pas
	var err error = &MyError{} // ok
	fmt.Println(err)           // boom
}

La règle pratique: si l’erreur est petite, immuable et ne contient pas de cause interne, il est commode de définir Error() avec une value receiver et de retourner la valeur. Si l’erreur contient un Err error imbriqué, ou si on souhaite éviter une copie, on utilise un pointer receiver et on retourne un pointeur

Wrappers d’erreur

Lorsque les erreurs remontent dans la pile, on souhaite conserver le contexte. On utilise alors %w qui fournit le wrapping. Il est parfois plus pratique d’avoir son propre wrapper ave des champs (Op, ID, Filename).

En Go, la convention est établie: si une erreur contient une autre erreur et que l’on souhaite que le code externe puisse atteindre la cause, on implémente la mùthode Unwrap() error qui retourne l’erreur imbriquée,

type OpError struct {
	Op  string
	Err error
}

func (e *OpError) Error() string { return e.Op + ": " + e.Err.Error() }
func (e *OpError) Unwrap() error { return e.Err }

On obtient alors une chaine d’erreurs, la couche externe indique quelle operation, la couche interne la raison de l’erreur.

On implémente une interface de stockage:

type Storage interface {
	MarkDone(id int) error
}

On ajoute la fonction permettant de passer une task comnme Done:

func MarkDone(s Storage, rawID string) error {
	id, err := ParseID(rawID)
	if err != nil {
		return &OpError{Op: "parse id", Err: err}
	}

	if err := s.MarkDone(id); err != nil {
		return &OpError{Op: "mark done", Err: err}
	}
	return nil
}

Chaque Wrapper ajoute un sens et la cause est préservé

Le stokcage peut renvoyer une erreur metier si la tache existe pas

type InMemoryStorage struct {
	done map[int]bool
}

func (s *InMemoryStorage) MarkDone(id int) error {
	if _, ok := s.done[id]; !ok {
		return TaskNotFoundError{ID: id}
	}
	s.done[id] = true
	return nil
}

25-4: Extraction du contexte via errors.As

errors.As() permet de trouver dans la chaîne d’erreurs, celle que l’on peut affecter à une variable d’un type donné. Si trouvée, alors elle est placer dans la variable pour que l’on puisse lire ses champs.

On vient passer err et &.target pour passer l’adresse de la variable qui vient récupérer la valeur.

type ValidationError struct {
	Field string
	Msg   string
}

func (e ValidationError) Error() string {
	return "invalid " + e.Field + ": " + e.Msg
}

func main() {
	err := fmt.Errorf("create task: %w", ValidationError{Field: "title", Msg: "empty"})
    
    // prépare la varaible de stockage 
	var ve ValidationError
    // vérification du type de l'erreur 
	if errors.As(err, &ve) {
		fmt.Println(ve.Field) // title
	}
}

Ici &ve signifie: prends l’adresse de la variable ve pour que errors.As puisse y écrire l’erreur trouvée.

Exemple

// déclaration du modèle de données
type Task struct {
	ID    int
	Title string
	Done  bool
}

// déclaration du stockage 
var tasks []Task
var nextID = 1

// déclaration du typed error
type ValidationError struct {
	Field string
	Msg   string
}

func (e ValidationError) Error() string {
	return fmt.Sprintf("invalid %s: %s", e.Field, e.Msg)
}

// wrapper d'erreur qui permet d'ajouter le nom de l'operation
type OpError struct {
	Op  string
	Err error
}

func (e *OpError) Error() string { return e.Op + ": " + e.Err.Error() }
func (e *OpError) Unwrap() error { return e.Err }

// couche présentation => la struct permet de représenter ce qui compte pour l'UX 
// elle se convertit en JSON ou texte
type UserError struct {
	Code    string            // "validation", "not_found", "internal"
	Message string            // court pour l'utilisateur
	Fields  map[string]string // details de validation (s'il y en a)
}

// AddTask: valide les données et retourne l'erreur 
func AddTask(title string) (Task, error) {
    // validation 
	if title == "" {
        // on utilise l'erreur typer 
        base := ValidationError{Field: "title", Msg: "must not be empty"}
        // on ajoute le contexte via le wrapper 
		return Task{}, &OpError{Op: "AddTask", Err: base}
	}

    // création de la nouvelle task et ajoute dans le slice 
	t := Task{ID: nextID, Title: title}
	nextID++
	tasks = append(tasks, t)

	fmt.Println("created task:", t.ID) // created task: 1
    // si echec d'ajoute, on obtient cette erreur: AddTask: invalid title: must not be empty 
	return t, nil
}

// permet de gérer un affichage propre des erreurs pour l'utilisateur 
// selon le type de l'erreur, on affiche un message différents
func ToUserError(err error) UserError {
	var ve ValidationError
	if errors.As(err, &ve) {
		return UserError{
			Code:    "validation",
			Message: "Veuillez verifier les donnees saisies",
			Fields:  map[string]string{ve.Field: ve.Msg},
		}
	}

    	var nf *NotFoundError
	if errors.As(err, &nf) {
		return UserError{
			Code:    "not_found",
			Message: "La tache est introuvable",
		}
	}

    // pour une erreur inconnu, on affiche pas la raison technique => cache les détails interne
	return UserError{
		Code:    "internal",
		Message: "Impossible d'executer l'operation",
	}
}

// la fonction permet d'obtenir un affichage adapté selon la cible: dev ou utilisateur 
func handleErrorAtBoundary(err error) {
    // appelle de la fonction de formatage d'erreur 
	ux := ToUserError(err)
    
    // ajoute le prefix adapté
    // on envoi un message pour user et un message pour le diag 
	fmt.Println("USER:", ux.Message)   // USER: Veuillez verifier les donnees saisies => envoyer dans la stderr
	fmt.Println("DEBUG:", err.Error()) // DEBUG: AddTask: invalid title: must not be empty => envoyer dans les logs 
}

// RECUPERATION DE TACHE 
// création de la typed error 
type NotFoundError struct {
	ID int
}

// formatage du message d'erreur 
func (e *NotFoundError) Error() string {
	return fmt.Sprintf("task %d not found", e.ID)
}

// fonction recherche
func FindTask(id int) (Task, error) {
	for _, t := range tasks {
		if t.ID == id {
			return t, nil
		}
	}
	return Task{}, &NotFoundError{ID: id}
}

// assemblage 
func main() {
	var title string
	fmt.Scan(&title)

	_, err := AddTask(title)
	if err != nil {
		handleErrorAtBoundary(err)
		return
	}

	fmt.Println("OK") // OK
}