Introduction au langage Go pour les devs Python
Au programme
- Comparaisons entre Go et Python
- Les bases de Go
- Programmation asynchrone en Go
- Serveurs web
- Conclusion
Comparaisons entre Python et Go
| Caractéristique | Python 🐍 | Go 🐹 |
|---|---|---|
| Exécution | Interprété | Compilé |
| Portable | Oui | Oui* |
| Orienté Objet | Oui | Non |
| Typage | Dynamique | Statique |
| Erreurs | Exception | Valeurs |
| Mémoire | Garbage collector | Garbage collector |
Distribution
- Python 🐍 : Fichiers python + dépendances (requirements.txt, pyproject.toml)
- Go 🐹 : Compile vers un binaire statique
Image Docker
FROM python:3.14
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY src .
CMD src/main.py
FROM golang:1.25 as builder
COPY go.mod go.sum .
RUN go build -o dist/my-binary .
FROM scratch as runner
RUN mkdir -p /app
COPY --from=builder dist/my-binary /app
RUN chmod +x /app/my-binary
ENTRYPOINT /app/my-binary
Cas d'utilisations courants
- Python 🐍 : Serveurs web, Data Science, Automatisation, Scripting
- Go 🐹 : Serveurs, Tooling, DevOps, Cloud
Runtime
- Python 🐍 : Binaire python qui gère l'exécution ainsi que les allocations et le garbage collector
- Go 🐹 : Runtime inclus à la compilation dans le binaire. Il gère les goroutines ainsi que le garbage collector
Les bases de Go
En Go on organise le code en dossiers, qu'on appelle des packages. Tout programme en Go commence par un package main.
// supermodule/main.go
package main
import "fmt"
func main() {
fmt.Println("Coucou")
}
Ces packages peuvent être organisés en modules matérialisés par un fichier go.mod. C'est donc l'inverse de ce qu'on retrouve en Python.
# Initialisation d'un module Go
$ go mod init "gitlab.com/Dhawos/supermodule"
Si le module est public, n'importe qui peut le télécharger en dépendance de son projet.
$ go get "gitlab.com/Dhawos/supermodule"
Chaque package peut exporter ses symboles. Tous les symboles commençant par une majuscule sont exportés, les autres ne seront pas visibles à l'extérieur. Contrairement à Python, cette règle est appliquée par le compilateur.
// Pas exporté
func echo(s string) {
fmt.Println(s)
}
// Exporté
func Echo(s string) {
fmt.Println(s)
}
En parlant de nommage, voici quelques conventions de nommage en Go :
- Packages: Un seul mot simple en minuscule
- Structs, Variables, Fonctions, etc.. : CamelCase
Définir des variables et constantes
const a int = 42 // valeur explicite constante
var b int = 42 // valeur explicite variable
c := 42 // le plus utilisé, déclaration + assignation
Types de base
- bool
- int,uint
- float
- complex
- string
- rune
Zero values
Chaque type a une "zero value" qui est la valeur assignée à toute variable qui ne serait pas initialisée explicitement.
- bool = false
- int, uint = 0
- float = 0
- complex = 0
- string = ""
- rune = 0
Les pointeurs
En Go, les pointeurs existent mais ils sont beaucoup plus simples à utiliser qu'en C/C++. Ils permettent de prendre une valeur par "référence" sans avoir à la recopier.
⚠️ Tenter d'utiliser un pointeur nil = panic
Zero Value : nil
Les slices
C'est l'équivalent de la liste en Python.
Sous le capot, c'est un array de taille fixe mais qui peut être réalloué si besoin pour augmenter sa capacité.
Contrairement aux listes en python, un slice en Go ne peut contenir qu'un type d'éléments.
mySlice := make([]int, 0, 12) // type, taille, capacité
mySlice = append(mySlice, 42)
Zero Value : nil
Les maps
C'est l'équivalent du "dict" en Python.
myMap := make(map[string]int, 12) // type clé, type valeur, capacité
Zero Value : nil
Fonctions
Pour définir des fonctions en Go, on utilise le mot clé func.
// prend deux entiers en entrées et un en sortie
func sum(a int, b int) int {
return a + b
}
// une fonction peut retourner plusieurs valeurs
func div(a int, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("cannot divide by 0")
} else {
return a / b, nil
}
Fonctions en tant que valeur
En Go, comme en Python, les fonctions peuvent être utilisées en tant que valeurs.
var myFunc := func(a int) int {return a}
Flux d'exécution habituels
Ici on retrouve ce qu'on a l'habitude d'avoir en Python :
- if
- for (pas de while en Go)
- switch
// L'opérateur "range" pour les boucles for
// ressemble très fort au enumerate en Python
mySlice := []int{42, 12, 40, 2}
for i, value := range mySlice {
fmt.Println(i, value)
}
Flux d'exécution moins habituels
Mais on a aussi quelques primitives pas si habituelles :
- defer: exécuter des commandes en fin de scope
- go: lancer une goroutine
- select: lire des channels
func main() {
defer func() { fmt.Println("deferred call 1") }()
defer func() { fmt.Println("deferred call 2") }()
fmt.Println("normal call")
}
Les Structs
En Go comme en Python, on peut définir ses propres types. En Python on va définir des classes tandis qu'on va parler de structs en Go. Les concepts sont similaires mais ils comportent certaines différences :
- Pas d'héritage
- Pas de fonctions "spéciales" comme __init__ ou de surcharge d'opérateur
- La définition des "méthodes" ne fait pas partie de la définition de la struct
Exemple de struct
type Person struct {
Name string
Surname string
Age int
}
⚠️ Les champs qui commencent avec une minuscule ne seront pas exportés et donc ne seront pas modifiables par un autre package.
Définir des méthodes
En Python, on définit les méthodes d'une classe à l'intérieur de celle-ci. Typiquement avec un argument : self.
En Go, on définit les méthodes où on veut au sein du même package et on définit un receiver.
// p est notre receiver
func (p Person) Greet() {
fmt.Printf("Hello %s %s", p.Name, p.Surname)
}
Autre exemple
func (p Person) AgeUp() {
p.Age = p.Age + 1
}
func main() {
person := Person{
Age: 31,
}
person.AgeUp()
fmt.Println(person.Age)
}
À votre avis quelle sera la sortie de ce programme ?
- 31
- 32
Valeur ou pointeur ?
func (p Person) Greet() {
fmt.Printf("Hello %s %s", p.Name, p.Surname)
}
et :
func (p *Person) Greet() {
fmt.Printf("Hello %s %s", p.Name, p.Surname)
}
Quelques règles pour choisir
Valeur (copie):
- Petite struct
- Read-only
Pointeur (référence):
- Grosse struct
- Read-write
- ✅ Dans le doute : Pointeur
- ✅ On garde le même fonctionnement partout
Interfaces
En Go, on peut définir des interfaces qui sont des types abstraits respectant un certain contrat.
Ce concept est assez différent en Go de ce qui peut exister en Python ou même en Java à cause de ces propriétés :
- Les interfaces définissent des "comportements" et pas des attributs
- Une struct doit implémenter toutes les méthodes d'une interface pour remplir le contrat
- ‼️ Les interfaces sont implémentées implicitement
Exemple d'interfaces et d'implémentation
type Vehicle interface {
Move(fuel int)
Stop()
}
type Car struct{}
func (c Car) Move(fuel int) {
fmt.Println("moving car")
}
func (c Car) Stop() {
fmt.Println("Stopping")
}
Interfaces implicites
Le fait que les interfaces soient implémentées de manière implicite en Go permet une grande flexibilité.
Il est par exemple possible et même recommandé dans de nombreux cas de définir les interfaces au niveau du consommateur plutôt que du producteur.
Proverbe de Gopher
The bigger the interface, the weaker the abstraction.
La gestion des erreurs
⛔ Pas d'exceptions en Go
✅ Les erreurs sont traitées comme des valeurs
N'importe quel type peut être utilisé comme erreur. Le seul pré-requis est que le type implémente l'interface :
type error interface {
Error() string
}
La ligne de code la plus présente dans l'écosystème Go
if err != nil
Fonction typique en Go
// Pseudo-code
func myFunction() (string,error) {
client, err := example.NewClient()
if err != nil {
return "", fmt.Errorf("creating client: %w",err)
}
result1, err := example.DoRequest1()
if err != nil {
return "", fmt.Errorf("making request: %w",err)
}
var response myStruct
err := json.Unmarshal(result1, &response)
if err != nil {
return "", fmt.Errorf("unmarshaling response: %w",err)
}
return response.Field, nil
}
Traiter des erreurs spécifiques
Si je veux retourner une erreur que la fonction appelante pourra facilement identifier, le mieux est de définir une "sentinel value".
package main
import (
"errors"
"fmt"
)
var ErrDataInvalid = errors.New("data invalid")
func getData() (string, error) {
response, err := example.MakeRequest()
if err != nil {
return "", err
}
if !isValid(response) {
return "", ErrDataInvalid
}
return response
}
func main() {
data, err := getData()
if err != nil {
if err == ErrDataInvalid {
fmt.Println("invalid data")
return
} else {
panic(err)
}
}
fmt.Println(data)
}
Types d'erreurs custom
Si on veut pouvoir faire ses propres types d'erreurs, il suffit d'implémenter l'interface Error mentionnée précédemment.
type APIError struct {
msg string
errorCode int
}
func (e APIError) Error() string {
return fmt.Sprintf("api error %s (%d)", e.msg, e.errorCode)
}
Chaînage d'erreur
Puisqu'on n'a pas d'exceptions, il n'y a pas non plus de stack traces en Go.*
Mais on peut "wrapper" les erreurs pour avoir le maximum de contexte.
err := bad()
if err != nil {
return fmt.Errorf("processing in inner: %w", err)
}
* Techniquement si, mais uniquement dans le cas d'un panic
Retrouver une erreur chaînée par valeur
Chaîner les erreurs permet de rajouter du contexte, MAIS casse la comparaison pour trouver.
Heureusement on peut retrouver une erreur enfouie dans une chaîne :
err = outerSpecificError()
if err != nil {
if errors.Is(err, ErrInvalidData) {
fmt.Println("handled invalid data error gracefully")
} else {
fmt.Printf("unknown error: %s\n", err)
}
}
Playground
Retrouver une erreur chainée par type
On peut également le faire avec les types d'erreurs custom.
err = outerSpecificError()
if err != nil {
if errors.As(err, &specificError) {
fmt.Println("handled invalid data error gracefully")
} else {
fmt.Printf("unknown error: %s\n", err)
}
}
Playground
Composition plutôt qu'héritage
Le Go n'est pas un langage strictement OOP, il n'y a pas d'héritage*.
Pour remplacer les cas ou l'héritage est utile, on préférera utiliser la composition.
type Person struct {
Name string
Age int
}
type Employee struct {
Person Person
Salary int
}
type Contractor struct {
Person Person
DailyRate uint64
}
* Techniquement le "struct embedding" peut y être assimilé mais il ne permet que des cas très simples et il faut prendre des précautions pour le manier.
Tester son code
Go offre des fonctionnalités de test dans sa bibliothèque standard. Admettons que l'on veuille tester cette fonction :
// example/sum.go
package example
func Sum(a, b int) int {
return a + b
}
Tester les fonctions exportées
Pour tester cette fonction on peut créer un fichier suffixé de _test.go.
// example/sum_test.go
// On peut suffixer le nom du package de _test
// Dans ce cas on n'a accès qu'aux fonctions et
// structs publiques
package example_test
import (
"playground/example"
"testing"
)
func TestSum(t *testing.T) {
result := example.Sum(3, 4)
expected := 7
if result != expected {
t.Errorf("sum failed, expected %d but got %d", result, expected)
}
}
Tester les fonctions internes
Il faudra utiliser le même nom de package.
// example/sum_test.go
// Sans le _test dans le nom du package
// notre test est considéré faisant partie du package
package example
import (
"testing"
)
func TestSum(t *testing.T) {
result := sum(3, 4) // La fonction non exportée reste testable
expected := 8
if result != expected {
t.Errorf("sum failed, expected %d but got %d", result, expected)
}
}
Asynchrone
Asynchrone
Go a été pensé pour permettre d'écrire facilement des applications dans des contextes avec une concurrency très forte, comme des services web ou réseau.
C'est donc un langage qui par construction est très puissant dans les contextes I/O bound.
Les Goroutines
Faire du code asynchrone est trivial au premier abord grâce au mot-clé go.
package main
import "fmt"
func func1() {
fmt.Println("Hello from func1")
}
func main() {
fmt.Println("Start func1")
go func1()
fmt.Println("End func1 ?")
}
Lorsque la goroutine principale se termine, toutes les goroutines "enfant" sont terminées instantanément.
Se synchroniser grâce aux wait groups
Évidemment on veut souvent contrôler de nos goroutines. Et pour ça on a plusieurs options, comme par exemple les wait groups.
func heavyTask(id int) {
fmt.Printf("Processing task %d\n", id)
}
func main() {
var wg sync.WaitGroup
for i := range 5 {
wg.Go(func() { heavyTask(i) })
}
wg.Wait()
}
Se synchroniser grâce aux channels
On peut également se synchroniser grâce aux channels qui sont une primitive de la concurrency en Go. Là où dans d'autres langages on va principalement communiquer en partageant de la mémoire, en Go on va surtout partager en communiquant via ces channels.
func enqeueTasks(queue chan<- Task) {
for i := range 100 {
fmt.Printf("Enqueueing task %d\n", i)
queue <- Task{ID: i}
}
close(queue)
}
func taskWorker(queue <-chan Task) {
for task := range queue {
fmt.Printf("Task %d processed\n", task.ID)
}
}
Le mot clé select
Pour exécuter une branche de code en fonction de quel channel répond en premier, on peut utiliser le mot clé select.
c := make(chan string)
ctxWithTimeout, cancel := context.WithTimeout(context.Background(), 2*time.Second)
go sendMsg(c)
defer cancel()
select {
case msg := <-c:
fmt.Println(msg)
case <-ctxWithTimeout.Done():
fmt.Println("timeout")
}
Partager de la mémoire mais mal
Bien que ça ne soit pas la façon de faire principale en Go, on peut quand même partager de la mémoire entre les goroutines. Mais il faut faire attention lorsqu'on le fait.
Si on fait ça de façon naïve :
var m = map[string]int{"toto": 1}
func Read() {
for {
fmt.Println(m["toto"])
}
}
func Write() {
for {
n := rand.Intn(10)
m["toto"] = n
}
}
Partager de la mémoire comme il faut
Il faut introduire un sync.Mutex qui permet de Lock en lecture/écriture.
type SafeMap struct {
sync.Mutex
m map[string]int
}
var safeMap = SafeMap{m: make(map[string]int)}
func Read() {
for {
safeMap.Lock()
fmt.Println(safeMap.m["toto"])
safeMap.Unlock()
}
}
func Write() {
for {
n := rand.Intn(10)
safeMap.Lock()
safeMap.m["toto"] = n
safeMap.Unlock()
}
}
Serveurs web
Écrire un serveur http
package main
import (
"fmt"
"log/slog"
"net/http"
)
func healthzHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
}
func main() {
const addr = "0.0.0.0:8080"
http.HandleFunc("/healthz", healthzHandler)
slog.Info("Starting server", slog.String("addr", addr))
if err := http.ListenAndServe(addr, nil); err != nil {
slog.Error("serving requests:")
}
}
Écrire un middleware
On peut facilement écrire des middlewares en restant dans la bibliothèque standard.
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
received := time.Now()
next.ServeHTTP(w, r)
handled := time.Now()
duration := handled.Sub(received)
slog.Info("Request handled",
slog.Time("start", received),
slog.Time("end", handled),
slog.Duration("duration", duration),
)
})
}
Appeler un middleware
http.Handle("/logged", LoggingMiddleware(http.HandlerFunc(healthzHandler)))
Grouper des routes
On peut facilement grouper des routes en créant un ServeMux et en pensant à supprimer le préfixe des requêtes.
usersGroup := http.NewServeMux()
usersGroup.Handle("/hello", http.HandlerFunc(helloUser))
http.Handle("/users/", http.StripPrefix("/users", usersGroup))
Contexte
Le package contexte va être très utile dans des cas d'usages de clients ou de serveurs.
Il permet de partager des informations dans une suite d'appels de fonctions.
func DoSomething(ctx context.Context, arg Arg) error {
client.Call(ctx,arg)
otherclient.OtherCall(ctx,arg)
}
Hiérarchie des contextes
En général on va créer des contextes à partir d'un contexte parent. Par exemple :
- L'invocation d'une commande dans un outil en CLI
- Une requête dans le cas d'un serveur web
Lorsqu'un contexte est annulé, tous ses enfants le sont également, ce qui permet de facilement partager l'information qu'une action peut être abandonnée.
Timeouts via contexte
On peut s'en servir pour gérer des timeouts.
func slowOperationWithTimeout(ctx context.Context) (Result, error) {
ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
defer cancel() // releases resources if slowOperation completes before timeout elapses
return slowOperation(ctx)
}
Passage d'informations via contexte
On peut aussi s'en servir pour passer des informations liées à une requête dans le cas d'un serveur.
type traceIDkey string
var key = traceIDkey("traceID")
func TraceIDMiddleware(ctx context.Context, arg Arg) error {
traceID := generateTraceID()
ctx := context.WithValue(ctx, key, traceID)
}
func processRequest(ctx context.Context) {
err := doSomething()
if err != nil {
if traceID := ctx.Value(key); v != nil {
tracing.Send(string(traceID))
return
}
}
fmt.Println("request processed")
}
Conclusion
Nous venons de faire un petit tour d'horizon (loin d'être exhaustif) de ce que le langage Go a à offrir.
Parmi les sujets importants qui n'ont pas été évoqués, on retrouve :
- Les Generics
- Le package reflect
- Le fonctionnement du GC
- Le race detector
- CGO
- WASM
Je vous propose quelques pistes si vous voulez creuser quelques cas d'usages particulièrement adaptés à ce langage.
Développement autour de Kubernetes
Kubernetes est développé en Go et il y a donc des bibliothèques clientes très utilisées et très matures. Celles qui sont utilisées dans kubectl d'ailleurs.
C'est donc naturellement aussi un très bon choix pour le développement de controller, il existe même des frameworks comme kubebuilder pour le faire "facilement".
Outils en CLI
La nature des outputs du build en Go (un binaire, compilable sur plusieurs plateformes) se prête très bien à des outils en CLI.
Pour ça il existe la bibliothèque Cobra utilisée notamment dans :
- kubectl
- Github CLI
- Docker
Pour aller plus loin
Si apprendre le Go vous intéresse, voici quelques idées de projets pour explorer le langage et sa bibliothèque standard :
- Réécrire ses propres commandes GNU (cat, ls, grep etc...)
- Son propre Redis simplifié
- Advent of code
Quelques idées de projets intéressants à faire ensuite
- Une application web (SSR) avec html/template ou templ
- Un bot Slack/Discord
- Un controller Kubernetes avec Kubebuilder
Références et ressources
Amusez-vous !