Hallo Habr! Ich präsentiere Ihnen die Übersetzung des Artikels "Perils of Constructors" von Aleksey Kladov.
Einer meiner Lieblings-Rust-Blog-Beiträge ist Things Rust Shipped Without von Graydon Hoare . Für mich ist das Fehlen jeglicher Merkmale in der Sprache, die ins Bein schießen können, normalerweise wichtiger als Ausdruckskraft. In diesem leicht philosophischen Aufsatz möchte ich über mein besonders beliebtes Feature sprechen, das in Rust fehlt - über Konstruktoren.
Was ist ein Konstruktor?
Konstruktoren werden üblicherweise in OO-Sprachen verwendet. Die Aufgabe des Konstruktors besteht darin, das Objekt vollständig zu initialisieren, bevor der Rest der Welt es sieht. Auf den ersten Blick scheint dies eine wirklich gute Idee zu sein:
- Sie legen die Invarianten im Konstruktor fest.
- Jede Methode kümmert sich um die Erhaltung von Invarianten.
- Zusammen bedeuten diese beiden Eigenschaften, dass Sie Objekte als Invarianten und nicht als spezifische interne Zustände betrachten können.
Der Konstruktor spielt hier die Rolle einer Induktionsbasis und ist die einzige Möglichkeit, ein neues Objekt zu erstellen.
Leider gibt es eine Lücke in diesen Argumenten: Der Designer selbst beobachtet das Objekt in einem unfertigen Zustand, was viele Probleme verursacht.
Dieser Wert
Wenn der Konstruktor das Objekt initialisiert, beginnt es mit einem leeren Zustand. Aber wie definieren Sie diesen leeren Zustand für ein beliebiges Objekt?
Der einfachste Weg, dies zu tun, besteht darin, alle Felder auf ihre Standardwerte zu setzen: false für bool, 0 für Zahlen, null für alle Links. Dieser Ansatz erfordert jedoch, dass alle Typen Standardwerte haben, und führt die berüchtigte Null in die Sprache ein. Dies ist der Pfad, den Java eingeschlagen hat: Zu Beginn der Erstellung des Objekts sind alle Felder 0 oder null.
Mit diesem Ansatz wird es sehr schwierig sein, null danach loszuwerden. Ein gutes Beispiel zum Lernen ist Kotlin. Kotlin verwendet standardmäßig nicht nullfähige Typen, muss jedoch mit bereits vorhandener JVM-Semantik arbeiten. Das Design der Sprache verbirgt diese Tatsache gut und ist in der Praxis gut anwendbar, aber unhaltbar . Mit anderen Worten, mit Konstruktoren ist es möglich, Nullprüfungen in Kotlin zu umgehen.
Das Hauptmerkmal von Kotlin ist die Ermutigung, sogenannte "Primärkonstruktoren" zu erstellen, die sowohl ein Feld deklarieren als auch ihm einen Wert zuweisen, bevor benutzerdefinierter Code ausgeführt wird:
class Person( val firstName: String, val lastName: String ) { ... }
Eine weitere Option: Wenn das Feld im Konstruktor nicht deklariert ist, sollte der Programmierer es sofort initialisieren:
class Person(val firstName: String, val lastName: String) { val fullName: String = "$firstName $lastName" }
Der Versuch, ein Feld vor der Initialisierung zu verwenden, wird statisch abgelehnt:
class Person(val firstName: String, val lastName: String) { val fullName: String init { println(fullName)
Aber mit ein bisschen Kreativität kann jeder diese Schecks umgehen. Hierzu eignet sich beispielsweise ein Methodenaufruf:
class A { val x: Any init { observeNull() x = 92 } fun observeNull() = println(x)
Auch dies mit einem Lambda (das in Kotlin wie folgt erstellt wird: {args -> body}) zu greifen, ist ebenfalls geeignet:
class B { val x: Any = { y }() val y: Any = x } fun main() { println(B().x)
Beispiele wie diese scheinen in der Realität unrealistisch zu sein (und das ist es auch), aber ich habe ähnliche Fehler im realen Code gefunden (Kolmogorovs Wahrscheinlichkeitsregel 0-1 in der Softwareentwicklung: In einer ausreichend großen Datenbank ist fast garantiert, dass jeder Code existiert, zumindest wenn nicht statisch vom Compiler verboten (in diesem Fall existiert es mit ziemlicher Sicherheit nicht).
Der Grund, warum Kotlin bei diesem Fehler möglicherweise vorhanden ist, ist der gleiche wie bei kovarianten Java-Arrays: Zur Laufzeit werden weiterhin Überprüfungen durchgeführt. Am Ende möchte ich das Kotlin-Typ-System nicht komplizieren, um die oben genannten Fälle in der Kompilierungsphase falsch zu machen: Angesichts der bestehenden Einschränkungen (JVM-Semantik) ist das Preis-Leistungs-Verhältnis von Validierungen zur Laufzeit viel besser als das von statischen.
Was aber, wenn die Sprache nicht für jeden Typ einen angemessenen Standardwert hat? In C ++, wo benutzerdefinierte Typen nicht unbedingt Referenzen sind, können Sie beispielsweise nicht einfach jedem Feld null zuweisen und sagen, dass dies funktioniert! Stattdessen verwendet C ++ eine spezielle Syntax, um Anfangswerte für Felder festzulegen: Initialisierungslisten:
#include <string> #include <utility> class person { person(std::string first_name, std::string last_name) : first_name(std::move(first_name)) , last_name(std::move(last_name)) {} std::string first_name; std::string last_name; };
Da dies eine spezielle Syntax ist, funktioniert der Rest der Sprache nicht einwandfrei. Zum Beispiel ist es schwierig, beliebige Operationen in die Initialisierungslisten aufzunehmen, da C ++ keine ausdrucksorientierte Sprache ist (was an sich normal ist). Um mit Ausnahmen zu arbeiten, die in Initialisierungslisten auftreten, müssen Sie eine andere undurchsichtige Funktion der Sprache verwenden .
Methoden aus dem Konstruktor aufrufen
Wie die Beispiele von Kotlin andeuten, zerbricht alles in Chips, sobald wir versuchen, eine Methode vom Konstruktor aufzurufen. Grundsätzlich erwarten Methoden, dass das dadurch zugängliche Objekt bereits vollständig konstruiert und korrekt ist (im Einklang mit Invarianten). In Kotlin oder Java hindert Sie nichts daran, Methoden vom Konstruktor aufzurufen, und auf diese Weise können wir versehentlich ein halbkonstruiertes Objekt bearbeiten. Der Designer verspricht, Invarianten zu etablieren, aber gleichzeitig ist dies der einfachste Ort für ihre mögliche Verletzung.
Besonders seltsame Dinge passieren, wenn der Basisklassenkonstruktor eine in einer abgeleiteten Klasse überschriebene Methode aufruft:
abstract class Base { init { initialize() } abstract fun initialize() } class Derived: Base() { val x: Any = 92 override fun initialize() = println(x)
Denken Sie nur einmal darüber nach: Der Code einer beliebigen Klasse wird ausgeführt, bevor der Konstruktor aufgerufen wird! Ähnlicher C ++ - Code führt zu noch interessanteren Ergebnissen. Anstatt die Funktion der abgeleiteten Klasse aufzurufen, wird die Funktion der Basisklasse aufgerufen. Dies ist wenig sinnvoll, da die abgeleitete Klasse noch nicht initialisiert wurde (denken Sie daran, wir können nicht einfach sagen, dass alle Felder null sind). Wenn die Funktion in der Basisklasse jedoch rein virtuell ist, führt ihr Aufruf zu UB.
Designer-Unterschrift
Die Verletzung von Invarianten ist nicht das einzige Problem für Designer. Sie haben eine Signatur mit einem festen Namen (leer) und einen Rückgabetyp (die Klasse selbst). Dies macht es für Menschen schwierig, Designüberladungen zu verstehen.
Backfill-Frage: Was entspricht std :: vector <int> xs (92, 2)?
a. Vektor von zwei Längen 92
b. [92, 92]
c. [92, 2]
Probleme mit dem Rückgabewert treten in der Regel auf, wenn ein Objekt nicht erstellt werden kann. Sie können nicht einfach das Ergebnis <MyClass, io :: Error> oder null vom Konstruktor zurückgeben!
Dies wird häufig als Argument dafür verwendet, dass die Verwendung von C ++ ohne Ausnahmen schwierig ist und dass die Verwendung von Konstruktoren Sie auch zur Verwendung von Ausnahmen zwingt. Ich halte dieses Argument jedoch nicht für richtig: Factory-Methoden lösen beide Probleme, da sie beliebige Namen haben und beliebige Typen zurückgeben können. Ich glaube, dass das folgende Muster in OO-Sprachen manchmal nützlich sein kann:
Erstellen Sie einen privaten Konstruktor, der die Werte aller Felder als Argumente verwendet und sie einfach zuweist. Ein solcher Konstruktor würde also als Strukturliteral in Rust arbeiten. Es kann auch nach Invarianten suchen, sollte aber nichts anderes mit Argumenten oder Feldern tun.
Für die öffentliche API werden öffentliche Factory-Methoden mit entsprechenden Namen und Rückgabetypen bereitgestellt.
Ein ähnliches Problem bei Konstruktoren besteht darin, dass sie spezifisch sind und daher nicht verallgemeinert werden können. In C ++ kann "es gibt einen Standardkonstruktor" oder "es gibt einen Kopierkonstruktor" nicht einfacher ausgedrückt werden als "bestimmte Syntax funktioniert". Vergleichen Sie dies mit Rust, wo diese Konzepte geeignete Signaturen haben:
trait Default { fn default() -> Self; } trait Clone { fn clone(&self) -> Self; }
Leben ohne Designer
Rust hat nur eine Möglichkeit, eine Struktur zu erstellen: Werte für alle Felder bereitzustellen. Factory-Funktionen, wie die allgemein akzeptierten neuen, spielen die Rolle von Konstruktoren, aber vor allem können Sie keine Methoden aufrufen, bis Sie mindestens eine mehr oder weniger korrekte Instanz der Struktur haben.
Der Nachteil dieses Ansatzes besteht darin, dass jeder Code eine Struktur erstellen kann, sodass es keinen einzigen Ort wie einen Konstruktor gibt, an dem Invarianten verwaltet werden können. In der Praxis lässt sich dies leicht durch den Datenschutz lösen: Wenn die Felder der Struktur privat sind, kann diese Struktur nur im selben Modul erstellt werden. Innerhalb eines Moduls ist es nicht schwierig, die Vereinbarung einzuhalten, dass "alle Methoden zum Erstellen einer Struktur die neue Methode verwenden müssen". Sie können sich sogar eine Spracherweiterung vorstellen, mit der Sie einige Funktionen mit dem Attribut # [Konstruktor] markieren können, sodass die Strukturliteral-Syntax nur in markierten Funktionen verfügbar ist. Aber auch hier scheinen mir zusätzliche sprachliche Mechanismen überflüssig zu sein: Das Befolgen lokaler Konventionen erfordert wenig Aufwand.
Persönlich glaube ich, dass dieser Kompromiss für die Vertragsprogrammierung im Allgemeinen genauso aussieht. Verträge wie "nicht null" oder "positiver Wert" werden am besten in Typen codiert. Für komplexe Invarianten ist es nicht so schwierig, in jeder Methode nur assert! (Self.validate ()) zu schreiben. Zwischen diesen beiden Mustern ist wenig Platz für # [vor] und # [nach] Bedingungen, die auf Sprachebene implementiert sind oder auf Makros basieren.
Was ist mit Swift?
Swift ist eine weitere interessante Sprache, die einen Blick auf die Entwurfsmechanismen wert ist. Swift ist wie Kotlin eine null sichere Sprache. Im Gegensatz zu Kotlin sind die Nullprüfungen von Swift stärker, sodass die Sprache interessante Tricks verwendet, um den durch die Konstruktoren verursachten Schaden zu verringern.
Erstens verwendet Swift benannte Argumente und hilft ein wenig bei "Alle Konstruktoren haben den gleichen Namen". Insbesondere zwei Konstruktoren mit denselben Parametertypen sind kein Problem:
Celsius(fromFahrenheit: 212.0) Celsius(fromKelvin: 273.15)
Zweitens verwendet Swift zur Lösung des Problems "der Konstruktor ruft die virtuelle Methode der Klasse des Objekts auf, die noch nicht vollständig erstellt wurde" ein gut durchdachtes zweiphasiges Initialisierungsprotokoll. Obwohl es keine spezielle Syntax für Initialisierungslisten gibt, überprüft der Compiler statisch, ob der Hauptteil des Konstruktors die richtige und sichere Form hat. Das Aufrufen von Methoden ist beispielsweise erst möglich, nachdem alle Felder der Klasse und ihrer Nachkommen initialisiert wurden.
Drittens werden auf Sprachebene Konstruktoren unterstützt, deren Aufruf möglicherweise fehlschlägt. Der Konstruktor kann als nullwert festgelegt werden, wodurch das Ergebnis des Aufrufs der Klasse eine Option darstellt. Der Konstruktor kann auch einen Throws-Modifikator haben, der mit der Semantik der zweiphasigen Initialisierung in Swift besser funktioniert als mit der Syntax von Initialisierungslisten in C ++.
Swift schafft es, alle Löcher in den Konstruktoren zu schließen, über die ich mich beschwert habe. Dies hat jedoch seinen Preis: Das Initialisierungskapitel ist eines der größten im Swift-Buch.
Wenn Konstruktoren wirklich benötigt werden
Trotz aller Widrigkeiten kann ich mir mindestens zwei Gründe ausdenken, warum Konstruktoren nicht durch Strukturliterale wie in Rust ersetzt werden können.
Erstens zwingt die Vererbung die Sprache bis zu dem einen oder anderen Grad dazu, Konstruktoren zu haben. Sie können sich eine Erweiterung der Syntax von Strukturen mit Unterstützung für Basisklassen vorstellen:
struct Base { ... } struct Derived: Base { foo: i32 } impl Derived { fn new() -> Derived { Derived { Base::new().., foo: 92, } } }
Dies funktioniert jedoch nicht in einem typischen Objektlayout einer OO-Sprache mit einfacher Vererbung! In der Regel beginnt ein Objekt mit einem Titel, gefolgt von Klassenfeldern, von der Basis bis zu den am meisten abgeleiteten. Somit ist das Präfix des Objekts der abgeleiteten Klasse das richtige Objekt der Basisklasse. Damit ein solches Layout funktioniert, muss der Designer jedoch Speicher für das gesamte Objekt gleichzeitig zuweisen. Es kann nicht nur Speicher nur für die Basisklasse zuweisen und dann abgeleitete Felder anhängen. Eine solche Zuordnung des Speichers in Teilen ist jedoch erforderlich, wenn wir die Syntax zum Erstellen einer Struktur verwenden möchten, in der wir einen Wert für die Basisklasse angeben können.
Zweitens verfügen Designer im Gegensatz zur Strukturliteral-Syntax über einen ABI, der sich gut zum Platzieren von Objektunterobjekten im Speicher eignet (platzierungsfreundlicher ABI). Der Konstruktor arbeitet mit einem Zeiger darauf, der auf den Speicherbereich zeigt, den das neue Objekt belegen soll. Am wichtigsten ist, dass ein Konstruktor leicht einen Zeiger an Unterobjektkonstruktoren übergeben kann, wodurch die Erstellung komplexer Wertebäume "an Ort und Stelle" ermöglicht wird. Im Gegensatz dazu enthält das Konstruieren von Strukturen in Rust semantisch einige Kopien, und hier hoffen wir auf die Gnade des Optimierers. Es ist kein Zufall, dass Rust noch keinen akzeptierten Arbeitsvorschlag zur Platzierung von Unterobjekten im Speicher hat!
Update 1: Tippfehler behoben. Das "Schreibliteral" wurde durch "Strukturliteral" ersetzt.