Aller au contenu principal
Version: 2025

TP 02 - Possession et arguments du programme

Assignment

info

Vous devez accepter la tâche d'ici et travailler avec ce dépôt: TP2025.

Resources

  1. La possesion
  2. Vecteurs
  3. Strings
  4. An I/O Project: Building a Command Line Program

Possession

Possession (alnglais ownership) est un ensemble de règles qui régissent la façon dont un programme Rust gère la mémoire. Tous les programmes doivent gérer la façon dont ils utilisent la mémoire d'un ordinateur pendant leur exécution.

Certains langages ont garbage collection qui recherche régulièrement la mémoire non utilisée pendant l'exécution du programme ; dans d'autres langages, le programmeur doit explicitement allouer et libérer la mémoire.

Rust utilise une troisième approche : la mémoire est gérée via un système de propriété avec un ensemble de règles que le compilateur vérifie. Si l'une des règles est violée, le programme ne compilera pas. Aucune des caractéristiques de propriété ne ralentira votre programme pendant son exécution.

Règles de possession

  1. Chaque valeur en Rust a un possesseur.
  2. Une valeur ne peut pas avoir qu'un seul possesseur à la fois.
  3. Quand le posseseur est hors de portée, la valeur est supprimée. La bibliothèque standard est divisée en trois niveaux :

Portée des variables

Une portée est la zone de code d'un programme dans laquelle un élément est valide.

Voici un exemple pour comprendre le concept:

 {
// s n'est pas valide ici, car il n'est pas encore déclaré
let s = "hello"; // s est valide à partir de ce point

// fait quelque chose avec s
}

Mémoire et allocation

Quand on a besoin d'allouer de la mémoire sur le tas (ex: variables qui sont mutables et n'ont pas une taille connue à la compilation, donc la taille peut être modifiée pendant l'exécution du programme) nous devons nous assurer que cette mémoire est transmise à l'allocateur au moment où nous n'avons plus besoin de notre variable.

Pour ça, en Rust, la mémoire est automatiquement libérée une fois que la variable qui la possède sort de la portée. Lorsqu'une variable sort de la portée, Rust appelle une fonction spéciale pour nous, drop, automatiquement à l'accolade fermante (la fin de la portée).

remarque

Pour en savoir plus, veuillez lire la section Mémoire et allocation du chapitre 4!

Possession et fonctions

Les mécanismes de transmission d'une valeur à une fonction sont similaires à ceux de l'affectation d'une valeur à une variable. Passer une variable à une fonction déplacera ou copiera, tout comme le fait l'affectation.

Example (lisez les commentaires):

fn main() {
let s = String::from("hello"); // s comes into scope

takes_ownership(s); // s's value moves into the function...
// ... and so is no longer valid here

let x = 5; // x comes into scope

makes_copy(x); // a copy to x is passed to the function,
// but i32 is Copy, so it's okay to still
// use x afterward

} // Here, x goes out of scope, then s. But because s's value was moved, nothing
// special happens.

fn takes_ownership(some_string: String) { // some_string comes into scope
println!("{}", some_string);
} // Here, some_string goes out of scope and `drop` is called. The backing
// memory is freed.

fn makes_copy(some_integer: i32) { // some_integer comes into scope
println!("{}", some_integer);
} // Here, some_integer goes out of scope. Nothing special happens.

Si nous essayions d'utiliser s après l'appel à take_ownership, Rust renverrait une erreur de compilation. Ces vérifications statiques nous protègent des erreurs.

Valeurs de retour et scope

Les valeurs de retour peuvent également transférer le ownership.

La possession d'une variable suit le même schéma à chaque fois : l'affectation d'une valeur à une autre variable la déplace. Lorsqu'une variable qui inclut des données sur le tas sort de la scope, la valeur sera nettoyée par drop à moins que la propriété des données n'ait été déplacée vers une autre variable.

Example (lisez les commentaires):

fn main() {
let s1 = gives_ownership(); // gives_ownership moves its return
// value into s1

let s2 = String::from("hello"); // s2 comes into scope

let s3 = takes_and_gives_back(s2); // s2 is moved into
// takes_and_gives_back, which also
// moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing
// happens. s1 goes out of scope and is dropped.

fn gives_ownership() -> String { // gives_ownership will move its
// return value into the function
// that calls it

let some_string = String::from("yours"); // some_string comes into scope

some_string // some_string is returned and
// moves out to the calling
// function
}

// This function takes a String and returns one
fn takes_and_gives_back(a_string: String) -> String { // a_string comes into
// scope

a_string // a_string is returned and moves out to the calling function
}

References et Emprunt (Borrow)

Une référence est comme un pointeur au sens où il s'agit d'une adresse que nous pouvons suivre pour accéder aux données stockées à cette adresse ; ces données appartiennent à une autre variable. Contrairement à un pointeur, une référence est garantie de pointer vers une valeur valide d'un type particulier pour la durée de vie de cette référence.

Le symbole & est utilisé pour marquer une référence, soit avant le nom d'une variable, soit, pour le cas de paramètre d'une fonction, avant le type du parametre. Ces esperluettes représentent des références et vous permettent de vous référer à une valeur sans vous en approprier.

let x: u16 = 10;
let y = &x;

Exemple d'une fonction qui prend une référence vers un objet comme paramètre au lieu de prendre la possession de cette valeur:

fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1);
println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize { // s is a reference to a String
s.len()
} // Here, s goes out of scope. But because it does not have ownership of what
// it refers to, it is not dropped.

La syntaxe &s1 nous permet de créer une référence qui fait référence à la valeur de s1 mais qui ne la possède pas. Comme la référence ne possède pas la valeur vers laquelle elle pointe, la valeur de s1 ne sera pas supprimée lorsque la référence cessera d'être utilisée.

De même, la signature de la fonction utilise & pour indiquer que le type du paramètre s est une référence.

Nous appelons emprunt (borrowing en anglais) l'action de créer une référence. Comme dans la vraie vie, vous pouvez emprunter quelque chose à quelqu'un. Lorsque vous n'avez plus besoin de la chose empruntée, vous devez la rendre. Vous ne la possédez pas.

Tout comme les variables sont immuables par défaut, les références le sont aussi. Nous ne sommes pas autorisés à modifier la valeur pointée par une référence.

Mutable references

Si on veut modifier la valeur d'une référence on doit dire ça explicitement au compilateur en utilisant le mot clé mut. Les références mutables ont une grande restriction : si vous avez une référence mutable à une valeur, vous ne pouvez pas avoir d'autres références à cette valeur.

Nous ne pouvons pas non plus avoir une référence mutable alors que nous en avons une immuable à la même valeur.

fn main() {
let mut s = String::from("hello");
change(&mut s);
}

fn change(some_string: &mut String) {
some_string.push_str(", world");
}
attention

Règles pour les références:

  1. À tout moment, vous pouvez avoir une référence mutable ou un nombre quelconque de références immuables.
  2. Les références doivent toujours être valides.

Slices

Les slices vous permettent de référencer une séquence contiguë d'éléments dans une collection plutôt que la collection entière. Une slice est une sorte de référence, elle n'a donc pas la possession.

String slices

Une slice de String est une référence à une partie d'une String.

let s = String::from("hello world");

let hello = &s[0..5];
let world = &s[6..11];

Plutôt qu'une référence à la chaîne entière, hello est une référence à une partie de la chaîne, spécifiée entre crochets [0..5]. Nous créons des slices en utilisant une plage entre crochets en spécifiant [starting_index..ending_index], où starting_index est la première position dans la slice et ending_index est un de plus que la dernière position dans la slice.

En interne, la structure de données de la slice stocke la position de départ et la longueur de la tranche, qui correspond à l'index_fin moins l'index_départ.

Ainsi, dans le cas de let world = &s[6..11];, world serait une slice contenant un pointeur vers l'octet à l'index 6 de s avec une valeur de longueur de 5.

Autre slices

Tout comme nous pourrions vouloir faire référence à une partie d'une chaîne, nous pourrions vouloir faire référence à une partie d'un tableau. On ferait comme ça :

let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
assert_eq!(slice, &[2, 3]);

Cette tranche est de type &[i32]. Cela fonctionne de la même manière que les tranches de chaîne, en stockant une référence au premier élément et une longueur.

L'enum Option

Option est une autre énumération définie par la bibliothèque standard. Le type Option encode le scénario très courant où une valeur peut être quelque chose ou rien.

Rust n'a pas la fonctionnalité null que beaucoup d'autres langages ont. Null est une valeur qui signifie qu'il n'y a aucune valeur ici. Dans les langages avec null, les variables peuvent toujours être dans l'un des deux états suivants : null ou non-null.

En tant que tel, Rust n'a pas de valeurs nulles, mais il a une énumération qui peut coder le concept d'une valeur présente ou absente. Cette énumération est Option<T>, et elle est définie par la bibliothèque standard comme suit :

enum Option<T> {
None,
Some(T),
}

L'énumération Option<T> est si utile qu'elle est même incluse dans le prélude ; vous n'avez pas besoin de l'inclure explicitement dans la scope. Ses variantes sont également incluses dans le prélude : vous pouvez utiliser Some et None directement sans le préfixe Option ::. L'énumération Option<T> n'est qu'une énumération normale, et Some(T) et None sont toujours des variantes du type Option<T>.

Pour l'instant, tout ce que vous devez savoir à propos de <T> est qu'il s'agit d'un marquer de type qui sera remplacé par n'importe quel type, créant ainsi une nouvelle énumération Option.

let some_number = Some(5);
let some_char = Some('e');

let absent_number: Option<i32> = None;

Le type de some_number est Option<i32>. Le type de some_char est Option<char>, qui est un type différent.

Lorsque nous avons une valeur Some, nous savons qu'une valeur est présente et que la valeur est contenue dans Some. Lorsque nous avons une valeur None, cela signifie en quelque sorte la même chose que null : nous n'avons pas de valeur valide.

remarque

Vous devez convertir une Option<T> en T avant de pouvoir effectuer des opérations T avec elle. Généralement, cela aide à détecter l'un des problèmes les plus courants avec null : supposer que quelque chose n'est pas null alors qu'il l'est réellement. Comment faire ça ?

Vecteurs

Les vecteurs vous permettent de stocker plusieurs valeurs dans une seule structure de données qui place toutes les valeurs les unes à côté des autres en mémoire. Les vecteurs ne peuvent stocker que des valeurs de même type.

Pour créer un nouveau vecteur vide, nous appelons la fonction Vec::new.

let v: Vec<i32> = Vec::new();

Lorsque nous créons un vecteur pour contenir un type spécifique, nous pouvons spécifier le type entre crochets.

Push

Pour créer un vecteur puis y ajouter des éléments, nous pouvons utiliser la méthode push.

let mut v = Vec::new();

v.push(5);
v.push(6);
v.push(7);
v.push(8);

Comme pour toute variable, si nous voulons pouvoir changer sa valeur, nous devons la rendre modifiable en utilisant le mot-clé mut.

Lire des éléments

Il existe deux façons de référencer une valeur stockée dans un vecteur : via l'indexation ou en utilisant la méthode get.

let v = vec![1, 2, 3, 4, 5];

let third: &i32 = &v[2];
println!("The third element is {third}");

let third: Option<&i32> = v.get(2);
match third {
Some(third) => println!("The third element is {third}"),
None => println!("There is no third element."),
}

Rust fournit commodément la macro vec!, qui créera un nouveau vecteur contenant les valeurs que vous lui donnez.

Itérer un vecteur

Pour accéder à tour de rôle à chaque élément d'un vecteur, nous parcourrons tous les éléments plutôt que d'utiliser des indices pour accéder à un à la fois. L'exemple montre comment utiliser une boucle for pour obtenir des références immuables à chaque élément dans un vecteur de valeurs i32 et les imprimer.

let v = vec![100, 32, 57];
for i in &v {
println!("{i}");
}

Nous pouvons également itérer sur des références mutables à chaque élément dans un vecteur mutable afin d'apporter des modifications à tous les éléments.

let mut v = vec![100, 32, 57];
for i in &mut v {
*i += 50;
}

Accepter les arguments de la ligne de commande

Pour permettre à notre programme de lire les valeurs des arguments de ligne de commande que nous lui transmettons, nous aurons besoin de la fonction std::env::args fournie dans la bibliothèque standard de Rust. Cette fonction renvoie un itérateur des arguments de ligne de commande transmis à notre programme.

Pour l'instant, vous n'avez besoin de connaître que deux détails sur les itérateurs : les itérateurs produisent une série de valeurs, et nous pouvons appeler la méthode collect sur un itérateur pour le transformer en une collection, telle qu'un vecteur, qui contient tous les éléments produits par l'itérateur.

use std::env;

fn main() {
let args: Vec<String> = env::args().collect();
println!(args);
}

Sur la première ligne de main, nous appelons env::args, et nous utilisons immédiatement collect pour transformer l'itérateur en un vecteur contenant toutes les valeurs produites par l'itérateur.

remarque

La première valeur retournée par env::args est le nom de notre exécutable!

Enregistrement des valeurs d'argument dans les variables

Le programme est actuellement en mesure d'accéder aux valeurs spécifiées en tant qu'arguments de ligne de commande. Nous devons maintenant enregistrer les valeurs des deux arguments dans des variables afin de pouvoir utiliser les valeurs dans le reste du programme.

use std::env;

fn main() {
let args: Vec<String> = env::args().collect();

let query = &args[1];
let file_path = &args[2];

println!("Searching for {}", query);
println!("In file {}", file_path);
}

Exercises

  1. Écrivez une fonction qui reçoit deux nombres comme arguments et les divise. Gérez le cas de la division par zéro à l'aide du type Option.

  2. Écrivez un programme qui reçoit deux nombres de la ligne de commande et les divise. Écrivez le résultat à l'écran. S'il y a une erreur, retournez -1 depuis main.

  1. Écrivez un programme qui reçoit en paramètres une commande et deux nombres. La commande est l'une des suivantes : add, sub, mul, div, rem. Écrivez le résultat à l'écran ou renvoyez une erreur.
  2. Écrivez un programme similaire au précédent, à l'exception qu'il prend la commande de la variable d'environnement CMD. Un exemple d'utilisation est CMD=mul cargo run 5 3.
astuce

Utilisez env::var() pour prendre la valeur de CMD.

  1. Écrivez un programme qui prend comme premier argument une commande suivie d'une liste de nombres. Les commandes peuvent être add, sub, mul, div, avg (moyenne), sort, unique.
  2. Modifiez le programme afin qu'il n'exécute pas les commandes dans la fonction principale, mais que chaque commande soit exécutée dans sa propre fonction. Les fonctions reçoivent comme arguments les entrées exactes de la ligne de commande (chaînes, pas de nombres).