Article écrit par Nicolas Cavigneaux
Le 25 décembre 2020, la version 3.0 de Ruby a officiellement été publiée. Beaucoup de Rubyistes l’attendaient avec impatience tant elle s’annonçait prometteuse.
Voyons aujourd’hui ce qu’elle nous apporte d’excitant à utiliser.
Performances
La nouveauté la plus discutée depuis longtemps concernant cette nouvelle version était le fameux « Ruby 3 × 3 » qui promettait que Ruby 3 serait 3 fois plus performant que Ruby 2. Je dis dit bien que Ruby 2 et pas Ruby 2.x parce que les différentes améliorations de performance ont été distillées tout au long du développement de Ruby 2.x.
Cette promesse a été tenue et les performances sont au rendez-vous. Cette promesse a toujours été relative à un cas d’usage bien précis qui servirait de référence tout au long du développement. Il s’agissait de faire tourner un émulateur NES à 60 FPS. Il faut savoir que Ruby 2.0 n’arrivait qu’à 20 FPS.
Ces gains de performances sont dus à de nombreuses optimisations mais surtout à la mise en place du compilateur JIT. JIT signifie « Just In Time » compilation qui est une technique fréquemment utilisée pour améliorer les performances d’un langage. Il s’agit de faire en sorte que le code applicatif puisse être compilé pendant son exécution, au runtime. C’est une approche dynamique de la compilation classique des sources. On a donc, à la fois, du code interprété et compilé, ce qui permet d’avoir le meilleur de deux mondes : un code rapide à exécuter mais qui reste très flexible et dynamique.
Mémoire
Le compactage du garbage collector avait été ajouté dans Ruby 2.7.
Il faut distinguer ici garbage collection et garbage compaction. Le premier permet de nettoyer les allocations redondantes en mémoire pour gagner de l’espace et pouvoir en allouer à de nouvelles choses. Quant à celui qui nous intéresse ici, le compactage, il regroupe les objets éparpillés dans la mémoire, laissant ainsi des espaces mémoire assez gros pour en allouer à des objets plus lourds plus facilement.
Il était possible de le déclencher manuellement en invoquant GC.compact
. Ruby 3 déclenche désormais ce compactage de manière totalement automatisée. Le compactage se fait aux moments les plus adéquats pour assurer que la mémoire reste la plus défragmentée possible tout au long de l’exécution de l’application.
Il n’est donc plus nécessaire de le gérer à la main pour assurer les meilleures performances possibles.
Typage
Ruby n’est pas un langage typé. Ce choix a été fait pour permettre aux développeurs de pouvoir prototyper plus rapidement et plus facilement.
Ceci étant, il est parfois bien utile de pouvoir typer son code pour gagner en robustesse. C’est notamment vrai lorsqu’il s’agit de maintenir de grosses applications.
Avec Ruby 3, il est maintenant possible de jouer un peu dans les deux camps. La notion de vérification statique de type (static type checking) a officiellement été intégrée.
On peut donc, si on le souhaite, documenter ses classes et méthodes pour faire connaître à l’interpréteur vos intentions quant à l’utilisation de votre code. Grâce à cela il est possible de découvrir des incohérences beaucoup plus tôt.
Ce système de typage s’appelle RBS et peut donc être utilisé pour décrire le fonctionnement de vos classes, méthodes, variables d’instance, modules et constantes.
La description du typage de votre code ne se fait pas directement dans le code source Ruby. Les signatures sont stockées dans des fichiers .rbs
ce qui permet de typer une application existante après coup sans avoir à en modifier son code source. Cette fonctionnalité est donc rétro-compatible.
Voici un exemple de déclaration :
class Message attr_reader id: String attr_reader string: String attr_reader from: User | Bot attr_reader reply_to: Message? def initialize: (from: User | Bot, string: String) -> void def reply: (from: User | Bot, string: String) -> Message end
Cette nouvelle fonctionnalité nous permettra de
- trouver plus facilement des bugs (comme des
undefined
, des valeursnil
là où c’est théoriquement impossible…), Il est également possible de déclarer des interfaces - optimiser l’intégration dans les éditeurs en améliorant la complétion, en reportant les erreurs en temps réel ou encore en aidant au refactoring
- mettre en place du duck typing de manière plus sûre grâce à la déclaration d’interfaces
Parallélisme et concurrence
La gestion de la concurrence en Ruby a toujours été un sujet sensible. Jusque-là nous ne pouvions qu’utiliser les threads qui ont malheureusement beaucoup de défauts et sont assez difficiles à gérer correctement.
Ruby 3 nous apporte de nouvelles façons de mettre en place de la concurrence.
Fibers
Les fibers sont une alternative légère aux threads. Elle permet la mise en place de « concurrence coopérative ».
Elles consomment moins de mémoire et permettent un contrôle plus fin que les threads.
Ce n’est plus la VM qui décide de quand un morceau de code concurrent doit être arrêté et repris. On laisse le développeur s’en charger.
Ruby 3 apporte le Fiber Scheduler qui est capable d’intercepter les opérations bloquantes (I/O
) et de les jouer de manière concurrente. On peut donc avoir une boucle d’événements qui sera séparée du code applicatif.
Le scheduler est une interface, il faut donc l’intégrer dans un wrapper. Des gems telles que Async ou EventMachine intègrent déjà cette interface.
On peut donc, par exemple, télécharger plusieurs fichiers en même temps avec de la vraie concurrence.
puts "1 : c'est parti" # On crée une nouvelle fibre f = Fiber.new do puts "3: on entre dans la fibre." Fiber.yield # On met la fibre en pause puts "5: la fibre a été redémarrée." end puts "2: on démarre la fibre" f.resume puts "4: on reprend là ou la fibre s'était arrêtée." f.resume puts "6: c'est fini."
Ractors
Le verrou global qui existe sur la VM Ruby nous empêche d’avoir des threads Ruby (green threads) qui travaillent en parallèle. L’utilité globale des threads est donc assez limitée.
Ractor est une réponse à cette problématique. Ractor se base sur le modèle Acteur que les utilisateurs d’Elixir connaissent bien.
Ractor est aussi lourd en termes de ressource que les threads mais a l’avantage de ne pas souffrir du verrou global de la VM. Chaque Ractor s’exécute en parallèle.
Avec ce modèle il devient beaucoup plus facile d’être thread safe. Le fonctionnement même de ce modèle incite à écrire les tâches concurrentes d’une manière qui évite ce problème. Les informations ne sont pas partagées entre Ractor contrairement aux threads. Dans le modèle Acteur, les différents acteurs se communiquent les informations en s’échangeant des messages.
ractor1, ractor2 = *(1..2).map do Ractor.new do arg = Ractor.receive "opération longue #{arg}" end end # On envoie le paramètres à nos instances ractor1.send 1 ractor2.send 2 p ractor1.take #=> "opération longue 1" p ractor2.take #=> "opération longue 2"
Pattern matching
Le pattern matching avait été mis à disposition de manière expérimentale avec Ruby 2.7 mais est officiel depuis Ruby 3.
Le pattern matching permet d’associer automatiquement des valeurs d’une structure à des variables (si la structure passée correspond à l’attendu).
Pour ceux qui avaient déjà commencé à l’utiliser, sachez que la syntaxe a changée :
# Ruby 2.7 { name: "Jon", role: "CTO" } in {name:} p name # => 'Jon' # Ruby 3.0 { name: "Jon", role: "CTO" } => {name:} p name # => 'Jon'
On peut également utiliser le pattern matching dans les case
:
users = [ { name: "Jon", role: "CTO" }, { name: "Marcel", role: "Manager" }, { role: "Client" }, { name: "Lucie", city: "Lille" }, { name: "Nico" }, { city: "Paris" } ] users.each do |person| case person in { name:, role: "CTO" } p "#{name} est le CTO." in { name:, role: designation } p "#{name} est #{designation}." in { name:, city: "Lille" } p "#{name} vit à Lille." in {role: designation} p "Un inconnu est #{designation}." in { name: } p "#{name} n'a pas de rôle." else p "Aucun pattern ne correspond." end end "Jon est le CTO." "Marcel est Manager." "Un inconnu est client." "Lucie vit à Lille." "Nico n'a pas de rôle." "Aucun pattern ne correspond."
Définition courte de méthode
Un sucre syntaxique a été ajouté permettant d’avoir une syntaxe concise lorsqu’il s’agit de définir une méthode courte.
def: foo(bar) = puts bar foo("ruby") #=> "ruby"
except
Pour les nombreux d’entre vous qui utilisent Rails, vous avez déjà sûrement utilisé la méthode except
fourni par ActiveRecord et qui permet d’obtenir un Hash dénué d’un ou plusieurs de ses éléments.
Cette méthode a été intégrée directement à Ruby !
user = { name: "Jean", city: "Lille", role: "Dev" } user.except(:role) #=> {:name=> "Jean", :city=> "Lille"}
Conclusion
Comme vous pouvez le voir, les nouveautés intéressantes sont nombreuses. Elles devraient encore nous faciliter la vie et nous permettre d’écrire du code avec toujours autant de plaisir.
De nouveaux outils sont mis à notre disposition et nous ouvrent de nouvelles portes notamment côté performances et concurrence.
Je n’ai fait part dans cet article que des fonctionnalités qui m’ont le plus marquées, mais je vous invite à consulter la liste des changements si vous voulez connaître l’ensemble des changements qui ont eu lieu.
Je vous conseille également de lire cette interview de Matz qui nous parle des nouveautés de Ruby 3 et notamment de comment faire le choix entre Ractor et Fiber lorsqu’on a besoin de mettre en place de la concurrence.