Explication simple des protocoles de clojure

131

J'essaie de comprendre les protocoles de clojure et quel problème ils sont censés résoudre. Quelqu'un a-t-il une explication claire sur le quoi et le pourquoi des protocoles de clôture?

appshare.co
la source
7
Clojure 1.2 Protocoles en 27 minutes: vimeo.com/11236603
miku
3
Une analogie très proche avec les protocoles sont les traits (mixins) dans Scala: stackoverflow.com/questions/4508125/...
Vasil Remeniuk

Réponses:

284

Le but des protocoles dans Clojure est de résoudre le problème d'expression de manière efficace.

Alors, quel est le problème d'expression? Il fait référence au problème de base de l'extensibilité: nos programmes manipulent des types de données à l'aide d'opérations. À mesure que nos programmes évoluent, nous devons les étendre avec de nouveaux types de données et de nouvelles opérations. Et en particulier, nous voulons pouvoir ajouter de nouvelles opérations qui fonctionnent avec les types de données existants, et nous voulons ajouter de nouveaux types de données qui fonctionnent avec les opérations existantes. Et nous voulons que ce soit une vraie extension , c'est-à-dire que nous ne voulons pas modifier l' existantprogramme, nous voulons respecter les abstractions existantes, nous voulons que nos extensions soient des modules séparés, dans des espaces de noms séparés, compilés séparément, déployés séparément, type vérifié séparément. Nous voulons qu'ils soient sécurisés. [Remarque: tous ces éléments n'ont pas de sens dans toutes les langues. Mais, par exemple, l'objectif de leur sécurité de type a du sens même dans un langage comme Clojure. Ce n'est pas parce que nous ne pouvons pas vérifier statiquement la sécurité de type que nous voulons que notre code soit interrompu au hasard, non?]

Le problème de l'expression est: comment fournir une telle extensibilité dans une langue?

Il s'avère que pour les implémentations naïves typiques de la programmation procédurale et / ou fonctionnelle, il est très facile d'ajouter de nouvelles opérations (procédures, fonctions), mais très difficile d'ajouter de nouveaux types de données, car fondamentalement les opérations fonctionnent avec les types de données en utilisant certains sorte de discrimination de cas ( switch, case, pattern matching) et vous avez besoin d'ajouter de nouveaux cas pour eux, par exemple modifier le code existant:

func print(node):
  case node of:
    AddOperator => print(node.left) + '+' + print(node.right)
    NotOperator => '!' + print(node)

func eval(node):
  case node of:
    AddOperator => eval(node.left) + eval(node.right)
    NotOperator => !eval(node)

Maintenant, si vous souhaitez ajouter une nouvelle opération, par exemple, la vérification de type, c'est facile, mais si vous souhaitez ajouter un nouveau type de nœud, vous devez modifier toutes les expressions de correspondance de modèle existantes dans toutes les opérations.

Et pour un OO naïf typique, vous avez exactement le problème inverse: il est facile d'ajouter de nouveaux types de données qui fonctionnent avec les opérations existantes (soit en les héritant, soit en les remplaçant), mais il est difficile d'ajouter de nouvelles opérations, car cela signifie essentiellement modifier classes / objets existants.

class AddOperator(left: Node, right: Node) < Node:
  meth print:
    left.print + '+' + right.print

  meth eval
    left.eval + right.eval

class NotOperator(expr: Node) < Node:
  meth print:
    '!' + expr.print

  meth eval
    !expr.eval

Ici, ajouter un nouveau type de nœud est facile, car vous héritez, remplacez ou implémentez toutes les opérations requises, mais l'ajout d'une nouvelle opération est difficile, car vous devez l'ajouter soit à toutes les classes feuilles, soit à une classe de base, modifiant ainsi code.

Plusieurs langages ont plusieurs constructions pour résoudre le problème d'expression: Haskell a des classes de types, Scala a des arguments implicites, Racket a des unités, Go a des interfaces, CLOS et Clojure ont des multiméthodes. Il existe aussi des «solutions» qui tentent de le résoudre, mais échouent d'une manière ou d'une autre: Interfaces et méthodes d'extension en C # et Java, Monkeypatching en Ruby, Python, ECMAScript.

Notez que Clojure a déjà en fait un mécanisme pour résoudre le problème d'expression: les multiméthodes. Le problème qu'OO a avec le PE est qu'ils regroupent les opérations et les types ensemble. Avec Multimethods, ils sont séparés. Le problème de FP est qu'il regroupe l'opération et la discrimination des cas. Encore une fois, avec les multiméthodes, ils sont séparés.

Alors, comparons les protocoles avec les multiméthodes, car les deux font la même chose. Ou, pour le dire autrement: pourquoi des protocoles si nous avons déjà des multiméthodes?

La principale chose que les protocoles offrent par rapport aux méthodes multimétiers est le regroupement: vous pouvez regrouper plusieurs fonctions et dire "ces 3 fonctions forment ensemble le protocole Foo". Vous ne pouvez pas faire cela avec Multimethods, ils sont toujours autonomes. Par exemple, vous pouvez déclarer qu'un Stackprotocole consiste à la fois une pushet une popfonction ensemble .

Alors, pourquoi ne pas simplement ajouter la possibilité de regrouper les multiméthodes? Il y a une raison purement pragmatique, et c'est pourquoi j'ai utilisé le mot «efficace» dans ma phrase d'introduction: la performance.

Clojure est une langue hébergée. C'est-à-dire qu'il est spécifiquement conçu pour être exécuté sur la plate-forme d' une autre langue. Et il s'avère que pratiquement toutes les plates-formes sur lesquelles vous souhaitez que Clojure s'exécute (JVM, CLI, ECMAScript, Objective-C) ont un support spécialisé haute performance pour la distribution uniquement sur le type du premier argument. Clojure Multimethods OTOH envoie sur les propriétés arbitraires de tous les arguments .

Ainsi, les protocoles vous limitent à la distribution uniquement sur le premier argument et uniquement sur son type (ou comme cas particulier sur nil).

Ce n'est pas une limitation à l'idée de protocoles en soi, c'est un choix pragmatique pour accéder aux optimisations de performances de la plateforme sous-jacente. En particulier, cela signifie que les protocoles ont un mappage trivial avec les interfaces JVM / CLI, ce qui les rend très rapides. Assez rapidement, en fait, pour pouvoir réécrire les parties de Clojure qui sont actuellement écrites en Java ou C # dans Clojure lui-même.

Clojure a déjà eu des protocoles depuis la version 1.0: Seqest un protocole, par exemple. Mais jusqu'à la 1.2, vous ne pouviez pas écrire de protocoles dans Clojure, vous deviez les écrire dans la langue hôte.

Jörg W Mittag
la source
Merci pour une réponse aussi approfondie, mais pouvez-vous clarifier votre point concernant Ruby. Je suppose que la capacité de (re) définir des méthodes de n'importe quelle classe (par exemple String, Fixnum) dans Ruby est une analogie avec le defprotocol de Clojure.
défhlt le
3
Un excellent article sur le problème d'expression et les protocoles de clojure
ibm.com/developerworks/library/j-clojure-protocols
Désolé de publier un commentaire sur une réponse aussi ancienne, mais pourriez-vous expliquer pourquoi les extensions et les interfaces (C # / Java) ne sont pas une bonne solution au problème d'expression?
Onorio Catenacci
Java n'a pas d'extensions au sens où le terme est utilisé ici.
user100464
Ruby a des améliorations qui rendent obsolète la correction des singes.
Marcin Bilski
65

Je trouve très utile de penser aux protocoles comme étant conceptuellement similaires à une «interface» dans des langages orientés objet tels que Java. Un protocole définit un ensemble abstrait de fonctions qui peuvent être implémentées de manière concrète pour un objet donné.

Un exemple:

(defprotocol my-protocol 
  (foo [x]))

Définit un protocole avec une fonction appelée "foo" qui agit sur un paramètre "x".

Vous pouvez ensuite créer des structures de données qui implémentent le protocole, par exemple

(defrecord constant-foo [value]  
  my-protocol
    (foo [x] value))

(def a (constant-foo. 7))

(foo a)
=> 7

Notez qu'ici l'objet implémentant le protocole est passé comme premier paramètre x- un peu comme le paramètre implicite "this" dans les langages orientés objet.

L'une des fonctionnalités très puissantes et utiles des protocoles est que vous pouvez les étendre à des objets même si l'objet n'a pas été conçu à l'origine pour prendre en charge le protocole . par exemple, vous pouvez étendre le protocole ci-dessus à la classe java.lang.String si vous le souhaitez:

(extend-protocol my-protocol
  java.lang.String
    (foo [x] (.length x)))

(foo "Hello")
=> 5
Mikera
la source
2
> comme le paramètre implicite "this" en langage orienté objet, j'ai remarqué que le var passé aux fonctions de protocole est souvent appelé thisdans le code Clojure.
Kris