5 avril 2017

A propos de Clojure

Ça fait maintenant plus de 2 ans que je me suis mis à Clojure. Bien m’en a pris. C’est aujourd’hui le langage où je suis le plus à l’aise.

Je tâcherais dans cet article d’expliquer pourquoi Clojure est un langage à la fois simple, puissant, et extrêmement fun.

Histoire

La version 1.0 de Clojure est sortie en 2009. Le langage fonctionne sur la JVM (sa cible initiale) mais compile également en Javascript (via le projet ClojureScript). Un port de Clojure sur CLR existe également, mais moins populaire que les version Java/JS.

La force de Clojure est qu’il s’interface parfaitement avec sa plateforme cible, c’est à dire qu’il est très facile d’utiliser l’énorme écosystème (libraries, frameworks, outils…​) de ces plateformes depuis Clojure.

Syntaxe

Clojure est inspiré de LISP. Pour un développeur habitué à C, Java, Python…​ cela peut faire peur. Pourtant la syntaxe de Clojure (et de LISP en général) est une de ses plus grandes forces.

Voici une explication simple de la syntaxe de Clojure :

1 + 1

foo(bar)

foo(bar, baz)
(+ 1 1)

(foo bar)

(foo bar baz)

On se rend compte facilement qu’en Clojure, l’opérateur (ou la fonction, le mot clé…​) se trouve en premier (après une parenthèse) suivi de ses arguments.

Allons un peu plus loin :

public int addFive(int number) {
    return number + 5;
}

public int addFiveIfOdd(int number) {
    if (number % 2 == 0) {
        return number + 5;
    }
    return number;
}
(defn add-five
  [number]
  (+ number 5))

(defn add-five-if-odd
  [number]
  (if (odd? number)
    (+ 5 number)
    number))

On voit ici que la syntaxe de Clojure respecte toujours le même format. Une ouverture de parenthèse, un mot clé, et une suite d’arguments. Quelle que soit l’action à réaliser (un if, une définition de fonction, un appel de fonction…​), la syntaxe reste la même.

Je trouve personnellement cela très intéressant, et une fois l’habitude prise, lire du Clojure est très agréable (je vous promets que les parenthèses, vous ne les verrez plus ;)).

D’ailleurs, en parlant de parenthèses, comparez les deux codes suivants :

List<Integer> mylist =
    Arrays.asList(1, 2, 3, 4);
myList.stream()
   .map( x -> x + 1)
   .mapToInt(x -> x)
   .sum();
(reduce + (map inc [1 2 3 4]))

Comptez le nombre de symboles différents dans le code Java. On a (){}<>,→;., et beaucoup plus de parenthèses que dans la version Clojure.

Immutabilité

En Clojure, (presque) tout est immutable. Cela se voit dès que l’on touche au langage :

riemann.bin> (def foo [1 2])
#'riemann.bin/foo
riemann.bin> (conj foo 3)
[1 2 3]
riemann.bin> (conj foo 4)
[1 2 4]
riemann.bin> foo
[1 2]
riemann.bin>

La même chose s’applique lors de passage de paramètre à des fonctions :

riemann.bin> (defn my-fn [my-vec] (conj my-vec 10))
#'riemann.bin/my-fn
riemann.bin> (my-fn foo)
[1 2 10]
riemann.bin> (my-fn foo)
[1 2 10]
riemann.bin> (my-fn [1 2 3])
[1 2 3 10]

Il n’y a plus à s’inquiéter des effets de bords, toutes les structures de données de Clojure sont immutables. Attention par contre si vous utilisez des objets Java (en utilisant l’intéropérabilité Clojure/Java), le code perdra cette propriété.

REPL, développement intéractif

J’adore Clojure car la façon de coder en Clojure correspond bien à ma façon de réfléchir. Lorsque je code en Clojure, j’ai toujours le REPL (l’interpréteur Clojure) ouvert. Je peux comme cela coder dans mon fichier .clj, le charger dans le REPL et le "tester" en live.

Cela est très intéressant et permet d’avoir très rapidement un retour sur ce qu’on écrit. C’est vraiment la technique ultime pour expérimenter, tester rapidement plusieurs solutions à un problème. C’est aussi très bien pour apprendre le langage.

Pour résumer, mon workflow quand je code en Clojure c’est :

  • Explorer les solutions à un problème avec le REPL. En faisant cela, je comprends mieux mon problème, les différentes solutions qui s’offrent à moi, comment je peux découper le code…​

  • Ecrire des tests

  • Refactorer ma solution (qui est souvent bancale et ne passe pas mes tests).

Les aficionados du TDD ne seraient pas forcément d’accord avec moi, mais je trouve que manipuler un peu le problème avant d’écrire des tests apporte une énorme plus value.

Le REPL est également agréable à utiliser grâce à la syntaxe simple et expressive de Clojure. Parait que Java 9 aura également un REPL, cool, mais je me vois mal taper dans un REPL à longueur de journée :

ArrayList<String> foolist = new ArrayList<String>();
list.add("foo");
list.add("bar");
list.add("baz");
Map<Integer, <List<String>> foomap = new HashMap<>();
foomap.put(20, foolist)

Et vous ? Pour information, l’équivalent Clojure est {20 ["foo" "bar" "baz"]}

Le fait que les fonctions prennent et retournent généralement des structures de données immutables aide aussi. Pour tout ce qui est état (connexions aux base de données, web servers…​) des outils comme mount permettent de définir et recharger en une commande l’intégralité du programme.

Le REPL est probablement ce qui me manque le plus dans d’autres langages.

Programmation concurrente

Clojure fournit plusieurs outils permettant de partager facilement des ressources entre threads.

Un atom permet de définir une variable où chaque opération sera atomique. Exemple :

riemann.bin> (def foo (atom [1 2]))
#'riemann.bin/foo
riemann.bin> foo
#atom[[1 2] 0x58749e6e]
riemann.bin> @foo
[1 2]
riemann.bin> (swap! foo conj 3)
[1 2 3]
riemann.bin> @foo
[1 2 3]

Ici, je définis un atom foo. @foo permet de déférencer l’atom, c’est à dire récupérer sa valeur. swap! permet d’appliquer une opération sur un atom (ici en y ajoutant la valeur 3).

Les atom ont donc un état (et ne sont pas immutables). L’intêret des atom est qu’ils sont thread safe.

Imaginons que 2 threads appellent swap! simultanément sur un atom, par exemple (swap! foo conj 3) sur le thread 1 puis (swap! foo conj 4) sur le thread 2. Si l’atom valait initialement [1 2], les "bonnes" réponses possibles sont [1 2 3 4] ou [1 2 4 3] une fois l’opération exécutée.

Imaginons que l’opération 1 se termine. L’atom vaudra donc [1 2 3]. Pas de chance, pendant ce temps là sur le thread 2, l’opération swap! produit [1 2 4] (les deux opérations ayant été lancés au même moment, l’état de l’atom en entrée était le même pour les deux).

Nous ne voulons surtout pas que le résultat final soit [1 2 4]. Heureusement, swap! détectera que l’atom a changé pendant qu’il réalisait l’opération, et va donc re-réaliser l’opération en prenant le nouveau état comme paramètre d’entrée. On obtiendra donc comme résultat final [1 2 3 4]. Le tout sans lock :)

Un peu de la même façon, les refs permettent de définir des opérations entre plusieurs variables mutables partagées entre plusieurs threads de manière safe, grâce à un système de transaction.

Les atom et les ref viennent en plus avec des fonctionnalités intéressantes, comme le fait de pouvoir définir des fonctions qui seront appelées (avec en paramètre l’ancien et le nouveau état de l’atom ou de la ref) une fois une action réalisée. Des fonctions de validations peuvent également être liées aux refs ou atom pour refuser certains états.

Clojure implémente également d’autres mécanismes pour la gestion de la concurrence (comme les agents, ou bien core.async qui permet de créer plusieurs processes qui communiqueront avec des channels (un peu à la Go)). Et évidemment, tout ce qui tourne sur la JVM (donc java.util.concurrent par exemple) est également disponible.

Macros

Une liste se définit de cette façon en Clojure:

riemann.bin> '(1 2 3)
(1 2 3)

Définissons une nouvelle liste (rappel: de part sa nature dynamique, une liste en Clojure peut contenir tout et n’importe quoi) :

riemann.bin> '(defn my-fn [my-vec] (conj my-vec 10))
(defn my-fn [my-vec] (conj my-vec 10))

Ma liste contient ici la définition de la fonction my-fn ! En clojure (et en LISP de façon plus générale), le code est une structure de données manipulable via un mécanisme appelé macros. Par exemple, defn lui même est une macro:

riemann.bin> (macroexpand '(defn my-fn2 [my-vec] (conj my-vec 10)))
(def my-fn2 (clojure.core/fn ([my-vec] (conj my-vec 10))))

macroexpand retourne la forme "réelle" d’une expression Clojure. On voit ici que defn est en fait un assemblage des mots-clés def et fn.

Les macros sont un outil très puissant (mais à utiliser avec modération), permettant de définir par exemple des DSL.

Conclusion

Clojure n’est pas parfait. Il reste beaucoup à faire, comme par exemple les messages d’erreurs (qui sont des stacktrace Java peu expressives) qui ont tendances à faire fuir les nouveaux venus dans le langage. La façon de travailler avec le REPL n’est pas également facile à acquérir seul.

Mais le langage est solide, cohérent, et a complètement changé ma façon de programmer (en bien). La philosophie du langage (immutabilité, manipulation facile de structures de données, REPL, fonctions/librairies versus frameworks…​) correspond parfaitement à ma façon de développer.

Si vous ne l’avez pas encore fait, vous DEVEZ jeter un oeil à Clojure.

Ressources

Tags: clojure programming
Top of page