Чим риси іржі відрізняються від Go Interfaces?


64

Я відносно знайомий з Go, написавши в ньому ряд невеликих програм. Іржу, звичайно, я менш знайомий, але слідкую за цим.

Нещодавно прочитавши http://yager.io/programming/go.html , я подумав, що я особисто вивчу два способи роботи з дженеріками, оскільки стаття, здавалося, несправедливо критикує Іде, коли на практиці інтерфейсів було не так багато не вдалося виконати елегантно. Я продовжував чути галас про те, наскільки потужні риси Руста, і нічого, окрім критики з боку людей щодо Go. Маючи певний досвід роботи в Go, я замислився, наскільки це правда і якими були в кінцевому рахунку відмінності. Що я виявив, що риси та інтерфейси досить схожі! Зрештою, я не впевнений, чи щось мені не вистачає, тож ось короткий навчальний цикл їх подібності, щоб ви могли сказати мені, що я пропустив!

Тепер давайте розглянемо Go Interfaces з їх документації :

Інтерфейси в Go надають спосіб уточнити поведінку об'єкта: якщо щось може це зробити, то це можна використовувати тут.

На сьогодні найпоширеніший інтерфейс, Stringerякий повертає рядок, що представляє об'єкт.

type Stringer interface {
    String() string
}

Отже, будь-який об’єкт, який String()визначив на ньому, є Stringerоб'єктом. Це може бути використано в таких підписах, що func (s Stringer) print()приймають майже всі об'єкти та друкують їх.

У нас також є interface{}який приймає будь-який об’єкт. Потім ми повинні визначити тип під час виконання за допомогою відображення.


Тепер давайте розглянемо риси іржі з їх документації :

Найпростіше, ознака - це набір нульових або більше підписів методів. Наприклад, ми можемо оголосити ознаку Printable для речей, які можна надрукувати на консолі, з єдиним підписом методу:

trait Printable {
    fn print(&self);
}

Це відразу схоже на наші Go Interfaces. Єдина відмінність, яку я бачу, полягає в тому, що ми визначаємо «Реалізацію» рис, а не просто визначаємо методи. Отже, ми

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

замість

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

Питання про бонус: Що станеться в Rust, якщо ви визначите функцію, яка реалізує ознаку, але ви не використовуєте impl? Це просто не працює?

На відміну від інтерфейсів Go, система типу Руста має параметри типу, які дозволяють робити належні загальні відомості та такі речі, як interface{}компілятор та час виконання фактично знають тип. Наприклад,

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

працює над будь-яким типом, і компілятор знає, що тип елементів послідовності під час компіляції, а не використання відображення.


Тепер, власне питання: я пропускаю тут якісь відмінності? Чи справді вони такі схожі? Чи не є якась більш принципова різниця, яку мені тут не вистачає? (У використанні. Деталі впровадження цікаві, але в кінцевому рахунку не важливі, якщо вони функціонують однаково.)

Крім синтаксичних відмінностей, я бачу фактичні відмінності:

  1. Go має автоматичну розсилку методів проти Руста, яка вимагає (?) implS для реалізації ознаки
    • Елегантний проти явного
  2. У іржі є параметри типу, які дозволяють отримувати належні генеричні дані без відображення.
    • Тут дійсно немає відповіді. Це єдине, що значно потужніше, і це, в кінцевому рахунку, лише заміна методів копіювання та вставлення з різними типами підписів.

Це єдині нетривіальні відмінності? Якщо так, то, здається, система інтерфейсу / типу Go на практиці не така слабка, як сприймається.

Відповіді:


59

Що відбувається в Rust, якщо ви визначаєте функцію, яка реалізує ознаку, але ви не використовуєте impl? Це просто не працює?

Вам потрібно чітко реалізувати ознаку; трапляється, що метод, який відповідає імені / підпису, для Руста не має сенсу.

Загальне відправлення дзвінків

Це єдині нетривіальні відмінності? Якщо так, то, здається, система інтерфейсу / типу Go на практиці не така слабка, як сприймається.

Забезпечення статичної відправки може бути значним ударом у певних випадках (наприклад, той, про Iteratorякий я згадую нижче). Я думаю, це саме те, що ти маєш на увазі

Тут дійсно немає відповіді. Це єдине, що значно потужніше, і це, в кінцевому рахунку, лише заміна методів копіювання та вставлення з різними типами підписів.

але я розкрию це більш докладно, тому що варто глибоко зрозуміти різницю.

В Іржі

Підхід Руста дозволяє користувачеві вибирати між статичною та динамічною . Як приклад, якщо у вас є

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_bars завжди будуть викликати вище call_bar, з адресою, barзавантаженою з vtable інтерфейсу .

Низький рівень

Перефразовуючи сказане, у позначенні С. Версія Руста створює

/* "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 може ввімкнути такі динамічні, як Go (з по суті однаковою реалізацією), через об'єкти trait. Позначений як &Foo, що є запозиченою посиланням на невідомий тип, який реалізує Fooознаку. Ці значення мають однакове / дуже схоже vtable-представлення на об'єкт інтерфейсу Go. (Об'єкт ознаки є прикладом "екзистенційного типу" .)

Бувають випадки, коли динамічна відправка справді корисна (а іноді і більш ефективна, наприклад, наприклад, зменшення розпилення / дублювання коду), але статична відправка дозволяє компіляторам вбудовувати позиції та застосовувати всі їх оптимізації, тобто зазвичай це швидше. Це особливо важливо для таких речей, як протокол ітерації Руста , де статичний метод диспетчеризації викликів методу дозволяє тим ітераторам бути такими ж швидкими, як і їх еквіваленти, при цьому все ще здається високим і виразним .

Tl; dr: підхід Руста пропонує як статичну, так і динамічну диспетчерику в генериці, на розсуд програмістів; Перехід дозволяє лише для динамічної відправки.

Параметричний поліморфізм

Крім того, підкреслюючи риси та деффазуюче відображення надає Русту набагато сильніший параметричний поліморфізм : програміст точно знає, що функція може зробити з її аргументами, оскільки він повинен оголошувати риси, які загальні типи реалізують у підписі функції.

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

Будівництво абстракцій

Це дещо боляче, тому я поговорю лише коротко, але наявність "належних" дженериків, таких як Rust, дозволяє використовувати типи даних низького рівня, такі як Go, mapі []реально реалізовуватися безпосередньо в стандартній бібліотеці сильно безпечним способом, і написані іржею ( HashMapі Vecвідповідно).

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


Мій власний AnyMap- це хороша демонстрація сильних сторін Іржі, поєднання об'єктів риси з загальними, щоб забезпечити безпечну та виразну абстракцію тендітної речі, про яку в «Го» потрібно було б написати map[string]interface{}.
Кріс Морган

Як я і очікував, Rust є більш потужним і пропонує більше вибору на самому / елегантному рівні, але система Go досить близька, що більшість речей, які вона сумує, можна виконати за допомогою невеликих хакерів interface{}. Хоча Руст здається технічно вищим, я все ще думаю, що критика Go ... була занадто сувора. Потужність програміста майже на одному рівні для 99% завдань.
Логан

22
@Logan, для доменів низького / високоефективного використання Rust прагне (наприклад, операційні системи, веб-браузери ... основні програми "системи"), не маючи можливості статичної диспетчеризації (та продуктивність, яку вона дає / оптимізація це дозволяє) неприпустимо. Це одна з причин того, що Go не настільки підходить, як Rust для таких програм. У будь-якому випадку потужність програміста насправді не збігається, ви втрачаєте (час компіляції) безпеку типу для будь-якої багаторазової та не вбудованої структури даних, повертаючись до тверджень про час виконання.
хун

10
Це точно так - Іржа пропонує вам набагато більше сил. Я вважаю, що Руст як безпечний C ++, а Go - як швидкий Python (або значно спрощена Java). Для великого відсотка завдань, де продуктивність розробників найбільше важлива (а такі речі, як час виконання та збирання сміття, не є проблематичними), виберіть «Перейти» (наприклад, веб-сервери, паралельні системи, утиліти командного рядка, користувацькі програми тощо). Якщо вам потрібен кожен останній біт продуктивності (і продуктивність розробника буде проклята), виберіть Rust (наприклад, браузери, операційні системи, вбудовані системи з обмеженими ресурсами).
weberc2
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.