¿En qué se diferencian los rasgos de óxido de las interfaces Go?

64

Estoy relativamente familiarizado con Go, habiendo escrito una serie de pequeños programas en él. Rust, por supuesto, estoy menos familiarizado pero vigilando.

Después de leer recientemente http://yager.io/programming/go.html , pensé en examinar personalmente las dos formas en que se manejan los genéricos porque el artículo parecía criticar injustamente a Go cuando, en la práctica, no había mucho que Interfaces no pudo lograr con elegancia. Seguía escuchando la exageración sobre cuán poderosos eran los Rasgos de Rust y nada más que las críticas de la gente sobre Go. Teniendo algo de experiencia en Go, me preguntaba qué tan cierto era y cuáles eran las diferencias en última instancia. ¡Lo que encontré fue que los rasgos e interfaces son bastante similares! En última instancia, no estoy seguro de si me estoy perdiendo algo, así que aquí hay un resumen educativo rápido de sus similitudes para que pueda decirme lo que me perdí.

Ahora, echemos un vistazo a Go Interfaces desde su documentación :

Las interfaces en Go proporcionan una forma de especificar el comportamiento de un objeto: si algo puede hacer esto, entonces puede usarse aquí.

Con mucho, la interfaz más común es la Stringerque devuelve una cadena que representa el objeto.

type Stringer interface {
    String() string
}

Entonces, cualquier objeto que se haya String()definido en él es un Stringerobjeto. Esto se puede usar en firmas de tipo tal que func (s Stringer) print()tome casi todos los objetos y los imprima.

También tenemos el interface{}que lleva cualquier objeto. Luego debemos determinar el tipo en tiempo de ejecución a través de la reflexión.


Ahora, echemos un vistazo a Rust Traits de su documentación :

En su forma más simple, un rasgo es un conjunto de cero o más firmas de método. Por ejemplo, podríamos declarar el rasgo Imprimible para cosas que se pueden imprimir en la consola, con una firma de método único:

trait Printable {
    fn print(&self);
}

Esto inmediatamente se parece bastante a nuestras interfaces Go. La única diferencia que veo es que definimos 'Implementaciones' de Rasgos en lugar de solo definir los métodos. Entonces lo hacemos

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

en lugar de

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

Pregunta adicional: ¿Qué sucede en Rust si define una función que implementa un rasgo pero no la usa impl? ¿Simplemente no funciona?

A diferencia de las interfaces de Go, el sistema de tipos de Rust tiene parámetros de tipo que le permiten hacer genéricos adecuados y cosas como interface{}mientras el compilador y el tiempo de ejecución realmente conocen el tipo. Por ejemplo,

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

funciona en cualquier tipo y el compilador sabe que el tipo de los elementos de secuencia en tiempo de compilación en lugar de usar la reflexión.


Ahora, la pregunta real: ¿me estoy perdiendo alguna diferencia aquí? ¿Son realmente tan similares? ¿No hay alguna diferencia más fundamental que me estoy perdiendo aquí? (En uso. Los detalles de implementación son interesantes, pero en última instancia no son importantes si funcionan de la misma manera).

Además de las diferencias sintácticas, las diferencias reales que veo son:

  1. Go tiene un envío automático de métodos vs. Rust requiere (?) implS para implementar un Rasgo
    • Elegante vs explícito
  2. Rust tiene parámetros de tipo que permiten genéricos adecuados sin reflexión.
    • Ir realmente no tiene respuesta aquí. Esto es lo único que es significativamente más poderoso y, en última instancia, es solo un reemplazo para copiar y pegar métodos con diferentes tipos de firmas.

¿Son estas las únicas diferencias no triviales? Si es así, parecería que el sistema de interfaz / tipo de Go, en la práctica, no es tan débil como se percibe.

Logan
fuente

Respuestas:

59

¿Qué sucede en Rust si define una función que implementa un rasgo pero no usa impl? ¿Simplemente no funciona?

Necesita implementar explícitamente el rasgo; tener un método que coincida con el nombre / firma no tiene sentido para Rust.

Despacho genérico de llamadas

¿Son estas las únicas diferencias no triviales? Si es así, parecería que el sistema de interfaz / tipo de Go, en la práctica, no es tan débil como se percibe.

No proporcionar un envío estático puede ser un impacto significativo en el rendimiento para ciertos casos (por ejemplo, el Iteratorque menciono a continuación). Creo que esto es lo que quieres decir con

Ir realmente no tiene respuesta aquí. Esto es lo único que es significativamente más poderoso y, en última instancia, es solo un reemplazo para copiar y pegar métodos con diferentes tipos de firmas.

pero lo cubriré con más detalle, porque vale la pena entender la diferencia profundamente.

En óxido

El enfoque de Rust permite al usuario elegir entre despacho estático y despacho dinámico . Como ejemplo, si tienes

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

entonces las dos call_barllamadas anteriores se compilarán para llamadas a, respectivamente,

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

donde esas .bar()llamadas a métodos son llamadas a funciones estáticas, es decir, a una dirección de función fija en la memoria. Esto permite optimizaciones como la alineación, porque el compilador sabe exactamente qué función se llama. (Esto es lo que hace C ++ también, a veces llamado "monomorfización").

En ir

Go solo permite el despacho dinámico para funciones "genéricas", es decir, la dirección del método se carga desde el valor y luego se llama desde allí, por lo que la función exacta solo se conoce en tiempo de ejecución. Usando el ejemplo anterior

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

Ahora, esos dos call_bars siempre llamarán a lo anterior call_bar, con la dirección de barcargado desde la vtable de la interfaz .

Nivel bajo

Para reformular lo anterior, en notación C. La versión de 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;
}

Para Go, es algo más 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);
}

(Esto no es exactamente correcto --- debe haber más información en la tabla vtable --- pero la llamada al método como puntero de función dinámica es lo relevante aquí).

Rust ofrece la opción

Volviendo a

El enfoque de Rust permite al usuario elegir entre despacho estático y despacho dinámico.

Hasta ahora solo he demostrado que Rust tiene genéricos despachados estáticamente, pero Rust puede optar por los dinámicos como Go (con esencialmente la misma implementación), a través de objetos de rasgo. Notated like &Foo, que es una referencia prestada a un tipo desconocido que implementa el Foorasgo. Estos valores tienen la misma representación vtable muy similar al objeto de interfaz Go. (Un objeto de rasgo es un ejemplo de un "tipo existencial" ).

Hay casos en los que el despacho dinámico es realmente útil (y a veces más eficaz, por ejemplo, reduciendo la hinchazón / duplicación de código), pero el despacho estático permite a los compiladores en línea los sitios de llamadas y aplicar todas sus optimizaciones, lo que significa que normalmente es más rápido. Esto es especialmente importante para cosas como el protocolo de iteración de Rust , donde las llamadas de método de rasgo de despacho estático permiten que esos iteradores sean tan rápidos como los equivalentes en C, sin dejar de parecer de alto nivel y expresivos .

Tl; dr: el enfoque de Rust ofrece despacho estático y dinámico en genéricos, a discreción de los programadores; Ir solo permite el despacho dinámico.

Polimorfismo paramétrico

Además, enfatizar los rasgos y enfatizar la reflexión le da a Rust un polimorfismo paramétrico mucho más fuerte : el programador sabe exactamente qué puede hacer una función con sus argumentos, porque tiene que declarar los rasgos que los tipos genéricos implementan en la firma de la función.

El enfoque de Go es muy flexible, pero tiene menos garantías para las personas que llaman (lo que hace que sea más difícil para el programador razonar), porque las partes internas de una función pueden (y lo hacen) consultar información de tipo adicional (hubo un error en Go biblioteca estándar donde, iirc, una función que toma un escritor usaría la reflexión para invocar Flushalgunas entradas, pero no otras).

Construyendo abstracciones

Esto es algo así como un punto delicado, por lo que sólo va a hablar brevemente, pero con los genéricos "adecuados" como Rust tiene permite que los tipos de datos de bajo nivel como Go mapy []para realmente ser implementado directamente en la biblioteca estándar de una manera fuertemente typesafe, y escrito en Rust ( HashMapy Vecrespectivamente).

Y no se trata solo de esos tipos, puede construir estructuras genéricas con seguridad de tipos encima de ellas, por ejemplo, LruCachehay una capa de almacenamiento en caché genérica sobre un hashmap. Esto significa que las personas pueden usar las estructuras de datos directamente desde la biblioteca estándar, sin tener que almacenar datos como interface{}y usar aserciones de tipo al insertar / extraer. Es decir, si tiene un LruCache<int, String>, tiene la garantía de que las claves son siempre intsy los valores son siempre Strings: no hay forma de insertar accidentalmente el valor incorrecto (o tratar de extraer un no String).

huon
fuente
La mía AnyMapes una buena demostración de las fortalezas de Rust, combinando objetos de rasgos con genéricos para proporcionar una abstracción segura y expresiva de lo frágil que necesariamente se escribiría en Go map[string]interface{}.
Chris Morgan
Como esperaba, Rust es más potente y ofrece más opciones de forma nativa / elegante, pero el sistema de Go es lo suficientemente cercano como para que la mayoría de las cosas que se pierden puedan lograrse con pequeños hacks como interface{}. Si bien Rust parece técnicamente superior, sigo pensando que las críticas a Go ... han sido demasiado duras. El poder del programador está más o menos a la par para el 99% de las tareas.
Logan
22
@Logan, para los dominios de bajo nivel / alto rendimiento que Rust está buscando (por ejemplo, sistemas operativos, navegadores web ... el núcleo de programación de "sistemas"), no tener la opción de envío estático (y el rendimiento que brinda / optimización permite) es inaceptable. Es una de las razones por las que Go no es tan adecuado como Rust para ese tipo de aplicaciones. En cualquier caso, el poder del programador no está realmente a la par, pierde la seguridad de tipo (tiempo de compilación) para cualquier estructura de datos reutilizable y no incorporada, volviendo a las afirmaciones de tipo de tiempo de ejecución.
huon
10
Eso es exactamente correcto: Rust le ofrece mucha más potencia. Pienso en Rust como un C ++ seguro, y Go como un Python rápido (o un Java enormemente simplificado). Para el gran porcentaje de tareas donde la productividad del desarrollador es más importante (y cosas como los tiempos de ejecución y la recolección de basura no son problemáticos), elija Ir (por ejemplo, servidores web, sistemas concurrentes, utilidades de línea de comandos, aplicaciones de usuario, etc.). Si necesita hasta el último bit de rendimiento (y la productividad del desarrollador sea condenada), elija Rust (por ejemplo, navegadores, sistemas operativos, sistemas integrados con recursos limitados).
weberc2