Blog Arolla

Introduction et vulgarisation du Clojure

Je vous présente une mise en bouche de Clojure pour vous donner un avant-goût de ce langage peu connu. Cet article est destiné aux curieux et braves aventuriers qui ne connaissent pas encore ce langage. A la fin de cette brève lecture, vous ne serez pas un expert mais vous serez en mesure de lire et comprendre en partie un code en Clojure.

Clojure en quelques mots

Clojure est un langage de programmation fonctionnel proche du Lisp. Le développement consiste principalement à manipuler des structures de données persistantes et immutables pour obtenir une nouvelle structure de donnée plus adaptée au besoin du métier. Le langage de programmation permet d'écrire un code concis mais efficace. Etant compilé sur une JVM, l'utilisation de classe Java est faisable dans le code Clojure. Le code compilé est transformable en exécutable .JAR. A partir de là, les développeurs Java sont en terrain connu, rien de différent par rapport au déploiement ou à l’exécution d’une application Java standard.   Pour bien comprendre la différence de paradigme et de la sémantique, je recommande des supports plus adaptés tels que :

  • "Clojure for the Brave and Truede Daniel Higginbotham disponible en livre ou en ligne, un livre facile à lire avec beaucoup d’exemples et des explications bien plus détaillées et méticuleuses que ce que je pourrais écrire dans cet article
  • un peu d’application avec les Koans pour vérifier que vous avez retenu
  • créer une application, rien ne vaut une application concrète pour se faire un avis

Vous aurez besoin pour pratiquer de :

  • JDK 8 ou plus
  • Leiningen pour créer un REPL (Read–eval–print loop, un bac à sable indispensable pour évaluer du code rapidement) et exécuter des lignes de commande (comme Maven)
  • un IDE, j’utilise IntelliJ avec le plugin "Cursive" mais il y a d’autres possibilités (ex : Emacs)

Les bases du langage

En Clojure, il n’y a pas de classe pour créer des objets. On manipule directement des structures de données et des fonctions dans des "namespaces". Voici quelques exemples de structures de données basiques.

"jean"                        ; string
12                            ; int
:nom                          ; keyword
{:nom "jean" :age 12}         ; map ⇔ une structure clé - valeur
[1 2 3 4]                     ; un type de collection
[1 "tata" nil 4.0]            ; une collection étrange mais valide
...

Pour faire simple, un namespace est un espace dans lequel on peut trouver des variables ou bien des fonctions. Le bloc ci-dessous est le namespace "clojure-sandbox.status". Il ne contient dans cet exemple que la variable "statuses" et une fonction "is-shipped?". Une variable est déclarée par le mot-clé "def", une fonction par le mot-clé "defn". Les paramètres de la fonction sont entre crochet. Je ne le montre pas dans les exemples ici, mais il est possible de faire des implémentations de fonction différentes en fonction du nombre de paramètre ou bien du contexte d'exécution. Le retour de la fonction est le résultat de la dernière structure de donnée lors de l'exécution du code, soit le retour du prédicat qui vérifie si le statut d’une commande est "expédiée" avec le keyword ":shipped".

(ns clojure-sandbox.status)

(def statuses [:new :cancelled :shipped])  ; def pour déclarer une variable

(defn is-shipped?                          ; defn pour déclarer une fonction
  [order]                                  ; paramètre(s) de la fonction entre crochet
  (= :shipped (:status order))             ; corps de la fonction
)

Un namespace peut dépendre d’un autre namespace et faire appel à ses variables et fonctions mais, il n’est pas possible de faire de dépendance circulaire. Sans créer cette dépendance, les variables et fonctions de l'autre namespace sont inaccessibles. Dans l’exemple ci-dessous, nous avons un namespace "clojure-sandbox.order" qui dépend de "clojure-sandbox.status". Sa fonction "is-cancellable?" vérifiant si une commande est toujours annulable s’appuie sur la fonction "is-shipped?" implémentée dans le namespace "clojure-sandbox.status".

(ns clojure-sandbox.order
  (:require [clojure-sandbox.status :as status]))

(defn is-cancellable? 
  [order]
  (not (status/is-shipped? order)))

Comme mentionné auparavant, l'outil indispensable pour coder en Clojure est le REPL. Il permet de vérifier que le code compile toujours à tout moment et d’exécuter les tests ou des bouts de code spécifiques pour une boucle de feedback instantanée. Une fois que votre REPL est démarré, vous pouvez exécuter par exemple :

(in-ns ‘clojure-sandbox.order)        ; charger le namespace (faisable avec raccourci)

(is-cancellable? {:status :new})      ; tester ce que vous retourne votre fonction            
=> true

(is-cancellable? {:status :shipped})
=> false

status/statuses                       ; évaluer la valeur de certaines variables
=> [:new :cancelled :shipped]
                         
(remove (fn [status]                  ; évaluer tout ce que vous avez envie
          (= :shipped status))
        status/statuses)
=> (:new :cancelled)

Un code fonctionnel et moins verbeux

Si je veux calculer le prix d’un panier, ajouter les frais de port et appliquer mon coupon de réduction, cela donnerait :

(apply-discount (add-shipping-fees (calculate-price cart))) 
                voucher)

Dans une masse de code écrit comme ça, il est difficile de trouver la donnée qui va faire l’objet de modifications. Il existe une manière d’implémenter exactement la même chose de manière plus lisible et intuitive avec le threading. Cela permet de mettre en valeur la donnée qui sera transformée et d’appliquer une fonction en plaçant le résultat précédent en premier ou en dernier paramètre. Voici l’équivalent du bloc ci-dessus en thread first:

(-> cart
   (calculate-price)           ; applique la fonction à cart
   (add-shipping-fees)         ; applique la fonction au résultat obtenu ci-dessus
   (apply-discount voucher))   ; idem en passant voucher en deuxième paramètre

En Java, il aurait fallu créer une classe Cart qui contient une méthode "calculate-price" et toutes ses propriétés. Si l'application de "calculate-price" retourne un objet de type différent, il faudrait créer une nouvelle classe et ainsi de suite. Vous l’aurez compris, selon moi, il faut beaucoup moins de lignes de code Clojure qu’en Java pour faire la même chose. En Clojure, il n'y a que deux questions à se poser:

  • est-ce que mes trois fonctions sont disponibles dans le namespace ? L'IDE et le REPL vous répondra tout de suite.
  • est-ce que j'ai bien fermé mes parenthèses, crochets, accolades ... ? Oui, vous avez déjà dû remarquer avec les exemples qu'il y en a un certain nombre. Il faut s'y faire mais ça vient avec la pratique.

Des données persistantes et immutables

Les données manipulées en Clojure ne sont pas altérables et restent toujours disponibles. En Java, en particulier si l’application est codée avec des fonctions "set", on ne peut pas être sûr qu’une instance d’un objet passée dans une fonction reviendra inchangée. En Clojure, la seule manière d’avoir une variable altérable en Clojure est de la déclarer explicitement via un "atom". Si vous ne voyez pas d’atoms dans le code, vous pouvez être sûr que l’exécution de la fonction n'altérera pas la variable, peu importe le développeur qui l’a écrite. Je profite de l'exemple ci-dessous pour introduire le mot "let" qui créé des variables uniquement disponibles entre ses parenthèses. Une fois dehors, elle ne vaut plus rien.

(defn price-cart 
  [cart voucher]
  (let [priced-cart (-> cart
                       (calculate-price)              
                       (add-shipping-fees)       
                       (apply-discount voucher))]  
  cart              ; cart n’a pas changé, il a toujours la même valeur
  priced-cart       ; priced-cart est le résultat des transformations subies par cart
))

Le destructuring

Une dernière notion nécessaire pour lire du Clojure dont vous aurez besoin est le destructuring. Il s’agit d’une pratique qui extrait des valeurs d’une structure et qui est beaucoup utilisée pour être plus concis. Si vous n’êtes pas familiers avec, vous pouvez être rapidement perdus en tombant dessus.

(defn add-shipping-fees
  [select-shipper {:keys [postcode country price] :as cart}]
  (let [shipper (select-shipper postcode country)]
    (assoc cart :price (+ price (:shipping-fees shipper)))))

Dans l’exemple ci-dessus, la fonction ne prend que deux paramètres :

  • Une fonction permettant de choisir un transporteur "select-shipper"
  • Une map avec le contenu d’un panier "cart".

"{:keys [postcode country price] :as cart}" est une destructuration du contenu du panier en paramètre de la fonction. A l’aide du mot ":keys", on récupère ici les variables "postcode", "country" et "price" du panier. Le mot ":as" nous permet de récupérer l’intégralité du panier si besoin en se référant à "cart" et de savoir que la structure manipulée est un panier. Pour cette dernière raison, il est intéressant de créer cette référence et, si votre IDE vous alerte car vous n’utilisez pas la structure, vous pouvez préfixer le nom de la variable avec le caractère "_" pour que l’IDE comprenne que vous savez déjà qu’elle ne sera pas utilisée (ex: :as _cart).    Il est aussi possible de destructurer dans le corps de la fonction. Avec le mot ":keys", on rend disponible des variables qui ont par défaut le même nom et la même valeur que la clé correspondante dans la structure de cart.

(def cart {:postcode 75001 
           :country "France"
           :price "15.00"})
       
(defn delivery-test
  [cart]
  (let [{:keys [:postcode :country]} cart]
    (println "Delivery is in " postcode " in " country)))

(delivery-test cart)
Delivery is in 75001  in  France
=> nil

Si on veut changer les noms des variables, il suffit d’écrire de la manière suivante :

(defn delivery-test
  [cart]
  (let [{cart-postcode :postcode
         cart-country :country} cart]
    (println "Delivery is in " cart-postcode " in " cart-country)))

Pour rassurer certains sceptiques sur la maturité du langage

Clojure est un langage qui a plus de 10 ans, beaucoup de développeurs ont déjà contribué à son développement. Il existe de nombreuses librairies qui vous faciliteront le développement de votre application et l’intégration de middlewares. Voici quelques exemples de dépendances qui m’ont simplifié la vie pendant mon travail :

  • integrant (framework d’injection de dépendances)
  • reitit-pedestal (routing de serveur web )
  • postgresql (base de données Postgre)
  • java.jdbc (base de données H2)
  • datomic-pro (base de données Datomic)
  • spandex (client Elasticsearch)
  • kinsky (client Kafka)
  • clojure specs (validation du format de structures de données et documentation)
Plus de publications

Comments are closed.