Просте пояснення протоколів clojure


131

Я намагаюся зрозуміти протоколи Clojure та яку проблему вони мають вирішити. Хтось має чітке пояснення того, що і чому протоколу клоджура?


7
Clojure 1.2 Протоколи в 27 хвилин: vimeo.com/11236603
Міку

3
Дуже близькою аналогією до протоколів є риси (міксини) у Scala: stackoverflow.com/questions/4508125/…
Василь Ременюк

Відповіді:


284

Метою протоколів у Clojure є ефективне вирішення проблеми вираження.

Отже, що таке проблема вираження? Це стосується основної проблеми розширюваності: наші програми маніпулюють типами даних за допомогою операцій. У міру розвитку наших програм нам потрібно поширювати їх на нові типи даних та нові операції. І, зокрема, ми хочемо додати нові операції, які працюють з існуючими типами даних, і ми хочемо додати нові типи даних, які працюють з існуючими операціями. І ми хочемо, щоб це було правдивим розширенням , тобто ми не хочемо змінювати існуючеПрограма, ми хочемо поважати існуючі абстракції, ми хочемо, щоб наші розширення були окремими модулями, в окремих просторах імен, окремо складені, окремо розгорнуті, окремо перевірені типу. Ми хочемо, щоб вони були безпечними для типу. [Примітка: не всі з них мають сенс на всіх мовах. Але, наприклад, мета, щоб вони були безпечними для типу, має сенс навіть у такій мові, як Clojure. Тільки тому, що ми не можемо статично перевірити безпеку типу, не означає, що ми хочемо, щоб наш код випадковим чином зламався, правда?]

Проблема вираження полягає в тому, як ви насправді забезпечуєте таку розширюваність мовою?

Виявляється, що для типових наївних реалізацій процедурного та / або функціонального програмування дуже легко додати нові операції (процедури, функції), але дуже важко додати нові типи даних, оскільки в основному операції працюють з типами даних, використовуючи деякі вид випадку дискримінації ( switch, case, шаблон відповідності) і вам потрібно додати нові випадки до них, тобто змінити існуючий код:

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)

Тепер, якщо ви хочете додати нову операцію, скажімо, перевірку типу, це просто, але якщо ви хочете додати новий тип вузла, ви повинні змінити всі існуючі вирази відповідності шаблону у всіх операціях.

А для типових наївних ОО є у вас прямо протилежна проблема: легко додавати нові типи даних, які працюють з існуючими операціями (або успадковуючи, або переосмислюючи їх), але важко додати нові операції, оскільки це в основному означає модифікацію існуючі класи / об’єкти.

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

Тут додавання нового типу вузла є простим, оскільки ви або успадковуєте, переосмислюєте або реалізуєте всі необхідні операції, але додати нову операцію важко, тому що її потрібно додати або до всіх класів листів, або до базового класу, модифікуючи таким чином існуючий код.

Кілька мов мають кілька конструкцій для вирішення проблеми виразів: Haskell має типові класи, Scala має неявні аргументи, Racket має одиниці, Go має інтерфейси, CLOS і Clojure мають багатометоди. Існують також "рішення", які намагаються вирішити це, але виходять з ладу тим чи іншим способом: Інтерфейси та методи розширення в C # і Java, Monkeypatching в Ruby, Python, ECMAScript.

Зауважимо, що Clojure насправді вже має механізм вирішення проблеми вираження: мультиметоди. Проблема, з якою у OO є EP, полягає в тому, що вони поєднують операції та типи разом. З мультиметодами вони розділені. Проблема, яку має ПП, полягає в тому, що вони поєднують операцію та дискримінацію у випадку. Знову ж таки, з мультиметодами вони розділені.

Отже, порівняємо протоколи з мультиметодами, оскільки обидва роблять те саме. Або, простіше кажучи: навіщо протоколи, якщо у нас вже є багатометоди?

Головне, що Протоколи пропонують для мультиметодів, - це групування: ви можете згрупувати кілька функцій разом і сказати "ці 3 функції разом утворюють протокол Foo". З мультиметодами це зробити не можна, вони завжди стоять самостійно. Наприклад, ви можете заявити, що Stackпротокол складається як з pushа, так і з popфункції разом .

Отже, чому б не просто додати можливість групувати багатометоди разом? Є суто прагматична причина, і саме тому я в своєму вступному реченні я використав слово "ефективний".

Clojure - мова, що розміщується. Тобто це спеціально розроблено для роботи над платформою іншої мови. І виявляється, що майже будь-яка платформа, на якій ви хотіли б працювати Clojure (JVM, CLI, ECMAScript, Objective-C), має спеціалізовану високопродуктивну підтримку для диспетчеризації виключно за типом першого аргументу. Clojure мультиметод OTOH відправки на довільні властивості з усіх аргументів .

Таким чином, протоколи обмежують вас направити тільки на перший аргумент і тільки від його типу (або як окремий випадок на nil).

Це не обмеження ідеї протоколів як такої, це прагматичний вибір для отримання доступу до оптимізацій ефективності базової платформи. Зокрема, це означає, що протоколи мають тривіальне відображення до інтерфейсів JVM / CLI, що робить їх дуже швидкими. Насправді досить швидко, щоб можна було переписати ті частини Clojure, які зараз написані на Java або C # у самому Clojure.

Clojure фактично вже мав протоколи з версії 1.0: наприклад Seq, це протокол. Але до 1.2 року ви не могли написати протоколи в Clojure, вам довелося їх писати мовою хосту.


Дякую за таку грунтовну відповідь, але чи можете ви уточнити свою думку щодо Рубі. Я вважаю, що здатність (повторно) визначати методи будь-якого класу (наприклад, String, Fixnum) у Ruby є аналогією дефпротоколу Clojure.
дефтер

3
Чудова стаття про проблему вираження та протоколи clojure - ibm.com/developerworks/library/j-clojure-protocols
navgeet

Вибачте, що виклали коментар до такої старої відповіді, але чи можете ви детальніше пояснити, чому розширення та інтерфейси (C # / Java) не є хорошим рішенням проблеми вираження?
Оноріо Катенач

У Java немає розширень у тому сенсі, що термін тут використовується.
користувач100464

У Ruby є вдосконалення, що робить виправлення мавп застарілим.
Марцін Більський

64

Мені найбільш корисно вважати протоколи як концептуально схожими на "інтерфейс" в об'єктно-орієнтованих мовах, таких як Java. Протокол визначає абстрактний набір функцій, який може бути реалізований конкретним чином для даного об'єкта.

Приклад:

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

Визначає протокол з однією функцією під назвою "foo", яка діє на один параметр "x".

Потім можна створити структури даних, які реалізують протокол, наприклад

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

(def a (constant-foo. 7))

(foo a)
=> 7

Зауважимо, що тут об'єкт, що реалізує протокол, передається як перший параметр x- дещо схожий на неявний параметр "цей" в об'єктно-орієнтованих мовах.

Однією з дуже потужних і корисних особливостей протоколів є те, що ви можете поширити їх на об'єкти, навіть якщо об'єкт спочатку не був призначений для підтримки протоколу . наприклад, ви можете поширити протокол вище до класу java.lang.String, якщо вам подобається:

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

(foo "Hello")
=> 5

1
> як і неявний параметр "цей" в об'єктно-орієнтованій мові, я помітив, що var, переданий функціям протоколу, часто також викликається thisв коді Clojure.
Кріс
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.