Acheter version papier

Rust

Fork me on GitHub

IV. Annexes

2. Comparaison avec C++

Ce chapitre n'a pas pour but de savoir quel langage est le meilleur des deux mais plutôt de comparer comment une chose est faite différemment. L'ordre ne revêt pas d'importance particulière non plus. Tous les concepts Rust évoqués ici sont abordés dans ce livre.

Les variables

En Rust comme en C++, on peut déclarer des variables sans déclarer leur type grâce à l'inférence :

auto valeur = 10;

En Rust :

Runlet valeur = 10;

La grosse différence entre les 2 langages, c'est qu'en Rust, quand on déclare une variable, on est obligés de l'initialiser (le fameux RAII "Resource Acquisition Is Initialization").

Gestion des erreurs

En C++, il existe 2 façons de gérer des erreurs : les exceptions et les valeurs de retours de fonctions/méthodes. En Rust c'est uniquement les valeurs de retours avec les types Option et Result.

Pour l'ouverture d'un fichier par-exemple :

ifstream input_stream;
input_stream.open("file", ios::in);
if (input_stream) {
    // Le fichier a bien été ouvert.
} else {
    // Gestion de l'erreur
}

En Rust :

Runmatch File::open("file") {
    Ok(file) => {
        // Le fichier a bien été ouvert.
    }
    Err(err) => {
        // Gestion de l'erreur.
    }
}

La seule différence ici est donc le typage fort en Rust qui force à matcher sur la valeur de retour pour pouvoir s'en servir.

Gestion de la mémoire

En C++ comme en Rust, on peut allouer de la mémoire dans la heap (le "tas") ou sur la stack (la "pile). Pour la stack, les deux langages sont similaires : le destructeur est appelé quand on le scope courant est détruit. Exemple :

void func() {
    string valeur = "baguette";

    // Le scope de `func` est détruit, donc le destructeur de `valeur`
    // est appelé et la mémoire utilisée est libérée.
}

En Rust :

Runfn func() {
    let valeur = String::from("baguette");

    // Le scope de `func` est détruit, donc le destructeur de `valeur`
    // est appelé et la mémoire utilisée est libérée.
}

Par-contre, pour la heap, les choses se passent différemment. Il est plus rare de s'en servir en Rust (par-rapport à la stack) et on le fera avec le type Box. La mémoire sera libérée automatiquement quand la Box sortira du scope.

En C++ par-contre, l'utilisation de la heap est beaucoup plus courante. Pour ce faire, on utilisera le mot-clé new et on devra libérer cette mémoire nous-même avec le mot-clé delete.

int *pointeur = new int;
if (pointeur) {
    *pointeur = 10;
    // On libère la mémoire.
    delete pointeur;
} else {
    // L'allocation de la mémoire a échoué.
}

En Rust :

Runlet valeur = Box::new(10);
// La mémoire est supprimée quand on sort du scope courant.

Métaprogrammation

La métaprogrammation est plus permissive en C++ car en Rust elle se fera uniquement au travers des traits. Un petit exemple avec une fonction retournant la plus grande valeur entre deux arguments :

template <typename T>
T get_max(T x, T y) {
    if (x > y) {
        return x;
    }
    return y;
}

En Rust :

Runfn get_max<T: PartialOrd>(x: T, y: T) -> T {
    if x > y {
        x
    } else {
        y
    }
}

Comme vous pouvez le voir, Rust a besoin de plus d'information que C++ car on a besoin de préciser que le type T doit implémenter le trait PartialOrd pour pouvoir utiliser l'opérateur > pour que ce code compile.

Les macros

En Rust, les macros sont beaucoup plus puissante : elles reçoivent un flux de tokens et en renvoient un autre, modifiant le code source qui sera ensuite compilé. On peut les considérer comme une extension du compilateur.

Cependant, pour les codes simples, on peut faire des comparaisons avec C++ :

Runmacro_rules! bonjour {
    ($name:literal) => {
        println!(concat!("Bonjour ", $name, " !"));
    }
}

fn main() {
    bonjour!("monde");
}

En C++ :

#define say_hello(name) std::cout << "Bonjour " << (name) << " !" << std::endl;

int main() {
    say_hello("monde");
    return 0;
}

Cependant là où les macros en Rust deviennent vraiment utiles, c'est quand on se sert des proc-macros. Cela peut permettre de faire en sorte que le compilateur de Rust compile un autre langage au moment de la compilation !

Par-exemple, la crate rinja transforme des templates Jinja en code Rust pendant la compilation. Et tout ce qu'il y a besoin de faire pour ça, c'est ajouter derive(Template) sur notre type :

Runuse rinja::Template;

#[derive(Template)]
// On indique quel template on veut compiler.
#[template(path = "template.html")]
struct Template {
    name: String,
}

fn main() {
    let template = Template { name: String::from("monde") };
    // Le trait `Template` implémente la méthode `render`.
    println!("{}", template.render().unwrap());
}

Multi-threading

C++ fournit une API pour des threads et des mutexes dans sa bibliothèque standard, cependant ça reste aux développeurs de s'assurer que leur code ne va pas créer des accès concurrents.

En Rust, c'est aussi fourni par la bibliothèque standard. Cependant, le système de type va tout simplement interdire d'utiliser un type dans un thread si elle n'implémente pas les traits Sync et Send. Il faudra donc utiliser des types implémentant ces 2 traits.

Prenons un exemple d'un thread qui met à jour une valeur dans un vecteur pendant que le thread principal affiche ce vecteur :

#include <chrono>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>

struct Data {
    std::vector<int> data;
    std::mutex mutex;
};

void update_vector(struct Data *v) {
    for (int i = 0; i < 10; ++i) {
        std::this_thread::sleep_for(std::chrono::milliseconds(10));
        // On locke la donnée.
        std::lock_guard<std::mutex> guard(v->mutex);
        // On la met à jour.
        v->data[2] = v->data[2] + 1;
    }
}

int main() {
    struct Data d = {
        {8, 4, 5, 9},
        std::mutex()
    };

    // On lance le thread.
    std::thread t(update_vector, &d);

    for (int i = 0; i < 10; ++i) {
        std::this_thread::sleep_for(std::chrono::milliseconds(10));
        // On locke la donnée.
        std::lock_guard<std::mutex> guard(d.mutex);
        // On l'affiche.
        for (auto value : d.data) {
            std::cout << value << ",";
        }
        std::cout << std::endl;
    }
    return 0;
}

Comme vous pouvez le voir, le mutex n'est pas lié à la donnée, c'est à l'utilisateur de lier les 2.

En Rust cela donne :

Runuse std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;

fn main() {
    let mut data = Arc::new(Mutex::new(vec![8, 4, 5, 9]));

    let data_copy = Arc::downgrade(&mut data);
    // On lance le thread.
    thread::spawn(move || {
        let data = data_copy.upgrade().expect("upgrade a échoué");
        for _ in 0..10 {
            thread::sleep(Duration::from_millis(10));
            // On locke la donnée.
            if let Ok(mut content) = data.lock() {
                // On la met à jour.
                content[2] += 1;
            }
        }
    });

    for _ in 0..10 {
        thread::sleep(Duration::from_millis(10));
        // On locke la donnée.
        if let Ok(content) = data.lock() {
            // On l'affiche.
            println!("{:?}", content);
        }
    }
}

Pour pouvoir afficher le vecteur tout en le mettant à jour, on est obligés de le garder dans un Mutex (qui implémente Sync) et dans un Arc (qui implémente Send).

Déclarations

C++ hérite directement du C de ce point de vue : si on veut utiliser quelque chose, il faut que ce quelque chose soit déclaré avant d'être utilisé. Si cela se trouve dans un autre fichier, il faudra inclure un fichier header décrivant cet objet.

En Rust, on peut se servir d'un item tant que cet item est accessible dans le scope courant parce qu'il y est déclaré ou parce qu'il y est importé.

#include <vector>

struct Data {
    // On peut se servir de `vector` parce qu'il a été importé dans le
    // `#include <vector>`.
    std::vector<int> data;
};

int func() {
    // On peut utiliser `Data` parce qu'il est déclaré avant.
    struct Data = {{0, 1}};
}

En Rust :

Runfn func() {
    // `Duration` est importé dans le scope courant donc c'est bon.
    let s = Duration::from_millis(10);
    // `Bonjour` est déclaré dans le scope courant donc c'est bon.
    let s = Bonjour;
}

use std::time::Duration;

struct Bonjour;

Gestion des dépendances

C++ ne possède pas d'outil officiel (bien qu'il en existe un certain nombre) pour gérer un projet et ses dépendances. Parmi les outils les plus connus, il y a Makefile et CMake. Le premier ne gère pas les dépendances tandis que le deuxième fournit les outils pour, mais cela reste au développeur de gérer ça.

En Rust, il y a cargo. Il permet de gérer le build ainsi que les dépendances d'un projet, que ce soit à partir d'un dépot git ou bien de crates.io (qui centralise toutes les crates publiées).

Outils autour du langage

C++ a beaucoup d'outils mais rien qui soit officiel. Cependant, clang fournit un linter et même un formatteur de code. Mais de manière générale, si on veut faire quelque chose en C++, il faudra chercher et installer soi-même l'outil.

En Rust, tout tourne autour de cargo. Il y a un linter officiel (clippy), un formatteur de code (rustfmt), un outil pour générer la documentation (rustdoc), un outil pour garder Rust et ses outils à jour (rustup), etc. Les outils étant des crates, ils sont disponibles sur crates.io et donc installables avec cargo.

Cross-compilation

La cross-compilation est un sujet complexe en C++ et il n'y a rien de standard.

En Rust, cargo et rustup simplifient grandement les choses. Par-exemple pour compiler depuis Linux vers Android :

# On installe la target Android.
rustup target add arm-linux-androideabi
# On compile vers cette target.
cargo build --target=arm-linux-androideabi