Introduction à Rust

rust

Récemment je me suis intéressé au langage Rust. Il est édité par la fondation Mozilla qui l’utilise à travers le moteur de rendu Servo de Firefox.

Voici donc une courte introduction pour vous faire découvrir ce langage innovant.

Dans ce tutoriel, je pars du principe que vous avez de solides notions en programmation.

Ce tutoriel est loin d’être complet.

Sommaire

Introduction

Rust est un langage de programmation qui se veut sûr et ultra-rapide. C’est un langage:

  • Compilé, il faut donc passer par une étape de compilation
  • Avec un typage fort & statique
  • orienté bas niveau

Il a été conçu pour être “un langage sécurisé, concurrent, pratique”, supportant les styles de programmation purement fonctionnels, procéduraux et orientés objet.

Wikipedia

Pourquoi utiliser Rust?

Rust bénéficie d’une grande communauté et a d’ailleurs été élu le langage le plus apprécié des développeurs en 2016 et même en 2017 par le sondage de Stackoverflow.

A l’inverse du C et du C++, Rust simplifie grandement la libération de la mémoire avec son système d’appartenance. Concrètement cela signifie:

  • Les performances du C sans la gestion de la mémoire
  • protection contre les fuites de mémoire

Bénéficiant de performances proche du C, pourquoi s’en priver?.

Pourquoi ne pas utiliser Rust?

De plus, Rust est un langage bas niveau qui est, pour l’instant, peu adapté au développement web par exemple.

Rust est un langage encore très jeune. Du fait qu’il soit jeune, les librairies existantes sont souvent peu documentées et il faut souvent mettre les mains dans le code pour comprendre le fonctionnement de celles-ci.

De plus, Rust est encore peu utilisé dans le monde professionnel. Si vous recherchez un langage à mettre en avant dans une recherche d’emploi, ce n’est actuellement pas le meilleur choix.

Installation

L’installation se fait très facilement. Sur Linux, une ligne de commande suffit:

curl https://sh.rustup.rs -sSf | sh

Ensuite pour vérifier que tout fonctionne on crée un Hello World

// hello_world.rs
fn main() {
    println!("Hello, world!");
}

Pour compiler on utilise rustc:

$ rustc hello_world.rs

Cela va nous créer un fichier binaire exécutable hello_world qu’il suffit d’exécuter.

$ ./hello_world
Hello, world!

Les bases

Les variables & les types

On retrouve tous les types de base:

  • Entier
    • unsigned: i8, i16, i32, i64, isize (ne peut pas être négatif)
    • signed: u8, u16, u32, u64, usize
  • Décimal f32 et f64
  • booléen: bool
  • caractère: instancié avec des '

Les variables s’instancient avec let:

let name = "alexandre";

Elles sont typées.

let age = 5;// un nombre entier
let age : i8 = 5;// un entier de 8 bytes
let age = 5i8;// un entier de 8 bytes

Pour les constantes on utilise const mais le type doit être défini:

const MAX_POINTS: u32 = 100_000;

Types pour grouper

Les Tuples sont des tableaux sans limite de taille et sans contrainte de type

let tup: (i32, f64, u8) = (500, 6.4, 1);
let first_element = tup.1;// => 6.4

Les Array sont des tableaux avec taille fixe et les éléments doivent être de la même famille

let array            = ['a', 'b', 'c'];
// s'écrit aussi
let array [String;3] = ['a', 'b', 'c'];
let first_element    = array[0];// => 6.4

Les slices sont des pointeurs vers une zone d’un array:

let array = ['a', 'b', 'c'];
println!("{:?}", &array[1..3]);// => ['b', 'c']

Les vecteur sont des tableaux d’éléments de la même famille mais sans contrainte de taille.

let mut vector: Vec<i8> = Vec::new();
vector.push(1);
vector.push(2);
vector.push(3);
println!("{:?}", vector);//[1, 2, 3]
println!("{:?}", &vector[2..3]);//[3]

Mutabilité

Toutes les variables sont immutables par défaut. Si vous ne le spécifiez pas, elles ne pourront être modifiées. Par exemple ce code ne pourra pas être compilé.

let x = 1;
x = x + 1;
// => re-assignment of immutable variable `x`

Il faut déclarer la variable comme mutable avec le mot clé mut:

let mut x = 1;
x = x + 1;

Un autre moyen consiste à utiliser le shadowing (je n’ai pas trouvé de traduction à ce terme). Cela consiste à redéfinir la variable avec let. Ceci, à la différence du mut, permet de définir le type de la variable.

let mut x = 1;
let x = x + 1;

Les conditions

Ici rien de spécifique à Rust, on retrouve le if, else et le else if. Cependant, contrairement à d’autres langages, la condition ne s’entoure pas de parenthèses

let number = 3;

if number < 5 {
    println!("superior to 5");
} else if number == 3  {
    println!("equals to 3");
} else {
    println!("something else");
}

Il est possible d’utiliser des valeurs de retour pour les conditions

let condition = true;
let number = if condition {
    5
} else {
    6
};

Pour lier plusieurs conditions, on peut utiliser le match:

let number = 3i8;

match number {
    10...100 => println!("La variable est entre 10 et 100 (inclus)"),
    5 | 8    => println!("Egal à 5 ou 8"),
    _ => println!("I don't know"),
}

Les boucles

Là aussi, pas de différence. Nous pouvons utiliser le while:

let mut number = 3;

while number != 0 {
    println!("{}!", number);

    number = number - 1;
}

… ou la boucle for:

let a = [10, 20, 30, 40, 50];

for element in a.iter() {
    println!("the value is: {}", element);
}

Les fonctions

Définies par le mot clé fn, les paramètres et la valeur de retour sont typés. La valeur de retour est la dernière ligne qui ne comporte pas de ; à la fin. Pour plus de lisibilité, on peut utiliser le mot clé return.

fn multiply(a: i8, b: i8) -> i8 {
  a * b
}

Un peu plus loin dans le langage

Nous avons vu toutes les notions de base de Rust. Jusqu’ici, il y avait beaucoup de similitudes par rapport au C. Attaquons les notions plus avancées (et il y en a!).

Référence

Comme le C ou bien le C++, Rust utilise les pointeurs. Au lieu de copier la variable, nous travaillons directement sur une référence de celle-ci.

Pour créer une référence, il suffit de préfixer la variable d’un &.

let mut hello = "Bonjour";
println!("{:?}", hello); // => "Bonjour"
println!("{:?}", &hello);// => "Bonjour"
hello = "Holla";
println!("{:?}", &hello);// => "Holla"

Rust gère la mémoire pour nous mais n’utilise pas de garbage collector qui passe afin de libérer la mémoire. Il utilise un système d’Ownership. Les variables “vivent” dans un scope limité et la mémoire est libérée au fur et à mesure.

if true {
    let me = "Alex";
}
println!("{:?}", alex);
// => cannot find value `alex` in this scope

Dandling pointers

Il s’agit là d’une des plus grandes forces de Rust. L’un des plus gros problèmes des pointers sont les Dandling pointers.

Imaginez que vous utilisez un pointer vers une variable définie dans un scope. Nous avons vu qu’en sortant du scope (fn, if, etc..) cette variable est libérée. Nous obtenons donc un pointer vers une référence nulle. Ceci augmente la mémoire utilisée par votre programme et crée une fuite de mémoire.

Voici un exemple avec Rust.

// Fonction qui va créer un dangling pointer
fn dangle() -> &String {
    // on crée une variable limitée au scope de la fonction
    let s = String::from("hello");
    // on renvoie une réference qui pointe sur la variable
    // limitée au scope de la fonction
    &s
}
fn main() {
    let reference_to_nothing = dangle();
    // => missing lifetime specifier
}

Une des plus grandes forces de Rust est qu’il ne vous laissera pas compiller ce code.

Le système d’appartenance

Voici un autre gros point fort de Rust. Une fonction ne peut pas modifier un pointeur qui ne lui appartient pas.

Prenons cet exemple. Une fonction change permet d’ajouter le text ”, world” à la fin d’un texte donné en paramètre. Nous utilisons un pointeur vers le texte qui sera modifié.

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

    change(&s);
}

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

Le problème est que Rust refusera de compiler ce code.

cannot borrow immutable borrowed content *some_string as mutable

Pour que ce code compile, il faut spécifier que la valeur pourra être modifiée par la fonction avec la mention &mut.

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

    change(&mut s);
}

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

Structure

Si vous venez d’un langage orienté objet (Ruby, PHP, Java, etc..), les structures correspondent un peu aux classes.

Prenons une Human qui possède un name et un sex (jusque là ça se tient).

#[derive(Debug)]
struct Human {
    name: String,
    sex: String,
}

fn main() {
    let alex = Human {
        name: String::from("Alexandre"),
        sex: String::from("Big"),
    };
    println!("{:?}", alex);
    // => Human { name: "Alexandre", sex: "Big" }
}

Comme en POO, nous pouvons ajouter des méthodes sur ces structures avec les implémentations. Pour cela on utilise impl. Pour les fonctions, si elle prennent en paramètre &self, ce sont des méthodes d’instances. Si elle ne prennent pas en paramètre &self, ce sont des métodes statiques.

Essayons ça avec une une méthode describe qui permet d’afficher les informations.

struct Human {
    name: String,
    sex: String,
}

impl Human {
    fn describe(&self) {
        println!("My name is {} and my sex is {}", self.name, self.sex);
    }
}


fn main() {
    let alex = Human {
        name: String::from("Alexandre"),
        sex: String::from("Big"),
    };
    alex.describe();
    // => My name is Alexandre and my sex is Big
}

Les enum

alex est bien sympa, mais au lieu de remplir le champs sex par Male ou Femmale, il a mis “big” (le con!).

Pour parer à cela, nous pouvons forcer le choix à des types définis avec un enum ressemblant à cela

#[derive(Debug)]
enum Sex {
    Male,
    Femmale
}

Et pour implémenter l’enum à notre classe, il suffit de changer le type de l’attribut sex:

struct Human {
    name: String,
    sex: Sex,
}

impl Human {
    fn describe(&self) {
        println!("My name is {} and my sex is {:?}", self.name, self.sex);
    }
}

Et maintenant l’instanciation de notre alex ne peut plus choisir autre chose que les deux sexes proposé:

fn main() {
    let alex = Human {
        name: String::from("Alexandre"),
        sex: Sex::Male,
    };
    alex.describe();
    // => My name is Alexandre and my sex is Male
}

Les options

Imaginons qu’un Human puisse ne pas avoir de sex (pourquoi pas?). Rust implémente la notion d’Option

struct Human {
    name: String,
    sex: Option<Sex>,
}

Maintenant que le sex est optionnel, nous pouvons créer alex sans sex:

fn main() {
    let alex = Human {
        name: String::from("Alexandre"), 
        sex: None
    };
    alex.describe();
    // => My name is Alexandre and my sex is None
}

Pattern matching & Gestion des erreurs

Reprenons l’exemple précédent. Nous voulons implémenter une fonction d’instance print_my_sex afin de savoir à tout moment si l’Human possède un Sex ou non. Là où dans la majorité des langages on teste le résultat renvoyé par la fonction, avec Rust on utilise le pattern matching avec match. Voici l’implémentation:

impl Human {
    // affichage du sexe
    pub fn print_my_sex(&self) {
        match self.sex {
            Some(ref _sex) => println!("J'ai un sexe :)"),
            None => println!("Je n'ai pas de sexe :'("),
        }
    }
}

Maintenant on teste la fonction

fn main() {
    let mut alex = Human::new();
    alex.print_my_sex();// => "Je n'ai pas de sexe :'("
    // maintenant, on lui définit un sexe
    alex.sex = Some(Sex::Male);
    alex.print_my_sex();// => "J'ai un sexe :)"
}

Qu’importe la situation, notre code gère la situation. Cette méthode peut sembler lourde mais ainsi notre code est bulletproof!

Notre première librairie

Notre Human nous servira très certainement dans nos futurs projets.

Essayons maintenant de créer un Crate afin que notre Human soit ré-utilisable. Pour cela on utilise Cargo, le gestionnaire de dépendance de Rust.

$ cargo new human

Cargo nous crée un dossier human avec une arborescence de ce type

human/
├── Cargo.toml
└── src
    └── lib.rs
  • Cargo.toml contient les informations et les dépendances de notre librairie
  • src/lib.rs contient

Voyons de plus près:

$ cd human

On commence par créer un fichier main.rs dans le dossier src.

// src/main.rs
fn main() {
    println!("My first crate");
}

Plus besoin de compiler nos fichiers à la main, Cargo s’en charge pour nous

$ cargo run

My first crate

Le module

Maintenant nous allons créer un nouveau fichier pour notre Human. Il suffit de créer notre nouveau fichier human.rs dans la dossier src.

// src/human.rs

pub struct Size{
    pub length: i8
}

pub enum Sex{
    Male(Size),
    Femmale
}

pub struct Human {
    pub name: String,
    pub sex: Option<Sex>,
}

impl Human {

    pub fn print_my_sex(&self) {
        match self.sex {
            Some(ref _sex) => println!("J'ai un sexe :)"),
            None => println!("Je n'ai pas de sexe :'("),
        }
    }
}

Je n’ai pas parlé des pub. Ils signifient que la définition est publique et donc qu’elle peut être utilisée en dehors du fichier. On modifie un peu notre main.rs pour utiliser notre Human.

Pour importer notre fichier human.rs dans notre main.rs, il suffit alors d’utiliser mod. Dans le fichier main.rs, pour utiliser Human nous devons le préfixer du module. Comme ceci human::Human.

// src/main.rs

mod human;

fn main() {

    let size = human::Size{length: 8};

    let alex = human::Human {
        name: String::from("Alexandre"),
        sex: Some(human::Sex::Male(size))
    };
    alex.print_my_sex();// => "J'ai un sexe :)"
}

Et voilà. Il suffira d’ajouter ce crate à votre projet et de l’appeler en faisant

extern crate human;

Conclusion

Le gros avantage de Rust est bien évidement son compilateur qui nous protège vraiment des runtime erreurs en empêchant la compilation en amont. Il nous apporte des performances proche du C ce qui en fait un excellent choix pour les projets nécessitants de bonne performances (embarqué, jeux-vidéos, etc..).

De plus, le code écris en Rust est réutilisable et portable car le compilateur peut même être installé sur des architecture ARM (Raspberry PI).

Liens utiles