Jak modelować ten przykład
Jak można to modelować za pomocą monady Reader?
Nie jestem pewien, czy powinno to być wzorowane na Czytniku, ale można to zrobić przez:
- kodowanie klas jako funkcji, dzięki czemu kod jest przyjemniejszy w programie Reader
- komponowanie funkcji w programie Reader w celu zrozumienia i używania
Tuż przed rozpoczęciem muszę opowiedzieć o niewielkich korektach kodu przykładowego, które uznałem za korzystne dla tej odpowiedzi. Pierwsza zmiana dotyczy FindUsers.inactive
metody. Pozwoliłem mu zwrócić, List[String]
aby lista adresów mogła zostać użyta w UserReminder.emailInactive
metodzie. Dodałem również proste implementacje do metod. Na koniec przykład będzie używał następującej ręcznie zwijanej wersji monady Reader:
case class Reader[Conf, T](read: Conf => T) { self =>
def map[U](convert: T => U): Reader[Conf, U] =
Reader(self.read andThen convert)
def flatMap[V](toReader: T => Reader[Conf, V]): Reader[Conf, V] =
Reader[Conf, V](conf => toReader(self.read(conf)).read(conf))
def local[BiggerConf](extractFrom: BiggerConf => Conf): Reader[BiggerConf, T] =
Reader[BiggerConf, T](extractFrom andThen self.read)
}
object Reader {
def pure[C, A](a: A): Reader[C, A] =
Reader(_ => a)
implicit def funToReader[Conf, A](read: Conf => A): Reader[Conf, A] =
Reader(read)
}
Krok modelowania 1. Kodowanie klas jako funkcji
Może to jest opcjonalne, nie jestem pewien, ale później sprawia, że zrozumienie wygląda lepiej. Zwróć uwagę, że wynikowa funkcja jest curried. Przyjmuje również poprzednie argumenty konstruktora jako ich pierwszy parametr (lista parametrów). W ten sposób
class Foo(dep: Dep) {
def bar(arg: Arg): Res = ???
}
staje się
object Foo {
def bar: Dep => Arg => Res = ???
}
Należy pamiętać, że każdy Dep
, Arg
, Res
typy mogą być całkowicie arbitralny: krotki, funkcję lub typ prosty.
Oto przykładowy kod po wstępnych dostosowaniach, przekształcony w funkcje:
trait Datastore { def runQuery(query: String): List[String] }
trait EmailServer { def sendEmail(to: String, content: String): Unit }
object FindUsers {
def inactive: Datastore => () => List[String] =
dataStore => () => dataStore.runQuery("select inactive")
}
object UserReminder {
def emailInactive(inactive: () => List[String]): EmailServer => () => Unit =
emailServer => () => inactive().foreach(emailServer.sendEmail(_, "We miss you"))
}
object CustomerRelations {
def retainUsers(emailInactive: () => Unit): () => Unit =
() => {
println("emailing inactive users")
emailInactive()
}
}
Należy tu zauważyć, że poszczególne funkcje nie zależą od całych obiektów, ale tylko od bezpośrednio używanych części. Gdzie w OOP wersja UserReminder.emailInactive()
przykład nazwałbym userFinder.inactive()
tu właśnie nazywa inactive()
- funkcja przeszedł do niego w pierwszym parametrze.
Zwróć uwagę, że kod wykazuje trzy pożądane właściwości z pytania:
- jest jasne, jakiego rodzaju zależności potrzebuje każda funkcjonalność
- ukrywa zależności jednej funkcjonalności od innej
retainUsers
nie musi wiedzieć o zależności Datastore
Modelowanie krok 2. Używanie programu Reader do tworzenia funkcji i uruchamiania ich
Reader Monad umożliwia tworzenie tylko funkcji, które wszystkie zależą od tego samego typu. To często nie jest przypadek. W naszym przykładzie
FindUsers.inactive
zależy od Datastore
i UserReminder.emailInactive
od EmailServer
. Aby rozwiązać ten problem, można wprowadzić nowy typ (często nazywany Config), który zawiera wszystkie zależności, a następnie zmienić funkcje tak, aby wszystkie od niego zależały i pobierały z niego tylko odpowiednie dane. To oczywiście jest złe z punktu widzenia zarządzania zależnościami, ponieważ w ten sposób uzależniasz te funkcje także od typów, o których nie powinny wiedzieć.
Na szczęście okazuje się, że istnieje sposób, aby funkcja działała, Config
nawet jeśli przyjmuje tylko część jej jako parametr. Jest to metoda o nazwie local
, zdefiniowana w programie Reader. Musi mieć możliwość wyodrębnienia odpowiedniej części z pliku Config
.
Ta wiedza zastosowana do podanego przykładu wyglądałaby następująco:
object Main extends App {
case class Config(dataStore: Datastore, emailServer: EmailServer)
val config = Config(
new Datastore { def runQuery(query: String) = List("john.doe@fizzbuzz.com") },
new EmailServer { def sendEmail(to: String, content: String) = println(s"sending [$content] to $to") }
)
import Reader._
val reader = for {
getAddresses <- FindUsers.inactive.local[Config](_.dataStore)
emailInactive <- UserReminder.emailInactive(getAddresses).local[Config](_.emailServer)
retainUsers <- pure(CustomerRelations.retainUsers(emailInactive))
} yield retainUsers
reader.read(config)()
}
Zalety w stosunku do używania parametrów konstruktora
W jakich aspektach użycie Reader Monad dla takiej „aplikacji biznesowej” byłoby lepsze niż użycie parametrów konstruktora?
Mam nadzieję, że przygotowując tę odpowiedź, ułatwiłem sobie ocenę, w jakich aspektach pokonałby zwykłych konstruktorów. Jeśli jednak miałbym je wymienić, oto moja lista. Zastrzeżenie: Mam tło OOP i mogę nie doceniać w pełni Czytnika i Kleisli, ponieważ ich nie używam.
- Jednolitość - bez względu na to, jak krótkie / długie jest zrozumienie, to tylko czytnik i możesz łatwo skomponować go z inną instancją, być może wprowadzając tylko jeszcze jeden typ konfiguracji i posypując kilka
local
wywołań. To kwestia IMO raczej kwestia gustu, ponieważ gdy używasz konstruktorów, nikt nie stoi na przeszkodzie, aby skomponować to, co lubisz, chyba że ktoś zrobi coś głupiego, jak praca w konstruktorze, co jest uważane za złą praktykę w OOP.
- Czytnik jest monada, więc robi się wszystkie korzyści związane że -
sequence
, traverse
metody realizowane za darmo.
- W niektórych przypadkach może się okazać, że lepiej jest zbudować czytnik tylko raz i używać go do wielu różnych konfiguracji. Z konstruktorami nikt ci tego nie zabroni, po prostu musisz zbudować cały wykres obiektu od nowa dla każdego przychodzącego Config. Chociaż nie mam z tym problemu (nawet wolę to robić przy każdym zgłoszeniu), dla wielu osób nie jest to oczywisty pomysł z powodów, o których mogę tylko spekulować.
- Czytnik popycha Cię do większego wykorzystania funkcji, które będą działać lepiej z aplikacjami napisanymi głównie w stylu FP.
- Czytelnik oddziela obawy; możesz tworzyć, współdziałać ze wszystkim, definiować logikę bez dostarczania zależności. Właściwie dostarczyć później, osobno. (Dzięki Ken Scrambler za ten punkt). Jest to często słyszalna zaleta programu Reader, ale jest to również możliwe w przypadku zwykłych konstruktorów.
Chciałbym też powiedzieć, czego nie lubię w Czytniku.
- Marketing. Czasami odnoszę wrażenie, że Reader jest sprzedawany pod kątem wszelkiego rodzaju zależności, bez rozróżnienia, czy jest to plik cookie sesji, czy baza danych. Dla mnie nie ma sensu używanie programu Reader do praktycznie stałych obiektów, takich jak serwer poczty lub repozytorium z tego przykładu. Dla takich zależności uważam, że zwykłe konstruktory i / lub częściowo zastosowane funkcje są o wiele lepsze. Zasadniczo Reader zapewnia elastyczność, dzięki czemu możesz określić swoje zależności przy każdym połączeniu, ale jeśli tak naprawdę tego nie potrzebujesz, płacisz tylko podatek.
- Ukryta ciężkość - używanie programu Reader bez implikacji sprawiłoby, że przykład byłby trudny do odczytania. Z drugiej strony, kiedy ukryjesz zaszumione części za pomocą implikacji i popełnisz jakiś błąd, kompilator czasami daje trudne do rozszyfrowania wiadomości.
- Uroczystość z
pure
, local
i tworzenie własnych klas config / używając krotki za to. Czytnik zmusza Cię do dodania kodu, który nie dotyczy problematycznej domeny, wprowadzając w ten sposób pewien szum w kodzie. Z drugiej strony aplikacja korzystająca z konstruktorów często używa wzorca fabrycznego, który również pochodzi spoza domeny problemowej, więc ta słabość nie jest aż tak poważna.
A jeśli nie chcę konwertować moich klas na obiekty z funkcjami?
Chcesz. Technicznie można tego uniknąć, ale spójrz tylko, co by się stało, gdybym nie przekształcił FindUsers
klasy w obiekt. Odpowiedni wiersz do zrozumienia wyglądałby następująco:
getAddresses <- ((ds: Datastore) => new FindUsers(ds).inactive _).local[Config](_.dataStore)
który nie jest tak czytelny, prawda? Chodzi o to, że Reader działa na funkcjach, więc jeśli jeszcze ich nie masz, musisz je skonstruować w linii, co często nie jest takie ładne.