[Перевод] Что я изменил бы в Go

image


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


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

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


  • Why Go Is Not Good
  • Everyday hassles in Go
  • Three Months of Go (from a Haskeller«s perspective)
  • The Language I Wish Go Was

Ниже я добавлю свои соображения. Чтобы показать, как именно можно улучшить 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 
props.onLogin(event, emailInput)}> emailInput = thisElement} />
} 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 {
    None,    // Не содержит данных
    Some(T), // Содержит значение определённого типа
}

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


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


fn checked_division(dividend: i32, divisor: i32) -> Option {
    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 представляет собой ссылочный тип (reference type), то во время runtime не получается безопасно представить None в качестве нулевого указателя (null pointer). Поэтому обёртки Some и None зачастую исчезают при компилировании. В подобных случаях код получается эффективным настолько, насколько язык позволяет использовать безопасные null-значения.


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


pub enum Result {
    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, 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 и Option может быть не труднее проверок на nil. И если бы в Rust мы опустили проверку на ошибки, то система выдала бы ошибку при компилировании.


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


fn fetch_all_by_same_author(post_id: &str) -> Result, 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, 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. Если значение представляет собой положительный результат (successful result), то выполняется колбэк, который должен вернуть новое значение Result. Если значение — это ошибочный результат (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 не получил такую «исчезающую при компилировании» оптимизацию, как у Option, потому что оба варианта enum содержат данные. Но его эффективность выше, чем у множественных возвращаемых значений Go. Для каждого возвращаемого значения Go выделяет достаточно памяти. А Rust выделяет достаточно памяти для хранения T или E (например, чтобы хватило для самого большого из возможных значений), а также для тега, позволяющего различать значения Ok(value) и Err(err).


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


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


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


fn map(callback: F, xs: &[A]) -> Vec
    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, 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: функции, полиморфные с точки зрения

© Habrahabr.ru