Czym różnią się cechy rdzy od interfejsów Go?

64

Go znam stosunkowo dobrze, ponieważ napisałem w nim wiele małych programów. Rdza, oczywiście, jestem mniej obeznana, ale pilnuje.

Po niedawnym przeczytaniu http://yager.io/programming/go.html pomyślałem, że osobiście zbadam dwa sposoby postępowania z lekami generycznymi, ponieważ artykuł wydawał się niesłusznie krytykować Go, gdy w praktyce niewiele było interfejsów nie udało się osiągnąć elegancko. Ciągle słyszałem szum o tym, jak potężne były Cechy Rdza i tylko krytykę ludzi na temat Go. Mając pewne doświadczenie w Go, zastanawiałem się, jak to była prawda i jakie były ostatecznie różnice. Odkryłem, że cechy i interfejsy są bardzo podobne! Ostatecznie nie jestem pewien, czy coś mi umknęło, więc oto krótki przegląd ich podobieństw, abyś mógł mi powiedzieć, co przegapiłem!

Teraz spójrzmy na interfejsy Go z ich dokumentacji :

Interfejsy w Go umożliwiają sposób określania zachowania obiektu: jeśli coś może to zrobić, można go tutaj użyć.

Zdecydowanie najczęściej spotykanym interfejsem jest Stringerciąg znaków reprezentujący obiekt.

type Stringer interface {
    String() string
}

Tak więc każdy obiekt, który się String()na nim zdefiniował, jest Stringerobiektem. Można tego użyć w podpisach typu, które func (s Stringer) print()pobierają prawie wszystkie obiekty i drukują je.

Mamy również, interface{}który bierze dowolny przedmiot. Następnie musimy określić typ w czasie wykonywania przez odbicie.


Teraz spójrzmy na cechy rdzy z ich dokumentacji :

Najprościej mówiąc, cechą jest zbiór zer lub więcej sygnatur metod. Na przykład, moglibyśmy zadeklarować cechę Printable dla rzeczy, które można wydrukować na konsoli, za pomocą jednej sygnatury metody:

trait Printable {
    fn print(&self);
}

To natychmiast wygląda całkiem podobnie do naszych interfejsów Go. Jedyną różnicą, którą widzę, jest to, że definiujemy „Implementacje” Cech, a nie tylko definiujemy metody. Tak robimy

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

zamiast

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

Pytanie dodatkowe: Co dzieje się w Rust, jeśli zdefiniujesz funkcję, która implementuje cechę, ale nie używasz impl? To po prostu nie działa?

W przeciwieństwie do interfejsów Go, system typów Rust'a ma parametry typu, które pozwalają ci robić właściwe ogólne i takie rzeczy, jak interface{}podczas gdy kompilator i środowisko wykonawcze faktycznie znają typ. Na przykład,

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

działa na dowolnym typie, a kompilator wie, że typ elementów Sekwencji w czasie kompilacji zamiast używać odbicia.


Teraz pytanie: czy brakuje mi tutaj jakichkolwiek różnic? Są one naprawdę , że podobna? Czy nie brakuje mi bardziej fundamentalnej różnicy? (W użyciu. Szczegóły implementacji są interesujące, ale ostatecznie nie są ważne, jeśli działają tak samo.)

Oprócz różnic składniowych, rzeczywiste różnice, które widzę, to:

  1. Go ma automatyczną metodę wysyłania metod vs. Rust wymaga (?) implS do implementacji cechy
    • Elegancki kontra wyraźny
  2. Rdza ma parametry typu, które pozwalają na prawidłowe generyczne bez odbicia.
    • Go naprawdę nie ma tutaj odpowiedzi. Jest to jedyna rzecz, która jest znacznie bardziej wydajna i ostatecznie zastępuje metody kopiowania i wklejania z różnymi typami podpisów.

Czy to jedyne nietrywialne różnice? Jeśli tak, wydaje się, że system interfejsu / typu Go nie jest w praktyce tak słaby, jak się spostrzega.

Logan
źródło

Odpowiedzi:

59

Co dzieje się w Rust, jeśli zdefiniujesz funkcję, która implementuje cechę, ale nie używasz impl? To po prostu nie działa?

Musisz wyraźnie wdrożyć tę cechę; zdarza się, że metoda z dopasowaniem nazwy / podpisu nie ma znaczenia dla Rust.

Ogólne wysyłanie połączeń

Czy to jedyne nietrywialne różnice? Jeśli tak, wydaje się, że system interfejsu / typu Go nie jest w praktyce tak słaby, jak się spostrzega.

Brak zapewnienia wysyłki statycznej może być znaczącym spadkiem wydajności w niektórych przypadkach (np. Ten, o którym Iteratorwspomniałem poniżej). Myślę, że o to ci chodzi

Go naprawdę nie ma tutaj odpowiedzi. Jest to jedyna rzecz, która jest znacznie bardziej wydajna i ostatecznie zastępuje metody kopiowania i wklejania z różnymi typami podpisów.

ale omówię to bardziej szczegółowo, ponieważ warto głęboko zrozumieć różnicę.

W Rust

Podejście Rdza pozwala użytkownikowi wybrać pomiędzy wysyłką statyczną a dynamiczną . Jako przykład, jeśli masz

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

wówczas dwa call_barpowyższe wywołania zostaną skompilowane odpowiednio z wywołaniami

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

gdzie te .bar()wywołania metod są wywołaniami funkcji statycznych, tj. na stały adres funkcji w pamięci. Pozwala to na optymalizacje takie jak wstawianie, ponieważ kompilator dokładnie wie , która funkcja jest wywoływana. (To właśnie robi C ++, czasami nazywane „monomorfizacją”).

In Go

Go umożliwia dynamiczne wysyłanie tylko dla funkcji „ogólnych”, tzn. Adres metody jest ładowany z wartości, a następnie wywoływany stamtąd, więc dokładna funkcja jest znana tylko w czasie wykonywania. Korzystając z powyższego przykładu

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

Teraz te dwa call_barzawsze będą wywoływać powyższe call_bar, z adresem barzaładowanym z vtable interfejsu .

Niski poziom

Aby przeformułować powyższe, w notacji C. Tworzy się wersja Rust

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

W przypadku Go jest to coś więcej:

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

(To nie jest dokładnie właściwe --- musi być więcej informacji w vtable --- ale wywołanie metody będące wskaźnikiem funkcji dynamicznej jest tutaj istotne.)

Rdza oferuje wybór

Wracając do

Podejście Rdza pozwala użytkownikowi wybrać pomiędzy wysyłką statyczną a dynamiczną.

Jak dotąd pokazałem tylko, że Rust ma statycznie wysyłane leki generyczne, ale Rust może włączyć dynamiczne, takie jak Go (z zasadniczo tą samą implementacją), poprzez obiekty cech. Zapisany jako &Foo, który jest zapożyczonym odniesieniem do nieznanego typu, który implementuje tę Foocechę. Te wartości mają taką samą / bardzo podobną reprezentację vtable jak obiekt interfejsu Go. (Obiekt cech jest przykładem „typu egzystencjalnego” .)

Są przypadki, w których dynamiczne wysyłanie jest naprawdę pomocne (a czasami bardziej wydajne, np. Poprzez zmniejszenie rozdęcia / duplikacji kodu), ale statyczne wysyłanie pozwala kompilatorom na wstawianie stron wywoławczych i stosowanie wszystkich ich optymalizacji, co oznacza, że ​​zwykle jest szybszy. Jest to szczególnie ważne w takich rzeczach, jak protokół iteracyjny Rust'a , gdzie wywołania statycznej metody cechy dyspozytorskiej pozwalają tym iteratorom być tak szybkim jak ekwiwalenty C, jednocześnie zachowując wysoki poziom ekspresji .

Tl; dr: Podejście Rust oferuje zarówno statyczne, jak i dynamiczne wysyłanie w generics, według uznania programistów; Go pozwala tylko na dynamiczną wysyłkę.

Polimorfizm parametryczny

Co więcej, podkreślanie cech i odrzucanie refleksji daje Rustowi znacznie silniejszy polimorfizm parametryczny : programista wie dokładnie, co funkcja może zrobić z argumentami, ponieważ musi zadeklarować cechy, które typy ogólne zaimplementują w sygnaturze funkcji.

Podejście Go jest bardzo elastyczne, ale ma mniej gwarancji dla wywołujących (co nieco utrudnia programistom rozumowanie), ponieważ elementy wewnętrzne funkcji mogą (i robią) zapytania o dodatkowe informacje o typie (wystąpił błąd w Go standardowa biblioteka, w której, iirc, funkcja pisząca użyłaby refleksji do wywołania Flushniektórych danych wejściowych, ale nie innych).

Budowanie abstrakcji

Jest to nieco boląca kwestia, więc porozmawiam tylko krótko, ale posiadanie „właściwych” rodzajów ogólnych, takich jak Rust, pozwala na stosowanie typów danych niskiego poziomu, takich jak Go, mapi []może być zaimplementowane bezpośrednio w standardowej bibliotece w sposób bardzo bezpieczny i napisane w Rust ( HashMapi Vecodpowiednio).

I to nie tylko te typy, możesz na nich budować ogólne struktury bezpieczne dla typu, np. LruCacheJest to ogólna warstwa buforująca na szczycie mapy skrótów. Oznacza to, że ludzie mogą po prostu korzystać ze struktur danych bezpośrednio ze standardowej biblioteki, bez konieczności przechowywania danych jako interface{}i korzystania z asercji typu podczas wstawiania / wyciągania. Oznacza to, że jeśli masz LruCache<int, String>, masz gwarancję, że klucze są zawsze ints, a wartości są zawsze Strings: nie ma sposobu, aby przypadkowo wstawić niewłaściwą wartość (lub spróbować wyodrębnić wartość inną niż String).

huon
źródło
Mój własny AnyMapjest dobrym pokazem mocnych stron Rdzy, łącząc obiekty cech z generycznymi, aby zapewnić bezpieczną i ekspresyjną abstrakcję delikatnej rzeczy, którą w Go z konieczności trzeba by napisać map[string]interface{}.
Chris Morgan
Tak jak się spodziewałem, Rust jest mocniejszy i oferuje większy wybór natywny / elegancki, ale system Go jest wystarczająco blisko, aby większość rzeczy, których brakuje, można osiągnąć za pomocą małych hacków interface{}. Choć Rust wydaje się technicznie lepszy, nadal uważam, że krytyka Go ... była zbyt surowa. Moc programisty jest prawie równa 99% zadań.
Logan,
22
@Logan, w przypadku domen niskiego poziomu / wysokiej wydajności, których Rust szuka (np. Systemów operacyjnych, przeglądarek internetowych ... podstawowych rzeczy programistycznych "systemowych"), nie mając opcji statycznej wysyłki (i wydajności, którą daje / optymalizuje to pozwala) jest niedopuszczalne. Jest to jeden z powodów, dla których Go nie jest tak odpowiedni jak Rust do tego rodzaju zastosowań. W każdym razie moc programisty nie jest naprawdę na równi, tracisz bezpieczeństwo typu (czas kompilacji) dla wszelkich struktur danych wielokrotnego użytku i niewbudowanych, wracając do asercji typu wykonawczego.
huon
10
Dokładnie tak - Rust oferuje o wiele więcej mocy. Myślę o Rust jako o bezpiecznym C ++, a Go o szybkim Pythonie (lub znacznie uproszczonej Javie). W przypadku dużego odsetka zadań, w których produktywność programistów ma największe znaczenie (a takie elementy jak środowiska wykonawcze i odśmiecanie nie są problematyczne), wybierz opcję Go (np. Serwery WWW, systemy współbieżne, narzędzia wiersza poleceń, aplikacje użytkownika itp.). Jeśli potrzebujesz co najmniej wydajności (i cholernej produktywności programistów), wybierz Rust (np. Przeglądarki, systemy operacyjne, systemy osadzone o ograniczonych zasobach).
weberc2