PROJET AUTOBLOG


Le Geek Café

Archivé

source: Le Geek Café

⇐ retour index

Mozilla développe son langage de programmation

lundi 23 janvier 2012 à 19:05
Vendredi dernier, la fondation Mozilla (bien connue pour son navigateur web libre Firefox), a annoncé sur sa liste de diffusion être parvenue à développer un premier compilateur pour son propre langage de programmation, baptisé Rust. Annoncé en juillet 2010, Rust est un nouveau langage de programmation système, visant à implémenter des concepts de programmation modernes. À terme, les développeurs de Rust espèrent en faire un langage de programmation rapide mais fiable, capable de remplacer des langages bas niveau comme C.

Pourquoi un nouveau langage ?

La situation actuelle

Le C et le C++ sont des langages quasiment universels, qui bénéficient de nombreuses implémentations sur plusieurs architectures très différentes. De plus, ils présentent l'avantage d'être (au moins partiellement) standardisés ; c'est-à-dire que, plutôt qu'une implémentation de référence qui montre comment fonctionne le langage, il existe des documents textuels qui précisent le comportement d'une grande partie des programmes C et C++ qui peuvent être écrits.

C'est un avantage : toute compilateur écrit pour ces langages est tenu de respecter ces normes, et le programmeur qui utilise une implémentation particulière du langage sait donc comment elle doit se comporter. En contrepartie, cependant, ces deux langages sont difficiles à faire évoluer, d'une part car trouver un consensus prend du temps (la norme C++11 a mis plusieurs années a être établie), d'autre part car il est difficile, étant donnée la forte utilisation des deux langages, de changer ce qui existe déjà.

En outre, l'informatique actuelle est sur le point de changer. D'un côté, les processeurs possèdent maintenant plusieurs cœurs. À condition de savoir en tirer parti, ces multiples cœurs permettent d'effectuer plusieurs calculs simultanément, et donc d'améliorer les performances (c'est le parallélisme). D'autre part, les logiciels serveurs sont susceptibles de recevoir un nombre toujours plus grand de connexions clientes simultanées, qui tentent d'accéder à des ressources partagées, et il est vital que le serveur gère ces accès proprement, sans faire d'erreur (c'est la concurrence).

Le parallélisme comme la concurrence sont des problèmes très difficiles à traiter, et les outils utilisés jusqu'à là (tels les verrous, ou locks) ne sont pas adaptés, car trop inefficaces.

De nouveaux langages tous les ans

Le C comme le C++ souffrent de défauts de conception, qui remontent à leurs origines. Depuis leurs dates d'invention respectives, de nombreux concepts ont été inventés par les chercheurs en langages de programmation. Ces concepts permettent souvent d'écrire du code plus fiable, contenant moins de bugs potentiels, car ils permettent au programmeur de faire vérifier une partie de son code par le compilateur.

Chaque année, de nouveaux langages de programmations sont conçus, par différents acteurs. Il est faux de croire que personne n'a essayé de proposer de nouveaux outils ! Cependant, ces langages servent des buts souvent très différents. Parfois, ils sont conçus par des laboratoires de recherche en informatique, qui se préoccupent plutôt de tester leurs nouvelles idées. Mais parfois aussi, ce sont les entreprises qui ont besoin de développer de nouvelles technologies. C'est le cas de Google et de son langage Go, par exemple, qui cherche également à remplacer le C et le C++ dans un certain nombre de situations.

C'est aussi le cas de la Mozilla Foundation. Graydon Hoare, membre des Mozilla Labs (un ensemble de plusieurs équipes d'ingénieurs qui travaillent sur des projets expérimentaux), a commencé à concevoir le langage Rust en 2006, avant de proposer son projet à Mozilla en 2009. À cette époque, le langage n'était pas encore clairement défini, pas même sur le papier. Cependant, l'implémentation initiale (un compilateur pour Rust écrit dans le langage OCaml) était suffisamment stable pour convaincre : Mozilla allait développer son propre langage de programmation, et G. Hoare en serait l'architecte.

Le langage Rust

Changer de langage pour supprimer des erreurs

Rust ne cherche pas à être un langage expérimental. Tous les concepts de programmation qu'il inclut ont déjà été testés dans d'autres langages de programmation, et résultent de plusieurs années de travail. Cependant, il tente d'adapter ces idées au monde de la programmation système, comme l'écriture de logiciels serveurs (services internet, bases de données…). Le but officiel des concepteurs est de proposer un langage fortement typé, tel que la sûreté du code soit vérifiée à la compilation, et que la programmation concurrente soit facilitée, sans nuire aux performances.

La sûreté du code est une chose importante : débuguer un programme prend du temps, et est souvent très difficile (car il faut tester des scénarios réalistes, ce qui est particulièrement compliqué pour des logiciels serveurs, par exemple). Les concepteurs des langages veulent donc s'arranger pour qu'une portion aussi large que possible du code écrit par le programmeur soit vérifié à la compilation. C'est à ça que servent les systèmes de type, par exemple.

Certains types de bugs sont fréquents, dans les programmes écrits en C ou en C++. Par exemple, le bug du « pointeur nul » est bien connu des informaticiens qui utilisent ces langages. Il se produit lorsque le programme tente d'accéder au contenu d'un pointeur… qui n'est en fait pas défini. Même dans des logiciels commerciaux, écrits par des professionnels, il arrive que l'on tombe sur une « erreur de segmentation », ou sur une « NullPointerException ». Ces deux erreurs sont souvent provoquées par l'utilisation d'un pointeur nul.

Très clairement, c'est donc un avantage si un langage de programmation permet d'éviter cette erreur. La solution est bien sûr de *ne pas laisser le programmeur utiliser des pointeurs nuls*. Si le concept de pointeur nul n'existe pas dans le langage, impossible de tomber dessus, pas vrai ? Mais un tel choix de conception a de l'influence sur le reste du langage : si les pointeurs nuls n'existent pas, quelle est la valeur d'un pointeur qui n'a pas encore été initialisé ?

Il existe déjà plusieurs langages de programmation sans pointeurs nuls, qui utilisent des concepts différents (tels les types options en OCaml). Ces langages proposent souvent d'autres nouvelles idées, qui permettent de programmer autrement qu'en C, en C++ ou en Java. Le but de Rust est de reprendre ces idées pour les rendre plus familières aux programmeurs issus du C. En tant que tel, Rust *ressemble* au C, mais aussi à ces autres langages.

Une syntaxe un peu familière

La syntaxe de Rust ressemble un peu à celles de C ou JavaScript. Par exemple, les instructions sont toujours séparées par des point-virgules, certains mot-clefs usuels (while, if…) sont toujours présents, et les blocs sont marqués par des paires d'accolades. D'autres mot-clefs changent de nom : par exemple, les fonctions sont introduites par fn, les variables locales par let, et le mot return devient ret. De plus, les parenthèses ne sont pas obligatoires dans les expressions conditionnelles des boucles ou des tests, comme en Python.

Voici un exemple de programme Rust, une fonction qui calcule la factorielle de son argument (le produit des nombres de 1 jusqu'au nombre passé en argument).

Code : cpp
  1. fn fac(n: int) -> int {
  2. let result = 1, i = 1;
  3. while i <= n {
  4. result *= i;
  5. i += 1;
  6. }
  7. ret result;
  8. }

On remarquera aussi que les déclarations de types sont différentes. Notamment, elles ne sont pas nécessaires pour les variables locales, car le compilateur les déduit automatiquement (on parle d'inférence de types). Le type de retour de la fonction est indiqué après son nom et sa liste d'arguments, après le ->. Plusieurs types de base sont supportés : notamment, Rust dispose de n-uplets, qui permettent d'empaqueter plusieurs valeurs (et ainsi de renvoyer plusieurs valeurs en même temps depuis une fonction).

Pour l'instant, la syntaxe est encore susceptible d'évoluer. Notamment, des macros devraient être rajoutées.

Les fonctions

Les fonctions de Rust sont plus flexibles que celles du C. Premièrement, il n'est pas aussi désagréable qu'en C de passer des fonctions en argument d'autres fonctions. Il n'est pas nécessaire de manipuler explicitement des pointeurs de fonctions, et on peut parfaitement, lors de la déclaration d'une fonction, préciser le type de la (ou des) fonction(s) qu'elle attend. Par exemple, on peut alors faire

Code : cpp
  1. fn call_twice(f: fn()) {
  2. f();
  3. f();
  4. }

Dès lors, la fonction call_twice attendra une fonction qui ne prend pas d'argument, et renvoie le type Nil (qui est un peu un équivalent du void en C). De même, comme dans les langages fonctionnels, il est parfaitement possible de renvoyer une fonction à partir d'une autre fonction : on peut donc définir une fonction locale, et la renvoyer après, sous certaines conditions.

De plus, plusieurs formes de fermetures ("closure" en anglais) sont possibles. On parle de fermeture (d'environnement) quand on définit une fonction qui dépend de la valeurs de variables définies à l'extérieur : supposons par exemple que l'on veuille définir une fonction qui, à partir d'une chaîne de caractères, produit une *autre* fonction, celle-là prenant une autre chaîne de caractère en argument, et renvoyant la concaténation des deux.

L'effet de la deuxième fonction va bien sûr dépendre de l'argument que l'on aura passé à la première ; il va donc être nécessaire de le retenir, d'une certaine façon, en le capturant dans l'environnement de la fonction que l'on la fabrique.

En Rust, cet exemple s'écrit ainsi :

Code : cpp
  1. fn make_appender(suffixe: str) -> fn@(str) -> str {
  2. let f = fn@(s: str) -> str { s + suffixe };
  3. ret f;
  4. }
  5.  
  6. fn main() {
  7. let shout = make_appender(" !");
  8. std::io::println(shout("Bonjour, monde"));
  9. }

La première fonction attend bien une chaîne (appelée "suffixe"), et renvoie bien une fonction qui attend une chaîne, et renvoie une chaîne. La déclaration locale de la fonction renvoyée en argument se fait à l'aide du mot clef "fn@", qui permet la fermeture (ici, c'est encore la variable "suffixe" qui se retrouver emprisonnée).

De telles fermetures sont puissantes (elles permettent de simuler la programmation orientée objet), mais sont également connues pour être un peu coûteuses, puisqu'elles demandent une copie d'une partie de l'environnement (toutes les variables capturées). Une autre construction, plus légère, permet d'utiliser des fermetures qui partagent, en réalité, leur environnement avec le contexte dans lequel elles sont créées. La syntaxe est alors {|argument1, argument2, …| corps}.

Certaines fonctions prédéfinies sont capables de travailler avec de telles fermetures de façon assez élégante. C'est le cas de la fonction ver::map, par exemple. On peut, en Rust, écrire "let doubled = vec::map([1, 2, 3]) {|x| x*2};", ce qui produira le tableau [2, 4, 6]. Enfin, d'autres constructions permettent de manipuler élégamment des fonctions. Par exemple, l'application partielle se fait au moyen du mot-clef bind.

Types de données

Rust possède un certain nombre de types de données de base. Les enregistrements, par exemple, correspondent aux structures du C. On peut définir un type enregistrement avec le mot-clef "type" (qui sert à toutes les déclarations de types). Cependant, Rust permet de préciser si le champ d'une structure peut ou non être modifié, avec le mot-clef "mutable". Par exemple, on peut écrire

Code : cpp
  1. type point = {x: float, y: float}
  2. type stack = {content: [int], mutable head: int};


ce qui déclare un type point, puis un type stack dont le deuxième champ peut être modifié (on peut faire une_stack.head = 3 par exemple), mais aucun champ du type point ne peut, quant à lui, être modifié.

Les énumérations constituent un autre type important en Rust. Ce sont des types qui regroupent certaines valeurs particulières, appelées variants. Plus évolués qu'en C, ces variants peuvent porter un certain nombre de valeurs. Par exemple, on peut former l'énumération

Code : cpp
  1. enum forme {
  2. cercle(point, float),
  3. rectangle(point, point)
  4. }


pour déclarer un nouveau type "forme", qui possède deux valeurs particulières ("cercle" et "rectangle"), dont l'un est décrit par un point (son centre) et un flottant (son rayon), et l'autre est d'écrit par deux points.

Un autre mot-clef permet alors, un peu comme le switch en C (mais ici encore en plus évolué), de traiter différemment les variants d'une énumération.

Code : cpp
  1. fn aire(f: forme) -> float {
  2. alt f {
  3. cercle(_, rayon) {
  4. float::consts::pi * size * size
  5. }
  6. rectangle({x: x1, y: y1}, {x: x2, y: y2}) { 
  7. (x2 - x) * (y2 - y)
  8. }
  9. }
  10. }

Ici, on retourne l'air de la forme passée en argument, en distinguant bien sûr les deux cas.

Un petit mot sur les pointeurs : comme les créateurs de Rust veulent s'en servir pour écrire du code fiable, les pointeurs ne peuvent pas être aussi souples que ceux du C. On distingue donc trois types de pointeurs en Rust : des pointeurs non-vérifiés (comme ceux du C) qui ne sont utilisables que dans des blocs "unsafe" explicites (le but étant, bien sûr, de minimiser la taille de ces blocs !), des boîtes, et des boîtes uniques.

Les boîtes sont le type pointeur le plus utilisé. Elles ne peuvent pas être explicitement libérées, mais bénéficient d'un comptage de référence (avec détection de cycles) pour éviter au programmeur de risquer de manipuler des pointeurs nuls. Elles correspondent au préfixe @ : par exemple, on peut déclarer une référence vers le nombre 10 en écrivant "let x = @10".

Les boîtes uniques servent à s'assurer qu'une référence n'est pas dupliquée. Cela permet par exemple d'envoyer des valeurs aux "tâches", qui sont les processus légers utilisés en Rust (dans la partie concurrente du langage).

Des arguments génériques

Rust dispose de generics, une construction qu'il partage avec un certain nombre de langages (comme Java ou C#) et qui permet de définir des structures de données génériques, pouvant être paramétrés par plusieurs types. Par exemple, en C on peut définir des listes chaînées d'entiers, ou des listes chaînées de chaînes, mais les fonctions qui les manipuleront devront être distinctes (il faudra faire deux exemplaires à chaque fois).

En Rust, les types, les enums et les fonctions peuvent être paramétrés par un type inconnu, qui sera précisé lorsque l'on utilisera ces fonctions sur des valeurs concrètes. Par exemple, il est possible de faire une fonction "map" générique, qui applique une fonction à un tableau, en renvoyant le tableau des résultats :

Code : cpp
  1. fn map<T, U>(v: [T], f: fn(T) -> U) -> [U] {
  2. let acc = [];
  3. for elt in v { acc += [f(elt)]; }
  4. ret acc;
  5. }
  6.  

Comme on veut que cette fonction map accepte des tableaux d'entiers, ou de chaînes, ou même des tableaux d'autres tableaux d'un certain type, et que la fonction passée en argument peut, éventuellement, renvoyer un type différent de celui qu'elle reçoit, on a bien deux inconnues de type. Supposons que l'on veuille travailler sur un tableau de chaînes de caractères, et produire le tableau des longueurs de chaque chaîne. Alors T va correspondre au type str, et U au type int. On écrira alors tout naturellement map(tableau_chaînes, length).

Du polymorphisme

Les arguments génériques correspondent à ce que l'on appelle le polymorphisme paramétrique, qui permet d'écrire des fonctions sur des structures de données sans se soucier exactement de la nature des données qu'elles contiennent. Il correspond à ce que l'on trouve ordinairement dans les langages fonctionnels statiquement typés.

Mais une autre forme de polymorphisme, complémentaire à la première, est également possible : le polymorphisme ad-hoc, qui consiste à définir une certaine signature (un type de fonction), et à implémenter cette fonction sur différents types de données. Cette forme de polymorphisme est plus connue des programmeurs utilisant des langages orientés objets, mais peut, en réalité, en être dissociée.

Très récemment, Rust s'est vu adjoindre un mécanisme d'interfaces, qui permet de définir des signatures (qui donnent donc les noms et les types des méthodes à implémenter). Ces signatures peuvent ensuite être implémentées pour un type donné.

On peut par exemple définir une interface qui explique comment les valeurs doivent être converties en chaînes (pour pouvoir les afficher, par exemple) :

Code : cpp
  1. iface to_str {
  2. fn to_str() -> str;
  3. }
  4.  
  5. impl of to_str for int {
  6. fn to_str() -> str { int::to_str(self, 10u) }
  7. }
  8. impl of to_str for str {
  9. fn to_str() -> str { self }
  10. }

Avec un tel code, il est possible d'écrire 1.to_str pour récupérer la chaîne "1". Ce mécanisme est à rapprocher des type classes du langage Haskell.

Et naturellement, on peut contraindre les arguments génériques d'une fonction pour qu'ils implémentent une certaine interface : on peut ainsi définir une fonction print qui n'accepte que des paramètres que l'on sait transformer en chaînes.

Des tâches

Finissons notre tour d'horizon de Rust par sa partie concurrente, les tâches. Rust possède un système de processus légers qui ressemble à celui du langages Erlang : ces processus légers ne partagent pas de données, et communiquent entre elles uniquement par messages.

Ces messages sont envoyés dans des canaux typés, qui doivent donc être déclarés avant la création d'une tâche. Voici un exemple :

Code : cpp
  1. fn main() {
  2. let port = comm::port::<int>();
  3. let chan = comm::chan::<int>(port);
  4. let child_task = task::spawn {||
  5. let result = 5;
  6. comm::send(chan, result);
  7. };
  8. std::io::println("On va recevoir un message :");
  9. log(error, comm::recv(port));
  10. }

Le port sert à recevoir des données, et le canal à les lui envoyer. Ici, on lance une tâche (dont le code est donné par une fermeture), qui se contente d'envoyer 5 sur le canal. Pendant ce temps, le programme principal continue de s'exécuter, et affiche le message lu.

Ce mécanisme de programmation parallèle intégré au langage permet d'écrire des programmes qui profitent de la présence de plusieurs unités de calcul (comme un processeur multi-cœur), mais aussi des processus serveur qui acceptent un grand nombre de connexions simultanées, et gèrent chaque client comme une tâche distincte. Bien sûr, le choix d'utiliser des processus légers et des messages a des conséquences sur l'écriture des programmes ; mais ils constituent des abstractions plus simples, et moins sujettes à l'erreur, que les mécanismes traditionnels (comme les verrous).

Un jeune langage

Il est impossible de ne pas comparer Rust à Go. Les deux langages, annoncés à peu près en même temps, visent tous les deux à être des langages système, et intègrent tous deux une conception de la programmation concurrente. Cependant, il semble que les concepteurs de Rust ont choisi de s'éloigner un petit peu plus des habitudes des programmeurs C, pour proposer un langage plus évolué. Au final, Rust est un petit peu plus riche que Go, et son développement est un peu en retard. Il est probable que des communautés se formeront autour des deux langages, qui commenceront à avoir de l'importance d'ici quelques années.

La version 0,1 de Rust consiste en un compilateur écrit lui-même en Rust, qui se base sur la LLVM. Pour l'instant, ce compilateur est le seul gros projet écrit en Rust. Brendan Eich (créateur de JavaScript et employé de Mozilla) a annoncé, en avril dernier, que Rust servirait probablement à tester de nouveaux concepts dans le développement d'un moteur de navigateur web plus moderne que Gecko. Notamment, les tâches (le parallélisme) et une gestion plus fiable de la mémoire sont deux des qualités qui justifient l'utilisation de Rust dans ce projet, baptisé Servo.
News rédigée par Euphorion