Próbuję zrozumieć protokoły clojure i jaki problem mają rozwiązać. Czy ktoś ma jasne wyjaśnienie, co i dlaczego mają zastosowanie do protokołów clojure?
Próbuję zrozumieć protokoły clojure i jaki problem mają rozwiązać. Czy ktoś ma jasne wyjaśnienie, co i dlaczego mają zastosowanie do protokołów clojure?
Odpowiedzi:
Celem protokołów w Clojure jest rozwiązanie problemu wyrażeń w skuteczny sposób.
Więc na czym polega problem z wyrażeniem? Odnosi się do podstawowego problemu rozszerzalności: nasze programy manipulują typami danych za pomocą operacji. W miarę rozwoju naszych programów musimy je rozszerzać o nowe typy danych i nowe operacje. W szczególności chcemy mieć możliwość dodawania nowych operacji, które działają z istniejącymi typami danych, i chcemy dodać nowe typy danych, które działają z istniejącymi operacjami. I chcemy to prawda rozszerzenie , czyli nie chcemy, aby zmodyfikować istniejącyprogram, chcemy szanować istniejące abstrakcje, chcemy, aby nasze rozszerzenia były osobnymi modułami, w osobnych przestrzeniach nazw, oddzielnie kompilowane, oddzielnie wdrażane, osobno sprawdzane typy. Chcemy, aby były bezpieczne dla typów. [Uwaga: nie wszystkie z nich mają sens we wszystkich językach. Ale na przykład cel, aby były bezpieczne dla typów, ma sens nawet w języku takim jak Clojure. Tylko dlatego, że nie możemy statycznie sprawdzić bezpieczeństwa typów, nie oznacza, że chcemy, aby nasz kod losowo się łamał, prawda?]
Problem z wyrażeniem polega na tym, jak właściwie zapewnić taką rozszerzalność w języku?
Okazuje się, że w typowych naiwnych implementacjach programowania proceduralnego i / lub funkcjonalnego bardzo łatwo jest dodawać nowe operacje (procedury, funkcje), ale bardzo trudno jest dodawać nowe typy danych, ponieważ w zasadzie operacje działają z typami danych przy użyciu niektórych rodzaju przypadek dyskryminacji ( switch
, case
, pasujące do wzorca) i trzeba dodać nowe przypadki do nich, tj zmodyfikować istniejący kod:
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)
Teraz, jeśli chcesz dodać nową operację, powiedzmy, sprawdzenie typu, jest to łatwe, ale jeśli chcesz dodać nowy typ węzła, musisz zmodyfikować wszystkie istniejące wyrażenia dopasowania wzorców we wszystkich operacjach.
A w przypadku typowego naiwnego OO masz dokładnie odwrotny problem: łatwo jest dodać nowe typy danych, które działają z istniejącymi operacjami (przez dziedziczenie lub nadpisanie ich), ale trudno jest dodać nowe operacje, ponieważ w zasadzie oznacza to modyfikację istniejące klasy / obiekty.
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
W tym przypadku dodanie nowego typu węzła jest łatwe, ponieważ albo dziedziczysz, nadpisujesz lub implementujesz wszystkie wymagane operacje, ale dodanie nowej operacji jest trudne, ponieważ musisz dodać ją albo do wszystkich klas liści, albo do klasy bazowej, modyfikując w ten sposób istniejące kod.
Kilka języków ma kilka konstrukcji do rozwiązania problemu wyrażeń: Haskell ma typeklasy, Scala ma ukryte argumenty, Racket ma jednostki, Go ma interfejsy, CLOS i Clojure mają multimetody. Są też „rozwiązania”, które próbują go rozwiązać, ale zawodzą w taki czy inny sposób: interfejsy i metody rozszerzeń w C # i Javie, Monkeypatching w Ruby, Python, ECMAScript.
Zauważ, że Clojure faktycznie ma już mechanizm rozwiązywania problemu wyrażeń: multimetody. Problem, który OO ma z EP polega na tym, że łączą razem operacje i typy. Z Multimethods są oddzielne. Problem z FP polega na tym, że łączą one operację i dyskryminację przypadków razem. Ponownie, w przypadku Multimethods są one oddzielne.
Porównajmy więc protokoły z multimetodami, ponieważ oba robią to samo. Albo inaczej: po co protokoły, skoro już mamy multimetody?
Główną rzeczą oferowaną przez protokoły zamiast metod multimetodowych jest grupowanie: możesz zgrupować wiele funkcji razem i powiedzieć „te 3 funkcje razem tworzą protokół Foo
”. Nie możesz tego zrobić z Multimethods, one zawsze stoją same. Na przykład, można zadeklarować, że Stack
Protokół składa się zarówno A push
i pop
funkcja jest łączne .
Dlaczego więc po prostu nie dodać możliwości grupowania metod multimetod? Jest powód czysto pragmatyczny i dlatego użyłem słowa „efektywny” w zdaniu wprowadzającym: wydajność.
Clojure to język hostowany. Oznacza to, że jest specjalnie zaprojektowany do uruchamiania na platformie innego języka. Okazuje się, że prawie każda platforma, na której chciałbyś, aby Clojure działała (JVM, CLI, ECMAScript, Objective-C), ma wyspecjalizowaną, wysokowydajną obsługę wysyłania wyłącznie na podstawie typu pierwszego argumentu. Clojure Multimethods OTOH wysyłką na dowolnych właściwościach od wszystkich argumentów .
Tak więc protokoły ograniczają wysyłanie tylko na pierwszy argument i tylko na jego typ (lub jako specjalny przypadek nanil
).
Nie jest to ograniczenie idei protokołów jako takich, jest to pragmatyczny wybór, aby uzyskać dostęp do optymalizacji wydajności platformy bazowej. W szczególności oznacza to, że protokoły mają trywialne mapowanie na interfejsy JVM / CLI, co czyni je bardzo szybkimi. W rzeczywistości wystarczająco szybko, aby móc przepisać te części Clojure, które są obecnie napisane w Javie lub C # w samym Clojure.
Na przykład Clojure ma już protokoły od wersji 1.0: Seq
jest protokołem. Ale do 1.2 nie można było pisać protokołów w Clojure, trzeba było pisać je w języku hosta.
Uważam, że najbardziej pomocne jest myślenie o protokołach jako koncepcyjnie podobnych do „interfejsu” w językach obiektowych, takich jak Java. Protokół definiuje abstrakcyjny zestaw funkcji, które mogą być implementowane w konkretny sposób dla danego obiektu.
Przykład:
(defprotocol my-protocol
(foo [x]))
Definiuje protokół z jedną funkcją o nazwie „foo”, która działa na jednym parametrze „x”.
Możesz wtedy tworzyć struktury danych implementujące protokół, np
(defrecord constant-foo [value]
my-protocol
(foo [x] value))
(def a (constant-foo. 7))
(foo a)
=> 7
Zauważ, że tutaj obiekt implementujący protokół jest przekazywany jako pierwszy parametr x
- podobnie jak niejawny parametr „this” w językach obiektowych.
Jedną z bardzo potężnych i przydatnych funkcji protokołów jest to, że można je rozszerzyć na obiekty, nawet jeśli obiekt nie był pierwotnie zaprojektowany do obsługi protokołu . np. możesz rozszerzyć powyższy protokół do klasy java.lang.String, jeśli chcesz:
(extend-protocol my-protocol
java.lang.String
(foo [x] (.length x)))
(foo "Hello")
=> 5
this
w kodzie Clojure.