Чем черты Rust отличаются от интерфейсов Go?

64

Я относительно знаком с Go, написав в нем несколько небольших программ. Руст, конечно, я менее знаком с, но следить за.

Прочитав недавно http://yager.io/programming/go.html , я подумал, что лично я рассмотрю два способа обработки Generics, потому что статья, похоже, несправедливо критикует Go, когда на практике интерфейсов было немного не мог сделать элегантно. Я продолжал слышать шумиху о том, насколько сильны черты Руста, и не что иное, как критика людей о Го. Имея некоторый опыт в го, я удивлялся, насколько это было правдой и каковы были различия в конечном итоге. Я обнаружил, что черты и интерфейсы очень похожи! В конечном счете, я не уверен, что что-то упустил, так что вот краткое образовательное краткое изложение их сходства, так что вы можете сказать мне, что я пропустил!

Теперь давайте посмотрим на интерфейсы Go из их документации :

Интерфейсы в Go предоставляют способ указать поведение объекта: если что-то может сделать это, то его можно использовать здесь.

Безусловно, самый распространенный интерфейс - Stringerэто строка, представляющая объект.

type Stringer interface {
    String() string
}

Таким образом, любой объект, который String()определен на нем, является Stringerобъектом. Это может быть использовано в сигнатурах типа, которые func (s Stringer) print()берут почти все объекты и печатают их.

У нас также есть, interface{}который принимает любой объект. Затем мы должны определить тип во время выполнения через отражение.


Теперь давайте взглянем на Rust Traits из их документации :

В простейшем случае признак - это набор из нуля или более сигнатур метода. Например, мы могли бы объявить черту Printable для вещей, которые можно распечатать на консоли, с единственной сигнатурой метода:

trait Printable {
    fn print(&self);
}

Это сразу выглядит очень похоже на наши интерфейсы Go. Единственное отличие, которое я вижу, состоит в том, что мы определяем «реализации» черт, а не просто определяем методы. Итак, мы делаем

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

вместо

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

Дополнительный вопрос: что происходит в Rust, если вы определяете функцию, которая реализует черту, но вы не используете impl? Это просто не работает?

В отличие от интерфейсов Go, система типов в Rust имеет параметры типа, которые позволяют вам делать правильные обобщения и тому подобное, в interface{}то время как компилятор и среда выполнения фактически знают тип. Например,

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

работает с любым типом, и компилятор знает, что тип элементов Sequence во время компиляции, а не с использованием отражения.


Теперь актуальный вопрос: я здесь скучаю? Являются ли они на самом деле , что подобное? Разве нет более фундаментальной разницы, что я здесь упускаю? (При использовании. Детали реализации интересны, но в конечном итоге не важны, если они функционируют одинаково.)

Помимо синтаксических различий, фактические различия, которые я вижу:

  1. Go имеет автоматическую отправку метода против Rust требует (?) implДля реализации черты
    • Элегантный против Явного
  2. В Rust есть параметры типа, которые позволяют создавать собственные дженерики без отражения.
    • Go действительно не имеет ответа здесь. Это единственное, что значительно более мощно, и в конечном итоге это просто замена для копирования и вставки методов с разными типами подписей.

Это единственные нетривиальные различия? Если так, то кажется, что система интерфейса / типа Go на практике не так слаба, как кажется.

логан
источник

Ответы:

59

Что происходит в Rust, если вы определяете функцию, которая реализует черту, но вы не используете impl? Это просто не работает?

Вы должны явно реализовать черту; случается, что метод с соответствующим именем / подписью не имеет смысла для Rust.

Общая диспетчеризация вызовов

Это единственные нетривиальные различия? Если так, то кажется, что система интерфейса / типа Go на практике не так слаба, как кажется.

Отсутствие статической диспетчеризации может быть значительным ударом по производительности для определенных случаев (например, тот, Iteratorкоторый я упомянул ниже). Я думаю, это то, что вы подразумеваете под

Go действительно не имеет ответа здесь. Это единственное, что значительно более мощно, и в конечном итоге это просто замена для копирования и вставки методов с разными типами подписей.

но я расскажу об этом более подробно, потому что стоит глубоко понимать разницу.

В ржавчине

Подход Rust позволяет пользователю выбирать между статической отправкой и динамической отправкой . Например, если у вас есть

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

тогда два call_barвышеуказанных вызова будут скомпилированы в вызовы, соответственно,

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

где эти .bar()вызовы методов являются статическими вызовами функций, то есть с фиксированным адресом функции в памяти. Это позволяет оптимизировать, например, встраивание, потому что компилятор точно знает , какая функция вызывается. (Это то, что делает C ++, иногда называемый «мономорфизацией».)

На ходу

Go разрешает динамическую диспетчеризацию только для «универсальных» функций, то есть адрес метода загружается из значения и затем вызывается оттуда, поэтому точная функция известна только во время выполнения. Используя пример выше

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

Теперь эти два call_barвсегда будут вызывать вышеуказанное call_barс адресом, barзагруженным из vtable интерфейса .

Низкий уровень

Перефразируя вышесказанное, в С обозначении. Версия 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;
}

Для Go это больше похоже на:

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

(Это не совсем верно - в vtable должно быть больше информации - но вызов метода, являющийся динамическим указателем на функцию, здесь важен.)

Руст предлагает выбор

Возвращаясь к

Подход Rust позволяет пользователю выбирать между статической отправкой и динамической отправкой.

До сих пор я только демонстрировал, что Rust имеет статически отправленные генерики, но Rust может подключаться к динамическим, таким как Go (с практически такой же реализацией), через объекты-черты. Отмеченный как &Foo, который является заимствованной ссылкой на неизвестный тип, который реализует Fooчерту. Эти значения имеют такое же / очень похожее представление vtable для объекта интерфейса Go. (Объект признака является примером «экзистенциального типа» .)

Существуют случаи, когда динамическая диспетчеризация действительно полезна (а иногда и более эффективна, например, путем уменьшения раздувания / дублирования кода), но статическая диспетчеризация позволяет компиляторам размещать на своих сайтах вызовы и применять все их оптимизации, то есть обычно это происходит быстрее. Это особенно важно для таких вещей, как итерационный протокол Rust , где вызовы метода статической диспетчеризации позволяют этим итераторам быть такими же быстрыми, как и эквиваленты C, при этом оставаясь высокоуровневыми и выразительными .

Tl; dr: подход Rust предлагает как статическую, так и динамическую диспетчеризацию в обобщениях на усмотрение программистов; Go позволяет только динамическую отправку.

Параметрический полиморфизм

Кроме того, подчеркивание черт и ослабление рефлексии дает Rust гораздо более сильный параметрический полиморфизм : программист точно знает, что функция может делать со своими аргументами, потому что она должна объявлять черты, которые универсальные типы реализуют в сигнатуре функции.

Подход Go очень гибкий, но у него меньше гарантий для вызывающих (что усложняет программисту размышления), потому что внутренняя часть функции может (и делает) запрашивать дополнительную информацию о типе (в Go была ошибка стандартная библиотека, где, iirc, функция, принимающая писателя, будет использовать отражение для вызова Flushнекоторых входных данных, но не других).

Строительные абстракции

Это в некоторой степени больной вопрос, поэтому я буду говорить только вкратце, но наличие «правильных» обобщений, таких как у Rust, позволяет использовать низкоуровневые типы данных, такие как Go, mapи []фактически быть реализованным непосредственно в стандартной библиотеке строго типобезопасным способом, и написано в Rust ( HashMapи Vecсоответственно).

И это не только эти типы, вы можете создавать над ними типичные безопасные типовые структуры, например LruCache, это общий уровень кэширования поверх хэш-карты. Это означает, что люди могут просто использовать структуры данных непосредственно из стандартной библиотеки, без необходимости сохранять данные как interface{}и использовать утверждения типа при вставке / извлечении. То есть, если у вас есть LruCache<int, String>, вы гарантируете, что ключи всегда ints, а значения всегда Strings: невозможно случайно вставить неправильное значение (или попытаться извлечь не String).

Юон
источник
Моя собственная AnyMap- хорошая демонстрация сильных сторон Rust, объединяющая объекты-черты с обобщениями, чтобы обеспечить безопасную и выразительную абстракцию хрупкой вещи, которую в Go необходимо будет написать map[string]interface{}.
Крис Морган
Как я и ожидал, Rust более мощный и предлагает более естественный и элегантный выбор, но система Go достаточно близка, так что большинство вещей, которые он пропускает, могут быть выполнены с помощью небольших хаков, таких как interface{}. Хотя Rust кажется технически превосходящим, я все же думаю, что критика Go ... была слишком резкой. Сила программиста в значительной степени соответствует 99% задач.
Логан
22
@Logan, для низкоуровневых / высокопроизводительных доменов, к которым стремится Rust (например, операционные системы, веб-браузеры ... основные "системные" средства программирования), не имеющие возможности статической диспетчеризации (и производительности, которую она дает / оптимизирует) это позволяет) недопустимо. Это одна из причин, по которой Go не подходит для таких приложений как Rust. В любом случае, мощность программиста на самом деле не на высоте, вы теряете (типа времени компиляции) безопасность типов для любой многократно используемой и не встроенной структуры данных, возвращаясь к утверждениям типа времени выполнения.
Хуон
10
Это точно - Rust предлагает вам гораздо больше энергии. Я думаю о Rust как о безопасном C ++, а о Go - как о быстром Python (или значительно упрощенном Java). Для большого процента задач, где производительность разработчика имеет наибольшее значение (а такие вещи, как время выполнения и сборка мусора не являются проблематичными), выберите Go (например, веб-серверы, параллельные системы, утилиты командной строки, пользовательские приложения и т. Д.). Если вам нужно все до последней черты производительности (и проклятие производительности разработчиков), выберите Rust (например, браузеры, операционные системы, встроенные системы с ограниченными ресурсами).
weberc2