Depuis le premier « vrai » article de la série Premiers pas en Rust, j’ai plutôt bien avancé sur le développement de bsc.

Il est donc temps pour moi de vous faire part des nouveautés de l’outil, et surtout, de ce que j’ai appris en Rust durant ces derniers jours.

Dans cet article, je vais donc évoquer la toute première sub-command, permettant de générer tout le squelette d’un projet C. Et on va voir ensemble les premières étapes de base pour générer le squelette du projet C. Cela me permettra aussi de vous partager ce que j’ai appris sur quelques notions fondamentales du langage.

Alors paré(e) ? C’est parti !

 

Petite piqûre de rappel

 

Vous vous souvenez peut-être, à la fin de l’article précédent, nous nous étions arrêtés à ces lignes de code :

match matches.subcommand() {
    ("create", Some(create_matches)) => create_project("./", create_matches.value_of("PROJECT_NAME").unwrap()),
    ("", None) => println!("No subcommand was used"), // If no subcommand was used it'll match the tuple ("", None)
    _ => unreachable!(), // If all subcommands are defined above, anything else is unreachable!()
}

 

En fonction de la sub-command choisie, on effectue différentes actions.

Pour le moment, c’est facile, nous n’avons qu’une seule sub-command correspondant à la première ligne du match :

bsc create PROJECT_NAME

 

On va donc voir aujourd’hui ce qui se cache derrière la fonction create_project qui est appelée par cette sub-command, et qui prend donc en paramètres le chemin du projet (ici, cela sera constamment le chemin du dossier courant, mais j’ai fait le choix de pouvoir spécifier ce chemin dans le cas d’une éventuelle évolution future), et le nom du projet, renseigné dans la sub-dommand (PROJECT_NAME ci-dessus).

La fameuse fonction create_project, située juste en dessous la fonction main dans le fichier main.rs :

fn create_project(path: &str, project_name: &str){
    create::create_project(&path, &project_name);
    dependencies_handler::update_dependencies_file("", &path, &project_name);
}

 

Son objectif est simple :

  • Créer un projet en générant le squelette de base
  • Créer le fichier pour gérer par la suite les dépendances, c’est a dire les modules dont dépend ce projet

 

Découpage du code source en plusieurs fichiers

 

Mon code commence maintenant à devenir un peu plus conséquent, et il est temps de débuter son découpage pour le structurer et l’ordonner au mieux.

Personnellement, je suis un adepte de l’adage :

 

La simplicité est le secret de la réussite

 

J’ai donc opté pour un découpage très simple, qui, je trouve, convient très bien à ce petit projet.

Chaque sub-command possède des fonctionnalités différentes, donc en terme de code, cela induit des fonctions spécifiques. Je rassemble toutes ces fonctions dans un fichier portant le nom  de la sub-command.

Par exemple, ici, j’ai créé un nouveau fichier Rust create.rs, dans le dossier src qui contient le code source spécifique lié à la création d’un projet.

Cela dit, les sub-command peuvent avoir des fonctionnalités réutilisant du code déjà écrit ailleurs (par exemple ouvrir un fichier, lire son contenu, lister les fichiers d’un dossier…). Donc je créé également un fichier nommé common.rs, toujours dans src qui contiendra tout le code réutilisé constamment un peu partout.

L’architecture du code source ressemble donc à ceci pour le moment :

$ cd src
$ git ls-files // Commande disponible via git-bash permettant d'avoir le même "ls" que sous linux.

common.rs
create.rs
main.rs

 

Maintenant, la question se pose de savoir comment appeler du code d’un autre fichier en Rust.

En gros, trouver, gross-modo, l’instruction équivalente au #include du C/C++, import du Java, require du JS…

Il s’avère que, dans le fichier main.rs, si je souhaite utiliser du code d’un autre fichier situé dans le même dossier (src), c’est très simple. Il suffit de préfixer le nom du fichier source qu’on souhaite utiliser par mod.

Par exemple, pour utiliser le code source du fichier create.rs, dans le fichier main.rs, j’ai juste à ajouter cette ligne tout en haut :

mod create;

 

Le compilateur va automatiquement chercher un fichier create.rs dans le dossier contenant main.rs, et importer son code source, en lui appliquant un namespace automatiquement.

C’est pour cela que dans la fonction create_project du fichier main.rs évoquée ci-dessus, il y a le préfixe create:: 

fn create_project(path: &str, project_name: &str){
    create::create_project(&path, &project_name); // Ici, j'utilise le namespace automatiquement créé pour appeler la fonction create_project du fichier create.rs
    dependencies_handler::update_dependencies_file("", &path, &project_name);
}

 

Maintenant, regardons de plus près ce qui se trouve dans le fichier create.rs jusqu’ici :

#[path = "common.rs"] mod common;


/*************/
/* FUNCTIONS */
/*************/
pub fn create_project(path: &str, project_name: &str){
    println!("Project name: {}", &project_name);
    /* instructions... */
    println!("The project is correclty created.");
}

 

Okay, pourquoi il y a la ligne #[path = « common.rs »] mod common; ?

 

Cette instruction pré-processeur est toute simple, elle permet simplement de spécifier au compilateur que j’inclus le code contenu dans mon fichier common.rs, et comme je ne suis plus dans le fichier main.rs (ni au passage dans un fichier nommé mod.rs, je vous invite à lire la documentation officielle sur ce sujet), il faut que je spécifie le chemin du fichier.

Ici, au final, je ne fais que dire au compilateur que le namespace « common » va me permettre d’appeler le code source public (déclaré grâce au mot clef pub) du fichier common.rs situé dans le même dossier courant.

Donc pour appeler les fonctions/structures… de common.rs, je dois utiliser le préfixe « common:: ».

 

Pour les quelques personnes possédant déjà de l’expérience en Rust et qui seraient amenées à lire ces lignes, rassurez-vous. Je sais que ce n’est pas la bonne manière d’inclure du code depuis d’autres fichiers, puisque le code importé est censé s’organiser sous forme de module.

Disons tout simplement que j’ai décidé de faire de cette manière pour mieux comprendre comment le compilateur effectue le linkage du code dispatché dans plusieurs fichiers. 😉
En revanche, si vous débutez en Rust, et que vous souhaitez savoir ce qu’est un module, et quelles sont les bonnes pratiques d’organisation du code dans un projet Rust, je vous invite à lire la documentation officielle très bien faite contenant des exemples d’utilisation des modules.

 

Bref, pour en revenir à la notion de public/privé, sachez que le code source inclut depuis un fichier annexe est privé par défaut en Rust. C’est pour cela que la fonction ci-dessus est déclarée avec le mot-clef pub.

Si ce n’était pas le cas, je ne pourrais donc pas l’appeler depuis main.rs.

Au final, pour clore cette partie, deux choses à retenir sur la structure du code en Rust (hors crates, notion évoquée dans le premier article) :

  • Utiliser les modules pour découper son code, comme indiqué dans la documentation officielle (méthode préconisée)
  • Utiliser l’instruction #[path = « chemin_fichier »] mod nom_fichier

Encore une fois, j’ai choisi intentionnellement de partir sur la deuxième méthode pour mieux comprendre le fonctionnement de Rust.

 

Génération du squelette de base du projet C : création des dossiers

 

Revenons à nos moutons !

La sub-command bsc create appelle la fonction create_project du fichier main.rs qui elle-même, sollicite une autre fonction create_project mais située dans create.rs cette fois.

Cette dernière fonction représente dorénavant le point d’entrée de toutes les instructions nécessaires à la création d’un nouveau projet C.

La question est maintenant de savoir quelles instructions coder pour générer le projet C.

Voici donc la liste de ces fameuses instructions à développer :

  • Créer des dossiers :
    • bsc_modules, qui contiendra le code des modules de notre package manager (sur le même principe que node_modules pour nodejs)
    • src, pour stocker les fichiers sources (.c) du projet
    • test, la même chose que src mais qui contiendra les fichiers sources pour les tests fonctionnels
    • include, pour stocker les fichiers headers (.h) du projet
  • Créer des fichiers :
    • CMakeLists.txt, pour configurer cmake. Il y en a trois à créer, un pour le projet global, à la racine, et deux autres pour gérer la génération du programme normal (dans src), et du programme des tests fonctionnels (dans test)
    • dependencies.bsc, permettant de lister les dépendances du projet, leur version… Sur le même principe que Cargo.toml en Rust, ou encore packages.json en nodejs

 

Comme vous l’aurez peut-être déjà deviné, je vais avoir besoin de code plutôt « standard », qui va sans aucun doute être utilisé un peu partout ailleurs dans ce projet comme une fonction pour gérer la création de fichier, de dossier, récupérer leurs contenus…

Je vais donc commencer par écrire une fonction me permettant de créer un dossier que je place dans le fichier common.rs, comme convenu précédemment.

// Dans le fichier common.rs

use std::fs; // Pour utiliser le code du module fs (file system) de la librairie standard (manipulation de fichiers/dossiers...)
use std::path; // Pour utiliser la structure Path de la librairie standard permettant de gérer les chemins des fichiers/dossiers
use std::error::Error; // Pour pouvoir gérer les erreurs le cas échéant


/*************/
/* FUNCTIONS */
/*************/
pub fn create_folder(folder_path: &str){
    if path::Path::new(&folder_path).exists(){ // Si le dossier existe déjà, pas la peine de continuer
        return;
    }

    match fs::create_dir(folder_path){
        Err(why) => panic!("Error occured when creating the \"{}\" folder. {}", &folder_path, why.description()), // Si une erreur intervient, j’arrête le programme, et j'affiche un message d'erreur (par exemple cela peut arriver si l'utilisateur n'a pas les droits pour créer un dossier dans le répertoire courant...)
        Ok(_) => (), // En cas de réussite, je ne fais rien, tout est bon ! Le dossier est bien créé  
    }
}

 

Cette fonction create_folder est très simple : elle prend en paramètre une référence sur une chaine de caractères, et créée un dossier si ce dernier n’existe pas, ou remonte une erreur si la création du dossier a échoué, en indiquant via un message la raison de l’erreur (why.description()).

Ce message d’erreur, vous l’aurez compris, est affiché via la macro panic!(« message »), de la libraire standard.

Cette macro, en plus d’afficher le message, va immédiatement arrêter le programme. C’est le même fonctionnement que les asserts en C/C++.

Vous remarquerez également qu’en Rust, l’affichage d’un message à l’écran contenant des variables, est assez similaire de tout autre langage de programmation moderne. Il suffit d’écrire {} aux emplacements voulus, et de lister par la suite, dans l’ordre, les variables qu’on souhaite afficher. Rien de bien compliqué.

Voici un tout petit exemple pour mieux comprendre :

let a = 5;
let b = 7;
// La fonction println! (le point d'exclamation fait partie du nom de la fonction) permet d'afficher un message à l’écran en Rust.
println!("Result {} + {} = {}", &a, &b, a+b); // on affiche a et b par référence, et on affiche aussi le résultat de l’opération

qui affiche donc le résultat suivant :

Result 5 + 7 = 12

 

Bref, pour en revenir à bsc, je peux dorénavant ajouter la création des dossiers, dans la fonction create_project de create.rs :

pub fn create_project(path: &str, project_name: &str){
    println!("Project name: {}", &project_name);
    common::create_folder(&format!("{}{}", &path, "src")); // création du dossier "./src"
    common::create_folder(&format!("{}{}", &path, "test")); // création du dossier "./test"
    common::create_folder(&format!("{}{}", &path, "include")); // création du dossier "./include"
    common::create_folder(&format!("{}{}", &path, "bsc_modules")); // création du dossier "./bsc_modules"
    println!("The project is correclty created.");
}

 

Qu’est ce-ce que signifie &format!(…) ici ?

 

Pour créer ces dossiers, j’ai besoin de fournir à ma fonction create_folder de common.rs les chemins des dossiers, sous forme de chaines de caractères (string).

Il faut donc que, pour chaque dossier, je concatène le chemin « global » correspondant à la variable path (dans mon cas, c’est « ./ ») avec le nom du dossier que je souhaite créer).

Et il s’avère que pour concaténer une chaine de caractere, en Rust, on utilise la macro format!. Son fonctionnement est très simple, et identique aux macros panic! et println! vues précédemment.

 

On ne peut pas faire un simple + pour additionner le contenu de plusieurs chaines de caractères, comme dans la majorité des autres langages de programmation ?

 

Si, on pourrait. Mais il faudrait utiliser des variables du type String, et non des str

 

Euh, c’est à dire ?

 

C’est parti pour une longue explications de ces deux types de variables permettant de gérer les chaines de caractères en Rust : str et String !

Laissez-moi commencer par une petite comparaison de ces deux structures, avant de rentrer dans des exemples concrets.

Le type str en Rust, est le type le plus primitif permettant de stocker une chaîne de caractères (aussi appelé string slice) quelque part en mémoire, et d’une taille fixe. Une variable de type str est immutable (ne peut pas être modifiée).

String quant à lui permet de faire la même chose en instanciant sur la pile, sa taille peut-être grandir.

Dans tous les cas, str et String permettent de stocker des chaines de caractères en encodage UTF-8.

Pour être plus exact concernant le type str, sa taille est par défaut fixe. Cela dit, en ajoutant le mot-clef mut on peut modifier son contenu, comme on va le voir par la suite.

 

Petit rappel pour les programmeurs débutants, un scope, est tout simplement un bloc de code délimité par des balises « { » et « } », qui défini la durée de vie d’une variable déclarée sur le tas (stack) dans ce bloc. En gros, toute variable déclarée dans ce bloc n’existe plus en dehors. Cela peut-être une fonction, une boucle for, une condition if…

 

Bref, l’immutabilité est la base d’un principe extrêmement important en Rust (si ce n’est le plus important) concernant la gestion de la mémoire : lOwnership.

 

Je préfère prévenir : comme je découvre le Rust en même temps que j’écris cette série, je n’ai, pour l’heure, que peu d’expérience avec ce langage. Cela induit donc que certaines notions associées à ce sujet sont encore un peu « obscures » pour moi. 
Je ne vais pas trop rentrer dans les détails intentionnellement. Mais cela pourra faire l’objet d’articles dans le futur, une fois que j’aurai acquis plus d’expérience, si cela vous intéresse. 🙂

 

Passons aux exemples, je pense que ça sera plus parlant :

fn main(){
    // Ces deux déclarations sont similaires
    let text = "text"; // Déclaration d'un str, par défaut statique, d'une taille fixe
    let text2: &str = "text";

    let mut text3 = text; // Déclaration d'un str mutable, c'est a dire qu'on peut modifier la chaine de caractère de cette variable.
    text3 = "text3"; // Puisque c'est mutable, je peux donc modifier ma chaine de caractères pour ajouter du texte

    // Ces deux déclarations sont similaires
    let text4 = String::new(); // Déclaration d'une variable String instanciée sur la pile, qui ne contient rien pour le moment
    let text5: String = String::new();

    // Ces deux déclarations sont similaires
    let text6 = String::from("test6"); // Déclaration d'un String contenant "test6"
    let text7 = "test6".to_string();

    // Ces deux lignes sont identiques
    // Dans les deux cas, le mot clef & signifie qu'on passe la valeur des variables de type str par référence
    let text4 = String::from(&text); // Je peux déclarer ou modifier la valeur d'un String en lui passant une nouvelle chaine de caractère avec String::from()
    let text5 = &text.to_string();

    // Remarquez que je peux aussi écrire :
    let text5 = "test5".to_string(); // Tout type primitif implémente de base le trait Display. Le compilateur permet donc appeler la fonction to_string() directement sur des nombres, chaines de caractères... 

    // Concaténation de plusieurs chaines :
    let text_concat_str = "Hello";
    let text_concat_str2 = " World !";
    let text_concat_str3 = &format("{}{}", &text_concat_str, &text_concat_str2); // = "Hello World !"

    let text_concat_string = String::from("Hello");
    let text_concat_string2 = String::from(" World !");
    let text_concat_string3 = text_concat_string + text_concat_string2; // = "Hello World !"
    // A noter qu'on peut obtenir le même résultat en utilisant aussi la macro format!() pour le type String
}

 

On ne peut donc jamais modifier un str passé par référence dans une autre fonction par exemple.

Soit on utilise une copie et on retourne la copie, soit on utilise une variable de type String.

Pour que ce soit un peu plus claire, voici un nouvel exemple de ce qui ne fonctionne pas :

fn main(){
    let mut text = "test";
    read_test(&mut text); // on passe la variable text par référence en spécifiant qu'elle est mutable (donc modifiable par read_test)
}

pub fn read_test(text: &mut str){
    text = "new_test";
}

Le compilateur lève l’erreur : on ne peut pas modifier une variable de type str en dehors de son scope, même si cette dernière est déclarée comme mutable.

$ cargo run
   
  Compiling article v0.1.0 (file:///C:/Users/Elkantor/Documents/Projects/rust_article/article)
error[E0308]: mismatched types
  --> src\main.rs:28:12
   |
28 |     text = "new_test";
   |            ^^^^^^^^^^ types differ in mutability
   |
   = note: expected type `&mut str`
              found type `&'static str`

error: aborting due to previous error

 

Okay, donc d’après le compilateur, on doit forcement assigner une référence sur un str mutable à la variable qu’on souhaite modifier…

Juste pour l’exemple, modifions un peu la fonction read_test pour tenter cette assignation :

fn main(){
    let mut text = "test";
    let mut text2 = "new_test";
    read_test(&mut text, &mut text2); // On passe en parametres des references des variables text et text2, en specifiant qu'elles sont mutables
}

pub fn read_test(text1: &mut str, text2: &mut str){
    text1 = text2; // On remplace le contenu de text1 par celui de text2. 
    // Logiquement, on respecte ici les regles du compilateur, car on a bien un &mut str qui remplace un autre &mut str
}

Voilà le résultat après compilation :

$ cargo run
   
  Compiling article v0.1.0 (file:///C:/Users/Elkantor/Documents/Projects/rust_article/article)
error[E0623]: lifetime mismatch
  --> src\main.rs:29:13
   |
28 | pub fn read_test(text1: &mut str, text2: &mut str){
   |                         --------         -------- these two types are declared with different lifetimes...
29 |     text1 = text2;
   |             ^^^^^ ...but data from `text2` flows into `text1` here

error: aborting due to previous error

 

Intéressant non ? Selon le compilateur, les deux variables text1 et text2 (qui sont simplement des références mutables sur text et text2 de la fonction main) n’ont pas la même durée de vie.

Bref, voilà la preuve concrète qu’il est impossible de modifier une variable de type str en dehors de son scope. Et croyez-moi, j’ai pas mal cherché avant de comprendre ça ;).

 

Le seul et unique moyen d’arriver à modifier la chaine de caractère, consiste à passer par un String :

fn main(){
    let mut text = String::from("test");
    read_test(&mut text); // on passe la variable text par référence en spécifiant qu'elle est mutable
}

pub fn read_test(text: &mut String){
    *text = String::from("new_test"); // le signe * devant "text" signifie, comme en C/C++, que je déréférence le String text pour lui assigner sa nouvelle valeur.
}

 

Au final, ce qu’il faut retenir selon moi :

  • Si on souhaite utiliser des chaines de caractères dans le même scope, ou accéder à leur valeur sans la modifier => str
  • Si on souhaite utiliser des chaines de caractères dans n’importe quel autre cas => String

 

Et voilà, on arrive à la conclusion de cet article.

Pour être tout a fait honnête avec vous, j’ai eu un peu de mal à rédiger cette deuxième partie de cette série sur Rust. Il y a plusieurs concepts que je souhaitais évoquer en détails, comme les modules par exemple, avant de me rendre compte que cela alourdissait l’article, rendant sa lecture moins interessante.

J’ai du réécrire plusieurs fois certains passages (notamment concernant les String et les str), et supprimer d’autres.

Bref, cela m’a pris bien plus de temps que j’avais initialement prévu (environ 6 heures contre 3, pour les curieux), mais j’espère vraiment que cela vous intéresse.

J’ai pu améliorer quelques points sur la forme vis a vis de l’article précédent selon vos conseils (moins de storytelling par exemple), et j’espère continuer de m’améliorer au fur et à mesure, avec votre aide. Mon but est vraiment de rendre cette série encore plus agréable à suivre – et surtout la plus claire possible – tout en continuant de vous partageant mon experience.

Dans tous les cas, je vous remercie encore une fois d’avoir pris le temps de lire cet article jusqu’au bout, et à bientot pour l’episode 3 !

 

 

 

Merci d’avoir pris le temps de lire cet article. Est-ce que cela vous a intéressé ? Est-ce que vous avez appris quelque chose ? N’hésitez pas à me partager votre opinion sur le sujet via les commentaires ci-dessous.

Et si vous aimez ce contenu, vous pouvez vous abonnez à ma newsletter (si le coeur vous en dit), afin de rester informé des dernières nouveautés du blog, et également pour recevoir (pas plus d’une fois par semaine), d’autres contenus que je juge intéressant sur tout ce qui touche la programmation ou le développement personnel.

Inscription à la newsletter :

[newsletter]

 

Victor Gallet

Victor Gallet

Étudiant programmeur jeu vidéo. J'aime par dessus tout apprendre, et je suis un éternel curieux de tout. Mon principal but dans la vie est d’être une meilleure personne, et de partager mes (faibles) connaissances avec les autres.

Leave a Reply

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.