Aller au contenu principal

Cours 1 — Introduction au langage Go

Historique​

Ce langage est typé et compilable développé par Google en 2007. La version 1.0 est sortie en 2012, c'est un langage informatique relativement nouveau. Il est disponible sur de multiples plateformes de système d'exploitation: Windows, GNU/Linux, macOS, BSD, ...

Particularités​

Il ressemble au C/C++, mais il a une meilleure protection de la mémoire grâce à son garbage collector. Il a également une meilleure gestion de la concurrence (c.-à-d. la possibilité de faire rouler plusieurs processus au sein de la même application). Le dernier point étant particulièrement utile dans ce cours-ci étant donné que nous allons développer nos connaissances en programmation réseautique.

La majorité des exemples des notes peuvent facilement être copiés et collés sur le site: https://go.dev/play/.

D'ailleurs vous avez la chance en chargeant cette page de voir le hello world:

package main
import (
"fmt"
)
func main() {
fmt.Println("Hello, playground")
}

Exemple

Installation​

Téléchargez go à partir du site officiel: https://go.dev/.

macOS​

1- Installer Brew

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

2- Mettre à jour le path

# Add Homebrew to your PATH in ~/.zprofile:
echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile
eval "$(/opt/homebrew/bin/brew shellenv)"

3- Installer go

brew install go

Module (go mod init)​

Avant d'écrire du code, chaque projet Go doit être initialisé comme module à l'aide de la commande suivante, exécutée à la racine du dossier du projet :

go mod init github.com/nom-utilisateur/nom-application

Cette commande crée un fichier go.mod qui identifie le module (son chemin) et qui retiendra, au fil du projet, la version de Go utilisée ainsi que les dépendances externes (paquetages téléchargés avec go get).

Dès ce premier atelier, prenez l'habitude d'utiliser la notation longue (ex. : github.com/nom-utilisateur/nom-application) plutôt qu'un nom court comme nom-application. Même si Go accepte n'importe quel nom au moment de l'initialisation, la convention de la communauté est d'utiliser le chemin qui correspondrait à l'emplacement du dépôt (GitHub, GitLab, ...), même si vous ne comptez pas le publier tout de suite. Cette habitude évite des renommages pénibles plus tard, notamment lorsque :

  • vous ajoutez des paquetages internes à votre projet (les imports internes se font par rapport au chemin du module) ;
  • vous poussez éventuellement le dépôt sur GitHub (le chemin du module doit correspondre exactement à l'URL du dépôt pour que d'autres puissent l'importer).

Exemple complet, de rien jusqu'à un programme qui roule :

mkdir mon-projet
cd mon-projet
go mod init github.com/nom-utilisateur/mon-projet

Cette dernière commande crée le fichier go.mod suivant à la racine du dossier :

module github.com/nom-utilisateur/mon-projet

go 1.27

Créez ensuite un fichier main.go dans ce même dossier :

package main

import "fmt"

func main() {
fmt.Println("Hello World!")
}

Puis exécutez-le :

go run .

(go run . équivaut ici à go run main.go, mais fonctionne aussi une fois que votre programme sera divisé en plusieurs fichiers/paquetages, puisque Go se base alors sur le fichier go.mod pour retrouver la racine du module.)

Paquetage​

Tous vos programmes sont réalisés en paquetages (packages) :

package main

import (
"fmt"
"math/rand"
)

func main() {
fmt.Println("My favorite number is", rand.Intn(10))
}

Ici, il y a deux paquetages qui sont importés (qui proviennent de la bibliothèque de go), fmt pour le formatage et le paquetage Rand contenu à l'intérieur du paquetage Math, comme nous pouvons le voir dans le lien suivant:

https://pkg.go.dev/math

Permettant ainsi ici de segmenter nos paquetages aisément en fonction de nos besoins : nos applications go peuvent être subdivisées en plusieurs paquets pour alléger le code et simplifier sa conception.

Exemple

Il est à savoir que les imports non utilisés feront que votre code ne compilera pas, ceci est voulu dans le sens où vous ne voulez pas garder des importations augmentant la taille de vos exécutables.

package main

import "rsc.io/quote"

func main() {
println(quote.Go())
}

Exemple

Premier programme​

Il n'y a pas de point-virgule à la fin de chaque ligne.

Si vous voulez tester, utilisez le paquet fmt pour afficher une donnée dans la console. Voici le fameux "hello world".

package main

import "fmt"

func main() {
fmt.Println("Hello World!")
}

https://go.dev/play/

Pour l'exécuter, exécutez la ligne suivante en ligne de commande, "main.go" est le nom de votre fichier à exécuter, go va exécuter la fonction "main".

go run main.go

Pour lire dans la console (utile pour l'atelier), il faut aussi utiliser le paquet fmt:

var s string
fmt.Scanln(&s)

var i int
fmt.Scanf("%d", &i)

Types de base​

Go a accès aux mêmes types de bases que dans la majorité des autres langages de programmation:

  • int8, int16, int32, int64, int (u en devant pour les non-signés)
  • rune (alias à int32) : ceci représente une valeur unicode
  • float32, float64 (nombre à virgule)
  • complex64, complex128
  • bool
  • string

Il n'y a pas de classes, mais le langage a un type nommé struct — un regroupement de champs nommés, sans méthodes intégrées — qui vous permettra d'obtenir à peu près les mêmes fonctionnalités.

Quand l'utiliser​

Un type non signé (uint, uint32, ...) semble tentant pour une valeur qui ne devrait logiquement jamais être négative — un nombre de connexions actives, une taille de buffer, un port.

Quand l'éviter​

Dès qu'un calcul intermédiaire risque de descendre sous zéro. Un uint ne devient pas négatif dans ce cas : il "boucle" par en dessous jusqu'à sa valeur maximale, sans erreur ni panique.

Piège classique : décrémenter un compteur non signé qui est déjà à 0 (ex.: une fermeture de connexion comptée deux fois par erreur) :

var connexionsActives uint = 0

func onConnexionFermee() {
connexionsActives-- // BUG : si connexionsActives vaut déjà 0, elle devient
// 18 446 744 073 709 551 615 (uint64) au lieu de rester à 0
}

Correction : utiliser un type signé (int) pour tout compteur qui peut être décrémenté, ou valider avant de décrémenter :

var connexionsActives int = 0

func onConnexionFermee() {
if connexionsActives > 0 {
connexionsActives--
}
}

Déclaration / initialisation / assignation​

Elle peut être statique :

var x float64
x = 20.0

Ou dynamique lorsque le compilateur peut déterminer le type par l'entremise de la valeur qui lui est assignée, dans ce cas-ci, il n'y a pas de déclaration avec var de la variable. En anglais on appelle cette déclaration une "short variable declaration".

a := 4
s := "J'aime les patates"

Personnellement je préfère la version longue à la version courte. Parfois vous n'aurez pas le choix d'utiliser un style ou l'autre, par exemple vous devez utiliser la version longue pour les variables globales.

Pour rendre une variable immuable, il suffit d'utiliser l'identifiant const avant la déclaration de la variable:

const x string = "Bonjour monde!"
x = "Réutilisation de variable"

Il est possible également d'effectuer des assignations multiples automatiques:

var i, j int = 1, 2
x, y := 3,4

Noms exportés​

Lorsque vous voulez que vos méthodes soient accessibles à l'extérieur de vos paquetages, vous devez les nommer avec une lettre majuscule.

Par exemple:

package main

import (
"fmt"
"math"
)

func main() {
fmt.Println(math.pi)
}

Dans cet exemple, math.pi est introuvable, car dans le paquet https://pkg.go.dev/math, pi n'existe pas, c'est Pi la constante.

Structure de contrôle​

if​

Le if ne doit pas être encadré de parenthèses, par contre les différentes sections devront être encadrées d'accolades comme en C.

func main() {
if 7%2 == 0 {
fmt.Println("7 est pair")
} else {
fmt.Println("7 est impair")
}
if 8%4 == 0 {
fmt.Println("8 est divisible par 4")
}
if num := 9; num < 0 {
fmt.Println(num, "est négatif")
} else if num < 10 {
fmt.Println(num, "n'a qu'un chiffre")
} else {
fmt.Println(num, "a plusieurs chiffres")
}
}

Le else est exactement comme en C. Il peut être présent ou non. Veuillez remarquer le calcul dans le dernier exemple, il est possible de faire une opération dans le if. Notez également que les parenthèses ne sont pas nécessaires, mais les accolades oui!

Piège classique : utiliser := dans la condition d'un if alors qu'une variable du même nom existe déjà à l'extérieur crée une nouvelle variable locale au bloc — elle masque (« shadow ») la variable extérieure au lieu de la modifier. C'est particulièrement sournois avec une connexion réseau et son erreur :

var conn net.Conn

if conn, err := net.Dial("tcp", "example.com:80"); err != nil {
log.Println(err)
return
} else {
fmt.Println("Connexion établie :", conn) // c'est la "vraie" connexion, mais locale au bloc
}

// BUG : peu importe l'issue de Dial, ici conn (la variable extérieure) est
// toujours nil — le ":=" du if a déclaré un "conn" (et un "err") locaux au
// bloc, qui n'ont jamais touché la variable extérieure.
fmt.Println(conn) // <nil>

Correction : déclarer err séparément (ou avec var) et utiliser = plutôt que := pour assigner à la variable déjà existante :

var conn net.Conn
var err error

conn, err = net.Dial("tcp", "example.com:80")
if err != nil {
log.Println(err)
return
}
fmt.Println(conn) // la vraie connexion

switch​

Il est vraiment identique au C également, cependant le break n'est pas nécessaire. Il ne va exécuter que le case nécessaire.

func main() {
fmt.Print("Go runs on ")
switch os := runtime.GOOS; os {
case "darwin":
fmt.Println("OS X.")
case "linux":
fmt.Println("Linux.")
default:
// freebsd, openbsd,
// plan9, windows...
fmt.Printf("%s.\n", os)
}
}

Une autre différence notable c'est qu'on peut faire un switch sur à peu près tous les types possibles:

t := time.Now()
switch {
case t.Hour() < 12:
fmt.Println("Avant-midi")
default:
fmt.Println("Après-dîner")
}

Quand l'utiliser​

Un switch remplace avantageusement une longue chaîne de if / else if quand on teste plusieurs conditions indépendantes sur la même variable ou le même contexte (ex.: router un message selon son type, ou une commande CLI selon un sous-argument) — c'est plus lisible, et Go n'exécute qu'un seul cas sans qu'on ait besoin d'un break comme en C.

Quand l'éviter​

Pour un test binaire à une seule condition, un if/else simple reste plus direct qu'un switch à deux cases.

Boucles​

Go n'a qu'un seul élément pour les boucles, le for.

Les 3 parties du for (comme vous vous en doutez):

  • init (optionnel)
  • condition (optionnel)
  • post (optionnel)

Voici un exemple de déclaration:

func main() {
somme := 0
for i := 0; i < 10; i++ {
somme += i
}
fmt.Println(somme)
}

En go, contrairement au C/C++, il n'y a pas de parenthèses après la déclaration du for. La présence des accolades est obligatoire (bien sûr). L'itération de la boucle va se terminer aussitôt que la condition i<10 sera fausse.

Lorsque le init et le post sont retirés, vous obtenez le while (vous pouvez même retirer les ;) :

func main() {
somme := 1
for somme < 1000 {
somme += somme
}
fmt.Println(somme)
}

Si finalement vous retirez les 3 paramètres du for, vous obtenez alors une boucle infinie:

package main

func main() {
for {
}
}

Range​

Le range permet de faire une itération sur un tableau ou un slice. Très utile pour boucler sur chacun des éléments sans être obligé d'ajouter une structure traditionnelle. Les 2 boucles suivantes font la même chose:

for i := 0 ; i < len(tab); i++ {
// i comme indice et tab[i] comme valeur
}
for i, v := range tab {
// i comme indice et v comme valeur
}

Par exemple:

var pow = []int{1, 2, 4, 8, 16, 32, 64, 128}
func main() {
for i, v := range pow {
fmt.Printf("2**%d = %d\n", i, v)
}
}

Si vous n'avez pas besoin des indices:

for _, v := range pow {
fmt.Printf("%d\n", v)
}

Quand l'utiliser​

range dès que vous devez parcourir tous les éléments d'un slice, d'un array ou d'une map, sans avoir besoin de sauter des indices.

Quand l'éviter​

Si vous devez avancer par un pas différent de 1, sauter des éléments selon une condition, ou parcourir dans un ordre autre que croissant depuis 0 — dans ces cas, la boucle for classique à 3 parties reste nécessaire.