„Klasa typu jest interfejsem, który definiuje pewne zachowanie. Mówiąc dokładniej, Klasa typu określa kilka funkcji, a kiedy decydujemy się uczynić Typ instancją klasy typu, definiujemy, co te funkcje oznaczają dla tego typu.”
Naucz się Haskella dla wielkiego dobra!
kod źródłowy
kod źródłowy wszystkich lekcji typu klasa jest dostępny pod następującym adresem URL:
- github.com/alvinj/FPTypeClasses
Kod tej lekcji znajduje się w typeklasach.v1_humanlike pakiet. Jedna uwaga na temat kodu źródłowego: zawiera dodatkową funkcję eatHumanFood
, która nie jest pokazana w poniższych przykładach. Włączam tę funkcję w kodzie źródłowym, dzięki czemu można zobaczyć, jak zdefiniować wiele funkcji w klasie typu.
wprowadzenie
Książka Advanced Scala with Cats definiuje klasę typów jako technikę programowania, która pozwala na dodawanie nowych zachowań do zamkniętych typów danych bez użycia dziedziczenia i bez dostępu do oryginalnego kodu źródłowego tych typów. Ściśle mówiąc, nie jest to technika, która ogranicza się do programowania funkcyjnego, ale ponieważ jest tak często używana w Bibliotece Cats, chcę pokazać kilka przykładów tego podejścia, jak również motywację do tej techniki.
Cats to popularna biblioteka FP dla Scali.
motywacja
autorzy zaawansowanej Scali z kotami dokonują ciekawych spostrzeżeń na temat dziedziczenia, a ja przedstawię lekką odmianę tego punktu.
dana klasa OOP Pizza
taka:
class Pizza(var crustSize: CrustSize, var crustType: CrustType) { val toppings = ArrayBuffer() def addTopping(t: Topping): Unit = { toppings += t } def removeTopping(t: Topping): Unit = { toppings -= t } def removeAllToppings(): Unit = { toppings.clear() }}
jeśli chcesz zmienić dane lub metody w tym podejściu OOP, co normalnie byś zrobił? Odpowiedź w obu przypadkach jest taka, że zmodyfikujesz tę klasę.
następny, biorąc pod uwagę modułową konstrukcję FP tego samego kodu:
case class Pizza ( crustSize: CrustSize, crustType: CrustType, toppings: Seq)trait PizzaService { def addTopping(p: Pizza, t: Topping): Pizza = ??? def removeTopping(p: Pizza, t: Topping): Pizza = ??? def removeAllToppings(p: Pizza): Pizza = ???}
jeśli chcesz zmienić dane lub metody w tym kodzie, co normalnie byś zrobił? Odpowiedź tutaj jest trochę inna. Jeśli chcesz zmienić dane, aktualizujesz klasę case
, a jeśli chcesz zmienić metody, aktualizujesz kod w PizzaService.
te dwa przykłady pokazują, że istnieje różnica w kodzie, który musisz zmodyfikować, gdy chcesz dodać nowe dane lub zachowanie do projektów OOP i FP.
klasy typu dają zupełnie inne podejście. Zamiast aktualizować istniejący kod źródłowy, tworzysz klasy typów, aby zaimplementować nowe zachowanie.
W tych lekcjach pokażę przykłady klas typu, abyś mógł poznać technikę w ogóle, a dokładniej dowiedzieć się o niej, abyś mógł zrozumieć, jak działa Biblioteka Cats, ponieważ większość z nich jest zaimplementowana za pomocą klas typu.
klasy typu Scala 2 mają trzy komponenty
przejdźmy do kilku przykładów tworzenia i używania klas typu w Scali 2, aby zobaczyć, jak działają.
klasy typu składają się z trzech elementów:
- Klasa type, która jest zdefiniowana jako cecha, która przyjmuje co najmniej jeden parametr ogólny (ogólny „typ”)
- wystąpienia klasy type dla typów, które chcesz rozszerzyć
- metody interfejsu, które udostępniasz użytkownikom nowego API
dane dla pierwszego przykładu
dla pierwszego przykładu
przykład, załóżmy, że mam te istniejące typy danych:
sealed trait Animalfinal case class Dog(name: String) extends Animalfinal case class Cat(name: String) extends Animalfinal case class Bird(name: String) extends Animal
teraz Załóżmy, że chcesz dodać nowe zachowanie do klasy Dog
. Ponieważ psy są dobrze znane ze swojej zdolności mówienia jak ludzie, chcesz dodać nowe zachowanie speak
do wystąpień Dog
, ale nie chcesz dodawać tego samego zachowania do kotów lub ptaków.
jeśli masz kod źródłowy tych zachowań, możesz po prostu dodać tam nową funkcję. Ale w tym przykładzie pokażę jak dodać zachowanie używając klasy typu.
Krok 1: klasa typu
pierwszym krokiem jest utworzenie cechy, która używa co najmniej jednego parametru ogólnego. W tym przypadku, ponieważ chcę dodać funkcję „speak”, która jest zachowaniem „podobnym do człowieka”, definiuję moją cechę w następujący sposób:
trait BehavesLikeHuman { def speak(a: A): Unit}
użycie typu ogólnego A
pozwala nam zastosować tę nową funkcjonalność do dowolnego typu. Na przykład, jeśli chcesz zastosować go do Dog
i Cat
, możesz to zrobić, ponieważ zostawiłem typ ogólny.
Krok 2: instancje klasy typu
drugim krokiem procesu jest utworzenie instancji klasy typu dla typów danych, które chcesz ulepszyć. W moim przypadku, ponieważ chcę tylko dodać to nowe zachowanie do typu Dog
, tworzę tylko jedną instancję, którą definiuję w ten sposób:
object BehavesLikeHumanInstances { // only for `Dog` implicit val dogBehavesLikeHuman = new BehavesLikeHuman { def speak(dog: Dog): Unit = { println(s"I'm a Dog, my name is ${dog.name}") } }}
kluczowe punkty dotyczące tego kroku to:
- tworzę tylko instancję
BehavesLikeHuman
dla typuDog
. - nie utworzyłem instancji dla
Cat
lubBird
, ponieważ nie chcę, aby miały takie zachowanie. - implementuję metodę
speak
zgodnie z potrzebami dla typuDog
. - oznaczam instancję jako
implicit
, więc można ją łatwo wciągnąć do kodu, który napiszę w następnych krokach. - zawijam kod w
object
, przede wszystkim jako sposób, aby pomóc mi go zorganizować. Nie jest to zbyt ważne w małym przykładzie, ale jest pomocne w większych, rzeczywistych zastosowaniach.
Krok 3: API (interfejs)
W trzecim kroku procesu tworzysz funkcje, które chcesz, aby klienci twojego API widzieli. Istnieją dwa możliwe podejścia w tym kroku:
- definiowanie funkcji w
object
, podobnie jak podejście „Utils” opisane w lekcji modelowania domeny - definiowanie funkcji
implicit
, która może być wywoływana w instancjiDog
nazywam te podejścia opcjami 3A i 3b, a dla konsumentów tych podejść ich kod będzie wyglądał następująco:
BehavesLikeHuman.speak(aDog) //3aaDog.speak //3b
pokażę, jak wdrożyć te podejścia, ale żeby było jasne, nie musisz wdrażać obu podejść. Są to dwie różne opcje-podejście konkurencyjne.
opcja 3a: Podejście Interface Objects approach
W książce Advanced Scala with Cats autorzy odnoszą się do podejścia 3A jako podejścia „Interface Objects”. Nazywam to podejściem „jawnym”, ponieważ wykorzystuje funkcje w obiektach, podobnie jak podejście” Utils”, które opisałem w lekcjach modelowania domeny.
dla przykładu mojego psa definiuję funkcję speak
w object
, jak to:
object BehavesLikeHuman { def speak(a: A)(implicit behavesLikeHumanInstance: BehavesLikeHuman): Unit = { behavesLikeHumanInstance.speak(a) }}
ponieważ speak
można zastosować do dowolnego typu, nadal muszę użyć typ ogólny do zdefiniowania funkcji. Funkcja oczekuje również, że instancja BehavesLikeHuman
będzie w zasięgu, gdy funkcja jest wykonywana, i że instancja jest wciągana do funkcji przez niejawny parametr w drugiej grupie parametrów.
jako konsument stosujesz to podejście 3a w następujący sposób. Najpierw zaimportuj instancję dogBehavesLikeHuman
:
import BehavesLikeHumanInstances.dogBehavesLikeHuman
pamiętaj, że zawiera metodę speak
zaimplementowaną dla metody Dog
. Następnie utwórz instancję Dog
:
val rover = Dog("Rover")
na koniec możesz zastosować funkcję BehavesLikeHuman.speak
do instancji rover
:
BehavesLikeHuman.speak(rover)
co skutkuje tym wyjściem:
I'm a Dog, my name is Rover
jest to podsumowanie całego podejścia wykorzystującego opcję 3a. na koniec zauważ, że możesz również ręcznie przekazać instancję dogBehavesLikeHuman
do drugiej grupy parametrów:
BehavesLikeHuman.speak(rover)(dogBehavesLikeHuman)
nie jest to konieczne, ponieważ parametr w drugiej grupie parametrów jest zdefiniowany jako zmienna implicit
, ale chciałem pokazać, że możesz również przekazać Typ ręcznie, jeśli wolisz.
zauważ, że ostatecznym rezultatem tego podejścia jest nowa funkcja o nazwie speak
, która działa dla typu Dog
. Jest to miłe, ale wydaje się również dużo pracy, aby utworzyć funkcję „Utils”, którą można zastosować do Dog
. Moim zdaniem w wariancie 3b cała ta praca naprawdę się opłaca.
opcja 3b: podejście do składni interfejsu
jako alternatywę dla opcji 3A, możesz użyć drugiego podejścia, które Książka Advanced Scala with Cats nazywa podejściem „składnia interfejsu”. Kluczowymi elementami tego podejścia są:
- ostatecznie pozwala na wywołanie nowej funkcji jako
dog.speak
- Książka Cats odnosi się do metod utworzonych jako „metody rozszerzenia”, ponieważ rozszerzają one istniejące typy danych o nowe metody
- projekt Cats odnosi się do tego jako „składnia” dla klasy typu
jako szybki przegląd, w Kroku 1. proces stworzyłem cechę, która używa typu generycznego:
trait BehavesLikeHuman { def speak(a: A): Unit}
następnie w Kroku 2 utworzyłem instancję klasy type dla typu danych Dog
:
object BehavesLikeHumanInstances { // only for `Dog` implicit val dogBehavesLikeHuman = new BehavesLikeHuman { def speak(dog: Dog): Unit = { println(s"I'm a Dog, my name is ${dog.name}") } }}
teraz w kroku 3b tworzę nową „składnię interfejsu” w ten sposób:
object BehavesLikeHumanSyntax { implicit class BehavesLikeHumanOps(value: A) { def speak(implicit behavesLikeHumanInstance: BehavesLikeHuman): Unit = { behavesLikeHumanInstance.speak(value) } }}
konsumenci tego podejścia będą pisać swój kod w następujący sposób. Najpierw importują instancję dogBehavesLikeHuman
jak wcześniej:
import BehavesLikeHumanInstances.dogBehavesLikeHuman
następnie importują klasę implicit
z wnętrza obiektu BehavesLikeHumanSyntax
:
import BehavesLikeHumanSyntax.BehavesLikeHumanOps
następnie jak zwykle tworzą instancję Dog
:
val rover = Dog("Rover")
wreszcie, duża różnica między opcjami 3a i 3b polega na tym, że w przypadku opcji 3b mogą one wywoływać twoje metody bezpośrednio na instancji rover
, w ten sposób:
rover.speak
pokazuje to korzyści z całej pracy prowadzącej do tego punktu: instancja Dog
ma nową metodę speak
. Jeśli nie masz dostępu do oryginalnego kodu źródłowego Dog
, byłoby to ogromne zwycięstwo. Bardziej ogólnie, pokazuje trzyetapowy proces, którego możesz użyć, aby dodać nową funkcjonalność do dowolnej „zamkniętej” klasy.
Uwaga: Jak wspomniałem wcześniej, kod źródłowy tej lekcji ma dodatkową funkcję
eatHumanFood
, która pokazuje, jak definiować wiele funkcji w klasie typu.
kluczowe punkty
jak wykazano, Klasa typu składa się z trzech komponentów:
- sama klasa typu, która jest zdefiniowana jako cecha, która pobiera co najmniej jeden parametr ogólny
- wystąpienia klasy typu dla typów danych, które chcesz rozszerzyć
- metody interfejsu, które udostępniasz użytkownikom nowego interfejsu API
korzyści z używania klasy typu to:
- zapewnia podejście, które pozwala dodawać nowe zachowanie do istniejących klas bez użycia tradycyjnego dziedziczenia, zwłaszcza w przypadku, gdy nie możesz (lub nie chcesz) modyfikować istniejącego kodu źródłowego istniejących typów danych