Question

I am relatively familiar with Go, having written a number of small programs in it. Rust, of course, I am less familiar with but keeping an eye on.

Having recently read http://yager.io/programming/go.html, I thought I'd personally examine the two ways Generics are handled because the article seemed to unfairly criticize Go when, in practice, there wasn't much that Interfaces couldn't accomplish elegantly. I kept hearing the hype about how powerful Rust's Traits were and nothing but criticism from people about Go. Having some experience in Go, I wondered how true it was and what the differences ultimately were. What I found was that Traits and Interfaces are pretty similar! Ultimately, I'm not sure if I'm missing something, so here is a quick educational rundown of their similarities so you can tell me what I missed!

Now, let's take a look at Go Interfaces from their documentation:

Interfaces in Go provide a way to specify the behavior of an object: if something can do this, then it can be used here.

By far the most common interface is Stringer which returns a string representing the object.

type Stringer interface {
    String() string
}

So, any object that has String() defined on it is a Stringer object. This can be used in type signatures such that func (s Stringer) print() takes almost all objects and prints them.

We also have interface{} which takes any object. We must then determine the type at runtime through reflection.


Now, let's take a look at Rust Traits from their documentation:

At its simplest, a trait is a set of zero or more method signatures. For example, we could declare the trait Printable for things that can be printed to the console, with a single method signature:

trait Printable {
    fn print(&self);
}

This immediately looks quite similar to our Go Interfaces. The only difference I see is that we define 'Implementations' of Traits rather than just defining the methods. So, we do

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

instead of

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

Bonus Question: What happens in Rust if you define a function that implements a trait but you don't use impl? It just doesn't work?

Unlike Go's Interfaces, Rust's type system has type parameters which let you do proper generics and things like interface{} while the compiler and the runtime actually know the type. For example,

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

works on any type and the compiler knows that the type of the Sequence elements at compile time rather than using reflection.


Now, the actual question: am I missing any differences here? Are they really that similar? Is there not some more fundamental difference that I'm missing here? (In usage. Implementation details are interesting, but ultimately not important if they function the same.)

Besides syntactic differences, the actual differences I see are:

  1. Go has automatic method dispatch vs. Rust requires(?) impls to implement a Trait
    • Elegant vs. Explicit
  2. Rust has type parameters which allow for proper generics without reflection.
    • Go really has no response here. This is the only thing that is significantly more powerful and it's ultimately just a replacement for copying and pasting methods with different type signatures.

Are these the only non-trivial differences? If so, it would appear Go's Interface/Type system is, in practice, not as weak as perceived.

Was it helpful?

Solution

What happens in Rust if you define a function that implements a trait but you don't use impl? It just doesn't work?

You need to explicitly implement the trait; happening to have a method with matching name/signature is meaningless for Rust.

Generic call dispatching

Are these the only non-trivial differences? If so, it would appear Go's Interface/Type system is, in practice, not as weak as perceived.

Not providing static dispatch can be a significant performance hit for certain cases (e.g. the Iterator one I mention below). I think this is what you mean by

Go really has no response here. This is the only thing that is significantly more powerful and it's ultimately just a replacement for copying and pasting methods with different type signatures.

but I'll cover it in more detail, because it's worth understanding the difference deeply.

In Rust

Rust's approach allows for the user to choose between static dispatch and dynamic dispatch. As an example, if you have

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

then the two call_bar calls above will compile to calls to, respectively,

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

where those .bar() method calls are static function calls, i.e. to a fixed function address in memory. This allows for optimisations like inlining, because the compiler knows exactly which function is being called. (This is what C++ does too, sometimes called "monomorphisation".)

In Go

Go only allows dynamic dispatch for "generic" functions, that is, the method address is loaded from the value and then called from there, so the exact function is only known at runtime. Using the example above

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

Now, those two call_bars will always be calling the above call_bar, with the address of bar loaded from the interface's vtable.

Low-level

To rephrase the above, in C notation. Rust's version creates

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

For Go, it's something more like:

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

(This isn't exactly right---there has to be more information in the vtable---but the method call being a dynamic function pointer is the relevant thing here.)

Rust offers the choice

Going back to

Rust's approach allows for the user to choose between static dispatch and dynamic dispatch.

So far I've only demonstrated Rust having statically dispatched generics, but Rust can opt-in to the dynamic ones like Go (with essentially the same implementation), via trait objects. Notated like &Foo, which is a borrowed reference to an unknown type that implements the Foo trait. These values have the same/very similar vtable representation to the Go interface object. (A trait object is an example of an "existential type".)

There are instances where dynamic dispatch is really helpful (and sometimes more performant, by, e.g. reducing code bloat/duplication), but static dispatch allows compilers to inline the callsites and apply all their optimisations, meaning it is normally faster. This is especially important for things like Rust's iteration protocol, where static dispatching trait method calls allows for those iterators to be as fast as the C equivalents, while still seeming high-level and expressive.

Tl;dr: Rust's approach offers both static and dynamic dispatch in generics, at the programmers discretion; Go only allows for dynamic dispatch.

Parametric polymorphism

Furthermore, emphasising traits and deemphasising reflection gives Rust much stronger parametric polymorphism: the programmer knows exactly what a function can do with its arguments, because it has to declare the traits the generic types implement in the function signature.

Go's approach is very flexible, but has fewer guarantees for the callers (making it somewhat harder for the programmer to reason about), because the internals of a function can (and do) query for additional type information (there was a bug in the Go standard library where, iirc, a function taking a writer would use reflection to call Flush on some inputs, but not others).

Building abstractions

This is somewhat of a sore point, so I'll only talk briefly, but having "proper" generics like Rust has allows for low level data types like Go's map and [] to actually be implemented directly in the standard library in a strongly typesafe way, and written in Rust (HashMap and Vec respectively).

And its not just those types, you can build type-safe generic structures on top of them, e.g. LruCache is a generic caching layer on top of a hashmap. This means people can just use the data structures directly from the standard library, without having to store data as interface{} and use type assertions when inserting/extracting. That is, if you have an LruCache<int, String>, you're guaranteed that the keys are always ints and the values are always Strings: there's no way to accidentally insert the wrong value (or try to extract a non-String).

Licensed under: CC-BY-SA with attribution
scroll top