Publié par
Il y a 4 mois · 15 minutes · Craft

Clojure : Entre parenthèses

Vous êtes développeur et voulez découvrir un nouveau langage ? Vous n’êtes pas phobique des parenthèses ?

Dans cet article je vais vous faire découvrir Clojure qui est un dialecte de LISP qui fonctionne sur la JVM.

Il est compilé, dynamique, fonctionnel et facilite la programmation multi-thread.

Pour ceux qui veulent exécuter les exemples, le plus simple est d’utiliser docker avec les commandes suivantes :

docker run -i -t clojure /bin/bash
lein repl

Vous avez maintenant accès au REPL (read-eval-print-loop), vous pouvez taper du code Clojure, il sera exécuté et le résultat sera affiché. Allez, un petit exemple ensemble : on va additionner 1 et 2.

user=> (+ 1 2) ; ceci est un commentaire
3

Un autre exemple, le fameux Hello World.

user=> (prn "Hello World")
Hello World
nil

Dans cette exemple, la chaîne de caractères est imprimée puis la valeur de l’évaluation de la fonction est retournée. Dans la suite de l’article le user=> ne sera plus repris, et les valeurs retournées seront mises en commentaire à coté du code évalué.

Comme vous pouvez le voir dans l’exemple précédent, la syntaxe est assez différente de la plupart des langages ; en effet Clojure utilise une notation préfixée. Tout le code Clojure sera donc écrit sous la forme :

(function arg1 arg2 argN)

Mais ne prenez pas peur, vous allez vous y habituer (ou presque !) d’ici la fin de cet article.

Tout d’abord regardons quelles sont les structures de données que l’on peut utiliser.

Structure de données

nil

C’est le null de Java, il est utilisé pour représenter l’absence de valeur.

Nombres

Clojure utilise les long Java pour représenter les entiers et les double pour les réels.

Il est également possible d’utiliser BigDecimal et BigInt avec les suffixes M et N respectivement pour les nombres de précision arbitraire.

Clojure peut également représenter une fraction avec le type Ratio.

Voici un exemple en utilisant la fonction class, qui retourne la classe de l’argument qui lui est passé.

(/ 1 3) ; 1/3
(class 1/3) ; clojure.lang.Ratio
(class 1M) ; java.lang.BigDecimal
(class 1N) ; java.lang.BigInt

String

Les String Clojure sont des String Java, elles sont entre double quotes et peuvent être sur plusieurs lignes.

"this is a string"

"this 
is
a 
multiline
string"
 
(println "hello world")

Caractères

Les caractères Clojure sont des char Java, ils sont précédés par un backslash.

\c
\u00f8 ou \ø
\space 

Mot-clés

Les mots-clés sont des identifiants, ils sont précédés par un : et peuvent être associés à un espace de nom de la façon suivante :my-ns/my-keyword.

La notation ::my-keyword permet de résoudre le namespace automatiquement avec le namespace du fichier dans lequel est déclaré le mot-clé.

(ns my-ns) ; déclaration du namespace
:toto ; keyword sans namespace
:namespace/toto ; keyword avec namespace
::automaticaly-namespaced-toto  ; equivalent à :my-ns/automaticaly-namespaced-toto

Ils sont généralement utilisés comme clés dans des maps (qui vous seront présentés un peu plus loin).

Booléens

Pas de révolution, true ou false.

Seul nil et false sont faux et toute autre valeur est vraie dans un contexte booléen, plus besoin de se torturer le cerveau pour savoir ce qui est « vrai » ou « faux ».

(if true "vrai" "faux") ; vrai
(if '() "vrai" "faux") ; vrai
(if "" "vrai" "faux") ; vrai
(if [] "vrai" "faux") ; vrai
(if 0 "vrai" "faux") ; vrai
 
(if false "vrai" "faux") ; faux
(if nil "vrai" "faux") ; faux
 

Ainsi des collections vides sont évaluées à vrai dans un contexte booléen, comme une liste vide ‘() ou un vecteur vide [].

Nous allons maintenant voir ces collections.

Collections

Les collections en Clojure sont immuables.

Liste

Une liste est composée de 0 à n éléments entre parenthèses.

Une expression Clojure est une liste et le premier élément est invoqué avec comme paramètres les éléments qui le suivent.

Pour utiliser une liste il faut soit utiliser la fonction list suivie des éléments de la liste, ou utiliser la fonction quote (ou la macro ‘) pour éviter l’évaluation.

(list 1 2 3) ; retourne (1 2 3)
(quote (1 2 3)) ; retourne (1 2 3)
'(1 2 3) ; retourne (1 2 3)
 

Il est important de noter que ‘ (simple quote) empêche totalement l’évaluation, la fonction list est donc souvent préférée. L’utilisation de ‘ est par contre recommandée pour créer une liste vide car c’est plus simple et plus clair que l’utilisation de la fonction list sans aucun argument.

; liste avec éléments
(def x 3) ; affecte 3 à la variable x
(list 1 2 x) ; retourne (1 2 3)
'(1 2 x) ; retourne (1 2 x), x n'est pas évalué
 
; liste vide
'() ; retourne ()
(list) ; retourne ()

Vecteur

Un vecteur est composé de 0 à n éléments entre crochets.

Un vecteur peut être utilisé comme fonction d’un argument qui est la position de l’élément souhaité.

[1 2 3] ; un vecteur de 3 éléments
([1 2 3] 0) ; retourne le premier élément du vecteur : 1

Map

Une map est composée de 0 à n paires clé/valeur entre accolades.

Les virgules sont considérées comme de simples espaces utilisés pour améliorer la lisibilité et peuvent être utilisées pour organiser les paires clé/valeur.

Une map peut être utilisée comme fonction d’un ou deux arguments, avec comme premier argument la clé et comme argument optionnel une valeur si aucune valeur n’a été trouvée.

{:a 1, :b 2} ; une map de 2 éléments
{:a 1 :b 2} ; syntaxe alternative
({"toto" 10, "titi" 12} "toto") ; retourne 10
({"toto" 10, "titi" 12} "tata" "rien trouve") ; retourne "rien trouve"

On peut également rechercher dans un map en utilisant un mot-clé en tant que fonction d’un ou deux arguments, avec comme premier argument la Map et comme argument optionnel une valeur si aucune valeur n’a été trouvée.

; ici le mot-clé :a est utilisé en tant que fonction
(:a {:a 1, :b 2}) ; :a est une clé de la map, la fonction retourne la valeur de :a soit 1.
(:a {:c 1, :b 2} :not-found) ; :a n'est pas une clé de la map, la valeur optionnelle est retournée soit :not-found

Par convention on utilise en général le mot-clé comme fonction dans le cas d’une map qui représente un objet, et la map comme fonction lorsque la map représente une collection.

(:age {:nom "Bob" :age 12}) ; la map représente un objet on utilise donc le mot clé comme fonction
({:bob 12, :joe 20, :bill 10} :bob) ; la map représente une collection on utilise donc la map comme fonction

Set

Un set est composé de 0 à n éléments entre accolades précédées d’un dièse.

Un set peut être utilisé comme une fonction d’un argument qui retourne l’argument s’il est présent dans le set ou nil sinon.

#{1 2 3} ; un set de 3 éléments
(#{1 2 3} 3) ; retourne 3
(#{1 2 3} 4) ; retourne nil

Abstraction Sequence

Clojure utilise une abstraction appelée sequence, qui permet de traiter une collection comme une liste logique.

Pour créer une sequence à partir d’une collection il faut appeler la fonction seq sur la collection

(seq '(1 2 3))
(seq [1 2 3])
(seq {:a 1, :b 2})

Cette abstraction permet d’utiliser une multitude de fonctions sur les différents types de collections. Cependant il n’est pas nécessaire d’appeler la fonction seq à la main, en effet toutes les fonctions qui agissent sur des sequences le font si la collection n’est pas déjà une sequence.

(first (seq '(1 2 3))) ; inutile d'appeler seq, la fonction first le fait pour nous 
(first '(1 2 3)) ; retourne 1 
(first {:a 1, :b 2}) ; retourne [:a 1]

Il y a beaucoup de fonctions utilisables, par exemple every? qui s’utilise de la façon suivante (every? pred coll). Si le prédicat pred est vrai pour tous les éléments de la sequence retourne vrai sinon faux.

Il y a également filter (filter pred coll) qui retourne une sequence avec seulement les éléments qui vérifient le prédicat.

(every? even? '(1 2 3)) ; retourne false
(filter even? '(1 2 3)) ; retourne (2)

Fonction

Pour déclarer une fonction on peut utiliser la fonction fn qui est définie comme ceci (fn name? ([params*] exprs*)+).

Voici un exemple de déclaration d’une fonction d’un argument, qui ajoute 1 à la valeur qui lui est passée.

(def my-simple-function 
(fn ([x] (+ 1 x)))
)

On peut déclarer une fonction qui prend un nombre d’argument variable en utilisant & autre-arguments.

Les arguments sont récupérés sous forme d’une liste.

(def my-simple-function-2 
(fn ([& more] (reduce + more))) ; function de 0 à n arguments
)
 
(def my-simple-function-3 
 ([x y & more] (+ 1 x y (reduce + more)))  ; function de 2 à n arguments
)

On peut également déclarer plusieurs signatures différentes pour une fonction de la manière suivante :

(def my-function 
(fn ; on peut déclarer plusieurs signatures pour la fonction
  ([x] (+ 1 x)) ; pour 1 argument
  ([x y] (+ 1 x y)) ; pour 2 arguments
  ([x y & more] (+ 1 x y (reduce + more))) ; pour 2 arguments + n autre argument sous forme de liste
  )
)

Et maintenant appelons la fonction nouvellement créée :

(my-function 1) ; retourne 2
(my-function 1 1) ; retourne 3
(my-function 0 0 2 3) ; retourne 6

Cette syntaxe de définition de fonction n’est pas très pratique, on peut utiliser la macro (nous allons voir plus en détail ce qu’est une macro juste après) defn pour simplifier la déclaration d’une fonction.

(defn my-function
([x] (+ 1 x))
([x y] (+ 1 x y))
([x y & more] (+ 1 x y (reduce + more)))
)

Pour une fonction anonyme on peut également utiliser les fonctions anonymes littérales #(…). Pour accéder aux arguments on a %n (ordre positionnel démarre à 1), % (équivalent à %1 premier argument), %& (le reste des arguments après le plus grand n utilisé)

(map (fn [x] (* x x)) '(1 2 3)) ; retourne 1 4 9

(map #(* % %) '(1 2 3)) ; retourne 1 4 9

(#(+ %1 %2) 1 2) ; retourne 3

(#(list %1 %2 %&) 1 2 3 4 5)  ; retourne (1 2 (3 4 5))

(#(list %1 %4 %&) 1 2 3 4 5) ; retourne (1 4 (5))

Macro

Le système de macro de Clojure permet d’écrire du code qui va écrire du code pour vous !

À quoi cela peut-il servir ? Nous en avons eu un exemple juste avant avec defn qui permet de simplifier la déclaration d’une fonction.

Prenons un autre exemple, vous avez une liste qui contient les entiers entre 1 et 6, vous voulez garder uniquement les nombres pairs, les multiplier par deux puis additionner le tout.

On pourrait écrire cela comme ça :

(reduce + 
        (map #(* % 2) 
               (filter even? '(1 2 3 4 5 6))))

Problème : la lecture peut rapidement devenir difficile car il faut lire les opérations dans l’ordre inverse de ce qu’on souhaite faire. Heureusement, la macro ->> vous permet de réécrire le code précédent de la façon suivante :

(->> '(1 2 3 4 5 6) 
(filter even?  ,,)  
(map #(* % 2) ,,) 
(reduce + ,,))  ; Les virgules en clojure sont comme des espaces et sont utilisées dans cet exemple pour montrer où vient se placer le résultat de la fonction précédente

Cette fois-ci la lecture de l’ordre des opérations est facilitée.

Les macros permettent bien d’autres choses, mais le sujet est trop vaste pour être abordé ici.

Référence à un état

Il est parfois nécessaire de garder une référence à un état muable, pour ce faire Clojure nous fournit plusieurs moyen : vars, refs, atoms et agents.

Vars

Les vars sont utilisés pour garder une référence à un état, comme quand on déclare une variable dans un autre langage. Pour déclarer une var, on utilise def (ou des macros qui vont simplifier l’écriture, comme defn vu précédemment).

(def x 1)
x ; 1

Par défaut une var est statique ; il est toutefois possible de la déclarer dynamique pour permettre de changer sa valeur dans un thread, mais nous ne rentrerons pas dans le détail ici.

Pour référencer un état muable, on préférera en général utiliser les refs, atoms et agents que nous allons voir maintenant.

Refs

Les refs sont utiles pour les états partagés, synchronisés et coordonnés.

La lecture de la valeur se fait avec la fonction deref ou la macro @.

(def x (ref 1))
 
(deref x) ou @x ; retourne 1

Pour changer la valeur d’une ref il faut utiliser la fonction ref-set.

La coordination est réalisée via des transactions qui sont gérées par le STM (software transactional memory) de Clojure.

Toute tentative de changement hors d’une transaction lève une exception.

(ref-set x 2) ; lance une exception, les changements ne sont autorisés que dans une transactions
 
@x ; retourne 1, la valeur n'a pas été changée.
(dosync 
(ref-set x 2)
)
@x ; retourne 2, la valeur a été changée avec succès 

On peut rajouter une fonction de validation à une ref, qui annulera la transaction si la fonction de validation n’est pas respectée.

On va déclarer une ref y qui doit être paire, puis essayer de modifier x et y avec une valeur de y impaire :

(def y (ref 2 :validator even?))
 
(dosync 
(ref-set x 5)
(ref-set y 5)
)
 
@x ; retourne 2 la transaction a été annulée, la valeur de x n'a pas été changée. 
@y ; retourne 2 la transaction a été annulée, la valeur de y n'a pas été changée non plus.

Les valeurs de x et y ne sont pas modifiées tant que la transaction n’est pas un succès, et le monde extérieur ne verra pas les modifications apportées à x et y durant la transaction.

Les refs sont utiles lorsque l’on souhaite synchroniser la mutation de plusieurs états (comme dans l’exemple avec x et y). Lorsque l’état que l’on souhaite modifier n’est modifié que de manière non coordonnée, Clojure nous fournit un type plus adapté : l’atom.

Atoms

Les atoms sont pour les états partagés, synchronisés et indépendants.

Comme pour les ref on peut lire la valeur avec deref ou @.

On peut changer de manière atomique la valeur d’un atom avec swap! qui prend une fonction et va l’appliquer à la valeur de l’atom puis retourner la valeur swappée.

(def x (atom 1))
 
(deref x) ou @x ; retourne 1
 
(swap! x #(+ 1 %)); retourne 2
 
@x ; retourne 2

Agents

Les agents sont pour les états partagés, indépendants et asynchrones.

On peut envoyer à un agent une fonction à exécuter qui changera son état.

(def x (agent 1))
 
(deref x) ou @x ; retourne 1
 
(send x #(+ 1 %)) ;
 
@x ; retourne 1 tant que le traitement n'est pas terminé, et retournera 2 quand le traitement aura été effectué.

Interopérabilité Java

On peut facilement appeler Java depuis Clojure avec les syntaxes suivantes :

; (.instanceMember instance args*)
(.charAt "hello" 0) ; \h
(.toUpperCase "hello") ; HELLO
 
; (Classname/staticMethod args*)
(String/valueOf 1) ; "1"
 
; Classname/staticField
(Math/PI) ;  3.141592653589793
 
;(.instanceMember Classname args*)
(.getName String) ; java.lang.String
 
;(.-instanceField instance)
 
(.-y (new java.awt.Point 5 3)) ; 3 On peut remplacer new MaClass par MaClass. ainsi on aurait pu écrire (.-y (java.awt.Point. 5 3))
  

Protocol et Record

Après avoir vu ce que nous offre Clojure, nous allons maintenant créer nos propres abstractions.

Pour ce faire nous avons les protocoles qui vont définir des spécifications uniquement sans implémentation.

On va par exemple définir un protocole Forme qui aura une méthode pour obtenir l’aire de cette forme.

(defprotocol Forme
(aire [forme])
)

Maintenant il nous faut une implémentation, pour cela nous allons utiliser un record.
Un record a des champs immuables auxquels on peut accéder via des mots clés, il peut implémenter des protocoles.

Voici un record que nous allons appeler Rectangle, qui aura comme champs x et y, et qui va implémenter le protocole que nous avons défini précédemment :

(defrecord Rectangle [x y]              ; Définit un record Rectangle avec des champs x et y
Forme                                   ; Implémente le protocol Forme
(aire [forme] (* (:x forme) (:y forme)))   ; (:x forme) permet d’accéder au champs x
)
 
(aire (Rectangle. 3 4)) ; 12

On peut également rajouter une implémentation de notre protocole sur un record (ou une classe) déjà existante.

Ainsi on peut rajouter l’implémentation de notre protocole sur la classe java.awt.Rectangle

(extend-type java.awt.Rectangle 
Forme
(aire [forme] (* (.width this) (.height this)))
)
 
(aire (java.awt.Rectangle. 3 4)) ; 12 

Conclusion

Pour conclure, quelques mots sur l’utilisation de Clojure en entreprise.

Théoriquement Clojure peut être utilisé pour les mêmes utilisations que Java. Sa bonne interopérabilité lui permet à la fois de pouvoir tirer parti des très nombreuses bibliothèques Java disponibles mais aussi de s’intégrer au code déjà existant dans l’entreprise, il est ainsi possible de réaliser le développement de nouveaux produits en Clojure ou de migrer progressivement une base de code existante sans répartir de zéro.

Il est utilisé en production par diverses entreprise, voici par exemple la liste des compagnies qui déclarent utiliser Clojure. Pour des cas plus concrets d’utilisation de Clojure par des entreprises vous pouvez lire cet article sur AppsFlyerregarder cette video de Walmart ou encore cet article de thoughtworks.

Par rapport à Java, il est beaucoup plus concis, apporte une gestion facilitée de la concurrence, permet la création simple de DSL et est adapté à la manipulation de données.

Il faut cependant noter que Clojure est très différent de Java (aussi bien pour la syntaxe que pour le paradigme) et que cela demande un changement important pour les développeurs qui ont l’habitude de Java, ainsi il est plus raisonnable de privilégier une équipe de taille réduite avec des gens motivés (par Clojure) avant d’entreprendre un projet.

Pour ceux qui veulent aller plus loin, je vous recommande le site officiel de Clojure, qui contient des informations intéressantes et notamment de nombreuses références à des ressources externes ici, parmi lesquelles on peut citer Clojure for the Brave and True qui est un très bon livre pour commencer Clojure.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *