Kann ich Haz? Von generischer Typprogrammierung getroffen

Hallo Habr.


Letztes Mal haben wir Has pattern beschrieben, die Probleme beschrieben, die es löst, und einige spezifische Instanzen geschrieben:


 instance HasDbConfig AppConfig where getDbConfig = dbConfig instance HasWebServerConfig AppConfig where getWebServerConfig = webServerConfig instance HasCronConfig AppConfig where getCronConfig = cronConfig 

Es sieht gut aus. Welche Schwierigkeiten können hier auftreten?


Bild


Überlegen wir uns, welche anderen Instanzen wir möglicherweise benötigen. Erstens sind konkrete Typen mit einer Konfiguration für sich genommen gute Kandidaten für die (triviale) Implementierung dieser Typklassen, was uns drei weitere Fälle gibt, in denen jede Methode beispielsweise über id implementiert wird


 instance HasDbConfig DbConfig where getDbConfig = id 

Sie ermöglichen es uns, auf einfache Weise einzelne Tests oder Hilfsprogramme zu schreiben, die von der gesamten AppConfig unabhängig sind.


Das ist schon langweilig, geht aber trotzdem weiter. Es ist leicht vorstellbar, dass einige Integrationstests die Interaktion eines Modulpaares überprüfen, und wir möchten immer noch nicht von der Konfiguration der gesamten Anwendung abhängen. fst müssen wir jetzt sechs Instanzen (zwei pro Typ) schreiben, von denen jede auf fst reduziert fst oder snd . Zum Beispiel für DbConfig :


 instance HasDbConfig (DbConfig, b) where getDbConfig = fst instance HasDbConfig (a, DbConfig) where getDbConfig = snd 

Horror Es ist zu hoffen, dass wir niemals den Betrieb von drei Modulen gleichzeitig testen müssen - andernfalls müssen Sie neun langweilige Instanzen schreiben. Auf jeden Fall fühle ich mich persönlich schon sehr unwohl und würde lieber mehrere Stunden damit verbringen, diese Angelegenheit zu automatisieren, als ein paar Minuten, um ein Dutzend zusätzliche Codezeilen zu schreiben.


Wenn Sie daran interessiert sind, wie Sie dieses Problem allgemein lösen können, sind es außerdem abhängige Typen und wie alles wie eine Haskell-Welkom-Katze aussehen wird.


Zusammenfassung der Has Klasse


Beachten Sie zunächst, dass wir unterschiedliche Klassen für unterschiedliche Umgebungen haben. Dies kann die Erstellung einer universellen Lösung beeinträchtigen. Daher wird die Umgebung in einem separaten Parameter herausgenommen:


 class Has part record where extract :: record -> part 

Wir können sagen, dass ein Has part record , dass ein Wert des Typtyps aus dem Wert des Typdatensatzes extrahiert werden kann. In diesen Begriffen wird unsere gute alte HasDbConfig zu Has DbConfig , und ähnlich für andere Typklassen, die wir zuvor geschrieben haben. Es stellt sich fast eine rein syntaktische Änderung heraus, und zum Beispiel wird der Typ einer der Funktionen aus unserem vorherigen Beitrag geändert


 doSmthWithDbAndCron :: (MonadReader rm, HasDbConfig r, HasCronConfig r) => ... 

in


 doSmthWithDbAndCron :: (MonadReader rm, Has DbConfig r, Has CronConfig r) => ... 

Die einzige Änderung sind ein paar Leerzeichen an den richtigen Stellen.


Darüber hinaus haben wir bei der Typinferenz nicht viel verloren: Ein Timer kann in den allermeisten Fällen, die in der Praxis auftreten, immer noch den erforderlichen Rückgabewert des extract im umgebenden Kontext ausgeben.


Nachdem wir uns nicht um den spezifischen Umgebungstyp kümmern, wollen wir sehen, welche Datensätze die Has part record für das feste part implementieren können. Diese Aufgabe hat eine gute induktive Struktur:


  1. Jeder Typ hat seine eigenen: Has record record ist auf triviale Weise implementiert ( extract = id ).
  2. Wenn record ein Produkt der Typen rec1 und rec2 , wird Has part record genau dann implementiert, wenn entweder Has part rec1 oder Has part rec2 .
  3. Wenn record die Summe der Typen rec1 und rec2 , wird Has part record genau dann implementiert, wenn Has part rec1 und Has part rec2 . Obwohl die praktische Verbreitung dieses Falles in diesem Zusammenhang nicht offensichtlich ist, ist es der Vollständigkeit halber noch erwähnenswert.

Es sieht also so aus, als hätten wir eine Skizze eines Algorithmus formuliert, um automatisch zu bestimmen, ob ein Has part record für part und record implementiert ist!


Glücklicherweise passt eine solche induktive Argumentation zu Typen sehr gut zum Haskell Generics- Mechanismus. Kurz und vereinfacht ausgedrückt, ist Generics eine der Methoden der verallgemeinerten Metaprogrammierung in Haskell, die sich aus der Beobachtung ergibt, dass jeder Typ entweder ein Summentyp, ein Produkttyp oder ein Basistyp mit einem Konstrukt und einem Feld ist.


Ich werde kein weiteres Tutorial über Generika schreiben, also fahren Sie einfach mit dem Code fort.


Erster Versuch


Wir werden die klassische Methode der Generic Implementierung unseres Has durch die Hilfsklasse GHas :


 class GHas part grecord where gextract :: grecord p -> part 

Hier ist grecord eine Generic Darstellung unseres record .


GHas Implementierungen folgen der oben angegebenen induktiven Struktur:


 instance GHas record (K1 i record) where gextract (K1 x) = x instance GHas part record => GHas part (M1 it record) where gextract (M1 x) = gextract x instance GHas part l => GHas part (l :*: r) where gextract (l :*: _) = gextract l instance GHas part r => GHas part (l :*: r) where gextract (_ :*: r) = gextract r 

  1. K1 entspricht dem Basisfall.
  2. M1 - Generika-spezifische Metadaten, die wir für unsere Aufgabe nicht benötigen. Wir ignorieren sie einfach und gehen sie durch.
  3. Die erste Instanz für den Produkttyp l :*: r entspricht dem Fall, dass der "linke" Teil des Produkts den Wert des Typs hat, den wir benötigen (möglicherweise rekursiv).
  4. In ähnlicher Weise entspricht die zweite Instanz für den Produkttyp l :*: r dem Fall, in dem der "richtige" Teil des Produkts den Wert des Typs hat, den wir benötigen (natürlich auch möglicherweise rekursiv).

Wir unterstützen hier nur Produkttypen. Mein subjektiver Eindruck ist, dass Beträge in Kontexten für MonadReader und ähnliche Klassen nicht so häufig verwendet werden, sodass sie zur Vereinfachung der Betrachtung vernachlässigt werden können.


Darüber hinaus ist es nützlich zu beachten, dass jeder n-ary Typ-Produkt (a1, ..., an) kann als Zusammensetzung dargestellt werden n1Paare (a1, (a2, (a3, (..., an)))) , daher erlaube ich mir, Produkttypen mit Paaren zu verknüpfen.


Mit unseren GHas können Sie eine Standardimplementierung für Has schreiben, die Generika verwendet:


 class Has part record where extract :: record -> part default extract :: Generic record => record -> part extract = gextract . from 

Fertig.


Oder nicht?


Das Problem


Wenn wir versuchen, diesen Code zu kompilieren, werden wir feststellen, dass er auch ohne den Versuch, diese Implementierung standardmäßig zu verwenden, nicht taypechaetsya ausführt und dort einige überlappende Instanzen meldet. Schlimmer noch, diese Instanzen sind in gewisser Hinsicht gleich. Es scheint an der Zeit zu sein, herauszufinden, wie der Mechanismus zum Auflösen von Instanzen in Haskell funktioniert.


Mögen wir haben


 instance context => Foo barPattern bazPattern where ... 

(Übrigens heißt dieses Ding nach => Instanzkopf.)


Es scheint natürlich, dies als zu lesen


Lassen Sie uns eine Instanz für Foo bar baz auswählen. Wenn der context erfüllt ist, können Sie diese Instanz auswählen, sofern bar und baz barPattern und bazPattern .

Dies ist jedoch eine Fehlinterpretation und genau das Gegenteil:


Lassen Sie uns eine Instanz für Foo bar baz auswählen. Wenn bar und baz barPattern und bazPattern , wählen wir diese Instanz aus und fügen der Liste der Konstanten, die aufgelöst werden müssen, context hinzu.

Jetzt ist es offensichtlich, wo das Problem liegt. Schauen wir uns die folgenden Instanzen genauer an:


 instance GHas part l => GHas part (l :*: r) where gextract (l :*: _) = gextract l instance GHas part r => GHas part (l :*: r) where gextract (_ :*: r) = gextract r 

Sie haben die gleichen Instanzköpfe, also kein Wunder, dass sie sich schneiden! Darüber hinaus ist keiner von ihnen spezifischer als der andere.


Darüber hinaus gibt es keine Möglichkeit, diese Instanzen so zu verfeinern, dass sie sich nicht mehr überschneiden. Nun, neben dem Hinzufügen weiterer GHas Parameter.


Ausdrucksstarke Typen eilen zur Rettung!


Die Lösung des Problems besteht darin, den "Pfad" zu dem für uns interessanten Wert vorab zu berechnen und diesen Pfad zu verwenden, um die Auswahl der Instanzen zu steuern.


Da wir uns darauf geeinigt haben, Summentypen nicht zu unterstützen, ist ein Pfad im wörtlichen Sinne eine Folge von Links- oder Rechtskurven in Produkttypen (dh Auswahl der ersten oder zweiten Komponente eines Paares), die mit einem großen HIER-Zeiger endet, sobald wir den gewünschten Typ gefunden haben . Wir schreiben dies:


 data Path = L Path | R Path | Here deriving (Show) 

Zum Beispiel

Betrachten Sie die folgenden Typen:


 data DbConfig = DbConfig { dbAddress :: DbAddress , dbUsername :: Username , dbPassword :: Password } data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig } 

Was sind einige Beispiele für Pfade von AppConfig ?


  1. Zu DbConfigL Here .
  2. Zu WebServerConfigR (L Here) .
  3. Zu CronConfigR (R Here) .
  4. Zu DbAddressL (L Here) .

Was könnte das Ergebnis einer Suche nach einem Wert des gewünschten Typs sein? Zwei Optionen liegen auf der Hand: Wir können es entweder finden oder nicht. Tatsächlich ist jedoch alles etwas komplizierter: Wir können mehr als einen Wert dieses Typs finden. Anscheinend wäre das vernünftigste Verhalten in diesem kontroversen Fall auch eine Fehlermeldung. Jede Wahl eines bestimmten Wertes hat eine gewisse Zufälligkeit.


Betrachten Sie in der Tat unser Standard-Webdienstbeispiel. Wenn jemand einen Wert vom Typ (Host, Port) möchte, sollte dies die Adresse des Datenbankservers oder die Adresse des Webservers sein? Es ist besser, es nicht zu riskieren.


Lassen Sie uns dies auf jeden Fall im Code ausdrücken:


 data MaybePath = NotFound | Conflict | Found Path deriving (Show) 

Wir trennen NotFound und Conflict , da die Behandlung dieser Fälle grundlegend anders ist: Wenn wir NotFound in einem der Zweige unseres Produkttyps erhalten, schadet es nicht, den gewünschten Wert in einem anderen Zweig zu finden, während Conflict in einem Zweig sofort voll bedeutet ein Fehler.


Nun betrachten wir einen Sonderfall von Produkttypen (die wir, wie vereinbart, paarweise betrachten). Wie finde ich den Wert des gewünschten Typs in ihnen? Sie können eine Suche rekursiv in jeder Komponente eines Paares ausführen, die Ergebnisse p1 bzw. p2 p1 und sie dann irgendwie kombinieren.


Da es sich um die Auswahl von Instanzen von Zeitklassen handelt, die während der Kompilierung auftreten, benötigen wir tatsächlich Kompilierungsberechnungen, die im Haskell durch Berechnungen von Typen ausgedrückt werden (selbst wenn Typen durch Begriffe dargestellt werden, die mit DataKinds im Universum DataKinds ). Dementsprechend wird eine solche Funktion für Typen als Typenfamilie dargestellt:


 type family Combine p1 p2 where Combine ('Found path) 'NotFound = 'Found ('L path) Combine 'NotFound ('Found path) = 'Found ('R path) Combine 'NotFound 'NotFound = 'NotFound Combine _ _ = 'Conflict 

Diese Funktion repräsentiert mehrere Fälle:


  1. Wenn eine der rekursiven Suchen erfolgreich ist und die andere zu NotFound , NotFound wir den Pfad von der erfolgreichen Suche und hängen die Kurve in die richtige Richtung an.
  2. Wenn beide rekursiven Suchen mit NotFound , endet die gesamte Suche offensichtlich mit NotFound .
  3. In jedem anderen Fall kommt es zu Conflict .

Jetzt schreiben wir eine Funktion auf Tipe-Ebene, die das zu findende Teil verwendet, und eine Generic Darstellung des Typs, in dem das part , und suchen:


 type family Search part (grecord :: k -> *) :: MaybePath where Search part (K1 _ part) = 'Found 'Here Search part (K1 _ other) = 'NotFound Search part (M1 _ _ x) = Search part x Search part (l :*: r) = Combine (Search part l) (Search part r) Search _ _ = 'NotFound 

Beachten Sie, dass wir eine sehr ähnliche Bedeutung haben wie unser vorheriger Versuch mit GHas . Dies ist zu erwarten, da wir tatsächlich den Algorithmus reproduzieren, den wir versucht haben, durch die Zeitklassen auszudrücken.


GHas : Wir müssen dieser Klasse nur noch einen zusätzlichen Parameter hinzufügen, der für den zuvor gefundenen Pfad verantwortlich ist und zur Auswahl bestimmter Instanzen dient:


 class GHas (path :: Path) part grecord where gextract :: Proxy path -> grecord p -> part 

Wir haben auch ein zusätzliches Argument für gextract damit der Compiler die richtige Instanz für den angegebenen Pfad auswählen kann (dies muss in der Funktionssignatur angegeben werden).


Das Schreiben von Instanzen ist jetzt ziemlich einfach:


 instance GHas 'Here record (K1 i record) where gextract _ (K1 x) = x instance GHas path part record => GHas path part (M1 it record) where gextract proxy (M1 x) = gextract proxy x instance GHas path part l => GHas ('L path) part (l :*: r) where gextract _ (l :*: _) = gextract (Proxy :: Proxy path) l instance GHas path part r => GHas ('R path) part (l :*: r) where gextract _ (_ :*: r) = gextract (Proxy :: Proxy path) r 

In der Tat wählen wir einfach die gewünschte Instanz basierend auf dem Pfad in dem path , den wir zuvor berechnet haben.


Wie schreibe ich jetzt unsere default der Funktion extract :: record -> part in die Klasse Has ? Wir haben mehrere Bedingungen:


  1. record muss Generic implementieren, damit der generische Mechanismus angewendet werden kann, damit wir einen Generic record .
  2. Die Search sollte einen part im record (oder besser gesagt in der Generic Darstellung des record , die als Rep record ausgedrückt wird). Im Code sieht dies etwas ungewöhnlicher aus: Search part (Rep record) ~ 'Found path . Dieser Datensatz bedeutet die Einschränkung, dass das Ergebnis des Search part (Rep record) gleich dem 'Found path für einen path (was für uns tatsächlich interessant ist).
  3. Wir sollten in der Lage sein, GHas zusammen mit part , der generischen Darstellung von record und path aus dem letzten Schritt, der sich in einen GHas path part (Rep record) verwandelt.

Wir werden die letzten beiden Konstanten noch mehrmals treffen, daher ist es nützlich, sie in ein separates Konstanten-Synonym zu setzen:


 type SuccessfulSearch part record path = (Search part (Rep record) ~ 'Found path, GHas path part (Rep record)) 

Mit diesem Synonym bekommen wir


 class Has part record where extract :: record -> part default extract :: forall path. (Generic record, SuccessfulSearch part record path) => record -> part extract = gextract (Proxy :: Proxy path) . from 

Jetzt alles!


Verwenden von generischen Has


Um all dies in Aktion zu betrachten, werden wir einige allgemeine Instanzen für Dummies schreiben:


 instance SuccessfulSearch a (a0, a1) path => Has a (a0, a1) instance SuccessfulSearch a (a0, a1, a2) path => Has a (a0, a1, a2) instance SuccessfulSearch a (a0, a1, a2, a3) path => Has a (a0, a1, a2, a3) 

Hier ist SuccessfulSearch a (a0, ..., an) path dafür verantwortlich, dass a unter a0, ..., an genau einmal vorkommt.


Mögen wir jetzt unser gutes altes haben


 data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig } 

und wir wollen Has DbConfig , Has WebServerConfig und Has CronConfig . Es reicht aus, die DeriveAnyClass DeriveGeneric und DeriveAnyClass und die richtige deriving hinzuzufügen:


 data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig } deriving (Generic, Has DbConfig, Has WebServerConfig, Has CronConfig) 

Wir haben das Glück (oder wir waren aufschlussreich genug), die Argumente für Has so anzuordnen, dass der Name des verschachtelten Typs an erster Stelle steht, sodass wir uns auf den DeriveAnyClass Mechanismus verlassen können, um das DeriveAnyClass zu minimieren.


Sicherheit steht an erster Stelle


Was ist, wenn wir keinen Typ haben?


 data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig } deriving (Generic, Has CronConfig) 

Nein, wir bekommen einen Fehler direkt an der Stelle der Typdefinition:


 Spec.hs:35:24: error: • Couldn't match type ''NotFound' with ''Found path0' arising from the 'deriving' clause of a data type declaration • When deriving the instance for (Has CronConfig AppConfig) | 35 | } deriving (Generic, Has CronConfig) | ^^^^^^^^^^^^^^ 

Nicht die freundlichste Fehlermeldung, aber selbst daraus können Sie immer noch verstehen, wo das Problem liegt: die ungerade Frequenz NotFound ungerade Frequenz CronConfig .


Was ist, wenn wir mehrere Felder mit demselben Typ haben?


 data AppConfig = AppConfig { prodDbConfig :: DbConfig , qaDbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig } deriving (Generic, Has DbConfig) 

Nein, wie erwartet:


 Spec.hs:37:24: error: • Couldn't match type ''Conflict' with ''Found path0' arising from the 'deriving' clause of a data type declaration • When deriving the instance for (Has DbConfig AppConfig) | 37 | } deriving (Generic, Has DbConfig) | ^^^^^^^^^^^^ 

Alles scheint wirklich gut zu sein.


Zusammenfassend


Wir werden also versuchen, die vorgeschlagene Methode kurz zu formulieren.


Angenommen, wir haben eine Art Typklass und möchten seine Instanzen automatisch nach einigen rekursiven Regeln anzeigen. Dann können wir die Mehrdeutigkeiten wie folgt vermeiden (und diese Regeln im Allgemeinen ausdrücken, wenn sie nicht trivial sind und nicht in den Standardmechanismus zum Auflösen von Instanzen passen):


  1. Wir codieren rekursive Regeln in Form eines induktiven Datentyps T
  2. Wir werden eine Funktion für Typen (in Form einer Typenfamilie) zur vorläufigen Berechnung des Werts v dieses Typs T (oder in Bezug auf Haskell Typ v Typs T - wo sind meine abhängigen Typen) schreiben, die die spezifische Abfolge der Schritte beschreibt, die ausgeführt werden müssen.
  3. Verwenden Sie dieses v als zusätzliches Argument für den Generic Helfer, um die spezifische Folge von Instanzen zu bestimmen, die jetzt mit den Werten von v übereinstimmen.

Nun, das ist es!


In den folgenden Beiträgen werden wir einige elegante Erweiterungen (sowie elegante Einschränkungen) dieses Ansatzes betrachten.


Oh und ja. Es ist interessant, die Abfolge unserer Verallgemeinerungen zu verfolgen.


  1. Begonnen mit Env -> Foo .
  2. Nicht allgemein genug, wickeln Sie sich in die Reader Env Monade ein.
  3. Nicht allgemein genug, schreiben Sie mit dem MonadReader Env m .
  4. Nicht allgemein genug, schreiben Sie MonadReader rm, HasEnv r .
  5. Nicht allgemein genug, schreiben MonadReader rm, Has Env r und fügen Generika hinzu, damit der Compiler dort alles macht.
  6. Nun die Norm.

Source: https://habr.com/ru/post/de471052/


All Articles