Einführung in die kontextorientierte Kotlin-Programmierung

Dies ist eine Übersetzung von Eine Einführung in die kontextorientierte Programmierung in Kotlin

In diesem Artikel werde ich versuchen, ein neues Phänomen zu beschreiben, das als Nebenprodukt der raschen Entwicklung der Kotlin-Sprache entstanden ist. Dies ist ein neuer Ansatz zum Entwerfen der Architektur von Anwendungen und Bibliotheken, den ich als kontextorientierte Programmierung bezeichnen werde.

Ein paar Worte zu Funktionsberechtigungen


Bekanntlich gibt es drei Hauptprogrammierparadigmen ( Anmerkung von Pedant : Es gibt andere Paradigmen):

  • Prozedurale Programmierung
  • Objektorientierte Programmierung
  • Funktionale Programmierung

Alle diese Ansätze arbeiten auf die eine oder andere Weise mit Funktionen. Betrachten wir dies unter dem Gesichtspunkt der Auflösung von Funktionen oder der Planung ihrer Aufrufe (dh der Auswahl der Funktion, die an dieser Stelle verwendet werden soll). Die prozedurale Programmierung ist durch die Verwendung globaler Funktionen und deren statische Auflösung auf der Grundlage des Funktionsnamens und der Argumenttypen gekennzeichnet. Natürlich können Typen nur bei statisch typisierten Sprachen verwendet werden. In Python werden Funktionen beispielsweise nach Namen aufgerufen, und wenn die Argumente falsch sind, wird zur Laufzeit während der Programmausführung eine Ausnahme ausgelöst. Die Auflösung von Funktionen in Sprachen mit prozeduralem Ansatz basiert nur auf dem Namen der Prozedur / Funktion und ihren Parametern und erfolgt in den meisten Fällen statisch.

Ein objektorientierter Programmierstil begrenzt den Funktionsumfang. Funktionen sind nicht global, sondern Teil von Klassen und können nur für eine Instanz der entsprechenden Klasse aufgerufen werden ( Anmerkung von Pedant : Einige klassische prozedurale Sprachen haben ein modulares System und daher einen Geltungsbereich; prozedurale Sprache! = C).

Natürlich können wir eine Mitgliedsfunktion einer Klasse immer durch eine globale Funktion durch ein zusätzliches Argument des Typs des aufgerufenen Objekts ersetzen, aber aus syntaktischer Sicht ist der Unterschied ziemlich bedeutend. In diesem Fall sind die Methoden beispielsweise in der Klasse gruppiert, auf die sie sich beziehen, und daher ist klarer ersichtlich, welche Art von Verhalten die Objekte dieses Typs liefern.

Am wichtigsten ist hier natürlich die Kapselung, aufgrund derer einige Felder einer Klasse oder ihr Verhalten privat und nur für Mitglieder dieser Klasse zugänglich sein können (Sie können dies nicht in einem rein prozeduralen Ansatz angeben), und der Polymorphismus, dank dessen die tatsächlich verwendete Methode nicht nur anhand des Namens bestimmt wird Methode, sondern auch basierend auf dem Typ des Objekts, von dem es aufgerufen wird. Das Auslösen eines Methodenaufrufs in einem objektorientierten Ansatz hängt vom Typ des zur Laufzeit definierten Objekts, dem Namen der Methode und dem Typ der Argumente in der Kompilierungsphase ab.

Ein funktionaler Ansatz bringt in Bezug auf die Funktionsauflösung nichts grundlegend Neues. In funktionsorientierten Sprachen gelten in der Regel bessere Regeln für die Unterscheidung zwischen Sichtbarkeitsbereichen ( Pedant-Hinweis : Wiederum sind nicht alle prozeduralen Sprachen in C enthalten, es gibt auch solche, in denen Sichtbarkeitsbereiche gut abgegrenzt sind), mit denen die Sichtbarkeit von Funktionen auf der Grundlage des Systems genauer gesteuert werden kann Module, aber ansonsten erfolgt die Auflösung zur Kompilierungszeit basierend auf der Art der Argumente.

Was ist das


Beim Objektansatz haben wir beim Aufrufen einer Methode für ein Objekt die Argumente, aber zusätzlich haben wir einen expliziten (im Fall von Python) oder einen impliziten Parameter, der eine Instanz der aufgerufenen Klasse darstellt (im Folgenden werden alle Beispiele in Kotlin geschrieben):

class A{ fun doSomething(){ println("    $this") } } 

Verschachtelte Klassen und Closures erschweren die Sache ein wenig:

 interface B{ fun doBSomething() } class A{ fun doASomething(){ val b = object: B{ override fun doBSomething(){ println("    $this  ${this@A}") } } b.doBSomething() } } 

In diesem Fall gibt es zwei implizite Bedingungen für die Funktion doBSomething : Die eine entspricht einer Instanz der Klasse B und die andere ergibt sich aus dem Abschluss von Instanz A. Das Gleiche passiert im weitaus häufigeren Fall des Lambda-Verschlusses. Es ist wichtig zu beachten, dass dies in diesem Fall nicht nur als impliziter Parameter funktioniert, sondern auch als Bereich oder Kontext für alle Funktionen und Objekte, die im lexikalischen Bereich aufgerufen werden. Die Methode doBSomething hat also Zugriff auf alle Mitglieder der Klasse A (öffentlich oder privat) sowie auf die Mitglieder von B selbst.

Und hier ist Kotlin


Kotlin gibt uns ein völlig neues "Spielzeug" - Erweiterungsfunktionen . ( Anmerkung von Pedant : Eigentlich sind sie nicht so neu, sie existieren auch in C #.) Sie können eine Funktion wie A.doASomething () an einer beliebigen Stelle im Programm definieren, nicht nur in A. In dieser Funktion gibt es einen impliziten Parameter namens receiver, der auf Instanz A verweist, auf der die Methode aufgerufen wird:

 class A fun A.doASomthing(){ println(" -   $this") } fun main(){ val a = A() a.doASomthing() } 

Erweiterungsfunktionen haben keinen Zugriff auf die privaten Mitglieder ihres Empfängers, sodass die Kapselung nicht verletzt wird.

Das nächste wichtige Ding, das Kotlin hat, sind Codeblöcke mit Empfängern. Sie können einen beliebigen Codeblock ausführen, indem Sie etwas als Empfänger verwenden:

 class A{ fun doInternalSomething(){} } fun A.doASomthing(){} fun main(){ val a = A() with(a){ doInternalSomething() doASomthing() } } 

In diesem Beispiel könnten beide Funktionen ohne ein zusätzliches „ a “ aufgerufen werden, da die with-Funktion den gesamten Code des nachfolgenden Blocks in den Kontext von a einfügt. Dies bedeutet, dass alle Funktionen in diesem Block so aufgerufen werden, als wären sie auf dem (explizit übergebenen) Objekt a aufgerufen worden.

Der letzte Schritt an dieser Stelle in der kontextorientierten Programmierung ist die Möglichkeit, Erweiterungen als Mitglieder einer Klasse zu deklarieren. In diesem Fall wird die Erweiterungsfunktion in einer anderen Klasse wie folgt definiert:

 class B class A{ fun B.doBSomething(){} } fun main(){ val a = A() val b = B() with(a){ b.doBSomething() //   } b.doBSomething() //   } 

Es ist wichtig, dass B hier ein neues Verhalten bekommt, aber nur, wenn es sich in einem bestimmten lexikalischen Kontext befindet. Eine Erweiterungsfunktion ist ein reguläres Mitglied der Klasse A. Dies bedeutet, dass die Auflösung der Funktion statisch basierend auf dem Kontext erfolgt, in dem sie aufgerufen wird. Die tatsächliche Implementierung wird jedoch durch die Instanz von A bestimmt , die als Kontext übergeben wird. Die Funktion kann sogar mit dem Zustand des Objekts a interagieren.

Kontextorientierter Versand


Zu Beginn des Artikels haben wir verschiedene Ansätze zum Verteilen von Funktionsaufrufen erörtert, und zwar aus einem bestimmten Grund. Tatsache ist, dass Sie mit den Erweiterungsfunktionen in Kotlin auf eine neue Art und Weise mit dem Disponieren arbeiten können. Die Entscheidung, welche bestimmte Funktion verwendet werden soll, basiert nun nicht nur auf der Art ihrer Parameter, sondern auch auf dem lexikalischen Kontext ihres Aufrufs. Das heißt, der gleiche Ausdruck in unterschiedlichen Kontexten kann unterschiedliche Bedeutungen haben. Aus Sicht der Implementierung ändert sich natürlich nichts, und wir haben immer noch ein explizites Empfängerobjekt, das das Dispatching für seine Methoden und Erweiterungen definiert, die im Hauptteil der Klasse selbst beschrieben sind (Member-Erweiterungen). Aus syntaktischer Sicht ist dies jedoch ein anderer Ansatz .

Schauen wir uns an, wie sich der kontextorientierte Ansatz vom klassischen objektorientierten Ansatz unterscheidet, und nehmen wir als Beispiel das klassische Problem der arithmetischen Operationen mit Zahlen in Java. Die Zahlenklasse in Java und Kotlin ist das übergeordnete Element für alle Zahlen, aber im Gegensatz zu spezialisierten Zahlen wie Double werden die mathematischen Operationen nicht definiert. So können Sie beispielsweise nicht schreiben:

 val n: Number = 1.0 n + 1.0 //  `plus`     `Number` 

Der Grund hierfür ist, dass es nicht möglich ist, arithmetische Operationen für alle numerischen Typen konsistent zu definieren. Beispielsweise unterscheidet sich die Ganzzahldivision von der Gleitkommadivision. In einigen besonderen Fällen weiß der Benutzer, welche Art von Operation erforderlich ist, aber normalerweise macht es keinen Sinn, solche Dinge global zu definieren. Eine objektorientierte (und tatsächlich funktionale) Lösung besteht darin, einen neuen Vererbungstyp der Number- Klasse zu definieren, die erforderlichen Operationen darin auszuführen und diese bei Bedarf zu verwenden (in Kotlin 1.3 können Sie Inline-Klassen verwenden). Definieren wir stattdessen einen Kontext mit diesen Operationen und wenden Sie ihn lokal an:

 interface NumberOperations{ operator fun Number.plus(other: Number) : Number operator fun Number.minus(other: Number) : Number operator fun Number.times(other: Number) : Number operator fun Number.div(other: Number) : Number } object DoubleOperations: NumberOperations{ override fun Number.plus(other: Number) = this.toDouble() + other.toDouble() override fun Number.minus(other: Number) = this.toDouble() - other.toDouble() override fun Number.times(other: Number) = this.toDouble() * other.toDouble() override fun Number.div(other: Number) = this.toDouble() / other.toDouble() } fun main(){ val n1: Number = 1.0 val n2: Number = 2 val res = with(DoubleOperations){ (n1 + n2)/2 } println(res) } 

In diesem Beispiel erfolgt die Berechnung von res in einem Kontext, der zusätzliche Operationen definiert. Ein Kontext muss nicht lokal definiert werden, sondern kann implizit als Empfänger einer Funktion übergeben werden. Zum Beispiel können Sie dies tun:

 fun NumberOperations.calculate(n1: Number, n2: Number) = (n1 + n2)/2 val res = DoubleOperations.calculate(n1, n2) 

Dies bedeutet, dass die Logik der Operationen innerhalb des Kontexts vollständig von der Implementierung dieses Kontexts getrennt ist und in einem anderen Teil des Programms oder sogar in einem anderen Modul geschrieben werden kann. In diesem einfachen Beispiel ist ein Kontext ein zustandsloser Singleton, es können jedoch auch Zustandskontexte verwendet werden.

Denken Sie auch daran, dass Kontexte verschachtelt werden können:

 with(a){ with(b){ doSomething() } } 

Dies führt dazu, dass das Verhalten beider Klassen kombiniert wird. Diese Funktion ist jedoch heute schwer zu steuern, da keine Erweiterungen mit mehreren Empfängern vorhanden sind ( KT-10468 ).

Die Kraft expliziter Koroutinen


Eines der besten Beispiele für einen kontextorientierten Ansatz wird in der Kotlinx-Coroutines-Bibliothek verwendet. Eine Erklärung der Idee findet sich in einem Artikel von Roman Elizarov. An dieser Stelle möchte ich nur betonen, dass es sich bei CoroutineScope um ein kontextorientiertes Design mit einem statusbehafteten Kontext handelt. CoroutineScope spielt zwei Rollen:

  • Es enthält den CoroutineContext , der zum Ausführen von Coroutine erforderlich ist und beim Starten einer neuen Coroutine geerbt wird.
  • Es enthält den Status der übergeordneten Coroutine, sodass Sie ihn abbrechen können, wenn die generierte Coroutine einen Fehler auslöst.

Strukturierte Parallelität ist auch ein gutes Beispiel für eine kontextorientierte Architektur:

 suspend fun CoroutineScope.doSomeWork(){} GlobalScope.launch{ launch{ delay(100) doSomeWork() } } 

DoSomeWork ist hier eine Kontextfunktion, die jedoch außerhalb ihres Kontexts definiert ist. Die Startmethoden erstellen zwei verschachtelte Kontexte, die den lexikalischen Bereichen der entsprechenden Funktionen entsprechen (in diesem Fall sind beide Kontexte vom gleichen Typ, sodass der innere Kontext den äußeren Kontext verdeckt). Ein guter Ausgangspunkt für das Erlernen von Kotlin-Koroutinen ist der offizielle Leitfaden.

DSL


Für Kotlin gibt es eine Vielzahl von Aufgaben, die üblicherweise als Aufgaben zum Aufbau von DSL (Domain Specific Language) bezeichnet werden. In diesem Fall wird DSL als Code verstanden, der einen benutzerfreundlichen Builder mit einer komplexen Struktur bereitstellt. Tatsächlich ist die Verwendung des Begriffs DSL hier nicht ganz richtig In solchen Fällen wird die grundlegende Kotlin-Syntax einfach ohne besondere Tricks verwendet. Verwenden wir jedoch diesen allgemeinen Begriff.

DSL-Builder sind in den meisten Fällen kontextorientiert. Wenn Sie beispielsweise ein HTML-Element erstellen möchten, müssen Sie zunächst prüfen, ob dieses bestimmte Element an dieser Stelle hinzugefügt werden kann. Die Bibliothek kotlinx.html stellt dazu kontextbasierte Klassenerweiterungen bereit , die ein bestimmtes Tag darstellen. Tatsächlich besteht die gesamte Bibliothek aus Kontexterweiterungen für vorhandene DOM-Elemente.

Ein weiteres Beispiel ist der TornadoFX GUI Builder . Der gesamte Builder des Szenendiagramms ist als Folge verschachtelter Context Builder angeordnet, wobei die inneren Blöcke für das Erstellen von Kindern für die äußeren Blöcke oder das Anpassen der Parameter der Eltern verantwortlich sind. Hier ist ein Beispiel aus der offiziellen Dokumentation:

 override val root = gridPane{ tabpane { gridpaneConstraints { vhGrow = Priority.ALWAYS } tab("Report", HBox()) { label("Report goes here") } tab("Data", GridPane()) { tableview<Person> { items = persons column("ID", Person::idProperty) column("Name", Person::nameProperty) column("Birthday", Person::birthdayProperty) column("Age", Person::ageProperty).cellFormat { if (it < 18) { style = "-fx-background-color:#8b0000; -fx-text-fill:white" text = it.toString() } else { text = it.toString() } } } } } } 

In diesem Beispiel definiert der lexikalische Bereich seinen Kontext (was logisch ist, da er den GUI-Abschnitt und seine interne Struktur darstellt) und hat Zugriff auf übergeordnete Kontexte.

Was kommt als nächstes? Mehrere Empfänger


Die kontextorientierte Programmierung bietet Kotlin-Entwicklern viele Tools und eröffnet neue Möglichkeiten für die Gestaltung der Anwendungsarchitektur. Brauchen wir noch etwas Wahrscheinlich ja

Derzeit ist die Entwicklung in einem kontextbezogenen Ansatz durch die Tatsache begrenzt, dass Sie Erweiterungen definieren müssen, um eine Art kontextbezogenes Klassenverhalten zu erzielen. Das ist in Ordnung, wenn es um eine benutzerdefinierte Klasse geht, aber was ist, wenn wir dasselbe für eine Klasse aus einer Bibliothek wollen? Oder möchten wir eine Erweiterung für ein Verhalten erstellen, dessen Umfang bereits begrenzt ist (z. B. eine Erweiterung in CoroutineScope hinzufügen)? Kotlin erlaubt derzeit nicht, dass Nebenstellenfunktionen mehr als einen Empfänger haben. Es können jedoch mehrere Empfänger zur Sprache hinzugefügt werden, ohne die Abwärtskompatibilität zu beeinträchtigen. Die Möglichkeit der Verwendung mehrerer Empfänger wird derzeit diskutiert ( KT-10468 ) und als KEEP- Anforderung ausgegeben (UPD: bereits ausgegeben ). Das Problem (oder vielleicht ein Chip) verschachtelter Kontexte besteht darin, dass Sie damit die meisten, wenn nicht sogar alle Optionen für die Verwendung von Typklassen ( Typklassen ) abdecken können. Dies ist ein weiteres sehr wünschenswertes Merkmal der vorgeschlagenen Funktionen. Es ist eher unwahrscheinlich, dass beide Funktionen gleichzeitig in der Sprache implementiert werden.

Zusatz


Wir möchten uns bei unserem hauptberuflichen Pedant- und Haskell-Liebhaber Alexei Khudyakov für seine Kommentare zum Text des Artikels und Änderungen an meiner ziemlich freien Verwendung von Begriffen bedanken. Ich danke auch Ilya Ryzhenkov für wertvolle Kommentare und das Korrekturlesen der englischen Version des Artikels.

Autor des Originalartikels: Alexander Nozik , stellvertretender Leiter des Labors für kernphysikalische Versuchsmethoden bei JetBrains Research .

Übersetzt von: Petr Klimay , Forscher am Labor für kernphysikalische Versuchsmethoden bei JetBrains Research

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


All Articles