En quoi les caractéristiques de rouille sont-elles différentes des interfaces Go?

64

Je connais assez bien Go, pour avoir écrit un certain nombre de petits programmes. La rouille, bien sûr, je la connais moins bien, mais je garde un œil dessus.

Ayant récemment lu http://yager.io/programming/go.html , je pensais que j’examinerais personnellement les deux méthodes de traitement des génériques car cet article semblait critiquer injustement Go. En pratique, Interfaces n’était pas très efficace. ne pouvait pas accomplir élégamment. Je n'arrêtais pas d'entendre le battage médiatique sur la puissance des traits de Rust et sur les critiques de Go. Ayant de l'expérience dans Go, je me suis demandé à quel point c'était vrai et quelles étaient les différences. Ce que j'ai trouvé, c'est que les traits et les interfaces sont assez similaires! En fin de compte, je ne suis pas sûr d’avoir oublié quelque chose; voici donc un bref aperçu éducatif de leurs similitudes afin que vous puissiez me dire ce que j’ai manqué!

Voyons maintenant les interfaces Go de leur documentation :

Les interfaces dans Go fournissent un moyen de spécifier le comportement d'un objet: si quelque chose peut le faire, il peut être utilisé ici.

De loin, l'interface la plus commune est celle Stringerqui renvoie une chaîne représentant l'objet.

type Stringer interface {
    String() string
}

Donc, tout objet qui a String()défini dessus est un Stringerobjet. Cela peut être utilisé dans des signatures de type telles que celles-ci func (s Stringer) print()prennent presque tous les objets et les impriment.

Nous avons aussi interface{}qui prend n'importe quel objet. Nous devons ensuite déterminer le type au moment de l'exécution par réflexion.


Jetons maintenant un coup d'œil à Rust Traits à partir de leur documentation :

Dans sa forme la plus simple, un trait est un ensemble de zéro ou plusieurs signatures de méthode. Par exemple, nous pourrions déclarer le trait Printable pour les éléments pouvant être imprimés sur la console, avec une signature de méthode unique:

trait Printable {
    fn print(&self);
}

Cela ressemble immédiatement à nos interfaces Go. La seule différence que je vois est que nous définissons des «implémentations» de traits plutôt que de simplement définir des méthodes. Alors on fait

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

au lieu de

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

Question bonus: que se passe-t-il dans Rust si vous définissez une fonction qui implémente un trait mais que vous n'utilisez pas impl? Ça ne marche pas?

Contrairement aux interfaces de Go, le système de typage de Rust comporte des paramètres de type qui vous permettent de créer des génériques appropriés, par exemple interface{}lorsque le compilateur et le moteur d'exécution connaissent réellement le type. Par exemple,

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

fonctionne sur n’importe quel type et le compilateur sait que le type des éléments de séquence au moment de la compilation plutôt que d’utiliser la réflexion.


Maintenant, la vraie question: est-ce que je manque des différences ici? Sont - ils vraiment que la même? N'y a-t-il pas une différence plus fondamentale qui me manque ici? (En usage. Les détails de la mise en œuvre sont intéressants, mais finalement pas importants s'ils fonctionnent de la même manière.)

Outre les différences syntaxiques, les différences réelles que je vois sont les suivantes:

  1. Aller a envoi automatique de méthode contre Rust nécessite (?) implS de mettre en œuvre un trait
    • Elégant vs Explicite
  2. La rouille a des paramètres de type qui permettent des génériques appropriés sans réflexion.
    • Allez vraiment n'a pas de réponse ici. C’est la seule chose qui est nettement plus puissante et c’est au final juste un remplacement pour les méthodes de copier-coller avec des signatures de types différentes.

S'agit-il des seules différences non négligeables? Si tel est le cas, il semblerait que le système d'interface / type de Go n'est, dans la pratique, pas aussi faible qu'il est perçu.

Logan
la source

Réponses:

59

Que se passe-t-il dans Rust si vous définissez une fonction qui implémente un trait mais que vous n'utilisez pas impl? Ça ne marche pas?

Vous devez implémenter explicitement le trait. avoir une méthode avec le nom / la signature correspondants n’a aucun sens pour Rust.

Répartition d'appel générique

S'agit-il des seules différences non négligeables? Si tel est le cas, il semblerait que le système d'interface / type de Go n'est, dans la pratique, pas aussi faible qu'il est perçu.

Ne pas fournir une répartition statique peut être un impact important sur les performances dans certains cas (par exemple, Iteratorcelui que je mentionne ci-dessous). Je pense que c'est ce que vous entendez par

Allez vraiment n'a pas de réponse ici. C’est la seule chose qui est nettement plus puissante et c’est au final juste un remplacement pour les méthodes de copier-coller avec des signatures de types différentes.

mais je vais en parler plus en détail, car il vaut la peine de comprendre la différence en profondeur.

En rouille

L'approche de Rust permet à l'utilisateur de choisir entre une répartition statique et une répartition dynamique . Par exemple, si vous avez

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

alors les deux call_barappels ci-dessus seront compilés pour appeler respectivement

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

où ces .bar()appels de méthode sont des appels de fonction statiques, c'est-à-dire à une adresse de fonction fixe en mémoire. Cela permet des optimisations telles que l'inline, car le compilateur sait exactement quelle fonction est appelée. (C’est ce que C ++ fait aussi, parfois appelé "monomorphisation".)

En aller

Go n'autorise la répartition dynamique que pour les fonctions "génériques", c'est-à-dire que l'adresse de la méthode est chargée à partir de la valeur, puis appelée à partir de là; la fonction exacte n'est donc connue qu'au moment de l'exécution. En utilisant l'exemple ci-dessus

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

Maintenant, ces deux call_bars vont toujours appeler ce qui précède call_bar, avec l’adresse barchargée depuis la table vtable de l’interface .

Niveau faible

Pour reformuler ce qui précède, en notation C. La version de Rust crée

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

Pour Go, cela ressemble plus à:

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

(Ce n'est pas tout à fait correct - il doit y avoir plus d'informations dans vtable --- mais l'appel de la méthode étant un pointeur de fonction dynamique est la chose pertinente ici.)

Rust offre le choix

Revenir à

L'approche de Rust permet à l'utilisateur de choisir entre une répartition statique et une répartition dynamique.

Jusqu'ici, je n'ai fait que démontrer que Rust disposait de génériques distribués de manière statique, mais Rust peut s'inscrire pour les produits dynamiques comme Go (avec essentiellement la même implémentation), via des objets trait. Noté comme &Foo, qui est une référence empruntée à un type inconnu qui implémente le Footrait. Ces valeurs ont la même / très similaire représentation de vtable par rapport à l'objet d'interface Go. (Un objet trait est un exemple de "type existentiel" .)

Il existe des cas où la répartition dynamique est vraiment utile (et parfois plus performante, par exemple en réduisant le volume de code / la duplication), mais la répartition statique permet aux compilateurs d’aligner les sites d’appel et d’appliquer toutes leurs optimisations, ce qui signifie qu’elle est normalement plus rapide. Ceci est particulièrement important pour des choses comme le protocole d'itération de Rust , où les appels de méthode de traitement de distribution statique permettent à ces itérateurs d'être aussi rapides que les équivalents de C, tout en semblant de haut niveau et expressifs .

L'approche de Tl; dr: Rust propose une répartition statique et dynamique des génériques, à la discrétion des programmeurs; Go ne permet que l'envoi dynamique.

Polymorphisme paramétrique

En outre, le fait de mettre l'accent sur les traits et d'atténuer la réflexion donne à Rust un polymorphisme paramétrique beaucoup plus fort : le programmeur sait exactement ce qu'une fonction peut faire avec ses arguments, car il doit déclarer les traits que les types génériques implémentent dans la signature de la fonction.

L'approche de Go est très flexible, mais offre moins de garanties aux appelants (ce qui rend le raisonnement plus difficile pour le programmeur), car les éléments internes d'une fonction peuvent (et font) interroger des informations de type supplémentaires (il y avait un bogue dans le Go). bibliothèque standard où, iirc, une fonction prenant un écrivain utiliserait la réflexion pour appeler Flushcertaines entrées, mais pas d’autres).

Construction d'abstractions

C’est un peu un point sensible, je ne parlerai donc que brièvement, mais le fait de posséder des génériques «appropriés» comme Rust l’a permis de stocker des types de données de bas niveau tels que Go mapet []de les implémenter directement dans la bibliothèque standard d’une manière très typée, et écrit en rouille ( HashMapet Vecrespectivement).

Et ce n'est pas seulement ces types, vous pouvez créer des structures génériques sécurisées, par exemple LruCacheune couche de mise en cache générique au-dessus d'une table de hachage. Cela signifie que les utilisateurs peuvent simplement utiliser les structures de données directement à partir de la bibliothèque standard, sans avoir à stocker de données interface{}et à utiliser des assertions de type lors de l'insertion / extraction. C'est-à-dire que si vous avez un LruCache<int, String>, vous avez la garantie que les clés sont toujours ints et les valeurs toujours Strings: il n'y a aucun moyen d'insérer accidentellement la mauvaise valeur (ou d'essayer d'extraire un non String).

huon
la source
Le mien AnyMapest une bonne démonstration des forces de Rust, combinant des objets de trait avec des génériques pour fournir une abstraction sûre et expressive de la chose fragile qui serait nécessairement écrite dans Go map[string]interface{}.
Chris Morgan
Comme je m'y attendais, Rust est plus puissant et offre plus de choix en mode natif / élégant, mais le système de Go est suffisamment proche pour que la plupart des choses manquantes puissent être accomplies avec de petits piratages similaires interface{}. Bien que Rust semble techniquement supérieur, je pense toujours que la critique de Go ... a été un peu trop dure. La puissance du programmeur est comparable à 99% des tâches.
Logan
22
@Logan, pour les domaines de bas niveau / hautes performances que Rust vise (systèmes d’exploitation, navigateurs Web, etc.), ne disposant pas de la possibilité d’envoi statique (et de ses performances / optimisation) cela permet) est inacceptable. C’est l’une des raisons pour lesquelles Go n’est pas aussi approprié que Rust pour ce type d’application. Dans tous les cas, la puissance du programmeur n’est pas vraiment à la hauteur, vous perdez la sécurité de type (temps de compilation) pour toute structure de données réutilisable et non intégrée, en revenant à des assertions de type à l’exécution.
huon
10
C'est tout à fait vrai - Rust vous offre beaucoup plus de puissance. Je considère Rust comme un C ++ sécurisé et Go comme un Python rapide (ou un Java très simplifié). Pour le grand pourcentage de tâches pour lesquelles la productivité des développeurs compte le plus (et des tâches telles que l'exécution et la récupération de place ne sont pas problématiques), choisissez Go (par exemple, serveurs Web, systèmes concurrents, utilitaires de ligne de commande, applications utilisateur, etc.). Si vous avez besoin de toutes les dernières performances (et que la productivité des développeurs soit maudite), choisissez Rust (par exemple, les navigateurs, les systèmes d'exploitation, les systèmes intégrés aux ressources limitées).
weberc2