Fonctions

Autant les structures de contrôle de Rust sont très similaires à celles de C++, en-dehors de match et de ses cousins if let et while let, autant au niveau des fonctions il y a plusieurs différences, que nous allons aborder dans ce chapitre.

Nous allons en particulier voir comment Rust gère le concept de fonction anonyme (lambda), et introduirons les trois modificateurs const, unsafe et async.

En revanche, la déclaration des méthodes associées à un type sera introduite plus tard dans le chapitre dédié aux blocs d’implémentation, et le fonctionnement des fonctions génériques sera introduit dans le chapitre dédié aux traits et à la généricité.

Déclaration

La syntaxe pour déclarer une fonction ne devrait pas vous surprendre beaucoup compte tenu des autres syntaxes que nous avons déjà abordées :

#![allow(unused)]
fn main() {
fn addition(x: u32, y: u32) -> u32 {
    x + y
}
}

On retrouve le mot-clé fn suivi du nom de la fonction, une liste de paramètres entre parenthèses, et le résultat après une flèche. Si il n’y a pas de résultat, on peut enlever cette dernière partie, et dans ce cas la fonction retourne le type unité ().

Notez qu’en général, on n’utilise pas explicitement le mot-clé “return” pour retourner des résultats d’une fonction en Rust, car celui-ci est redondant. Il suffit d’écrire une expression à la fin du code de la fonction pour que le résultat correspondant à cette expression soit retourné par la fonction.

Le mot-clé return n’est donc utilisé que quand on doit sortir d’une fonction plus tôt que prévu. Comme en C++, il est par exemple couramment utilisée pour gérer les cas exceptionnels sans se retrouver avec une pile de else imbriqués dans son code :

#![allow(unused)]
fn main() {
fn sinc(x: f32) -> f32 {
    // Cas exceptionnel
    if x == 0.0 {
        return 1.0;
    }

    // Cas général
    x.sin() / x
}
}

Comme les déclarations de variables, les déclarations de paramètres de fonction permettent l’utilisation de motifs irréfutables :

fn main() {
    // Notez qu'il existe aussi des motifs pour les tableaux
    fn dot3([x1, y1, z1]: [f32; 3], [x2, y2, z2]: [f32; 3]) -> f32 {
        x1 * x2 + y1 * y2 + z1 * z2
    }

    let v1 = [1.2, 3.4, 5.6];
    let v2 = [9.8, 7.6, 5.4];
    let produit = dot3(v1, v2);

    println!("{produit}");
}

Cela permet d’exposer une fonction qui prend un type structuré en paramètre pour l’utilisateur, et qui le déstructure dans l’implémentation.

Fonctions anonymes

La conception de Rust est fortement inspirée des langages de programmation fonctionnels, et le code Rust idiomatique fait un usage intensif du style fonctionnel. Il y a donc un fort besoin d’avoir une syntaxe concise pour déclarer des fonctions à usage unique en plein milieu du code, pour ne pas se retrouver avec ce genre de fatras de déclarations de fonctions :

#![allow(unused)]
fn main() {
// Création d'un tableau dont chaque élément est défini par l'application
// de la fonction "constructeur" à son indice.
//
// Notez qu'on peut utiliser "_" pour laisser le compilateur inférer une
// partie du type en précisant une autre partie.
fn constructeur(i: usize) -> u64 {
    3 * (i as u64)
}
let tableau: [_; 30] = std::array::from_fn(constructeur);

// Recherche d'un élément dans le tableau vérifiant le prédicat "recherche"
fn recherche(valeur: &u64) -> bool {
    *valeur == 42
}
let pos = tableau.iter().position(recherche);

// Si l'élément n'a pas été trouvé, on utilise un résultat par défaut,
// calculé uniquement dans ce cas car il son calcul est coûteux.
fn substitut() -> usize {
    /* ...un calcul compliqué... */
   usize::MAX
}
let resultat = pos.unwrap_or_else(substitut);
}

En Rust, comme en C++11, ce besoin est assuré par les fonctions anonymes, aussi appelées “lambdas” ou de façon plus obscure “clôtures lexicales” (lexical closures). Avec elles, l’exemple compliqué ci-dessus est grandement simplifié :

let tableau: [_; 30] = std::array::from_fn(|i| 3 * (i as u64));
let resultat = tableau.iter()
                      .position(|i| *i == 42)
                      .unwrap_or_else(|| /* ...un calcul compliqué... */)

Les fonctions anonymes ont deux propriétés importantes pour ce type d’application :

  • Leurs types d’entrée et de sortie sont inférables. Mais il est aussi possible de les préciser en cas d’échec de l’inférence de type ou si ça améliore la lisibilité du code :
    #![allow(unused)]
    fn main() {
    let constructeur = |i: usize| -> u64 {  3 * (i as u64)  };
    }
  • Elles peuvent capturer les valeurs de variables de leur environnement :
    #![allow(unused)]
    fn main() {
    // Recherche la position de `valeur` dans le tableau `tab`, retourne
    // `usize::MAX` si la valeur n'est pas trouvée.
    fn recherche(tab: [u32; 4], valeur: u32) -> usize {
        tab.iter().position(|x| *x == valeur).unwrap_or(usize::MAX)
    }
    }

Comme en C++, la capture peut s’effectuer par valeur ou par référence, mais la façon de le spécifier est fortement simplifiée par rapport à C++. Comme nous n’avons pas encore traité les références, je vais devoir survoler un peu, mais les exemples ultérieurs devraient clarifier les choses :

  • Si on ne précise rien lors de la déclaration de la fonction anonyme, toutes les variables référencées sont capturées par référence.
  • Si on ajoute le mot-clé move avant la déclaration de la fonction anonyme, les variables référencées sont capturées par valeur. Nous verrons ultérieurement ce que ça change et dans quel cas c’est intéressant.
    #![allow(unused)]
    fn main() {
    fn recherche(tab: [u32; 4], valeur: u32) -> usize {
        // Pas de différence visible dans ce cas précis
        tab.iter().position(move |x| *x == valeur).unwrap_or(usize::MAX)
    }
    }
  • Si on est dans un cas plus exotique où on désire un mélange de capture par valeur et par référence, on peut le faire en commençant par construire une référence, puis en capturant la référence par valeur. On utilise alors généralement un bloc pour clarifier l’intention :
    #![allow(unused)]
    fn main() {
    // Déclarations préalable
    let x = 123;
    let y = 456;
    
    // Mélange de capture par valeur et par référence
    {
        let ref_x = &x;
        let lambda = move |a: u32| a == *ref_x || a == y;
    }
    }

Fonctions const

Il n’est pas possible de calculer la valeur d’une variable statique ou d’une constante de compilation avec une fonction ordinaire. Le compilateur refusera donc ce genre de code :

#![allow(unused)]
fn main() {
fn valeur() -> u32 {
    42
}

const VALEUR: u32 = valeur();  // Erreur: valeur() ne peut pas être utilisée ici
}

Cela s’explique par le fait que certaines opérations ne peuvent pas être effectuées à la compilation :

  • Pour certaines opérations, c’est parce que le travail de conception associé n’est pas encore terminé. Par exemple, la liste des manipulations autorisées sur l’adresse mémoire d’une allocation effectuée à la compilation n’est pas finalisée à l’heure où ces lignes sont écrites, et c’est un prérequis pour que l’allocation mémoire à la compilation soit stabilisée.
  • Pour d’autres opérations, c’est parce que les concepteurs du langage pensent que c’est une mauvaise idée de permettre d’effectuer facilement ces opérations pendant le processus de compilation. Par exemple, écrire dans des fichiers à la compilation n’aura probablement pas le résultat attendu à l’exécution.

Or, un objectif de conception important de Rust est qu’il ne doit pas être possible de casser du code client en modifiant l’implémentation d’une fonction, sans toucher à son interface. Cet objectif n’est pas compatible avec le fait de permettre l’utilisation des fonctions normales, qui peuvent tout faire, dans un contexte restreint d’exécution de code à la compilation.

Pour rendre une fonction évaluable à la compilation, on ajoute donc le mot-clé const dans la déclaration. En échange de quoi le compilateur vérifiera que la fonction peut être évaluée à la compilation, puis acceptera son utilisation pour calculer des constantes de compilation :

#![allow(unused)]
fn main() {
const fn valeur() -> u32 {
    42
}

const VALEUR: u32 = valeur();  // OK
}

Ce mécanisme est analogue aux fonctions constexpr de C++, mais l’ensemble des opérations qui peuvent être effectuées dans une fonction const fn en Rust et une fonction constexpr en C++ est un peu différent : le code Rust peut faire certaines choses dans une const fn que le code C++ ne peut pas faire dans une fonction constexpr, et vice versa.

Comme avec constexpr en C++, il est très important de bien comprendre que l’ajout du mot-clé const ne force pas une fonction à être évaluée à la compilation. En dehors du contexte particulier du calcul des constantes de compilation, c’est une fonction comme les autres vue de l’extérieur, et on peut tout à fait lui passer en paramètre des valeurs qui ne sont connues qu’à l’exécution, pour calculer des résultats à l’exécution comme on le ferait avec n’importe quelle autre fonction.

De même, contrairement à une croyance tenace, appeler une fonction const fn pendant l’exécution avec une valeur connue à la compilation ne garantit aucunement que le résultat sera précalculé par l’optimiseur du compilateur pendant la compilation. Si vous tenez à cette garantie, pas besoin d’une sémantique consteval spéciale à la C++23, en Rust il suffit de créer une const et lui affecter le résultat du calcul.

Fonctions unsafe

Nous avons vu précédemment plusieurs exemples de fonctions et méthodes unsafe. Il s’agit de fonctions possédant des préconditions qui doivent être respectées par l’appelant, sous peine de quoi le comportement sera indéfini.

Puisque l’un des objectifs majeurs de Rust est qu’il ne doit pas être possible de causer du comportement indéfini avec du code normal, on ne peut appeler des fonctions unsafe qu’au sein d’un bloc unsafe. Cela rend les endroits du code où du comportement indéfini est possible repérables avec des outils de recherche textuelle simples tels que grep :

#![allow(unused)]
fn main() {
// Déclaration d'une fonction unsafe. Il est très fortement recommandé de
// clarifier les préconditions dans la documentation de la fonction.

/// SAFETY: Ne doit être appelée que le lundi matin
unsafe fn mort_au_lundi() {
    // ... actions spécifiques au maudit lundi, avec un risque de comportement
    // indéfini si on n'est pas lundi matin ...
}


// Interdit : Le comportement indéfini n'est pas autorisé en Rust hors unsafe
/* mort_au_lundi(); */


// Autorisé : On a utilisé un bloc unsafe, le compilateur suppose qu'on sait
// ce qu'on est en train de faire. Il est d'usage de clarifier pour les autres
// développeurs au nom de quoi on pense que le comportement est défini.

// SAFETY: J'ai briefé tous les collègues, ce code ne sera exécuté qu'un lundi.
unsafe { mort_au_lundi() };
}

Si la bibliothèque standard de C++ était traduite en Rust, la quasi-totalité de ses fonctions serait donc des unsafe fn, puisque toutes les opérations courantes de C++ sont susceptibles de déclencher du comportement indéfini si on les appelle avec de mauvais arguments. On mesure ici l’ampleur du défi que s’est lancé Rust en déclarant la guerre au comportement indéfini.

A l’heure où ces lignes sont écrites, il est possible d’utiliser des fonctions unsafe à l’intérieur de unsafe fn sans bloc unsafe supplémentaire. La motivation initiale de ce raccourci était que le rôle de unsafe fn est de créer une abstraction de plus haut niveau par dessus des opérations unsafe de bas niveau. Mais à l’usage, cette conception est piégeuse, car on risque d’appeler accidentellement des fonctions unsafe sans y prendre garde et vérifier leurs préconditions.

Il est donc probable que les règles de unsafe fn soient révisées dans une édition future du langage Rust, et je vous encourage dès à présent à utiliser des blocs unsafe à l’intérieur de vos fonctions unsafe pour clarifier à quels endroits du code vous effectuez des opérations dangereuses, et pourquoi vous pensez que vous le faites correctement.

Dans la suite de ce cours, nous reviendrons sur la question du code unsafe et les opérations supplémentaires permises dans un bloc unsafe en dehors de l’appel aux fonctions unsafe.

Fonctions async

Rust a assez récemment gagné un nouveau type de fonction, les fonctions asynchrones :

#![allow(unused)]
fn main() {
async fn foo() -> u32 {
    let x = 32;
    let resultat = bar(x).await;
    resultat
}

// L'ordre des déclarations n'a pas d'importance en Rust, ce qui permet de
// commencer par les fonctions de haut niveau qui intéressent l'utilisateur et
// terminer par les détails d'implémentation de bas niveau.
async fn bar(x: u32) -> u32 {
    2 * x
}
}

Cela fait parti de l’infrastructure de programmation asynchrone de Rust. En une phrase, celle-ci permet d’attendre que des événements se produisent au niveau de l’OS (temporisations, entrées/sorties…), de façon plus efficace qu’en bloquant un thread par événement attendu.

Un gros chapitre est dédié à l’asynchronisme en Rust à la fin de ce cours, et dans ce chapitre, je rentrerai dans les détails de cette infrastructure. Je vous invite à consulter ce chapitre pour en savoir plus, idéalement quand vous aurez une meilleure compréhension générale du langage car il a des recouvrements avec à peu près tous les autres sujets abordés dans ce cours…