Hallo Habr.
Heute werden wir ein solches FP-Muster wie Has
Klasse betrachten. Dies ist aus mehreren Gründen ziemlich interessant: Erstens werden wir noch einmal sicherstellen, dass es Muster in der FP gibt. Zweitens stellt sich heraus, dass die Implementierung dieses Musters der Maschine anvertraut werden kann, was sich bei Typklassen (und der Hackage-Bibliothek) als ziemlich interessanter Trick herausstellte, der erneut die praktische Nützlichkeit von Typsystemerweiterungen außerhalb von Haskell 2010 demonstriert, und IMHO ist viel interessanter als dieses Muster selbst. Drittens eine Gelegenheit für Katzen.

Vielleicht lohnt es sich jedoch, mit einer Beschreibung zu beginnen, was eine Has
Klasse ist, zumal es keine kurze Beschreibung (und insbesondere eine russischsprachige) gab.
Wie löst der Haskell das Problem der Verwaltung einer globalen schreibgeschützten Umgebung, die mehrere verschiedene Funktionen benötigen? Wie wird beispielsweise die globale Konfiguration der Anwendung ausgedrückt?
Die naheliegendste und direkteste Lösung ist, dass Sie, wenn eine Funktion einen Wert vom Typ Env
, dieser Funktion einfach einen Wert vom Typ Env
können!
iNeedEnv :: Env -> Foo iNeedEnv env =
Leider ist eine solche Funktion nicht sehr zusammensetzbar, insbesondere im Vergleich zu einigen anderen Objekten, die wir im Haskell gewohnt sind. Zum Beispiel im Vergleich zu Monaden.
Eine allgemeinere Lösung besteht darin, Funktionen, die Zugriff auf die Env
Umgebung benötigen, in die Reader Env
Monade Reader Env
:
import Control.Monad.Reader data Env = Env { someConfigVariable :: Int , otherConfigVariable :: [String] } iNeedEnv :: Reader Env Foo iNeedEnv = do
Dies kann noch weiter verallgemeinert werden, für die es ausreicht, die MonadReader-Typklasse zu verwenden und nur den Funktionstyp zu ändern:
iNeedEnv :: MonadReader Env m => m Foo iNeedEnv =
Jetzt spielt es für uns keine Rolle, in welchem monadischen Stapel wir uns befinden, solange wir den Wert vom Typ Env
daraus erhalten können (und wir drücken dies explizit im Typ unserer Funktion aus). Es ist uns egal, ob der gesamte Stack andere Funktionen wie MonadError
/ A oder Fehlerbehandlung über MonadError
:
someCaller :: (MonadIO m, MonadReader Env m, MonadError Err m) => m Bar someCaller = do theFoo <- iNeedEnv ...
Übrigens, etwas höher, habe ich tatsächlich gelogen, als ich sagte, dass der Ansatz, ein Argument explizit an eine Funktion zu übergeben, nicht so zusammensetzbar ist wie Monaden: Der „teilweise angewandte“ Funktionstyp r ->
ist eine Monade, und darüber hinaus ist er durchaus eine legitime Instanz der MonadReader r
Klasse. Die Entwicklung einer angemessenen Intuition wird dem Leser als Übung angeboten.
In jedem Fall ist dies ein guter Schritt in Richtung Modularität. Mal sehen, wohin er uns führt.
Warum hat
Lassen Sie uns an einer Art Webdienst arbeiten, der unter anderem die folgenden Komponenten haben kann:
- DB-Zugriffsschicht
- Webserver
- Timer aktiviert Cron-ähnliches Modul.
Jedes dieser Module kann eine eigene Konfiguration haben:
- Einzelheiten zum Zugriff auf die Datenbank,
- Host und Port für den Webserver,
- Timer-Betriebsintervall.
Wir können sagen, dass die Gesamtkonfiguration der gesamten Anwendung eine Kombination all dieser Einstellungen (und wahrscheinlich etwas anderes) ist.
Nehmen wir zur Vereinfachung an, dass die API jedes Moduls nur aus einer Funktion besteht:
setupDatabase
startServer
runCronJobs
Jede dieser Funktionen erfordert eine entsprechende Konfiguration. Wir haben bereits gelernt, dass MonadReader
eine gute Praxis ist, aber wie wird die Umgebung MonadReader
?
Die naheliegendste Lösung wäre so etwas wie
data AppConfig = AppConfig { dbCredentials :: DbCredentials , serverAddress :: (Host, Port) , cronPeriodicity :: Ratio Int } setupDatabase :: MonadReader AppConfig m => m Db startServer :: MonadReader AppConfig m => m Server runCronJobs :: MonadReader AppConfig m => m ()
Für diese Funktionen ist höchstwahrscheinlich MonadIO
und möglicherweise etwas anderes erforderlich, dies ist jedoch für unsere Diskussion nicht so wichtig.
Tatsächlich haben wir einfach eine schreckliche Sache gemacht. Warum? Na ja, spontan:
- Wir haben eine unnötige Verbindung zwischen völlig verschiedenen Komponenten hinzugefügt. Im Idealfall sollte die DB-Schicht nichts über eine Art Webserver wissen. Und natürlich sollten wir das Modul für die Arbeit mit der Datenbank nicht neu kompilieren, wenn wir die Liste der Konfigurationsoptionen für den Webserver ändern.
- Dies funktioniert überhaupt nicht, wenn wir den Quellcode für einige Module nicht bearbeiten können. Was kann ich beispielsweise tun, wenn das Cron-Modul in einer Bibliothek eines Drittanbieters implementiert ist, die nichts über unseren speziellen Benutzerfall weiß?
- Wir haben Möglichkeiten hinzugefügt, um einen Fehler zu machen. Was ist beispielsweise
serverAddress
? Ist dies die Adresse, die der Webserver abhören soll, oder ist dies die Adresse des Datenbankservers? Die Verwendung eines großen Typs für alle Optionen erhöht die Wahrscheinlichkeit solcher Kollisionen. - Aus einem Blick auf die Funktionssignaturen können wir nicht mehr schließen, welche Module welchen Teil der Konfiguration verwenden. Alles hat Zugang zu allem!
Was ist die Lösung für all das? Wie Sie dem Titel des Artikels entnehmen können, ist dies
Has
Muster
Tatsächlich kümmert sich nicht jedes Modul um den Typ der gesamten Umgebung, solange dieser Typ die für das Modul erforderlichen Daten enthält. Dies lässt sich am einfachsten anhand eines Beispiels zeigen.
Stellen Sie sich ein Modul für die Arbeit mit einer Datenbank vor und nehmen Sie an, dass es einen Typ definiert, der die gesamte Konfiguration enthält, die das Modul benötigt:
data DbConfig = DbConfig { dbCredentials :: DbCredentials , ... }
Has
-pattern wird als folgende Typklasse dargestellt:
class HasDbConfig rec where getDbConfig :: rec -> DbConfig
Dann setupDatabase
der setupDatabase
Typ so aus
setupDatabase :: (MonadReader rm, HasDbConfig r) => m Db
und im Hauptteil der Funktion müssen wir nur asks $ foo . getDbConfig
asks $ foo . getDbConfig
wo wir zuvor asks foo
haben, asks foo
aufgrund der zusätzlichen Abstraktionsschicht, die wir gerade hinzugefügt haben.
Ebenso haben wir die HasWebServerConfig
und HasCronConfig
.
Was ist, wenn eine Funktion zwei verschiedene Module verwendet? Nur kompatible Konstrate!
doSmthWithDbAndCron :: (MonadReader rm, HasDbConfig r, HasCronConfig r) => ...
Was ist mit den Implementierungen dieser Typklassen?
Wir haben immer noch AppConfig
auf der höchsten Ebene unserer Anwendung (gerade jetzt wissen die Module nichts darüber), und dafür können wir schreiben:
data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig } instance HasDbConfig AppConfig where getDbConfig = dbConfig instance HasWebServerConfig AppConfig where getWebServerConfig = webServerCOnfig instance HasCronConfig AppConfig where getCronConfig = cronConfig
Es sieht soweit gut aus. Dieser Ansatz hat jedoch ein Problem - zu viel Schreiben , und wir werden es im nächsten Beitrag genauer untersuchen.