In che modo i tratti di ruggine sono diversi dalle interfacce Go?


64

Conosco relativamente bene Go, dopo aver scritto un numero di piccoli programmi. Ruggine, ovviamente, mi è meno familiare ma mi tengo d'occhio.

Avendo letto di recente http://yager.io/programming/go.html , ho pensato di esaminare personalmente i due modi in cui i generici sono gestiti perché l'articolo sembrava criticare ingiustamente Go quando, in pratica, non c'erano molte interfacce non poteva realizzare elegantemente. Continuavo a sentire l'hype su quanto potenti fossero i tratti di Rust e nient'altro che le critiche della gente su Go. Avendo un po 'di esperienza in Go, mi chiedevo quanto fosse vero e quali fossero le differenze alla fine. Quello che ho scoperto è che i tratti e le interfacce sono abbastanza simili! Alla fine, non sono sicuro che mi manchi qualcosa, quindi ecco un rapido riassunto educativo delle loro somiglianze, così puoi dirmi cosa mi sono perso!

Ora diamo un'occhiata a Go Interfaces dalla loro documentazione :

Le interfacce in Go forniscono un modo per specificare il comportamento di un oggetto: se qualcosa può farlo, allora può essere usato qui.

Di gran lunga l'interfaccia più comune è quella Stringerche restituisce una stringa che rappresenta l'oggetto.

type Stringer interface {
    String() string
}

Quindi, qualsiasi oggetto che ha String()definito su di esso è un Stringeroggetto. Questo può essere usato nelle firme dei tipi in modo tale da func (s Stringer) print()prendere quasi tutti gli oggetti e stamparli.

Abbiamo anche interface{}che prende qualsiasi oggetto. Dobbiamo quindi determinare il tipo in fase di esecuzione attraverso la riflessione.


Ora diamo un'occhiata a Rust Traits dalla loro documentazione :

Nella sua forma più semplice, un tratto è un insieme di zero o più firme di metodi. Ad esempio, potremmo dichiarare il tratto stampabile per cose che possono essere stampate sulla console, con una firma con un solo metodo:

trait Printable {
    fn print(&self);
}

Questo sembra immediatamente abbastanza simile alle nostre interfacce Go. L'unica differenza che vedo è che definiamo 'Implementations' of Traits piuttosto che semplicemente definire i metodi. Quindi lo facciamo

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

invece di

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

Domanda bonus: cosa succede in Rust se si definisce una funzione che implementa un tratto ma non si utilizza impl? Semplicemente non funziona?

A differenza di Go's Interfaces, il sistema di tipi di Rust ha parametri di tipo che ti permettono di fare generici e cose come interface{}mentre il compilatore e il runtime conoscono effettivamente il tipo. Per esempio,

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

funziona su qualsiasi tipo e il compilatore sa che il tipo degli elementi Sequence al momento della compilazione piuttosto che usare la riflessione.


Ora, la vera domanda: mi sto perdendo qualche differenza qui? Sono davvero così simili? Non c'è qualche differenza fondamentale che mi manca qui? (In uso. I dettagli di implementazione sono interessanti, ma alla fine non importanti se funzionano allo stesso modo.)

Oltre alle differenze sintattiche, le differenze effettive che vedo sono:

  1. Go ha un metodo di invio automatico rispetto a Rust che richiede (?) implS per implementare un tratto
    • Elegante contro esplicito
  2. La ruggine ha parametri di tipo che consentono generici adeguati senza riflessione.
    • Go non ha davvero alcuna risposta qui. Questa è l'unica cosa che è significativamente più potente ed è in definitiva solo una sostituzione per i metodi di copia e incolla con firme di tipo diverso.

Sono queste le uniche differenze non banali? In tal caso, sembrerebbe che il sistema di interfaccia / tipo di Go non sia, in pratica, debole come percepito.

Risposte:


59

Cosa succede in Rust se si definisce una funzione che implementa un tratto ma non si usa impl? Semplicemente non funziona?

Devi implementare esplicitamente il tratto; capitare di avere un metodo con nome / firma corrispondenti non ha senso per Rust.

Invio di chiamate generico

Sono queste le uniche differenze non banali? In tal caso, sembrerebbe che il sistema di interfaccia / tipo di Go non sia, in pratica, debole come percepito.

Non fornire l'invio statico può essere un impatto significativo sulle prestazioni in alcuni casi (ad esempio Iteratorquello che cito di seguito). Penso che questo sia ciò che intendi

Go non ha davvero alcuna risposta qui. Questa è l'unica cosa che è significativamente più potente ed è in definitiva solo una sostituzione per i metodi di copia e incolla con firme di tipo diverso.

ma lo tratterò in modo più dettagliato, perché vale la pena comprendere a fondo la differenza.

In Rust

L'approccio di Rust consente all'utente di scegliere tra invio statico e invio dinamico . Ad esempio, se hai

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());
}

quindi le due call_barchiamate precedenti verranno compilate in chiamate a, rispettivamente,

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

dove tali .bar()chiamate di metodo sono chiamate di funzione statiche, cioè verso un indirizzo di funzione fissa in memoria. Ciò consente ottimizzazioni come inline, poiché il compilatore sa esattamente quale funzione viene chiamata. (Questo è anche ciò che fa C ++, a volte chiamato "monomorfizzazione".)

In Go

Go consente solo l'invio dinamico di funzioni "generiche", ovvero l'indirizzo del metodo viene caricato dal valore e quindi chiamato da lì, quindi la funzione esatta è nota solo in fase di esecuzione. Utilizzando l'esempio sopra

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"))
}

Ora, quei due call_bars chiameranno sempre quanto sopra call_bar, con l'indirizzo di barcaricato dalla vtable dell'interfaccia .

Basso livello

Per riformulare quanto sopra, in notazione C. La versione di Rust crea

/* "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;
}

Per Go, è qualcosa di più simile a:

/* 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);
}

(Questo non è esattamente corretto --- ci devono essere più informazioni nella vtable --- ma la chiamata del metodo essendo un puntatore a funzione dinamica è la cosa rilevante qui.)

Rust offre la scelta

Tornando a

L'approccio di Rust consente all'utente di scegliere tra invio statico e invio dinamico.

Finora ho dimostrato solo che Rust ha inviato statici generici, ma Rust può optare per quelli dinamici come Go (con essenzialmente la stessa implementazione), tramite oggetti tratto. Notato come &Foo, che è un riferimento preso in prestito a un tipo sconosciuto che implementa il Footratto. Questi valori hanno la stessa rappresentazione vtable / molto simile all'oggetto dell'interfaccia Go. (Un oggetto tratto è un esempio di "tipo esistenziale" .)

Ci sono casi in cui il dispacciamento dinamico è davvero utile (e talvolta più performante, ad esempio riducendo il gonfiore / duplicazione del codice), ma il dispaccio statico consente ai compilatori di incorporare i siti di chiamata e applicare tutte le loro ottimizzazioni, il che significa che normalmente è più veloce. Ciò è particolarmente importante per cose come il protocollo di iterazione di Rust , in cui le chiamate al metodo del tratto di dispacciamento statico consentono a quegli iteratori di essere veloci come gli equivalenti C, pur sembrando di alto livello ed espressivi .

Tl; dr: l'approccio di Rust offre invio sia statico che dinamico in generici, a discrezione dei programmatori; Go consente solo l'invio dinamico.

Polimorfismo parametrico

Inoltre, enfatizzare i tratti e delimitare la riflessione dà a Rust un polimorfismo parametrico molto più forte : il programmatore sa esattamente cosa può fare una funzione con i suoi argomenti, perché deve dichiarare i tratti che i tipi generici implementano nella firma della funzione.

L'approccio di Go è molto flessibile, ma ha meno garanzie per i chiamanti (rendendo un po 'più difficile per il programmatore ragionare), perché gli interni di una funzione possono (e fanno) richiedere informazioni aggiuntive sul tipo (c'era un bug nel Go libreria standard in cui, iirc, una funzione che utilizza uno scrittore userebbe la riflessione per richiamare Flushalcuni input, ma non altri).

Costruire astrazioni

Questo è un po 'un punto dolente, quindi parlerò solo brevemente, ma avere generici "corretti" come Rust ha permesso a tipi di dati di basso livello come Go's mape []di essere effettivamente implementati direttamente nella libreria standard in un modo fortemente tipicamente sicuro, e scritto in Rust ( HashMape Vecrispettivamente).

E non sono solo quei tipi, è possibile costruire strutture generiche sicure al di sopra di essi, ad esempio LruCacheè un livello di cache generico sopra una hashmap. Ciò significa che le persone possono semplicemente utilizzare le strutture di dati direttamente dalla libreria standard, senza dover archiviare i dati interface{}e utilizzare asserzioni di tipo durante l'inserimento / estrazione. Cioè, se ne hai uno LruCache<int, String>, sei sicuro che le chiavi sono sempre se inti valori sono sempre Strings: non c'è modo di inserire accidentalmente il valore sbagliato (o provare a estrarre un non String).


La mia AnyMapè una buona dimostrazione dei punti di forza di Rust, che combina oggetti tratti con generici per fornire un'astrazione sicura ed espressiva della cosa fragile che in Go verrebbe necessariamente scritta map[string]interface{}.
Chris Morgan,

Come mi aspettavo, Rust è più potente e offre più scelta in modo nativo / elegante, ma il sistema di Go è abbastanza vicino da poter realizzare la maggior parte delle cose che manca con piccoli hack come interface{}. Mentre Rust sembra tecnicamente superiore, penso ancora che la critica di Go ... sia stata un po 'troppo dura. La potenza del programmatore è praticamente alla pari per il 99% delle attività.
Logan,

22
@Logan, per i domini di basso livello / ad alte prestazioni che Rust punta (ad es. Sistemi operativi, browser web ... la roba di programmazione "sistemi" di base), non avendo l'opzione di invio statico (e le prestazioni che fornisce / ottimizzazione permette) è inaccettabile. È uno dei motivi per cui Go non è adatto come Rust per questo tipo di applicazioni. In ogni caso, la potenza del programmatore non è davvero alla pari, si perde (tempo di compilazione) la sicurezza del tipo per qualsiasi struttura di dati riutilizzabile e non incorporata, ricadendo su asserzioni di tipo runtime.
huon,

10
Esatto, Rust offre molta più potenza. Penso a Rust come un C ++ sicuro e Go come un Python veloce (o un Java ampiamente semplificato). Per la grande percentuale di attività in cui la produttività degli sviluppatori conta di più (e cose come i runtime e la garbage collection non sono problematiche), scegliere Vai (ad es. Server Web, sistemi simultanei, utilità della riga di comando, applicazioni utente, ecc.). Se hai bisogno di tutte le ultime prestazioni (e la produttività degli sviluppatori è dannosa), scegli Rust (ad es. Browser, sistemi operativi, sistemi integrati con risorse limitate).
weberc2
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.