wprowadzenie do typeklas w Scali

  • Część 1: Ten post
  • część 2: serializacja bezpieczna dla typów w Scali
  • Część 3: typowa obsługa błędów w typach o wyższym pokrewieństwie

czym jest klasa typu

jedną z największych barier w zrozumieniu i użyciu klas typów we własnym kodzie jest sama nazwa. W przeciwieństwie do tego, co nazwa przywołuje, Klasa typu nie jest klasą z języka obiektowego. Według Wikipedii:

w informatyce Klasa typu jest konstrukcją systemu typu, która wspiera ad hoc polimorfizm. Osiąga się to poprzez dodanie ograniczeń do zmiennych typu w parametrycznie polimorficznych typach.

mamy do rozpakowania:

  • czym jest polimorfizm ad hoc? Cokolwiek to jest, to właśnie klasy typu pomogą nam osiągnąć.
  • co to jest typ 'parametrycznie polimorficzny’? Cokolwiek to jest, w ten sposób stworzymy własne klasy typów.

klasy typów pojawiły się po raz pierwszy w Haskell i są podstawowym sposobem budowania abstrakcji w Haskell. Są one tak istotne, że zostały wprowadzone na pierwszej stronie książki Learn You a Haskell:

Klasa typu jest rodzajem interfejsu, który definiuje pewne zachowanie. Jeśli typ jest częścią klasy type, oznacza to, że obsługuje i implementuje zachowanie, które Klasa type opisuje.

ok, wygląda na to, że próbujemy zdefiniować pewne zachowanie dla naszych typów i zaimplementować je w różny sposób w zależności od typu. Ta ostatnia część, „zaimplementuj zachowanie inaczej w zależności od typu”jest fragmentem’ polimorfizmu ’ z poprzedniego. Termin zaczyna mieć sens: jest klasą dla typów. O klasie, która implementuje zachowanie wyznaczone przez klasę typu, mówi się, że jest członkiem tej klasy typów — „Klasa typu”.

w tym momencie wypadałoby powiedzieć, że brzmi to jak dziedziczenie. Oba pozwalają budować abstrakcje i dają polimorfizm.

więc po co w ogóle eksplorować klasy typu? W wielu przypadkach są one nieco bardziej wydajne i rozszerzalne niż hierarchia dziedziczenia. Mogą być o wiele prostsze i znacznie lepiej pasują, gdy twój kod już pochyla FP nad OO. Chociaż nawet w silnie OO projektu, klasy typu nadal mogą zapisać dzień.

Definiowanie typeklasy

to wystarczy wprowadzenie-do dalszej części artykułu będziemy eksplorować kawałek bardzo znanej funkcjonalności, która jest faktycznie zaimplementowana w Javie poprzez dziedziczenie, i zaimplementować ją za pomocą klasy typu: stringification.

wszyscy znamy metodę, która istnieje na wszystkich obiektach w Javie (a więc w Scali): .toString. Jego celem jest wytworzenie ciągowej reprezentacji danego obiektu. Przyjrzyjmy się, jak użylibyśmy klas typu, aby to osiągnąć. Wersja Haskella toString nazywa się Show, więc będziemy trzymać się mniej więcej tej nazwy:

trait Showable {
def show(a: A): String
}

to wszystko: jest nasza klasa typu Showable. Dlaczego przyjmuje parametr type A? Jeśli przypomnimy sobie naszą pierwszą definicję klas typów, czytamy, że ” osiągnięte przez dodanie ograniczeń do zmiennych typu w parametrycznie polimorficznych typach.”Showable jest naszym parametrycznie polimorficznym typem, A A jest naszą zmienną typu, co po prostu oznacza, że przyjmuje typ (A) jako parametr typu. (Zauważ, że nie umieściliśmy żadnych ograniczeń na A, zgodnie z definicją, ale to jest w porządku. To jest coś, co robimy podczas komponowania klas typu razem, lub korzystania z nich, co przyjdzie później.)

implementacja typeklasy

teraz musimy dostarczyć implementacje cechy Showable dla wszystkich typów, które chcemy pokazać. Implementacje te nazywane są” instancjami ” naszej klasy typu, ponieważ będą dosłownie instancjami cechy Showable. Najczęściej będą to anonimowe klasy, które nadpisują .show, a ponieważ w ich definiowaniu bierze udział jakiś boilerplate (pamiętasz 'obiekty funkcyjne’ w pre-lambda Java programming?), zdefiniujemy helper o nazwie make. Zacznijmy od implementacji typu UserName:

object Showable {
def make(showFn: A => String): Showable = new Showable {
override def show(a: A): String = showFn(a)
}
}final case class UserName(first: String, last: String)implicit val defaultUserNameShowable = Showable.make(userName => s"${userName.first} ${userName.last}")val janeName = UserName("Jane", "Doe")

więc jak się na to powołamy? Moglibyśmy bezpośrednio wywołać metodę z instancji showable, tak:

defaultUserNameShowable.show(janeName) // "Jane Doe"

poprawiona składnia

udało nam się wywołać naszą Showable implementację UserName, ale nie było to zbyt miłe. Musieliśmy mieć odniesienie do naszej instancji klasy type (defaultUserNameShowable) i znać jej nazwę. To jest coś, co chcemy odciągnąć. Gdyby historia zakończyła się tutaj, klasy typu w Scali nie byłyby tak przyjemne w użyciu. Na szczęście możemy drastycznie poprawić ergonomię za pomocą niejawnych wartości. Dodamy obiekt towarzyszący Showable za pomocą helpera wywołania:

implicit class ShowSyntax(a: A) extends AnyVal {
def show(implicit showable: Showable): String = showable.show(a)
}
...
janeName.show

umieszczając tę „metodę rozszerzenia” gdzieś w scope, możemy po prostu udawać, że wszystkie obiekty mają metodę .show.

co to osiągnęło

więc dlaczego zamiast tego nie nadpisać toString na UserName? Powiedzmy, że UserName mieszka w bibliotece, na której polegamy, i jest już skompilowana do czasu, gdy ją konsumujemy. Nie możemy jej podklasować, ponieważ jest to klasa final. Jednak korzystając z klas typu, możemy zasadniczo „dołączyć”do niego nowe zachowanie w sposób ad hoc. Jest to” doraźny ” fragment powyższej definicji: polimorfizm ten możemy dodać oddzielnie od miejsca, w którym zdefiniowany jest sam typ.

dodatkowo, w przeciwieństwie do dziedziczenia, kiedy definiujemy klasy typów w Scali, nie są one wymagane, aby były 'spójne’, co oznacza, że możemy zdefiniować wiele implementacji dla Showable i wybrać pomiędzy nimi. Dla niektórych klas jest to dobra rzecz, dla innych nie tak bardzo.

w Haskell klasy typów są spójne. W Scali 3, według Martina Odersky ’ ego, będziemy mieli możliwość wyboru, dla każdej klasy typu, czy ma być spójna, czy nie.

ale na razie podajmy alternatywne znaczenie show dla UserName:

implicit val secretUserNameShowable = Showable.make(_ => "<secret person>")

wszystko, co musimy teraz zrobić, to upewnić się, że tylko jedna z naszych instancji Showable jest w zasięgu naraz. A jeśli nie, to nic wielkiego, otrzymamy błąd 'niejednoznaczne ukryte wartości’ w czasie kompilacji:

janeName.show
> ambiguous implicit values:
both value secretUserNameShowable in object typeclasses of type => Showable
and value defaultUserNameShowable in object typeclasses of type => Showable
match expected type Showable

jedną z ważnych uwag jest to, że nie połączyliśmy UserNamei Showable. Wszystko, co zrobiliśmy, to zdefiniowanie znaczenia Showable dla UserName, zachowując je całkowicie niezależne od siebie. To odsprzęgnięcie ma ogromne implikacje konserwacyjne i pomaga zapobiegać splątanym związkom zależności w całym kodzie.

bezpieczeństwo kompilacji

co się stanie, jeśli spróbujemy wywołać .showna obiekcie, dla którego nie podaliśmy znaczenia show? Innymi słowy, kiedy nie ma wystąpienia Showable w zakresie naszego celu? Po prostu dostajemy błąd kompilatora:

java.util.UUID.randomUUID().show
> could not find implicit value for parameter showable: Showable

możemy rzeczywiście dostosować ten Komunikat o błędzie, gdy definiujemy naszą klasę typu:

@scala.annotation.implicitNotFound("No Showable in scope for A = ${A}. Try importing or defining one.")
trait Showable {
...
}
...
janeName.show
> No Showable in scope for A = java.util.UUID. Try importing or defining one.

kluczowe typy

  • klasy typów pozwalają nam definiować zestaw zachowań całkowicie oddzielnie od obiektów i typów, które będą je implementować.
  • klasy typów są wyrażane w czystej Scali z cechami, które pobierają parametry typu i niejawne wartości, aby wyczyścić składnię.
  • klasy typów pozwalają nam rozszerzyć lub zaimplementować zachowanie typów i obiektów, których źródła nie możemy modyfikować. (Dostarczają doraźnego polimorfizmu niezbędnego do rozwiązania problemu ekspresji).

co dalej

Showable jest trywialną klasą typu, choć nie bez jej zastosowań. Z łatwością możemy sobie wyobrazić znacznie bardziej użyteczne, jednak:

trait SafeSerializable {
def serialize(a: A): Array
def deserialize(bytes: Array): A
}
trait Convertible {
def to(a: A): B
def from(b: B): A
}
trait Functor] {
def map(a: F, f: A => B): F
}

SafeSerializable może być świetną alternatywą dla czegoś takiego jak serializacja Akka, gdzie czekamy do czasu uruchomienia, aby sprawdzić, czy serializacja jest zarejestrowana dla naszego typu. Znacznie bezpieczniej jest domyślnie przekazać implementację serializacji w czasie kompilacji!

Convertible może być świetnym sposobem wielokrotnego użytku do konwersji danych między typami. Istnieje Biblioteka Twittera o tym samym celu, zwana Bijection.

Functor jest budulcem dla niektórych niezwykle potężnych abstrakcji w obszarze programowania funkcyjnego zwanym „programowaniem kategorycznym”. Biblioteki takie jak ScalaZ i Cats są zbudowane na klasach typu Functor. Jednym z ważnych pojęć, których nie omawialiśmy tym razem, jest to, że klasy typu można łatwo skomponować, aby zbudować coraz bardziej złożone zachowanie z prostych bloków konstrukcyjnych, takich jak Functor.

ponadto wiele podstawowych bibliotek w ekosystemie Scala (Odtwórz JSON, Slick, et. al) wykorzystywać klasy typu i w wielu przypadkach wystawiać je jako główny sterownik ich API(np.G. Odtwórz klasę typu JSON Format). Samodzielne rozumienie klas typów to świetny sposób, aby stać się bardziej skutecznym w korzystaniu z tego, co jest w ekosystemie Scali, nawet jeśli nigdy nie piszesz własnych.

w przyszlosci bede patrzyl na jakies praktyczne zajecia typu SafeSerializable. Aby zapoznać się z bardziej abstrakcyjnymi klasami typów kategorycznych, takimi jak Functor] i Monad], nie szukaj dalej niż dokumentacja biblioteki cats. Aby uzyskać bardziej dogłębny przewodnik po programowaniu w Scali za pomocą klas typu, underscore.io ma fantastyczną książkę na ten temat: Scala z kotami

część 2: bezpieczna serializacja w Scali

GIST z kodem

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.