Domaines clés où Rust s’écarte de ce qui est typique dans d’autres langages de programmation populaires
Après avoir entendu parler du langage de programmation Rust sur Reddit et d’autres plates-formes similaires, j’ai décidé de commencer à l’apprendre plus tôt cette année. Alors que je traversais le Livre en ligne Rustj’ai constaté que de nombreuses hypothèses et conventions courantes présentes dans d’autres langages avaient été complètement révisées dans Rust.
Depuis, j’ai développé une vision positive de Rust et je crois qu’il a un bel avenir dans le domaine du développement logiciel. Pour ceux d’entre vous qui n’ont pas encore essayé Rust, je voudrais présenter quelques domaines où il s’écarte du statu quo.
Le problème de la gestion manuelle de la mémoire
En ce qui concerne la gestion de la mémoire, une opinion commune est que la gestion de la mémoire en toute sécurité n’est vraiment possible qu’avec un langage récupéré.
Dans un langage comme C, le programmeur a le contrôle total de la mémoire. Elle peut directement accéder/modifier le contenu des blocs de mémoire, déréférencer un pointeur vers n’importe quelle adresse (même une adresse absurde), et peut allouer et désallouer de la mémoire à tout moment.
Dans ces langages, la mémoire est gérée manuellement par le programmeur, ce qui permet plus de liberté au prix d’un risque élevé d’erreurs d’exécution obscures.
Considérez ce code en C qui a un pointeur pendant :
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
// Pool of first names
const char* firstNames[] = {
"John",
"Jane",
"Bob",
"Stacy",
"Sally",
};
// Pool of last names
const char* lastNames[] = {
"Smith",
"Doe",
"Roberts",
"Miller"
};
// Lengths for each array
int firstNamesLength = 5;
int lastNamesLength = 4;typedef struct {
const char* firstName;
const char* lastName;
} Person;
// Returns a pointer to the first of five random Person structs
Person** getFiveRandomPersons() {
// Set random seed using system time
srand(time(NULL));
Person* persons[5];
for (int i = 0; i < 5; i++)
{
Person person;
// Get a random first name from the first names array
int firstNameIndex = rand() % firstNamesLength;
// Get a random last name from the last names array
int lastNameIndex = rand() % lastNamesLength;
// Define first and last name on person
person.firstName = firstNames[firstNameIndex];
person.lastName = lastNames[lastNameIndex];
// Set ith pointer in array to address of person
persons[i] = &person;
}
return persons;
}
int main() {
Person** randomPersons = getFiveRandomPersons();
// Print each person to the console
for (int i = 0; i < 5; i++)
{
Person* randomPerson = randomPersons[i];
printf("randomPerson #%d: %s %s\n", i + 1, randomPerson->firstName, randomPerson->lastName);
}
return 0;
}
// Output: runtime error
Dans ce code, nous voulons générer cinq personnes aléatoires en utilisant deux tableaux, firstNames
et lastNames
. Le problème est que nous définissons notre persons
tableau dans la méthode getFiveRandomPersons
de sorte que lorsque nous revenons de ladite méthode, le tableau est extrait de la mémoire de la pile.
Ainsi, lorsque nous essayons d’imprimer les personnes aléatoires, nous obtenons une erreur d’exécution pour accéder à la mémoire libérée. Une solution à cela serait d’allouer en tas à la fois le persons
tableau et chaque personne dans getFiveRandomPersons
ainsi:
Person** getFiveRandomPersons() {
// Set random seed using system time
srand(time(NULL));
// Allocate a block of five Person struct pointers
Person** persons = (Person**)malloc(sizeof(Person*) * 5);for (int i = 0; i < 5; i++)
{
// Allocate memory on heap for Person struct
Person* person = (Person*)malloc(sizeof(Person));
// Get a random first name from the first names array
int firstNameIndex = rand() % firstNamesLength;
// Get a random last name from the last names array
int lastNameIndex = rand() % lastNamesLength;
// Define first and last name on person
person->firstName = firstNames[firstNameIndex];
person->lastName = lastNames[lastNameIndex];
// Set ith pointer in array to address of person
persons[i] = person;
}
return persons;
}
Néanmoins, nous devons maintenant nous assurer que nous libérons manuellement la mémoire lorsque nous finissons d’utiliser notre persons
déployer.
Voici une fonction qui va libérer notre mémoire :
void deleteFiveRandomPersons(Person** randomPersons) {
// Free each Person struct first
for (int i = 0; i < 5; i++)
{
free(randomPersons[i]);
}
// Then free the array of pointers to Person structs
free(randomPersons);
}
Et puis changer main
pour inclure un appel à deleteFiveRandomPersons
:
int main() {Person** randomPersons = getFiveRandomPersons();
// Print each person to the console
for (int i = 0; i < 5; i++)
{
Person* randomPerson = randomPersons[i];
printf("randomPerson #%d: %s %s\n", i + 1, randomPerson->firstName, randomPerson->lastName);
}
// Free persons array after using it
deleteFiveRandomPersons(randomPersons);
return 0;
}
// Sample Output
// randomPerson #1: Sally Smith
// randomPerson #2: John Roberts
// randomPerson #3: Sally Doe
// randomPerson #4: Bob Miller
// randomPerson #5: Jane Miller
Cela résout maintenant la fuite de mémoire et le problème de pointeur pendant. Cependant, nous devons maintenant nous rappeler d’appeler deleteFiveRandomPersons
pour chaque fois que nous appelons getFiveRandomPersons
ou sinon, des fuites de mémoire seront introduites dans l’application.
Pour cet exemple simple, ce n’est pas un problème important d’oublier cela, mais les applications qui exécutent en permanence du code avec des fuites de mémoire ralentiront l’application et entraîneront potentiellement des plantages.
Garbage collection comme alternative à la gestion manuelle de la mémoire
Considérez le code équivalent écrit en C#, un langage ramassé :
using static NameData;class Person
{
public string firstName = "";
public string lastName = "";
}
public static class NameData
{
// Pool of first names
private static string[] firstNames = new string[]
{
"John",
"Jane",
"Bob",
"Stacy",
"Sally",
};
public static string[] FirstNames { get => firstNames; }
// Pool of last names
private static string[] lastNames = new string[]
{
"Smith",
"Doe",
"Roberts",
"Miller"
};
public static string[] LastNames { get => lastNames; }
}
class RandomPersonGenerator
{
public Person[] getRandomPersons(int count)
{
// Allocate array of Persons with length of `count`
Person[] persons = new Person[count];
var random = new Random();
for (int i = 0; i < persons.Length; i++)
{
Person person = new Person();
// Get a random first name from the first names array
int firstNamesIndex = random.Next(0, FirstNames.Length - 1);
// Get a random last name from the last names array
int lastNamesIndex = random.Next(0, LastNames.Length - 1);
// Define first and last name on person
person.firstName = FirstNames[firstNamesIndex];
person.lastName = LastNames[lastNamesIndex];
// Set ith index of array to person
persons[i] = person;
}
return persons;
}
}
public static class MemoryManagement
{
public static void Main()
{
var randomPersonGenerator = new RandomPersonGenerator();
var randomPersons = randomPersonGenerator.getRandomPersons(5);
int i = 1;
// Print each person to the console
foreach (Person randomPerson in randomPersons)
{
Console.WriteLine($"randomPerson #{i++}: {randomPerson.firstName} {randomPerson.lastName}");
}
// Garbage collection cleans our randomPersons array for us after Main ends
}
}
// Sample Output
// randomPerson #1: Sally Smith
// randomPerson #2: John Roberts
// randomPerson #3: Sally Doe
// randomPerson #4: Bob Miller
// randomPerson #5: Jane Miller
En C#, notre persons
tableau dans getRandomPersons
est automatiquement alloué par tas. Ensuite, nous enregistrons une référence à celui-ci sur randomPersons
dans Main
.
Une fois que main
se termine, le ramasse-miettes libérera notre mémoire pour nous. Comme c’est pratique! Néanmoins, cette commodité a un coût – elle crée une surcharge d’exécution en diminuant la vitesse et en augmentant l’utilisation de la mémoire.
Malgré les inconvénients d’un langage récupéré, les langages les plus couramment utilisés aujourd’hui sont en fait récupérés, tels que C#, Java, JavaScript ou Python. Donc, rétrospectivement, nous voyons un compromis entre la gestion manuelle de la mémoire et le fait qu’un ramasse-miettes la gère pour nous.
Dans le premier cas, nous obtenons plus de performances et de contrôle au prix d’erreurs de mémoire probables. Dans ce dernier cas, nous obtenons une sécurité de mémoire pratiquement complète au prix d’une diminution des performances et de la surcharge d’exécution. Cette fausse dichotomie entre une mauvaise sécurité de la mémoire et le ramasse-miettes est exposée par Rust, car vous verrez que Rust garantit la même sécurité de la mémoire sans nécessiter de ramasse-miettes.
La solution de Rust pour la gestion de la mémoire : la vérification des emprunts à la compilation
Supposons que nous devions écrire le code C équivalent avec le pointeur pendant dans Rust. Cela pourrait ressembler à ceci :
// import rand crate for random generation
use rand::prelude::*;struct Person {
first_name: String,
last_name: String
}
impl Person {
fn new() -> Person {
return Person { first_name: String::new(), last_name: String::new() };
}
}
// Pool of first names
static FIRST_NAMES: &'static [&'static str] = &[
"John",
"Jane",
"Bob",
"Stacy",
"Sally"
];
// Pool of last names
static LAST_NAMES: &'static [&'static str] = &[
"Smith",
"Doe",
"Roberts",
"Miller"
];
fn get_five_random_persons<'a>() -> &'a [&'a mut Person; 5] {
let mut persons: [&mut Person; 5] = [
&mut Person::new(),
&mut Person::new(),
&mut Person::new(),
&mut Person::new(),
&mut Person::new()
];
// Create a random number generator seeded by the system
let mut random_number_generator = thread_rng();
for i in 0..persons.len() {
// Get random first name and last name
let first_name_index = random_number_generator.gen_range(0..FIRST_NAMES.len());
let last_name_index = random_number_generator.gen_range(0..LAST_NAMES.len());
// Set first_name on person to randomly selected first name
let first_name = FIRST_NAMES.get(first_name_index);
if let Some(first_name) = first_name {
persons[i].first_name = String::from(*first_name);
}
// Set last_name on person to randomly selected last name
let last_name = LAST_NAMES.get(last_name_index);
if let Some(last_name) = last_name {
persons[i].last_name = String::from(*last_name);
}
}
return &persons;
}
fn main() {
let random_persons = get_five_random_persons();
let mut i = 1;
// Print each random_person to the console
for random_person in random_persons {
println!("random_person #{}: {} {}", i, random_person.first_name, random_person.last_name);
i += 1;
}
}
// Output: compile-time error
Si vous tentez de compiler ce code, vous obtiendrez le message d’erreur suivant :
Dans Rust, chaque variable a exactement un propriétaire et il existe certaines règles pour emprunter la valeur via une référence. Parce que la durée de vie de persons
est le get_five_random_persons
portée de la méthode, le retour d’une référence viole la première règle d’emprunt de Rust, qui est que « tout emprunt doit durer pour une portée ne dépassant pas celle du propriétaire » [3]. Dans ce cas, la référence à persons
, random_persons
dure pour toute la portée de main
mais le propriétaire de persons
, get_five_random_persons
a une étendue qui se termine après la ligne suivante dans main
:
let random_persons = get_five_random_persons();
Ainsi, Rust attrape l’erreur au moment de la compilation qui aurait autrement causé une erreur d’exécution dans un langage comme C. De plus, dans un langage comme C #, le ramasse-miettes aurait ajouté une surcharge d’exécution pour gérer la mémoire.
Ce n’est qu’un exemple qui illustre l’approche unique de Rust en matière de sécurité de la mémoire. En passant, je ne vais pas entrer dans les nuances de la unsafe
bloc qui retarde parfois la vérification d’emprunt jusqu’à l’exécution.
Dans la grande majorité des cas, Rust s’assurera que son programme dispose d’une mémoire sécurisée au moment de la compilation. Avant de terminer cette section, voici une implémentation dans Rust qui respecte les règles de vérification d’emprunt :
use rand::prelude::*;struct Person {
first_name: String,
last_name: String
}
impl Person {
fn new() -> Person {
return Person { first_name: String::new(), last_name: String::new() };
}
}
// Pool of first names
static FIRST_NAMES: &'static [&'static str] = &[
"John",
"Jane",
"Bob",
"Stacy",
"Sally"
];
// Pool of last names
static LAST_NAMES: &'static [&'static str] = &[
"Smith",
"Doe",
"Roberts",
"Miller"
];
fn get_random_persons(count: i32) -> Vec<Person> {
let mut persons: Vec<Person> = vec![];
// Create a random number generator seeded by the system
let mut random_number_generator = thread_rng();
for _ in 0..count {
// Get random first name and last name
let first_name_index = random_number_generator.gen_range(0..FIRST_NAMES.len());
let last_name_index = random_number_generator.gen_range(0..LAST_NAMES.len());
// Define person
let mut person = Person::new();
// Set first_name on person to randomly selected first name
let first_name = FIRST_NAMES.get(first_name_index);
if let Some(first_name) = first_name {
person.first_name = String::from(*first_name);
}
// Set last_name on person to randomly selected last name
let last_name = LAST_NAMES.get(last_name_index);
if let Some(last_name) = last_name {
person.last_name = String::from(*last_name);
}
// Push each person onto the persons vector
persons.push(person);
}
// Move the persons vector to the caller's scope
return persons;
}
fn main() {
let random_persons = get_random_persons(5);
let mut i = 1;
// Print each random_person to the console
for random_person in random_persons {
println!("random_person #{}: {} {}", i, random_person.first_name, random_person.last_name);
i += 1;
}
}
// Sample Output
// randomPerson #1: Sally Smith
// randomPerson #2: John Roberts
// randomPerson #3: Sally Doe
// randomPerson #4: Bob Miller
// randomPerson #5: Jane Miller
Dans la solution ci-dessus, le persons
tableau est changé en vecteur et la propriété est transférée en déplaçant persons
à la portée de main
.
Ah oui, bon vieux null
, le mot clé qui désigne une non-valeur qui a été l’une des sources les plus courantes de bogues et de plantages en production. Null est inexistant dans Rust, et pour une bonne raison, pourrais-je ajouter. Considérez cet exemple en C# :
public string getContentsOfConfigFile(string fileName)
{
try
{
// Get the path to config file
var pathToFile = Path.Join(Directory.GetCurrentDirectory(), $"{ fileName }.config");
// Attempt to read from file
string configFileData = File.ReadAllText(pathToFile);
return configFileData;
}
catch (IOException)
{
// Return null when config file does not exist
return null;
}
}
Supposons que vous utilisiez cette fonction pour charger le contenu d’un fichier de configuration pour une application. Si le fichier existe, le contenu est chargé en mémoire et renvoyé. Cependant, si le fichier n’existe pas, un IOException
est intercepté et null est renvoyé.
Cela signifie qu’à chaque fois que cette méthode est appelée, une vérification nulle est requise avant d’accéder aux données de configuration. Une vérification if-null oubliée et l’application pourrait planter.
Regardons le code équivalent dans Rust :
fn get_contents_of_config_file(file_name: &str) -> Option<String> {
// Get the path to config file
let path_to_file = Path::new(&env::current_dir().unwrap()).join(format!("{}.config", file_name));
// Handle cases in case of error and return resulting expression
match fs::read_to_string(path_to_file) {
Ok(config_file_data) => Some(config_file_data),
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
Err(error) => panic!("An unexpected IO error occurred: {}", error),
}
}
L’alternative à null
à Rust est le Option<T>
enum générique, qui a deux variantes, Some(T)
et None
. Les énumérations de Rust diffèrent des énumérations dans d’autres langues en ce que chaque variante d’énumération peut contenir des données supplémentaires en elle-même. De plus, une instruction de correspondance est requise pour gérer chacun des cas de variante enum.
Dans ce cas, lorsque les données du fichier de configuration existent, un Some
variant est renvoyé à l’appelant et lorsqu’il n’existe pas un None
la variante est renvoyée à la place [1]. Vous remarquerez que l’instruction match dans le corps de la fonction est utilisée pour rechercher une erreur, qui sera abordée plus en détail dans la section suivante.
Considérez le code qui correspond à notre Option
revenu:
let config_data: Option<String> = get_contents_of_config_file("data");
match config_data {
Some(config_data) => println!("config_data: {}", config_data),
None => println!("No data to show."),
}
Penser à match
comme une instruction switch dans laquelle chaque cas est requis au moment de la compilation. Si un cas est omis de l’instruction match, une erreur de compilation est générée [4].
Ainsi, au lieu d’autoriser les erreurs de référence nulles lors de l’exécution, Rust oblige le programmeur à gérer les deux cas lorsque le Some
et None
les variantes sont renvoyées. Dans Rust, le programmeur n’a jamais à s’inquiéter d’oublier de vérifier null 🙂
Jetons un coup d’oeil au get_contents_of_config_file
méthode à nouveau :
fn get_contents_of_config_file(file_name: &str) -> Option<String> {
// Get the path to config file
let path_to_file = Path::new(&env::current_dir().unwrap()).join(format!("{}.config", file_name));
// Handle cases in case of error and return resulting expression
match fs::read_to_string(path_to_file) {
Ok(config_file_data) => Some(config_file_data),
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
Err(error) => panic!("An unexpected IO error occurred: {}", error),
}
}
En termes simples, les « vérifications nulles » dans Rust sont gérées de la même manière que les erreurs de gestion : en utilisant l’instruction de correspondance. Cela diffère largement du paradigme try-catch présent dans la plupart des langages de programmation populaires.
Dans le modèle try-catch, une opération est tentée et toutes les erreurs/exceptions qui en résultent peuvent être gérées dans le bloc catch ou renvoyées plus haut dans la pile des appels.
Dans Rust, une méthode qui peut générer une erreur renverra le Result<T, E>
enum, qui a deux variantes Ok<T>
et Err<E>
. Le premier contient la valeur résultant d’une opération réussie, et le second contient un objet d’erreur indiquant qu’un échec s’est produit [2].
Chaque fois qu’une méthode retourne un Result
enum, il doit être interrogé à l’aide d’une instruction de correspondance comme vous pouvez le voir ci-dessus. Dans le Ok
bras, nous renvoyons les données dans un Some
variante, et dans la première Err
bras nous retournons le None
variante chaque fois qu’il y a une exception de fichier introuvable (io::ErrorKind::NotFound
). De plus, dans la seconde Err
bras, nous paniquerons si nous recevons une erreur autre que io::ErrorKind::NotFound
.
La macro de panique est utilisée lorsqu’il y a une erreur irrécupérable, qui mettra fin au programme immédiatement. Dans l’exemple ci-dessus, nous pourrions retourner le None
variante au lieu de paniquer dans la seconde Err
bras. Choisir de paniquer ou de ne pas paniquer est une question importante à considérer lors de la gestion des erreurs dans Rust [5].
Pour conclure cette section, comme avec Option<T>
, Result<T, E>
les énumérations doivent être interrogées avec une instruction match, et l’instruction match doit gérer tous les cas possibles. None
variantes et Err
les variantes doivent être gérées au moment de la compilation.
De cette manière, Rust rend une application beaucoup plus résistante aux cas extrêmes que de nombreux langages courants qui attendent l’exécution pour évaluer les vérifications nulles et les exceptions.
Dans cet article, mon objectif était de présenter certaines des principales façons dont Rust s’écarte de ce qui est typique dans de nombreux langages de programmation populaires.
Les façons non conventionnelles de faire les choses ne sont pas toujours bonnes, mais je pense que dans le cas de Rust, c’est le cas. Si j’avais un dollar pour chaque fois qu’un null, une exception levée ou une erreur de gestion de la mémoire a interrompu ma progression de développement, alors je n’écrirais probablement pas cet article.
La conception du langage de Rust a été bien pensée pour éviter les pièges courants et, espérons-le, davantage d’entreprises reconnaîtront son utilité dans un proche avenir.
Merci d’avoir lu!
-Caleb