Was unterscheidet Rust Traits von Go Interfaces?

64

Ich bin relativ vertraut mit Go, da ich eine Reihe kleiner Programme darin geschrieben habe. Rust ist mir natürlich weniger vertraut, aber ich muss ein Auge darauf haben.

Nachdem ich kürzlich http://yager.io/programming/go.html gelesen hatte , dachte ich, ich würde die beiden Möglichkeiten des Umgangs mit Generika persönlich untersuchen, da der Artikel Go zu unfair kritisiert zu haben schien, als es in der Praxis nicht viel gab, was Schnittstellen betraf konnte nicht elegant erreichen. Ich hörte immer wieder den Hype darüber, wie mächtig Rusts Züge waren und nichts als Kritik von Leuten über Go. Nachdem ich einige Erfahrungen in Go gesammelt hatte, fragte ich mich, wie wahr das war und was die Unterschiede letztendlich waren. Was ich fand, war, dass Eigenschaften und Schnittstellen ziemlich ähnlich sind! Letztendlich bin ich mir nicht sicher, ob ich etwas vermisse. Hier ist ein kurzer Überblick über die Ähnlichkeiten, sodass Sie mir sagen können, was ich verpasst habe!

Werfen wir nun einen Blick auf Go Interfaces in der Dokumentation :

Schnittstellen in Go bieten eine Möglichkeit, das Verhalten eines Objekts anzugeben: Wenn dies möglich ist, kann es hier verwendet werden.

Die mit Abstand häufigste Schnittstelle ist Stringerdie, die eine Zeichenfolge zurückgibt, die das Objekt darstellt.

type Stringer interface {
    String() string
}

Jedes Objekt, das darauf String()definiert ist, ist ein StringerObjekt. Dies kann in Typensignaturen verwendet werden, bei denen func (s Stringer) print()fast alle Objekte erfasst und gedruckt werden.

Wir haben auch interface{}welche Objekte nimmt. Wir müssen dann den Typ zur Laufzeit durch Reflektion bestimmen.


Werfen wir nun einen Blick auf die Rostmerkmale in ihrer Dokumentation :

Im einfachsten Fall ist ein Merkmal eine Menge von null oder mehr Methodensignaturen. Zum Beispiel könnten wir das Merkmal Printable für Dinge deklarieren, die mit einer einzelnen Methodensignatur auf der Konsole gedruckt werden können:

trait Printable {
    fn print(&self);
}

Dies sieht unseren Go-Interfaces sofort sehr ähnlich. Der einzige Unterschied, den ich sehe, besteht darin, dass wir 'Implementierungen' von Merkmalen definieren, anstatt nur die Methoden zu definieren. So machen wir es

impl Printable for int {
    fn print(&self) { println!("{}", *self) }
}

Anstatt von

fn print(a: int) { ... }

Bonusfrage: Was passiert in Rust, wenn Sie eine Funktion definieren, die ein Merkmal implementiert, aber nicht verwendet impl? Es funktioniert einfach nicht?

Im Gegensatz zu Go's Interfaces verfügt Rusts Typsystem über Typparameter, mit denen Sie die richtigen Generika und ähnliches ausführen können, interface{}während der Compiler und die Laufzeit den Typ tatsächlich kennen. Zum Beispiel,

trait Seq<T> {
    fn length(&self) -> uint;
}

Funktioniert auf jedem Typ und der Compiler weiß, dass der Typ der Sequence-Elemente zur Kompilierungszeit nicht reflektiert wird.


Nun die eigentliche Frage: Vermisse ich hier irgendwelche Unterschiede? Sind sie wirklich so ähnlich? Gibt es nicht noch einen grundsätzlichen Unterschied, den ich hier vermisse? (Im Gebrauch. Implementierungsdetails sind interessant, aber letztendlich unwichtig, wenn sie gleich funktionieren.)

Neben den syntaktischen Unterschieden sehe ich folgende Unterschiede:

  1. Go hat einen automatischen Methodenversand. Rust benötigt (?) impl, Um ein Merkmal zu implementieren
    • Elegant gegen explizit
  2. Rust hat Typparameter, die korrekte Generika ohne Reflektion ermöglichen.
    • Go hat hier wirklich keine Antwort. Dies ist das einzige, was erheblich leistungsfähiger ist und letztendlich nur ein Ersatz für das Kopieren und Einfügen von Methoden mit unterschiedlichen Typensignaturen ist.

Sind dies die einzigen nicht trivialen Unterschiede? In diesem Fall scheint das Interface / Type-System von Go in der Praxis nicht so schwach zu sein, wie es angenommen wird.

Logan
quelle

Antworten:

59

Was passiert in Rust, wenn Sie eine Funktion definieren, die ein Merkmal implementiert, aber kein impl verwendet? Es geht einfach nicht?

Sie müssen das Merkmal explizit implementieren. zufällig eine Methode mit passendem Namen / Signatur zu haben, ist für Rust bedeutungslos.

Allgemeine Anrufverteilung

Sind dies die einzigen nicht trivialen Unterschiede? In diesem Fall scheint das Interface / Type-System von Go in der Praxis nicht so schwach zu sein, wie es angenommen wird.

Das Fehlen eines statischen Versands kann in bestimmten Fällen (z. B. in dem Iteratorunten genannten Fall) einen erheblichen Leistungseinbruch bedeuten . Ich denke, das ist was du meinst

Go hat hier wirklich keine Antwort. Dies ist das einzige, was erheblich leistungsfähiger ist und letztendlich nur ein Ersatz für das Kopieren und Einfügen von Methoden mit unterschiedlichen Typensignaturen ist.

Aber ich werde es detaillierter behandeln, da es sich lohnt, den Unterschied genau zu verstehen.

In Rust

Der Ansatz von Rust ermöglicht es dem Benutzer, zwischen statischem Versand und dynamischem Versand zu wählen . Zum Beispiel, wenn Sie haben

trait Foo { fn bar(&self); }

impl Foo for int { fn bar(&self) {} }
impl Foo for String { fn bar(&self) {} }

fn call_bar<T: Foo>(value: T) { value.bar() }

fn main() {
    call_bar(1i);
    call_bar("foo".to_string());
}

dann call_barkompilieren sich die beiden obigen Aufrufe zu Aufrufen zu

fn call_bar_int(value: int) { value.bar() }
fn call_bar_string(value: String) { value.bar() }

Bei diesen .bar()Methodenaufrufen handelt es sich um statische Funktionsaufrufe, dh an eine feste Funktionsadresse im Speicher. Dies ermöglicht Optimierungen wie Inlining, da der Compiler genau weiß , welche Funktion aufgerufen wird. (Dies ist, was C ++ auch tut, manchmal als "Monomorphisierung" bezeichnet.)

In Go

Go erlaubt nur dynamisches Versenden für "generische" Funktionen, dh die Methodenadresse wird aus dem Wert geladen und dann von dort aufgerufen, so dass die genaue Funktion nur zur Laufzeit bekannt ist. Im obigen Beispiel

type Foo interface { bar() }

func call_bar(value Foo) { value.bar() }

type X int;
type Y string;
func (X) bar() {}
func (Y) bar() {}

func main() {
    call_bar(X(1))
    call_bar(Y("foo"))
}

Jetzt call_barrufen diese beiden s immer die oben genannten auf call_bar, mit der Adresse von, bardie aus der vtable der Schnittstelle geladen wurde .

Low-Level

Um das oben Gesagte in C-Notation zu formulieren. Rusts Version schafft

/* "implementing" the trait */
void bar_int(...) { ... }
void bar_string(...) { ... }

/* the monomorphised `call_bar` function */
void call_bar_int(int value) {
    bar_int(value);
}
void call_bar_string(string value) {
    bar_string(value);
}

int main() {
    call_bar_int(1);
    call_bar_string("foo");
    // pretend that is the (hypothetical) `string` type, not a `char*`
    return 1;
}

Für Go ist es eher wie folgt:

/* implementing the interface */
void bar_int(...) { ... }
void bar_string(...) { ... }

// the Foo interface type
struct Foo {
    void* data;
    struct FooVTable* vtable;
}
struct FooVTable {
    void (*bar)(void*);
}

void call_bar(struct Foo value) {
    value.vtable.bar(value.data);
}

static struct FooVTable int_vtable = { bar_int };
static struct FooVTable string_vtable = { bar_string };

int main() {
    int* i = malloc(sizeof *i);
    *i = 1;
    struct Foo int_data = { i, &int_vtable };
    call_bar(int_data);

    string* s = malloc(sizeof *s);
    *s = "foo"; // again, pretend the types work
    struct Foo string_data = { s, &string_vtable };
    call_bar(string_data);
}

(Dies ist nicht genau richtig - es muss mehr Informationen in der vtable geben - aber der Methodenaufruf, der ein dynamischer Funktionszeiger ist, ist hier die relevante Sache.)

Rust bietet die Wahl

Zurück gehen zu

Rusts Ansatz ermöglicht es dem Benutzer, zwischen statischem Versand und dynamischem Versand zu wählen.

Bisher habe ich nur gezeigt, dass Rust Generika statisch versendet hat, aber Rust kann sich über Trait-Objekte für die dynamischen wie Go (mit im Wesentlichen derselben Implementierung) entscheiden. Notated like &Foo, ein geliehener Verweis auf einen unbekannten Typ, der das FooMerkmal implementiert . Diese Werte haben die gleiche / sehr ähnliche vtable-Darstellung wie das Go-Schnittstellenobjekt. (Ein Merkmalobjekt ist ein Beispiel für einen "existenziellen Typ" .)

Es gibt Fälle, in denen dynamisches Versenden sehr hilfreich ist (und manchmal eine höhere Leistung erzielt, z. B. durch Reduzierung von aufgeblähtem / dupliziertem Code), aber statisches Versenden es Compilern ermöglicht, die Aufrufseiten inline zu schalten und alle ihre Optimierungen anzuwenden, was bedeutet, dass es normalerweise schneller ist. Dies ist besonders wichtig für Dinge wie das Rust-Iterationsprotokoll , bei dem statische Dispatching-Trait-Methoden es ermöglichen, dass diese Iteratoren genauso schnell sind wie die C-Äquivalente, aber dennoch auf hohem Niveau und aussagekräftig wirken .

Tl; dr: Rusts Ansatz bietet sowohl statischen als auch dynamischen Versand in Generika, nach Ermessen des Programmierers. Go ermöglicht nur den dynamischen Versand.

Parametrischer Polymorphismus

Durch die Betonung von Merkmalen und die Deemphasisierung der Reflexion erhält Rust außerdem einen viel stärkeren parametrischen Polymorphismus : Der Programmierer weiß genau, was eine Funktion mit ihren Argumenten tun kann, da er die Merkmale deklarieren muss, die die generischen Typen in der Funktionssignatur implementieren.

Der Ansatz von Go ist sehr flexibel, hat jedoch weniger Garantien für die Aufrufer (was es dem Programmierer etwas schwerer macht, darüber nachzudenken), da die Interna einer Funktion zusätzliche Typinformationen abfragen können (und tun) (es gab einen Fehler in Go) Standardbibliothek, in der eine Funktion, die einen Writer verwendet, Reflektion verwendet, um Flusheinige Eingaben aufzurufen , andere jedoch nicht.

Abstraktionen aufbauen

Dies ist ein bisschen schmerzhaft , daher werde ich nur kurz darauf eingehen , aber mit "richtigen" Generika wie Rust können Datentypen auf niedriger Ebene wie Go's mapund []direkt in der Standardbibliothek auf eine stark typsichere Art und Weise implementiert werden geschrieben in Rust ( HashMapund Vecjeweils).

Darüber können Sie typsichere generische Strukturen erstellen, z. B. LruCacheeine generische Caching-Ebene auf einer Hashmap. Dies bedeutet, dass die Benutzer die Datenstrukturen direkt aus der Standardbibliothek verwenden können, ohne Daten als zu speichern interface{}und Typzusagen beim Einfügen / Extrahieren zu verwenden. Wenn Sie also einen haben LruCache<int, String>, ist garantiert, dass die Schlüssel immer ints und die Werte immer Strings sind: Es gibt keine Möglichkeit, versehentlich den falschen Wert einzufügen (oder zu versuchen, einen Nicht-Wert zu extrahieren String).

huon
quelle
Meine eigene AnyMapist eine gute Demonstration der Stärken von Rust, indem sie Merkmalsobjekte mit Generika kombiniert, um eine sichere und ausdrucksstarke Abstraktion der fragilen Dinge zu liefern, die in Go notgedrungen geschrieben werden müssten map[string]interface{}.
Chris Morgan
Wie ich erwartet hatte, ist Rust leistungsfähiger und bietet eine größere Auswahl an nativen und eleganten Funktionen. Das Go-System ist jedoch so eng, dass die meisten fehlenden Funktionen mit kleinen Hacks wie z interface{}. Obwohl Rust technisch überlegen erscheint, finde ich die Kritik an Go immer noch etwas zu hart. Die Leistung des Programmierers ist für 99% der Aufgaben ziemlich gleich hoch.
Logan
22
@Logan, für die Low-Level- / High-Performance-Domains, für die Rust das Ziel hat (z. B. Betriebssysteme, Webbrowser ... das Kernprodukt der "System" -Programmierung), ohne die Option des statischen Versands (und der damit verbundenen Leistung / Optimierung) es erlaubt) ist inakzeptabel. Dies ist einer der Gründe, warum Go für diese Art von Anwendungen nicht so geeignet ist wie Rust. In jedem Fall ist die Leistung des Programmierers nicht wirklich gleichwertig. Sie verlieren die Typensicherheit (Kompilierzeit) für wiederverwendbare und nicht integrierte Datenstrukturen, indem Sie auf Laufzeittyp-Zusicherungen zurückgreifen.
Huon
10
Genau das ist richtig - Rust bietet Ihnen viel mehr Leistung. Ich stelle mir Rust als sicheres C ++ vor und Go als schnelles Python (oder stark vereinfachtes Java). Für den großen Prozentsatz der Aufgaben, bei denen die Produktivität der Entwickler am wichtigsten ist (und Dinge wie Laufzeiten und Garbage Collection sind nicht problematisch), wählen Sie "Los" (z. B. Webserver, gleichzeitige Systeme, Befehlszeilendienstprogramme, Benutzeranwendungen usw.). Wenn Sie ein Minimum an Leistung benötigen (und die Entwicklerproduktivität ist verdammt), wählen Sie Rust (z. B. Browser, Betriebssysteme, eingebettete Systeme mit eingeschränkten Ressourcen).
weberc2