Qual a diferença entre Rust Traits e Go Interfaces?


64

Estou relativamente familiarizado com o Go, tendo escrito vários pequenos programas nele. Ferrugem, é claro, estou menos familiarizada, mas fico de olho.

Tendo lido recentemente http://yager.io/programming/go.html , pensei em examinar pessoalmente as duas maneiras pelas quais os genéricos são tratados porque o artigo parecia criticar injustamente o Go quando, na prática, não havia muito o que fazer. não poderia realizar de forma elegante. Eu ficava ouvindo o hype sobre o quão poderosos os Rust's Traits eram e nada além de críticas das pessoas sobre Go. Tendo alguma experiência em Go, me perguntei como era verdade e quais eram as diferenças. O que eu descobri foi que Traços e Interfaces são bem parecidos! Por fim, não tenho certeza se estou perdendo alguma coisa, então aqui está um rápido resumo educacional de suas semelhanças para que você possa me dizer o que eu perdi!

Agora, vamos dar uma olhada no Go Interfaces na documentação :

As interfaces no Go fornecem uma maneira de especificar o comportamento de um objeto: se algo puder fazer isso, ele poderá ser usado aqui.

De longe, a interface mais comum é a Stringerque retorna uma string que representa o objeto.

type Stringer interface {
    String() string
}

Portanto, qualquer objeto que tenha String()definido nele é um Stringerobjeto. Isso pode ser usado em assinaturas de tipo que func (s Stringer) print()pegam quase todos os objetos e os imprimem.

Também temos interface{}qual pega qualquer objeto. Devemos então determinar o tipo em tempo de execução através da reflexão.


Agora, vamos dar uma olhada no Rust Traits na documentação deles :

Na sua forma mais simples, uma característica é um conjunto de zero ou mais assinaturas de método. Por exemplo, poderíamos declarar a característica Printable para itens que podem ser impressos no console, com uma única assinatura de método:

trait Printable {
    fn print(&self);
}

Isso imediatamente se parece bastante com as nossas interfaces Go. A única diferença que vejo é que definimos 'Implementações' de Características em vez de apenas definir os métodos. Então nós fazemos

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

ao invés de

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

Pergunta bônus: O que acontece no Rust se você definir uma função que implementa uma característica, mas não a usa impl? Isso simplesmente não funciona?

Diferentemente das Interfaces da Go, o sistema de tipos da Rust possui parâmetros de tipos que permitem fazer genéricos apropriados e coisas do tipo interface{}enquanto o compilador e o tempo de execução realmente conhecem o tipo. Por exemplo,

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

funciona em qualquer tipo e o compilador sabe que o tipo dos elementos Sequence no tempo de compilação em vez de usar reflexão.


Agora, a pergunta real: estou perdendo alguma diferença aqui? Eles são realmente assim ? Não há nenhuma diferença mais fundamental que estou perdendo aqui? (Em uso. Os detalhes da implementação são interessantes, mas no final não são importantes se funcionarem da mesma forma.)

Além das diferenças sintáticas, as diferenças reais que vejo são:

  1. Go possui despacho de método automático vs. Rust requer (?) implS para implementar um Trait
    • Elegante vs. Explícito
  2. Rust possui parâmetros de tipo que permitem genéricos adequados sem reflexão.
    • Go realmente não tem resposta aqui. Essa é a única coisa significativamente mais poderosa e, em última análise, é apenas uma substituição dos métodos de copiar e colar com diferentes assinaturas de tipo.

Essas são as únicas diferenças não triviais? Nesse caso, parece que o sistema de interface / tipo da Go não é, na prática, tão fraco quanto percebido.

Respostas:


59

O que acontece no Rust se você define uma função que implementa uma característica, mas não usa impl? Isso simplesmente não funciona?

Você precisa implementar explicitamente a característica; acontecer de ter um método com nome / assinatura correspondente não faz sentido para o Rust.

Envio genérico de chamadas

Essas são as únicas diferenças não triviais? Nesse caso, parece que o sistema de interface / tipo da Go não é, na prática, tão fraco quanto percebido.

Não fornecer envio estático pode ser um impacto significativo no desempenho de certos casos (por exemplo, o Iteratorque mencionei abaixo). Eu acho que é isso que você quer dizer com

Go realmente não tem resposta aqui. Essa é a única coisa significativamente mais poderosa e, em última análise, é apenas uma substituição dos métodos de copiar e colar com diferentes assinaturas de tipo.

mas falarei mais detalhadamente, porque vale a pena entender profundamente a diferença.

Em Ferrugem

A abordagem da Rust permite ao usuário escolher entre despacho estático e despacho dinâmico . Como exemplo, se você tiver

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

as duas call_barchamadas acima serão compiladas em chamadas para, respectivamente,

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

onde essas .bar()chamadas de método são chamadas de função estáticas, ou seja, para um endereço de função fixo na memória. Isso permite otimizações como inlining, porque o compilador sabe exatamente qual função está sendo chamada. (É isso que o C ++ também faz, às vezes chamado de "monomorfização".)

In Go

Go permite apenas o envio dinâmico para funções "genéricas", ou seja, o endereço do método é carregado a partir do valor e depois chamado a partir daí, portanto, a função exata é conhecida apenas no tempo de execução. Usando o exemplo acima

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

Agora, esses dois call_bars sempre chamarão o acima call_bar, com o endereço barcarregado da tabela de interface .

Nível baixo

Para reformular o acima, em notação C. A versão do Rust cria

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

Para o Go, é algo mais como:

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

(Isso não é exatamente correto - é preciso haver mais informações na vtable - mas a chamada do método como um ponteiro de função dinâmica é a coisa relevante aqui.)

A ferrugem oferece a escolha

Retornando para

A abordagem da Rust permite ao usuário escolher entre despacho estático e despacho dinâmico.

Até agora, só demonstrei que o Rust tinha enviado genéricos estaticamente, mas o Rust pode optar por dinâmicos como Go (com essencialmente a mesma implementação), por meio de objetos de características. Notado como &Foo, que é uma referência emprestada a um tipo desconhecido que implementa a Foocaracterística. Esses valores têm a mesma / representação de tabela muito semelhante ao objeto de interface Go. (Um objeto de característica é um exemplo de um "tipo existencial" .)

Há casos em que o despacho dinâmico é realmente útil (e algumas vezes mais eficiente, por exemplo, reduzindo o inchaço / duplicação de código), mas o despacho estático permite que os compiladores incorporem os callites e apliquem todas as suas otimizações, o que significa que normalmente é mais rápido. Isso é especialmente importante para coisas como o protocolo de iteração da Rust , em que o método de característica de despacho estático permite que esses iteradores sejam tão rápidos quanto os equivalentes C, embora pareçam de alto nível e expressivos .

Tl; dr: A abordagem da Rust oferece expedição estática e dinâmica em genéricos, a critério dos programadores; Ir apenas permite o envio dinâmico.

Polimorfismo paramétrico

Além disso, enfatizar traços e enfatizar a reflexão dá ao Rust um polimorfismo paramétrico muito mais forte : o programador sabe exatamente o que uma função pode fazer com seus argumentos, porque precisa declarar os traços que os tipos genéricos implementam na assinatura da função.

A abordagem do Go é muito flexível, mas tem menos garantias para os chamadores (dificultando a discussão do programador), porque os internos de uma função podem (e fazem) consultar informações adicionais do tipo (houve um erro no Go biblioteca padrão em que, iirc, uma função que usa um escritor usaria reflexão para chamar Flushalgumas entradas, mas outras não).

Construindo abstrações

Isso é um pouco doloroso, portanto, falarei apenas brevemente, mas ter genéricos "adequados" como o Rust permite que tipos de dados de baixo nível como o Go mape []sejam realmente implementados diretamente na biblioteca padrão de uma maneira altamente tipicamente segura e escrito em Rust ( HashMape Vecrespectivamente).

E não são apenas esses tipos, você pode criar estruturas genéricas seguras para tipos sobre elas, por exemplo, LruCacheé uma camada de cache genérica sobre um mapa de hash. Isso significa que as pessoas podem simplesmente usar as estruturas de dados diretamente da biblioteca padrão, sem precisar armazenar dados interface{}e usar asserções de tipo ao inserir / extrair. Ou seja, se você possui um LruCache<int, String>, você tem a garantia de que as chaves são sempre se intos valores são sempre Strings: não há como inserir acidentalmente o valor errado (ou tentar extrair um não String).


Minha própria AnyMapé uma boa demonstração dos pontos fortes do Rust, combinando objetos de características com genéricos para fornecer uma abstração segura e expressiva da coisa frágil que em Go seria necessariamente escrita map[string]interface{}.
9789 Chris Morgan

Como eu esperava, o Rust é mais poderoso e oferece mais opções de forma nativa / elegante, mas o sistema da Go é próximo o suficiente para que a maioria das coisas que falta podem ser realizadas com pequenos hacks como interface{}. Embora Rust pareça tecnicamente superior, ainda acho que as críticas a Go ... têm sido um pouco severas. O poder do programador está praticamente igual a 99% das tarefas.
Logan

22
@Logan, para os domínios de baixo nível / alto desempenho que a Rust está almejando (por exemplo, sistemas operacionais, navegadores da web ... o principal material de programação de "sistemas"), não tendo a opção de envio estático (e desempenho que fornece / otimiza permite) é inaceitável. Esse é um dos motivos pelos quais o Go não é tão adequado quanto o Rust para esse tipo de aplicativo. De qualquer forma, o poder do programador não está realmente em pé de igualdade, você perde a segurança do tipo (tempo de compilação) para qualquer estrutura de dados reutilizável e não incorporada, voltando às asserções do tipo de tempo de execução.
huon

10
É exatamente isso: o Rust oferece muito mais potência. Penso no Rust como um C ++ seguro e no Go como um Python rápido (ou um Java bastante simplificado). Para a grande porcentagem de tarefas em que a produtividade do desenvolvedor é mais importante (e coisas como tempos de execução e coleta de lixo não são problemáticas), escolha Ir (por exemplo, servidores da web, sistemas simultâneos, utilitários de linha de comando, aplicativos de usuário etc.). Se você precisar de todos os últimos detalhes de desempenho (e a produtividade do desenvolvedor for reduzida), escolha Rust (por exemplo, navegadores, sistemas operacionais, sistemas embarcados com recursos limitados).
precisa saber é o seguinte
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.