Что я изменил бы в Go

в 9:01, , рубрики: Go, haskell, Rust, Блог компании Mail.Ru Group, высокая производительность, Компиляторы, никто не читает теги

image

В течение полугода я программировал преимущественно на Go. И я разочарован. По двум причинам:

  • В Go особенно трудно придерживаться функциональной парадигмы. По сути, язык препятствует функциональному программированию. Меня это разочаровало, потому что в императивном коде, который я пишу, большое количество шаблонных кусков. К тому же, как мне кажется, в этом случае выше риск ошибок, в отличие от использования функциональных абстракций.
  • Я считаю, что Go упускает свои шансы. В программных языках появились замечательные нововведения (особенно в сфере проверки и вывода типов — type inference), делающие код безопаснее, быстрее и чище. Мне хотелось бы, чтобы Google использовала своё влияние, чтобы поддержать некоторые из этих идей.

Я не первый, кто воспринимает Go подобным образом. Вот публикации других людей, разделяющих мои впечатления:

Ниже я добавлю свои соображения. Чтобы показать, как именно можно улучшить Go, я буду сравнивать его с Rust.

Работы над Go и Rust начались примерно в одно время: Go анонсировали в 2009-м, а Rust — в 2010-м. В обоих языках используются схожие подходы:

  • Компилирование в быстрые нативные бинарные файлы.
  • Избегание наследования в пользу композиции.
  • Поддержка императивного программирования.
  • Отказ от перехвата исключений в пользу явной передачи ошибочных результатов.
  • Упор на многопоточность.
  • Статическая проверка типов.
  • Современная система пакетов с поддержкой модульности.

Вероятно, оба языка предназначались для замены С++: разработчики Go утверждали, что первичным мотиватором для них стало недовольство сложностью С++. Servo — один из основных Rust-продуктов Mozilla. Это потенциальный преемник движка рендеринга Gecko HTML, написанного на С++.

На мой взгляд, ключевые различия этих языков таковы:

  • Rust больше подходит для высокой производительности и мощных надёжных (soundness) абстракций. (Soundness — такое свойство системы типов, когда любые «заявления», сделанные типами, гарантированно соблюдаются в течение всего выполнения программы. Если язык надёжен (sound), то во время выполнения не возникают ошибки типов — runtime type errors.)
  • Go обеспечивает доступность (accessible), он прост и быстр в компилировании.

Иными словами, Rust необязательно заменяет Go. Я не призываю всех, кто использует Go, переходить на Rust. В Rust есть поддержка операций реального времени, при необходимости он способен оперировать только стековой памятью. В Rust сложная система типов, которая может, к примеру, выявлять проблемы посредством многопоточного (concurrent) обращения к общим данным в ходе компилирования. Всё это увеличивает сложность Rust-программ. Тот же borrow-checker славится своей кривой обучения. Но я хочу привести сравнения с Rust в определённых контекстах, чтобы проиллюстрировать варианты улучшения Go. Rust позаимствовал из других языков много хороших идей и грамотно их скомбинировал. И мне кажется, что если бы Go перенял те же идеи, то ему это пошло бы на пользу.

Примеры кода из статьи доступны здесь. Там же можете взять исполняемые тесты и поэкспериментировать.

Хороший

Я считаю, что в Go прекрасный механизм использования интерфейсов для структурирования данных.

Мне нравится отделение поведения от самих данных: структуры хранят данные, методы манипулируют данными в структурах. Это чёткое разделение состояния (структур) и поведения (методов). Я считаю, что в языках с наследованием это различие может быть не столь явным.

Go просто изучать. Объектно ориентированные идеи в нём видоизменены таким образом, что они стали более доступны для программистов, знакомых с другими объектно ориентированными языками.

Зачастую используется довольно простой способ решения проблем «в стиле Go». То же самое можно сказать, например, про Python. Возникновение подобных устойчивых идиом говорит о том, что любой Go-программист наверняка поймёт код, написанный любым другим Go-программистом. Это часть философии простоты, описанной в публикации Простота и сотрудничество.

В стандартной библиотеке Go много тщательно продуманных возможностей. Одна из моих любимых:

fiveSeconds := 5 * time.Seconds

Горутины дёшевы, поэтому можно создавать программы, чья структура будет логичнее с точки зрения алгоритмов. Даже если это подразумевает использование большого количества горутин. Хотя Go в этом не уникален: в Erlang и Scala тоже реализованы легковесные акторы (actors). В Rust и других языках есть свои решения для дешёвого многопоточного (concurrent) и параллельного программирования.

Поскольку в качестве точки отсчёта я взял Rust, то отмечу, что разделение поведения и данных в нём реализовано очень похоже на Go. Также в Rust отдаётся предпочтение композиции, а не наследованию. Вместо структур и интерфейсов — структуры, enum’ы и трейты (traits). Здесь трейты играют роль интерфейсов, но они достаточно отличаются от последних и могут выглядеть довольно странно с точки зрения программистов, привыкших к объектно ориентированной парадигме. В отличие от Go, в Rust важнее выразительность, а не простота, и типобезопасность (type safety), а не быстрота компилирования. Скажем так: для разработчиков Rust повышение скорости компилирования важно, но стоит не на первом месте.

Я могу ещё долго расписывать преимущества Go. Но у него есть и недостатки.

Плохой

Что меня особенно раздражает в Go.

nil

Я разочарован решением включить null-указатели в новый язык, когда уже несколько десятилетий известны более безопасные варианты. Точнее, я считаю плохой идеей использовать nil в качестве типа нижнего уровня (bottom-ish type), сделав его совместимым с каждым типом, передаваемым по ссылке (pass-by-reference type).

Я понимаю, что технически nil — это не null-указатель. Но их поведение так похоже, что критика в адрес указателя справедлива и в отношении nil. Я прочитал статью Understanding Nil и понимаю, что можно реализовать методы, когда nil является получателем, когда он может быть полезен. Разработчики Go постарались сгладить недостатки nil. Но факт остаётся фактом: он совместим со всеми типами, передаваемыми по ссылке, вне зависимости от того, чувствительны ли методы из этих типов к получателям nil. А это открывает дорогу ошибкам runtime. На мой взгляд, можно внести в язык изменения, облегчающие проверку типов, чтобы вылавливать проблемы на стадии компилирования.

null присутствует и в некоторых более свежих языках, но как отдельный тип, в целом несовместимый с другими типами (например, Fantom и Flow). В этих языках значения по умолчанию не могут быть null. Вот как во Flow можно объявить и использовать переменную, допускающую null, при написании кода в React:

function LoginForm(props) {
  // `?` перед `HTMLInputElement` означает, что `emailInput` может быть `null`.
  let emailInput: ?HTMLInputElement

  // JSX-синтаксис допускает использование в коде тегов наподобие HTML
  return <form onSubmit={event => props.onLogin(event, emailInput)}>
    <input type="email" ref={thisElement => emailInput = thisElement} />
    <input type="submit" />
  </form>
}

function onLoginTake1(event: Event, emailInput: ?HTMLInputElement) {
  event.preventDefault()

  // Ошибка типа! Невозможно прочитать свойство `value` в значении, которое, вероятно, `null` или `undefined`.
  dispatch(loginEvent(emailInput.value))
}

function onLoginTake2(event: Event, emailInput: ?HTMLInputElement) {
  event.preventDefault()

  if (emailInput) {
    // Здесь всё нормально, потому что Flow полагает, что `emailInput` в этом блоке не может быть `null` или `undefined`.
    dispatch(loginEvent(emailInput.value))
  }
}

Без возможности принимать значение null использование nil противоречит тому, что заявлено в сигнатурах типов. В сигнатурах Go сообщается, что аргумент является указателем на структуру User. Но если принять это заявление за чистую монету, то вы наверняка получите ошибку nil pointer dereference:

func validate(user *User) bool {
    return user.Name != ""
}

В Go каждая переменная типа, передаваемого по ссылке, подразумевает двусмысленную ситуацию: «…или может быть nil». Поддержка типов, не допускающих null, делает язык достаточно выразительным, чтобы избежать такой двусмысленности.

Проблема с nil в Go усугубляется тем, что проверка на nil иногда сбоит. Если значение интерфейсного типа (interface value) имеет какой-то тип, а не nil, то при проверке на nil может вернуться не true. Пуристы объясняют это тем, что в таких ситуациях значение не совсем nil: это значение интерфейсного типа, у которого в слоте значения оказался nil. Меня такое объяснение не удовлетворяет. Когда метод отдаёт такое не-совсем-nil значение, то значение получателя (receiver value) в теле метода будет самым настоящим nil.

А что насчёт начального значения (zero value)? Каким оно будет для функционального типа, интерфейсного типа без nil? Думаю, что начальные значения — тоже плохая идея.

Одно из архитектурных решений в Go — требование чётко прописывать каждому типу значение по умолчанию, так называемое нулевое значение. Это бывает удобно, потому что вам не нужно вручную писать конструкторы, когда требуется получить значение по умолчанию. Но подозреваю, что реальная причина существования в Go нулевых значений такова: они ведут себя предсказуемо, когда нужно использовать неинициализированные переменные. С и С++ славятся неопределённым поведением, которое становится источником проблем при портировании кода между разными реализациями компиляторов. Характерный пример неопределённого поведения в обоих языках — неинициализированные переменные. (Возможно, это уже не так, спецификации С и С++ развиваются, а я не слежу за такими вещами.) Мне кажется, что разработчики Go учли ошибки С и С++ и постарались чётко определить как можно больше вариантов поведения. Я считаю этот подход образцом для подражания! Но есть и другое решение, лучше обеспечивающее безопасность кода: в Rust, Flow и других языках для выявления использования неинициализированных переменных применяется анализ потока данных. И если таковые факты обнаруживаются, то возникает сбой проверки типа.

Необходимость наличия нулевого значения накладывает ограничение: nil должен существовать, он может быть присвоен различным типам. Многие типы не имеют продуманных (sensible) значений по умолчанию, так что nil — единственный вариант. И это одна из проблем.

Другая проблема: у Go недостаточно информации для генерирования продуманных значений по умолчанию для предметно ориентированных типов (domain-specific types). Он всё равно пытается это делать, что вредит надёжности (soundness) кода. Нулевые значения для функций и значений интерфейсных типов (например, значений с тегами не runtime-типов) бесполезны в любых обстоятельствах. Указательные типы (pointer types) могут реализовывать методы с nil-получателями. Но это бесполезно для типов, для которых не предусмотрено продуманного поведения (sensible behavior) в случае с неинициализированными значениями. Значения по умолчанию структурного типа иногда бывают полезны. Но в остальных случаях они нарушают инвариантность, заложенную посредством самописного конструктора.

Автор статьи Три месяца Go так описал сложности с нулевыми значениями:

Нулевые значения принесли с собой множество проблем. Казалось, всё прекрасно работает, и вдруг что-то неожиданно ломается, потому что не было продумано использование нулевого значения в данном контексте. Возможно, к поломке привело изменение, не имеющее отношения к нулевому значению (например, структура получила дополнительное поле).

Как это сделано в Rust

В Rust нет ни null-, ни nil-значений. Здесь применяются enum’ы. Это типы, чьи значения могут быть разных видов, и каждый вид, по сути, — это отдельная структура. Если вы хотите выразить отсутствие значения, то используйте вид enum без данных. В обобщённом виде он называется Option-паттерном (Option Pattern). Примерно так выглядит определение типа Option из стандартной библиотеки Rust:

pub enum Option<T> {
    None,    // Не содержит данных
    Some(T), // Содержит значение определённого типа
}

None и Some — это конструкторы: каждый из них является функцией, возвращающей значение типа Option<T>. Some берёт один аргумент, None не берёт аргументов. Учитывая значение Option<T>, вы можете прибегнуть к сопоставлению с образцом (pattern matching), чтобы определить, какой конструктор использовался для создания значения. При сопоставлении вы также считываете обратно (read back) любые аргументы конструктора. Если значение создано посредством вызова Some(x), то сопоставление с образцом позволяет обратиться к значению x.

Пример с Option-паттерном (исходник):

fn checked_division(dividend: i32, divisor: i32) -> Option<i32> {
    if divisor == 0 {
        // Сбой представлен в виде `None`
        None
    } else {
        // Результат обёрнут в `Some`
        Some(dividend / divisor)
    }
}

#[test]
fn divides_a_number() {
    let result = checked_division(12, 4);
    match result {
        Some(num) => assert_eq!(3, num), // Паттерн слева выполняет привязку (bind) `num`
        None      => assert!(false, "Expected `Some` but got `None`")
    };
}

Преимущество Option-паттерна перед типами, допускающими значение null, заключается в том, что вы различите значения вроде None и Some(None). Если вы, допустим, ищете значения в кеше, то результат None может означать, что для этого ключа в кеше нет записей. А результат Some(None) может означать, что запись есть и её значение — None.

Однажды я порекомендовал использовать Option-паттерн в компании, где разработка велась на Java. Но как минимум одного моего коллегу не устроила идея размещения в куче дополнительного объекта лишь ради того, чтобы различать значение и его отсутствие. Rust построен с учётом Option-паттерна, в нём отдаётся приоритет абстракциям с нулевой стоимостью (zero-cost abstractions). Если параметр типа для Option<T> представляет собой ссылочный тип (reference type), то во время runtime не получается безопасно представить None в качестве нулевого указателя (null pointer). Поэтому обёртки Some и None зачастую исчезают при компилировании. В подобных случаях код получается эффективным настолько, насколько язык позволяет использовать безопасные null-значения.

В приведённом примере ни Option<i32>, ни i32 не являются ссылочными типами. Компилятор выделяет в стеке непрерывное пространство для числового результата, а также для тега, позволяющего различить Some и None. В куче дополнительной памяти не выделяется, добавленный указатель не разыменовывается.

В «Книге Rust» вы можете почерпнуть гораздо больше подробностей относительно обработки ошибок.

В Go можно было бы не менее эффективно реализовать Option-паттерн. Посредством реализации метода match даже можно было бы при компилировании проверять, что ошибки обработаны. Этот метод использует паттерн «посетитель» (пример). Но без дженериков не добиться типобезопасности для значений, обёрнутых в тип Option.

Шаблонность обработки ошибок и нехватка проверок на ошибки при компилировании

У обработки ошибок в Go есть две взаимосвязанные проблемы:

  • необходимо обильно использовать шаблонный код;
  • а если программист пренебрежёт проверкой на ошибки или допустит небольшую оплошность вроде проверки неправильной переменной ошибки (error variable), то компилятор не выявит проблему.

func doStuff() error {
    _, err := doThing1()
    if err != nil {
        return err
    }

    _, errr := doThing2()  // Error not propagated due to a bouncy key
    if errr != nil {
        return err
    }

    return nil
}

В Rust есть тип Result<T,E>, аналогичный Option<T>. Его отличие в том, что «сбойный» вариант enum’а Result<T,E> не пустой — он содержит код ошибки (тип E). Возвращаемое значение типа Result<T,E> может быть Ok(value) (в случае успеха) или Err(err) (в случае ошибки).

pub enum Result<T, E> {
    Ok(T),
    Err(E),
}

Многим программистам паттерны Option и Result не нравятся из-за трудности извлечения из обёртки положительных (successful) значений. Эту задачу облегчила бы поддержка сопоставления с образцом. А результирующие значения первого класса (first-class result values) позволили бы использовать комбинаторы, которые могут обрабатывать ряд потенциальных сбоев чище и безопаснее, чем явные проверки на наличие ошибок.

Рассмотрим эту функцию Go:

func fetchAllBySameAuthor(postID string) ([]Post, error) {
    post, err := fetchPost(postID)
    if err != nil {
        return nil, err
    }

    author, err := fetchUser(post.AuthorID)
    if err != nil {
        return nil, err
    }

    return fetchPosts(author.Posts)
}

В Rust функция fetchAllBySameAuthor могла бы быть реализована несколькими способами. Пожалуй, самый доступный вариант для тех, кто не имеет опыта работы с паттернами Option или Result, — сопоставление с образцом:

fn fetch_all_by_same_author(post_id: &str) -> Result<Vec<Post>, io::Error> {
    let post = match fetch_post(post_id) {
        Ok(p)    => p,
        Err(err) => return Err(err),
    };

    let author = match fetch_user(&post.author_id) {
        Ok(a)    => a,
        Err(err) => return Err(err),
    };

    fetch_posts(&author.posts)
}

Ключевое слово match обозначает блок сопоставления с образцом (pattern-match block). В него входят образцы (pattern) для каждого возможного варианта типа выражения, а также выражение, которое вычисляется при совпадении. Что-то вроде переключателя типа в Go, когда выполняемый код зависит от типа переменной в начале блока switch. Но в Rust при компилировании ещё выполняется проверка наличия образца для каждого возможного варианта данного типа. Это позволяет избежать потенциальных ошибок выполнения. Очень полезно при добавлении в кастомный тип новых вариантов: компилятор немедленно укажет все случаи использования этого типа, которые требуется обновить.

Код в Rust получается столь же многословен, как и в Go. Но он демонстрирует, что извлечение из обёртки значений Result<T,E> и Option<T> может быть не труднее проверок на nil. И если бы в Rust мы опустили проверку на ошибки, то система выдала бы ошибку при компилировании.

В Rust есть макрос try!, который абстрагирует сопоставления с образцом и ещё раньше возвращает то, что мы видели выше. То есть он эквивалентен функции:

fn fetch_all_by_same_author(post_id: &str) -> Result<Vec<Post>, io::Error> {
    let post   = try!(fetch_post(post_id));
    let author = try!(fetch_user(&post.author_id));
    fetch_posts(&author.posts)
}

try! переписывает выражение при компилировании. Например, try!(fetch_post(post_id)) помещает вызов fetch_post внутрь match и вставляет шаблонные сравнения для Ok и Err.

Макрос try! использовался столь активно, что разработчики Rust улучшили поддержку этого подхода: то же самое можно сделать, если поместить в конце выражения постфиксный оператор ?. Например, строку let post = try!(fetch_post(post_id)); можно написать как let post = fetch_post(post_id)?;. А если вы забудете про ?, то проверка типов не сработает.

Но Go не поддерживает макросы. К счастью, Result-паттерн не требует для краткости использования макросов. Есть другой, более функциональный вариант, с комбинаторными методами (combinator methods):

fn fetch_all_by_same_author(post_id: &str) -> Result<Vec<Post>, io::Error> {
    let post   = fetch_post(post_id);
    let author = post.and_then(|p| fetch_user(&p.author_id));
    author.and_then(|a| fetch_posts(&a.posts))
}

and_then — метод для значений Result<T,E>. Если значение представляет собой положительный результат (successful result), то выполняется колбэк, который должен вернуть новое значение Result<U,E>. Если значение — это ошибочный результат (error result), то and_then передаёт его напрямую. and_then во многом похож на метод then в промисах Javascript.

А если вы хотите обернуть ошибочный результат, чтобы добавить контекст? Для этого есть комбинатор map_err, позволяющий выполнять произвольные преобразования ошибочных результатов.

let post = fetch_post(post_id)
    .map_err(|e| io::Error::new(io::ErrorKind::NotFound, e));

Суть в том, что проверки на наличие ошибок почти всегда проходят одинаково: выполняется проверка, если ошибка есть — то она возвращается. Принцип DRY позволяет абстрагировать какой-то паттерн во вспомогательном методе или макросе. И снова повторюсь: в Rust-реализациях при компилировании гарантированно выполняется проверка на наличие ошибок. Это может делаться с помощью какого-то восстанавливающего кода (recovery code) или посредством передачи ошибки вверх по стеку.

Result<T,E> не получил такую «исчезающую при компилировании» оптимизацию, как у Option<T>, потому что оба варианта enum содержат данные. Но его эффективность выше, чем у множественных возвращаемых значений Go. Для каждого возвращаемого значения Go выделяет достаточно памяти. А Rust выделяет достаточно памяти для хранения T или E (например, чтобы хватило для самого большого из возможных значений), а также для тега, позволяющего различать значения Ok(value) и Err(err).

Обобщённость enum’ов Rust хороша тем, что если бы Result<T,E> не существовал, то его легко можно было бы реализовать в виде библиотеки. А что насчёт использования Result-паттерна в Go? Ну, можно положить методы в кортежи Go (т. е. во множественные возвращаемые значения), потому что они не являются значениями первого класса. Невозможно определить функцию, принимающую кортеж и колбэк: функция Go, принимающая кортеж, не может принимать дополнительные аргументы (потому что кортежи Go — не значения первого класса). Эти ограничения затрудняют использование комбинаторного паттерна. Можно реализовать кастомный структурный тип, но без дженериков это будет не слишком полезно.

Манипулирование списком непрактично

Ещё одна пощёчина Go от функционального программирования: в этом языке нет хорошего способа писать полиморфные функции, которые умеют манипулировать слайсами произвольных типов. В Rust можно создать функцию с подобной сигнатурой:

fn map<A, B, F>(callback: F, xs: &[A]) -> Vec<B>
    where F: Fn(&A) -> B {

Эта функция берёт колбэк и входной слайс (input slice), а возвращает новый массив, вычисленный посредством аккумулирования результатов применения колбэка к каждому элементу входного массива. Более того, в итераторных типах (iterator types) Rust есть встроенные методы, которые делают то же самое. Входной слайс может содержать любые типы значений. Переменные типов позволяют проверять типы, чтобы отслеживать, как типы выходного массива соотносятся с типами входного слайса. Также с помощью проверки типов можно контролировать, что колбэк имеет соответствующие входные и выходные типы.

В Go этот паттерн работает плохо. Без переменных типов выразить тип, полиморфный для всех типов слайсов, можно лишь с помощью высшего типа (top-type) []interface{}. Например:

func Map(callback func(x interface{}) interface{}, xs []interface{}) []interface{} {
    ys := make([]interface{}, len(xs))
    for i, x := range xs {
        ys[i] = f(x)
    }
    return ys
}

Но эта функция на самом деле не полиморфна. Тип слайса с более специфическим параметром типа (например, []int) несовместим (type-compatible) с []interface{}. Поэтому вы не можете передать функции Map переменную типа []int. Придётся сначала создать новый слайс типа []interface{}, а затем в цикле for по одному копировать значения int. Получив от Map результат, придётся скопировать эти значения в ещё один слайс, чтобы наконец получить нужный тип слайса. То есть при каждом вызове Map нужно прогонять два цикла, а также подтверждать тип при выполнении (runtime type assertion) либо переключать тип (type switch) в реализации колбэка.

Слайс с параметром произвольного типа совместим (type-compatible) с высшим типом interface{}. Если для каждого полиморфного аргумента вы используете тип interface{}, то получите такую сигнатуру:

func Map(callback interface{}, xs interface{}) interface{}

С такой сигнатурой можно передавать в слайс и колбэк любого типа. Также можно присваивать результат переменной определённого типа. Но чтобы всё это работало, необходимо использовать рефлексивный API для фиксации в ходе runtime тегов типов для входного слайса, входного колбэка и выходного слайса. Этот процесс описан в статье Writing type parametric functions in Go. Рефлексивный код непригляден, но его можно спрятать в реализациях функций общего назначения. Но вы неизбежно лишитесь в ходе компилирования всей типобезопасности, а также получите многократное снижение производительности.

Та же проблема характерна для других функций манипулирования со списком: Filter, Take, Reduce и т. д. Это плохо потому, что манипулирование списком — хлеб насущный функционального программирования. Go препятствует использованию таких базовых строительных блоков, как Map, и это означает, что функциональное программирование не слишком преуспевает в Go. И сообществу Go будут недоступны преимущества функционального программирования.

Вероятно, вы заметили, что Go не поддерживает дженерики. Это приводит к ряду проблем. Динамические языки вроде Javascript, Python и Ruby тоже не поддерживают дженерики. По крайней мере, с точки зрения проверки при компилировании. Но при этом в них прекрасно работают идиомы функционального программирования. К примеру, в Javascript можно просто передать любой список в манипулирующую списком функцию-дженерик, и всё будет работать. Go занял неудобную промежуточную позицию: проверяет типы при компилировании, но не даёт возможности объяснить компилятору, как соотносятся входные и выходные типы.

Дженерики — и в особенности переменные типов — предназначены для «беседы» о типах. Они позволяют использовать сигнатуры функционального программирования для написания выражений вроде «Эта функция берёт слайс значений такого-то типа и возвращает слайс значений того же типа». Работа с языком программирования, не имеющим переменных типов, раздражает меня так же, как общение на языке, в котором нет слова the. (Какая ограниченность мировоззрения. — Примеч. пер.)

В Go приходится везде перереализовывать абстракции списков (list abstractions). Рассмотрим функцию Go:

// Берёт заголовки первых незаархивированных документов `count`
func LatestTitles(docs []Document, count int) []string {
    var latest []string
    for _, doc := range docs {
        if len(latest) >= count {
            return latest
        }
        if !doc.IsArchived {
            latest = append(latest, doc.Title)
        }
    }
    return latest
}

Эта функция проходит по всей входной коллекции, пропускает одни значения, что-то делает с другими значениями, возвращает коллекцию с результатами. Иными словами, это операция filter, map, take. Эквивалент на Rust:

fn latest_titles(docs: &[Document], count: usize) -> Vec<&str> {
    docs.iter()
        .filter(|doc| !doc.is_archived)
        .map(|doc| doc.title.as_str())
        .take(count)
        .collect()
}

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

Однажды я пожаловался коллеге на отсутствие абстракций в Go. Он ответил: «Ну, возможно, тебе и не следует их использовать». Этим я хочу подчеркнуть, что функциональные абстракции необязательно снижают эффективность кода. Rust обычно манипулирует «списком» с помощью лениво вычисляемых (lazily-evaluated) итераторов. Вы можете создать цепочку filter, map, take без размещения промежуточных коллекций и без потери циклов на вычисление значений помимо запрошенных. Вышеприведённая функция не применяет колбэки filter и map к каждому элементу входной коллекции. Набрав достаточно результатов, удовлетворяющих take(count), она сразу же прекращает обработку элементов. Более того, iter, filter, map, take и collect — полиморфные методы, но благодаря этапу мономорфизации при компилировании они диспетчеризируются статически. А компилятор, вероятно, сделает колбэки filter и map инлайновыми. В «Книге Rust» есть ряд заметок о производительности функциональных абстракций в итераторах.

Возможно, мой коллега больше заботился о когнитивной нагрузке, чем о производительности. Я думаю, что жалобы на когнитивную нагрузку — это отчасти результат поиска незнакомых идиом. Для опытного в функциональном программировании человека вызов map, к примеру, означает: «Входная коллекция будет трансформирована в соответствии с этой функцией отображения (mapping function)». После некоторой практики можно быстро читать и понимать декларативный код. А проверки типов более эффективны при проверке декларативного кода, чем кастомных циклов for.

Перейдём к вышеупомянутой проблеме параллельной выборки (parallel-fetch). Вот функция Go, которую я написал для параллельной выборки набора документов:

func (client docClient) FetchDocuments(ids []int64) ([]models.Document, error) {
    docs := make([]models.Document, len(ids))
    var err error

    var wg sync.WaitGroup
    wg.Add(len(ids))

    for i, id := range ids {
        go func(i int, id int64) {
            doc, e := client.FetchDocument(id)
            if e != nil {
                err = e
            } else {
                docs[i] = *doc
            }
            wg.Done()
        }(i, id)
    }

    wg.Wait()

    return docs, err
}

Использование WaitGroup, размещение нового слайса, копирование значений результатов, явная проверка на ошибки — я не хочу заниматься всем этим каждый раз, когда мне нужно одновременно выполнять какие-то операции.

Но если подумать, то в приведённом примере могла бы быть проблема с одновременным доступом к слайсу docs. Возможно, неплохо бы использовать мьютекс для обновлений docs или для отправки результатов из горутин через канал обратно в основной поток выполнения. Но если я воспользуюсь каналом, то придётся реализовать кастомную структуру или прибегнуть к двум каналам. Ведь я хочу ловить ошибки и не могу отправлять через канал типы (models.Document, error), потому что кортежи Go не являются значениями первого класса…

Rust выдаёт при компилировании ошибку, если в функцию, которая может выполняться в другом потоке, передаётся изменяемая ссылка на небезопасную по потокам (thread-unsafe) структуру данных. Мне не нужно беспокоиться о том, что безопасно по потокам, а что нет. Но это почти обесценивается тем фактом, что Rust может прятать подробности многопоточного доступа (concurrency) в библиотечных функциях.

Сравните код Go с эквивалентной функцией Rust, использующей библиотеку futures:

fn fetch_documents(ids: &[i64]) -> Result<Vec<Document>, io::Error> {
    let results = ids.iter()
        .map(|&id| fetch_document_future(id));
    future::join_all(results).wait()
}

// Реализация `fetch_document_future` — упражнение для читателей.

Функция Rust работает так же, как функция Go: если все извлечения выполнены успешно, то вам достанется коллекция данных. Но если будет хоть один сбой, то вы получите его в качестве ошибочного значения. Разница в том, что в Rust одновременное выполнение, маппинг и проверка на ошибки выполняются библиотекой общего назначения. Кроме того, Rust возвращает ошибочное значение, как только возникает сбой, а Go всегда ждёт завершения всех извлечений.

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

Реализация Rust подразумевает, что fetch_document возвращает Future. Функция future::join_all тоже возвращает Future. Работа Future очень похожа на промисы в Javascript: они представляют конечный результат или ошибку. С точки зрения идиом программирования было бы правильнее напрямую возвращать последнюю Future, а не ждать использования wait для блокирования результата. Однако блокирование даёт нам функцию, логически эквивалентную версии Go, и демонстрирует, что Future в Rust не заставляет вас везде использовать колбэки.

Future и сопутствующий тип Stream сильно упрощают некоторые реализации сетевого сервера по сравнению с блокированием ввода-вывода. В частности, использование значений Stream облегчает реализацию поточной передачи запросов и ответов.

Сторонние библиотеки — граждане второго сорта

В Go есть «магическая» функция make. Похоже, она умеет делать с конкретными типами всё, что хотят авторы стандартной библиотеки. В отличие от большинства других функций Go, она берёт тип в качестве аргумента. Если её вызвать с одним аргументом, то функция инициализирует маленький слайс, карту (map) или канал. make способна принять один или два целочисленных аргумента, в зависимости от выбора первого аргумента. Например, при создании слайса вы можете передать его длину и ёмкость:

mySlice := make([]int, 16, 32)

При создании канала с помощью второго аргумента можно задать размер буфера канала. Насколько я знаю, это единственный способ настройки его размера буфера канала.

Похоже, стандартная библиотека обладает особой привилегией перегружать (overload) make, чтобы делегировать её коду кастомного конструктора при инициализации его типов. Сторонние библиотеки так делать не могут.

Нечто подобное демонстрирует оператор range. Это один из нескольких конструктов, меняющих своё поведение в зависимости от количества аргументов, присваиваемых из выходных данных:

for idx, value := range values { /* ... */ }  // `range` возвращает индексы и значения
for idx := range values { /* ... */ }  // в этот раз возвращает только индексы

Что ещё важнее, range можно применять только к типам из стандартной библиотеки. Нельзя сделать итерируемым тип данных из сторонней библиотеки. Авторы библиотек могут реализовать адаптеры для вывода вида их структур данных в качестве слайсов либо передавать значения через канал. Но это увеличивает сложность кода и требует нестандартных идиом.
Ещё одна привилегия заключается в том, что только типы из стандартной библиотеки могут сравниваться с помощью ==, >, и т. д.

Конечно, только стандартной библиотеке позволено определять типы дженериков. Это жёстко ограничивает развитие экосистемы библиотек Go. Например, сторонние библиотеки функциональных структур данных не смогут обрести такую же популярность, как коллекционные типы (collection types) стандартной библиотеки.

Rust поддерживает дженерики для стороннего кода. С помощью трейтов, которые могут быть реализованы любым сторонним типом, Rust реализует итерирование, тождественность и сравнение. Сторонние типы в Rust почти неотличимы от типов стандартной библиотеки, что способствует развитию экосистемы библиотек языка.

Между прочим, make и range — это подходящие к случаю примеры паттерна, имеющего обобщённую поддержку в Rust: функции, полиморфные с точки зрения их возвращаемого типа. Трейты в Rust универсальнее типичных объектно ориентированных интерфейсов: при диспетчеризации метода интерфейса выбор исполняемой реализации метода определяется исключительно типом получателя этого метода. Но выбрать реализацию метода трейта (trait method implementation) можно на основе типа получателя, типа любой позиции аргумента, комбинации типов множественных позиций аргументов (например, трейту тождественности (equality trait) может потребоваться, чтобы оба полиморфных аргумента для метода equals были того же типа) или на основе ожидаемого типа возвращаемого значения.

Как может выглядеть реализация make в Rust:

use std::sync::mpsc::{Receiver, Sender, channel};

// `trait` задаёт некое стандартное поведение
trait Make {
    fn make() -> Self;
    fn make_with_capacity(capacity: usize) -> Self;
}

// `impl` предоставляет реализации методов трейтов для данных конкретных типов.
// Прямо здесь можно реализовать `Make` для стандартного типа `Vec<T>`.
// Необязательно менять исходную реализацию `Vec<T>`.
impl <T> Make for Vec<T> {
    fn make() -> Vec<T> {
        Vec::new()
    }

    fn make_with_capacity(capacity: usize) -> Vec<T> {
        Vec::with_capacity(capacity)
    }
}

// `Sender` и `Receiver` — стандартные канальные типы (channel types) Rust.
// Эта реализация трейта создаёт соединённую пару отправитель/получатель.
impl <T> Make for (Sender<T>, Receiver<T>) {
    // Каналы в Rust не имеют буфера фиксированного размера. Канал не является ни      // блокирующим, ни неблокирующим (с неограниченным буфером). Но у неблокирующего 
    // канала другой тип, поэтому требуется другой трейт impl.
    fn make() -> (Sender<T>, Receiver<T>) {
        channel()
    }

    fn make_with_capacity(_: usize) -> (Sender<T>, Receiver<T>) {
        Make::make()
    }
}

#[test]
fn makes_a_vec() {
    // Мы определили тип переменной, содержащей выходное значение `make`.
    // Это говорит компилятору, какую реализацию `make` нужно вызвать.
    let mut v: Vec<&str> = Make::make();
    v.push("some string");
    assert_eq!("some string", v[0]);
}

#[test]
fn makes_a_sized_vec() {
    let v: Vec<isize> = Make::make_with_capacity(4);
    assert_eq!(4, v.capacity());
}

#[test]
fn makes_a_channel() {
    // Или можно просто позволить компилятору догадаться, какой тип мы ожидаем.
    let (sender, receiver) = Make::make();
    let msg    = "hello";
    let _      = sender.send(msg);
    let result = receiver.recv().expect("a successfully received value");
    assert_eq!(msg, result);
}

Любой тип может реализовать любой трейт. Единственное правило: код реализации должен быть в том же крейте (crate), что и тип или трейт. (Крейт — распространяемый пакет Rust.) Так что если сам Rust или его библиотека реализует make, то любая сторонняя библиотека сможет определять свои собственные кастомные реализации.

Мне пришлось реализовать make и make_with_capacity в виде отдельных методов, потому что Rust не поддерживает перезагрузку методов (method overloading). Теоретически Go её тоже не поддерживает.

Злой

К некоторым особенностям Go у меня более субъективная неприязнь. А кое-какие я бы обошёл, если бы были исправлены некоторые особенности из части «Плохо».

Отсутствие маркированных объединений, ограниченное сопоставление с образцом

Scala поощряет передачу сообщений через каналы. Scala поддерживает типы маркированных объединений (tagged union) в сочетании с сопоставлением с образцом. Эти возможности очень удобны при работе с каналами: маркированное объединение описывает фиксированный набор типов сообщений, которые канал может принять или создать.

Rust поддерживает типы маркированных объединений в виде enum:

use std::sync::mpsc::{Receiver, Sender, channel};
use std::thread;

// Это описание всех сообщений, которые могут быть отправлены счётчику.
// Отправка значения, не сгенерированного ни одним из этих конструкторов enum, 
// при компилировании приведёт к ошибке.
#[derive(Clone, Copy, Debug)]
pub enum CounterInstruction {
    Increment(isize),  // `isize` — целочисленный тип, сопоставляющий размер platform word size
    Reset,
    Terminate,
}

pub type CounterResponse = isize;

use self::CounterInstruction::*;

pub fn new_counter() -> (Sender<CounterInstruction>, Receiver<CounterResponse>) {
    // При создании канала формируется соединённая пара отправитель/получатель
    let (instr_tx, instr_rx) = channel::<CounterInstruction>();
    let (resp_tx,  resp_rx)  = channel::<CounterResponse>();

    // Запускаем счётчик в фоновом потоке выполнения
    thread::spawn(move || {
        let mut count: isize = 0;

        // Если канал закрылся, тогда `recv()` сгенерирует значение `Err`
        // вместо `Ok`, а цикл будет прерван.
        while let Ok(instr) = instr_rx.recv() {

            // Сопоставляем сообщения, чтобы вытащить возможные различающиеся значения
            // и типы из канала сообщений. Поскольку каждый тип `enum` запечатан (sealed),
            // ошибка компилирования исключает любые валидные паттерны.
            // Это позволяет избежать runtime-сбоев при последующем добавлении 
            // типов сообщений, но не пытайтесь обновлять здесь сопоставление.
            match instr {
                Increment(amount) => count += amount,
                Reset             => count = 0,
                Terminate         => return
            }

            // `send` возвращает `Result`, потому что отправка может по каким-то 
            // причинам сбоить. Но в данном примере мы присваиваем результат `_`,
            // чтобы его игнорировать.
            let _ = resp_tx.send(count);
        };

    });

    // Возвращаем отправителя инструкции и получателя ответа
    (instr_tx, resp_rx)
}

#[test]
fn runs_a_counter() {
    let (tx, rx) = new_counter();
    let _ = tx.send(Increment(1));
    let _ = tx.send(Increment(1));
    let _ = tx.send(Terminate);

    let mut latest_count = 0;
    while let Ok(resp) = rx.recv() {
        latest_count = resp
    }

    assert_eq!(2, latest_count);
}

Rust не требует явным образом закрывать каналы. Отправители и получатели каналов реализуют трейт Drop. Его может реализовать любой тип, чтобы распланировать запуск какого-то кода очистки (cleanup code) при выходе переменной за пределы области видимости. В данном примере, когда прерывается работа фонового потока, переменная resp_tx выходит за пределы области видимости и автоматически закрывается.

В этом блоке реализован паттерн сравнения с образцом из предыдущего примера:

match instr {
    Increment(amount) => count += amount,
    Reset             => count = 0,
    Terminate         => return
}

Происходит сопоставление с instr, имеющим тип CounterInstruction. Для CounterInstruction есть три варианта; каждый из них представлен паттерном в блоке match с кодом, который выполняется при совпадении конкретного паттерна.

В Go есть переключения типов, аналогичные сопоставлению с образцом. Сравнимый код сопоставления в Go выглядит так:

switch instr := <-instrChan.(type) {
    case Increment:
        count += Increment.Amount
    case Reset:
        count = 0
    case Terminate:
        return
    default:
        panic("received an unexpected message!")
}

Отличие заключается в типобезопасности при компилировании. Маркированное объединение описывает фиксированный набор возможных сообщений. В Rust при отправке значения в канал компилятор может проверить, знает ли потребитель канала, как обрабатывать тип этого значения. И что особенно ценно, также проверяется, чтобы все части кода, отправляемого или получаемого в канале, были консистентны по типам значений, которые генерируются или потребляются. Если в Rust-программе изменить набор возможных сообщений, но при этом не обновить критически важный код в соответствии с изменениями, то при компилировании модуль проверки типов выдаст ошибку.

Поскольку Go не поддерживает маркированные объединения, сообщения в полиморфных каналах типизируются динамически. Можно использовать интерфейс в качестве параметра типа для канала, который ограничивает набор типов сообщений, отправляемых в канал. Но в Go интерфейс не запечатан (sealed): при создании нового типа, реализующего интерфейс, при компилировании не выполняется проверка, позволяющая удостовериться, что все потребители канала способны обработать новый тип. Распаковка сообщений канала с переключением типов (в отличие от использования исключительно интерфейсных методов) может привести к багам, которые выловила бы другая система типов.

Динамическая типизация

В Go активно используется динамическая проверка типов. Любое обращение к interface{}, любое подтверждение типов или переключение типов является динамической типизацией. Это и хорошо, и плохо. Без дженериков зачастую необходимо принудительно сохранять другой тип значения. При этом более безопасны подтверждения динамических типов, а не неконтролируемые приведения типов (unchecked type casts). В любом случае программа (вероятно) упадёт при выполнении, если значение будет непредвиденного типа. Но при неконтролируемом приведении программа может перед падением повредить память, также не исключена утечка данных к злоумышленнику.

Поскольку об ошибках сообщается только во время runtime, проблемы наверняка останутся незамеченными без хорошего покрытия тестами. Речь не только о 100%-м покрытии: возможны разные ситуации, при которых не будут возникать сообщения от ветвей кода (code path) и сообщения об ошибке согласования типов (type error).

Модуль проверки типов, способный находить ошибки при компилировании, выступает в роли дополнительного тестового набора, который всегда проверяет каждое сочетание условий. В Rust почти не используется тестирование динамических типов, потому что отождествление типов (resolves types) выполняется в ходе компилирования. Сопоставления с образцом вариантов enum’ов немного похожи на тестирование динамических типов. Но всё же имеется качественное отличие, поскольку enum’ы запечатаны. При компилировании гарантируется успешность сопоставления с образцом.

Есть два предостережения:

  • Блоки unsafe, функции и трейты в Rust выполняют неконтролируемые приведения типов. Особенно при взаимодействии с интерфейсами функций других языков. Реализации unsafe представляют собой ограниченные зоны, в которых неприменимы нормальные правила. Лучшим подходом будет свести к минимуму код unsafe. Многие библиотеки вообще его не используют.
  • Rust поддерживает трейт-объекты. В подобных случаях компилятор не отождествляет (resolve) конкретный тип и значение. Но зато он проверяет при компилировании, что значение реализует данный трейт. Трейт-объекты во многом похожи на интерфейсные значения в Go, за исключением того, что трейт-объекты не могут иметь значение nil. Несмотря на нехватку конкретных типов при компилировании, всё же будет безопасным диспетчеризировать методы трейтов с трейт-объектами, потому что при компилировании проверяется, чтобы значение в объекте реализовывало данный трейт, а значит, и соответствующие методы.

Динамическая диспетчеризация, изобретение колеса

В целом Rust при компилировании отождествляет (resolve) каждое значение с конкретным типом. Это значит, что Rust может применять статическую диспетчеризацию при вызове методов трейтов. Как я уже упоминал, исключения — это трейт-объекты, использующие динамическую диспетчеризацию, как и интерфейсные значения в Go. Я понимаю это так, что идиоматический код Rust умеренно использует трейт-объекты. В большинстве случаев трейты в Rust играют роль привязки к переменной типа. Это приводит к отождествлению с конкретным типом при компилировании. О подробностях читайте в соответствующих главах «Книги Rust»: трейты, трейт-объекты.

В Go динамическая диспетчеризация используется при каждом вызове метода интерфейса. Динамическая диспетчеризация, подтверждения типов и переключения типов должны быть отражены в runtime (runtime reflection), что увеличивает накладные расходы.

Методика отождествления конкретных типов при компилировании для Rust не в новинку. Она не имеет ничего общего с проверкой заимствований (borrow-checking). В Haskell это делалось как минимум за 10 лет до Go. И в Haskell не меньше полиморфности, чем в Rust или Go. (Трейты в Rust — это адаптация классов типов (type classes) из Haskell.)

Разработчики Go хотели создать более простую и гибкую модель полиморфизма по сравнению с другими объектно ориентированными языками. В частности, получить преимущество комбинирования перед наследованием (composition over inheritance) и возможность реализовывать интерфейсы постфактум, чтобы можно было применять новые интерфейсы к уже существующим типам. Именно это и делают трейты и классы типов. Решение, предложенное в Go, кажется мне изобретением колеса.

Отсутствие кортежей первого класса

Go поддерживает возвращение множественных значений. Другие языки (в том числе Rust) поддерживают это возвращение посредством типов данных первого класса, называющихся «кортежи». Значения первого класса могут иметь методы, хранящиеся в структурах данных и передаваемые через каналы. Множественные значения в Go ничего из этого не умеют.

Мы уже видели кортежи возвращаемых значений в реализации трейта Make и в примере new_counter. Вот пример поменьше:

// Возвращает кортеж
// (`isize` — целочисленный тип, сопоставляющий platform word size)
fn min_and_max(xs: &[isize]) -> (isize, isize) {
    let init = (xs[0], xs[0]);
    xs.iter()
        .fold(init, |(min, max), &x| (cmp::min(min, x), cmp::max(max, x)))
}

#[test]
fn consume_tuple() {
    let xs = vec![1, 2, 3, 4, 5];
    let (min, max) = min_and_max(&xs); // распаковываем кортеж с помощью деструктурирующего присваивания
    assert_eq!(1, min);
    assert_eq!(5, max);
}

Из-за отсутствия первоклассности в Go нет очевидного способа сообщения о потенциальных сбоях по каналу. Кажется, что этот код должен работать, но на самом деле он не работает:

// Нет
results := make(chan (*models.Document, error))

Насколько мне известно, лучшим вариантом будет определить кастомный тип struct, чтобы принудительно удерживать тип interface{} для положительных и ошибочных значений. А для их различения на другом конце канала нужно использовать переключение типов либо отправлять разные виды значений по двум параллельным каналам.

Ещё одно следствие отсутствия первоклассности в Go — нет хорошего способа определять методы во множественных возвращаемых значениях либо функцию, которая принимает с колбэком эти множественные значения. В результате будет непрактичным определять для возвращаемых значений Go аналог комбинатора and_then из Rust.

Нехватка высокоуровневого параллелизма и средств многопоточного программирования

В статье Одних каналов недостаточно подробно описывается проблема нехватки многопоточных абстракций в Go. В разделе «Нельзя абстрагировать манипулирование списками» я указал на то, что Go не предоставляет простого способа параллельного запуска набора вычислений.

Пользователь kachayev отметил, что это более общая проблема. На самом деле это ещё один симптом недостатка дженериков.

В примере с параллельным извлечением я показал решение на Rust, использующее средства, прекрасно подходящие для многопоточного ввода-вывода. Библиотека futures опирается на более крупную популярную библиотеку асинхронного ввода-вывода Tokio. Но асинхронный ввод-вывод не предназначен для обеспечения параллелизма. Вот что говорится в прекрасной книге «Параллельное и многопоточное программирование на Haskell»:

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

Многопоточность — это методика структурирования программы, при которой используются множественные потоки управления (multiple threads of control). Теоретически потоки управления исполняются одновременно. То есть пользователь видит, что результаты их работы чередуются. А действительно ли они исполняются одновременно или нет, зависит от конкретной реализации. Многопоточная программа может выполняться на одном процессоре с чередованием либо на нескольких физических процессорах.

Для параллельного и многопоточного программирования в Go используются горутины. Библиотеки Rust предлагают ряд инструментов, чьи возможности особенно полезны для решения разных проблем.

Если вам нужна настоящая многоядерная параллельная обработка, то прекрасным решением будет паттерн Map-Reduce. Это ещё одно применение абстракций списков (list abstractions). В Rust можно использовать Map-Reduce с помощью параллельных итераторов из библиотеки Rayon:

// Импортируем метод `par_iter` в пространство видимости.
// `par_iter` — метод трейта Rayon, а Rayon предоставляет реализацию
// этого трейта для стандартных типов слайсов (standard slice types).
use rayon::prelude::*;

pub fn average_response_time(logs: &[LogEntry]) -> usize {
    let total = logs.par_iter()
        .map(|ref entry| entry.end_time - entry.start_time)
        .sum();
    total / logs.len()
}

Параллельные итераторы реализуют вариацию метода map, который распределяет работу среди очереди заданий (job queues), снабжающих работой пул воркеров (worker pool). Поэтому вызовы колбэка map выполняются параллельно. Как и в случае с горутинами, это упрощённый параллелизм, масштабируемый на большое количество параллельных задач. Но параллельные итераторы являются высокоуровневыми абстракциями, которые решают вместо вас некоторые сложные вещи. Например, Rayon прозрачно разбивает работу на пакеты (batches), такой подход более производителен по сравнению с помещением в очередь по отдельности каждого вызова колбэка map. (По умолчанию размер пакета — 5000 элементов, но это значение можно настраивать.) Метод sum (в этом примере — этап Reduce) тоже является частью Rayon. Это означает, что он оптимизирован для использования пакетов результатов от рабочих потоков (worker threads).

Может показаться странным обращение к двум разным библиотекам для написания многопоточного и параллельного кода. Но это разные задачи со своими собственными проблемами и предпосылками. Обычно не требуется одновременно использовать оба вида кода.

Заключение

В чём Go 2.0 мог бы оказаться лучше? Дженерики. Почти все мои сетования сводятся к нехватке поддержки дженериков. Но я считаю, что Go также пойдёт на пользу поддержка типов, не допускающих значений nil, и избавление от нулевых значений. Даже трейты в стиле Rust могут оказаться полезны. Трейты потребуют поддержки методов без получателей (receiver-less methods). Но, возможно, получится сделать трейты с помощью свойств неявной реализации Go.

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

Если вы хотите потратить время на то, чтобы разобраться с жизненными циклами и проверками заимствования, то рекомендую Rust: он делает всё то же, что и Go, но не имеет недостатков, которые я описал в части «Плохой».

Javascript лёгок в изучении, как и Go, и обладает прекрасной поддержкой многопоточности (если не параллелизма). В сочетании с Flow или Typescript вы получите более устойчивую типобезопасность по сравнению с Go. Вышеупомянутых недостатков, в частности, лишена комбинация Javascript и Flow.

Erlang и Scala поддерживают упрощённую многопоточность, как и Go. При этом они прекрасно подходят для функционального программирования.

Clojure не обеспечивает типобезопасности, но умеет делать замечательные вещи! Именно в этом языке используются мои самые любимые реализации функциональных структур данных. Clojure вообще поощряет функциональное программирование.

В Haskell невероятная типобезопасность, одна из лучших реализаций многопоточности и параллелизма. Он хорошо подходит для написания сетевого серверного кода. И Haskell тоже лишён недостатков из части «Плохо».

Сегодня нам доступно многообразие замечательных инструментов, позволяющих делать работу лучше. Даже Go — нравится он мне или нет — полезен для создания клёвых вещей. Но я надеюсь, что после моей статьи вы захотите познакомиться с мирами императивного и объектно ориентированного программирования. Рекомендую взять один из упомянутых языков, потратить время на изучение и прочувствовать его сильные стороны. Думаю, вы не пожалеете.

Автор: Mail.Ru Group

Источник

Поделиться

* - обязательные к заполнению поля