[Из песочницы] [Перевод] Почему Go не так хорош
Всем привет! Недавно вышел перевод статьи о том, как TJ Holowaychuk прощался с Node.js, решив двигаться в сторону Go. В конце статьи была ссылка на посвящённый сравнению и критике языка Go пост Уилла Ягера, который просили перевести — собственно, с результатами перевода я и предлагаю ознакомиться. Я пытался более-менее сохранить как многословный стиль изложения, присущий автору, так и оригинальную разбивку на предложения и параграфы.Буду очень рад любым конструктивным замечаниям и предложениям по переводу, опечаткам и/или оформлению, но очень прошу помнить, что точка зрения переводчика может не совпадать с позицией автора переведённой статьи.Почему Go не так хорошМне нравится Go. Я использую его для некоторых вещей (включая этот блог на момент написания статьи). Go удобен. Тем не менее, Go нельзя назвать хорошим языком. Он, конечно, не плох, но и не хорош.Нужно быть осторожным в использовании языков, которые не слишком хороши, ведь в конечном итоге можно застрять, и придётся использовать их лет 20 [как с PHP — прим. переводчика].Ниже я приведу список моих основных претензий к Go; некоторые из них встречаются довольно часто, а некоторые весьма редки.
Также я буду приводить сравнения с языками Rust и Haskell (которые я считаю хорошими языками) — для того, чтобы показать, что проблемы, о которых я буду говорить, на самом деле уже решены [в других языках — прим. переводчика].
Дженерики Суть проблемы Пусть мы хотим написать код, который мы могли бы использовать для множества разных вещей. Например, если я пишу функцию для суммирования списка чисел, было бы хорошо, если бы я мог использовать её и для списков чисел с плавающей точкой, списков целых чисел, списков элементов любых других типов, которые также могут быть просуммированы. Ещё было бы круто, если бы этот код обеспечивал такие же типобезопасность и скорость, как и отдельные функции для каждого типа — функции суммирования списков целых чисел, списков чисел с плавающей точкой и т.д. Правильный подход: дженерики с ограничениями и параметрический полиморфизм Я думаю, лучшие из существующих реализаций дженериков — те, что присутствуют в языках Rust и Haskell (они ещё могут называться «системами с ограниченными типами»). Версия из Haskell называется «классы типов», а вариант из Rust — «трейты» [или «примеси»/«миксины», в зависимости от перевода — прим. переводчика]. Выглядят он примерно так:(Rust, версия 0.11)
fn id
Дженерики можно использовать и для определения структур данных. Например,
(Rust)
struct Stack
Если же мы хотим написать generic-функцию, которая делает что-либо с параметрами, нужно как-нибудь указать компилятору, что эта функция может работать только с параметрами, для которых определены эти действия. Например, если мы хотим определить функцию, которая складывала бы три параметра и возвращала их сумму, нам нужно объяснить компилятору, что параметры должны поддерживать сложение. Можно сделать это примерно так:
(Rust)
fn add3
Подход Go: interface{} Из-за весьма посредственной системы типов, Go имеет очень плохую поддержку generic-программирования.Подобия generic-функций пишутся достаточно легко. Например, вы хотели бы написать функцию, выводящую хэш любого объекта, который может быть захэширован. Для этого вы можете определить интерфейс, который позволяет делать это с гарантией типобезопасности, то есть что-то вроде
type Hashable interface { Hash () []byte }
func printHash (item Hashable) { fmt.Println (item.Hash ()) } Теперь вы можете передавать любой реализующий интерфейс Hashable объект, и статическая проверка типов тоже будет выполняться, что, в общем-то, хорошо.
Но что будет, если вы хотите определить структуру данных с generic-типами? Давайте накидаем простой тип связного списка LinkedList. Вот типичный способ сделать это в Go:
type LinkedList struct { value interface{} next * LinkedList }
func (oldNode * LinkedList) prepend (value interface{}) * LinkedList { return &LinkedList{value, oldNode} }
func tail (value interface{}) * LinkedList { return &LinkedList{value, nil} }
func traverse (ll * LinkedList) { if ll == nil { return } fmt.Println (ll.value) traverse (ll.next) }
func main () { node:= tail (5).prepend (6).prepend (7) traverse (node) } Заметили что-нибудь? Тип поля value — interface{}. Здесь interface{} — то, что мы называем «базовым типом», что означает, что все остальные типы наследуются от него. Это прямой эквивалент класса Object в Java. Чёрт побери.(Примечание: есть некоторые разногласия о том, существует ли базовый тип в Go, поскольку в Go подразумевается отсутствие подтипов. Несмотря на это, аналогия остаётся.)
«Правильный» путь для построения generic-структур в Go — приведение сущностей к базовому типу и последующее добавление их в структуру данных. Это примерно то, как было принято в Java году так в 2004-м. Затем люди поняли, что этот подход полностью ломает систему типов. Когда структуры данных используются таким образом, все преимущества строгой системы типов просто испаряются [на самом деле не вижу здесь большой проблемы — вместо базового interface{} можно, в принципе, указывать более конкретный интерфейс, приводя к нему конкретные реализации и не руша таким образом проверку типов — прим. переводчика].
Например, вот абсолютно корректный код:
node:= tail (5).prepend («Hello»).prepend ([]byte{1,2,3,4}) Это лишает хорошо структурированную программу её преимуществ. Например, метод ожидает в качестве параметра связный список целых чисел, но вдруг какой-то усталый, упоровшийся кофе программист с дедлайном на носу внезапно передаст строку. И компилятор ничего не заметит, потому что generic-структуры в Go ничего не знают о типах их значений, и однажды программа просто упадёт на приведении к interface{}.
Аналогичные проблемы могут возникнуть с любыми generic-структурами — даже с list, map, graph, tree, queue.
Расширяемость языка Суть проблемы Языки высокого уровня часто имеют ключевые слова и символы, являющиеся сокращениями для более сложным задач. Например, во многих языках есть способ обхода всех элементов коллекций данных, хотя бы тех же массивов:(Java):
for (String name: names) { … } (Python):
for name in names: … Было бы хорошо, если бы мы могли использовать подобный синтаксический сахар для любой коллекции, а не только для встроенных в язык (вроде массивов).
Также было бы удобно, если бы мы могли определять для наших типов операции вроде сложения и писать штуки вроде таких:[речь, например, о перегрузке операторов — прим. переводчика]
point3 = point1 + point2 Правильный подход: операторы — это функции Хорошее решение — сделать операторы «ярлыками» к соответствующим функциям/методам, а ключевые слова — псевдонимами к стандартным функциям/методам.Некоторые языки: Python, Rust, Haskell и т.д. — разрешают переопределять операторы [а Haskell ещё и определять свои собственные — прим. переводчика]. Всё, что нужно сделать — написать методы класса, и тогда при использовании какого-нибудь оператора (например,»+») просто вызывается соответствующий метод. В Python оператор »+» вызывает __add__(). В Rust »+» определён в трейте Add как метод add (). В Haskell »+» соответствует функции +, определённой в типе Num.
Многие языки поддерживают способ расширения области применения разных ключевых слов вроде for-each. В Haskell нет циклов, но в языках вроде Rust, Java и Python есть итераторы, дающие возможность использовать for-each с любыми коллекциями любых типов.
Обратная сторона заключается в том, что можно переопределять операторы так, что они будут делать что-то совершенно не интуитивное. Например, быдлокодер [ориг. «crazy coder» — прим. переводчика] может определить оператор »-» как умножение двух векторов, но это всё же проблема не самой перегрузки операторов, поскольку убого называть функции можно в любом языке.
Подход Go: отсутствует Go не поддерживает перегрузку операторов и расширение применения ключевых слов.Но что, если мы вдруг захотим использовать ключевое слово range с чем-нибудь ещё — с деревом или со связным списком? Не получится. Язык это не поддерживает. Использовать range можно только со встроенными структурами. То же самое и с make — его можно использовать для выделения памяти и инициализации только встроенных структур.
Ближайшая доступная аналогия расширяемого итератора — написание обёртки над структурой данных, возвращающей chan (канал — прим. переводчика), и последующая итерация по нему, но это медленно, сложно и может вызвать кучу багов.
Такой подход обосновывают примерно следующим: «легко понять, и код, который написан странице — и есть код, который исполняется». То есть, если Go разрешал бы расширять штуки типа range, это могло бы вызвать путаницу, потому что детали реализации range для конкретного случая могут быть неочевидными. Но для меня этот аргумент мало что значит, ведь людям приходится обходить структуры данных независимо от того, делает Go это простым и удобным или нет. Вместо того, чтобы прятать детали реализацию за range, нам приходится прятать детали реализации за другой вспомогательной функцией — не слишком-то похоже на большое улучшение. Хорошо написанный код легко читать, а плохо написанный код — сложно, и, очевидно, Go не в силах это изменить.
Базовые случаи и проверки на ошибки Суть проблемы При работе с рекурсивными структурами данных — связными списками или деревьями — мы хотим иметь способ показать, что конец структуры ещё не достигнут.Используя же функции, которые могут завершиться неудачей, или структуры данных, в которых могут отсутствовать какие-нибудь данные, мы хотим иметь возможность показать, что задачу выполнить не удастся.
Подход Go: Nil и множественный возврат значений Я собираюсь сперва поговорить о подходе Go, потому что после этого станет проще объяснить правильный подход.В Go есть nil — нулевой указатель. Я думаю, постыдно, что столь новый и современный язык — так сказать, tabula rasa — реализует эту ненужную, костыльную, приводящую к багам функциональность.Нулевой указатель имеет давнюю и богатую на баги историю. По историческим и практическим причинам, используемые данные почти никогда не хранились по адресу 0×0, поэтому указатели на 0×0, как правило, использовались для представления некоторой особой ситуации. Например, функция, возвращающая указатель, может вернуть 0×0, если она завершилась неудачей. Рекурсивные структуры данных могут использовать 0×0 для определения некоторого базового случая (как, скажем, того, что текущий узел дерева — лист, или что текущий элемент связного списка — последний). Для всего этого нулевой указатель используется и в Go.
Однако, использование нулевого указателя может быть небезопасным. Фактически этот указатель — нарушение системы типов; он позволяет создавать экземпляр типа, который на самом деле и вовсе не является типом. Крайне распространена ситуация, когда программист забыл проверить указатель на нуль, и это потенциально приводит к падениям и, в ещё более ужасном случае, к уязвимостям. Компилятор же не может просто взять и защитить от этого, потому что нулевой указатель выбивается из принятой системы типов.
К чести Go, корректно и вообще предпочтительно усиливать принятый в Go механизм множественного возврата значений, возвращая второе «неудачное» значение, если есть вероятность того, что функция завершилась неудачей. Впрочем, этот механизм легко может быть проигнорирован или неправильно использован, и, как правило, бесполезен для представления базовых случаев в рекурсивных структурах данных.
Правильный подход: алгебраические типы данных и типобезопасные виды отказов Вместо насилия над системой типов для представления ошибочных состояний или базовых случаев, мы можем использовать систему типов для безопасного сокрытия всех этих ситуаций.Допустим, мы хотим реализовать связный список. Мы хотим представить два возможных случая: во-первых, если ещё не достигнут конец списка и у текущего элемента есть данные, и, во-вторых, если достигнут конец списка. Типобезопасный путь подразумевает реализацию двух типов, соответственно представляющих один из этих случаев, и последующее объединение их вместе, используя алгебраические типы данных. Пусть у нас есть тип Cons, представляющий элемент связного списка с какими-то данными, и тип End, представляюший собой конец списка. Мы можем записать это следующим образом:
(Rust)
enum List>),
End
}
let my_list = Cons (1, box Cons (2, box Cons (3, box End)));
(Haskell)
data List t = End | Cons t (List t)
let my_list = Cons 1 (Cons 2 (Cons 3 End))
Каждый тип определяет базовый случай (End) для любого рекурсивного алгоритма, производящего операции над структурой данных. Ни Rust, ни Haskell не разрешают нулевые указатели, так что мы стопроцентно уверены, что мы никогда не столкнёмся с багами, связанными с нулевыми указателями (конечно, до тех пор, пока мы не делаем какие-нибудь безумные низкоуровневые штуки).Эти алгебраические типы данных также позволяют писать невероятно краткий (и поэтому слабо подверженный багам) код благодаря таким возможностям языка, как сопоставление с образцом, что и будет показано ниже.
Что ж, а что мы должны делать, если функция может вернуть либо не вернуть значение некоторого типа, или если структура данных может содержать, а может не содержать данные? То есть, как мы можем сокрыть состояние ошибки в нашей системе типов? Для решения этой проблемы в Rust есть кое-что, называемое Option, а в Haskell есть кое-что, именуемое Maybe.
Представьте себе функцию, которая ищет строку, начинающуюся с 'H', в массиве непустых строк, и возвращает первую подходящую строку или некоторую ошибку, если такая строка не найдена. В Go в случае ошибки пришлось бы вернуть nil. А вот как мы можем сделать это безопасно и без костылей с указателями в Rust и Haskell:
(Rust):
fn search<'a>(strings: &'a[String]) → Option<&'a str> { for string in strings.iter () { if string.as_slice ()[0] == 'H' as u8 { return Some (string.as_slice ()); } } None } (Haskell):
search [] = Nothing search (x: xs) = if (head x) == 'H' then Just x else search xs Вместо возврата строки или нулевого указателя мы возвращаем объект, который может содержать, а может не содержать строку. Мы никогда не возвращаем нулевой указатель, и разработчики, использующие search (), знают, что функция может завершить успешно или неудачно, поскольку её тип говорит об этом, и что они должны быть готовы к обоим случаям. Прощайте, связанные с нулевым указателем баги!
Вывод типов Суть проблемы Становится немного старомодно указывать тип каждой переменной в коде программы. Есть ситуации, когда тип значения очевиден: int x = 5 y = x*2 Совершенно ясно, что y тоже имеет тип int. Конечно, есть более сложные ситуации, например, вывод возвращаемого функцией типа на основе типов её параметров (или наоборот).
Правильный подход: общий вывод типов Поскольку и Rust, и Haskell основаны на системе типов Хиндли-Милнера, они оба очень хороши в выводе типов, и можно делать вот такие крутые штуки:(Haskell):
map: (a → b) → [a] → [b] let doubleNums nums = map (*2) nums doubleNums: Num t => [t] → [t] Поскольку функция (*2) принимает параметр типа Num и возвращает значение типа Num, Haskell может определить, что тип и a, и b — Num, и отсюда может вывести, что функция принимает и возвращает список значений типа Num. Это гораздо мощнее, чем те простые системы вывода типов, что поддерживаются языками вроде Go и C++. Это позволяет делать минимальное число явных указаний типов, а компилятор может правильно определить всё остальное даже в очень сложных программах.
Подход Go: оператор := Go поддерживает оператор присваивания :=, который работает следующим образом:(Go):
foo:= bar () Всё, что он делает — выводит возвращаемый функцией bar () тип и присваивает foo такой же. Получается примерно то же, что и здесь:
(C++):
auto foo = bar (); Это не слишком-то интересно. Всё, что оно делает — избавляет от двухсекундных усилий на то, чтобы посмотреть возвращаемый функциейbar () тип, и от написания нескольких символов названия типа переменной foo.
Неизменяемость состояния Суть проблемы Неизменяемость состояния (иммутабельность) — идея, суть которой в том, что значения устанавливается единственный раз в момент создания и затем не могут меняться. Преимущества такого подхода весьма очевидны: если значения неизменны, возможность появления багов, вызванных изменением структуры данных в одном месте в момент использования в другом месте, значительно уменьшается.Неизменяемость состояния также бывает полезна для некоторых типов оптимизации.
Правильный подход: неизменяемость состояния по умолчанию Программисты должны пытаться использовать неизменяемые структуры данных так часто, как это возможно. Неизменяемость состояния позволяет намного проще определить возможные побочные эффекты и безопасность программы, избавляя от целых классов возможных ошибок.В Haskell все значения неизменяемые. Если же вы хотите изменить состояние структуры данных, вам придётся создать другую структуру с нужными значениями. Это по-прежнему быстро, потому что Haskell использует ленивые вычисления и персистентные структуры данных. Rust же — системный язык программирования, поэтому он не может использовать ленивые вычисления, и неизменяемость состояния не может всегда быть такой же практичной, как в Haskell. Тем не менее, в Rust все объявляемые переменные неизменны по умолчанию [в этом случае на совсем корректно называть их переменными, но так было в оригинале — прим. переводчика], но есть и возможность изменять состояние, если она требуется. И это замечательно, потому что принуждает программиста явно указывать, что объявляемая переменная должна быть изменяемой, и это способствует применению лучших практик программирования и позволяет компилятору более качественно проводить оптимизацию.
Подход Go: отсутствует Go не поддерживает неизменяемость состояния.Управляющие конструкции Суть проблемы Управляющие конструкции [ориг. «control flow structures» — прим. переводчика] — часть того, что отличает языки программирования от машинного кода. Они позволяют нам использовать абстракции для управления исполнением программы в нужном направлении. Очевидно, все языки программирования поддерживают какие-нибудь управляющие конструкции, иначе их бы никто не использовал. Однако, есть несколько управляющих конструкций, которых мне так не хватает в Go.Правильный подход: сопоставление с образцом и составные выражения Сопоставление с образцом — действительно крутой способ работы со структурами данных и значениями. Оно похоже на case/switch на стероидах. Сравнивать с образцом можно так:(Rust):
match x { 0×1 => action_1(), 2 … 9 => action_2(), _ => action_3() }; При этом можно работать со структурами подобным образом:
deg_kelvin = match temperature { Celsius (t) => t + 273.15, Fahrenheit (t) => (t — 32) / 1.8 + 273.15 }; Предыдущий пример показывает кое-что, называемое «составным выражением» [ориг. «compound expressions» — прим. переводчика]. В языках вроде C и Go операторы if и case/switch просто направляют поток исполнения программы; они не вычисляют значения. В языках вроде Rust и Haskell оператор if и сопоставление с образцом способны вычислять значения, которые могут быть чему-нибудь присвоены. Другими словами, оператор if и сопоставление с образцом действительно могут «возвращать» значения. Вот пример с оператором if:
(Haskell):
x = if (y == «foo») then 1 else 2 Подход Go: операторы без значения в стиле C Я сейчас не хочу унижать Go — в нём есть некоторые годные управляющие структуры для определённых вещей вроде select для распараллеливания. Однако, в нём нет составных выражений и сопоставления с образцом, которые я так люблю. Go поддерживает только присвоение атомарных выражений вроде x:= 5 или x:= foo ().Программирование для встроенных систем Написание программ для встроенных систем сильно отличается от написания программ с полнофункциональными операционными системами. Некоторые языки намного лучше подходят для работы с особыми требованиями программирования для встроенных систем.Я удивляюсь немалому количеству людей, предлагающих Go в качестве языка для программирования роботов. Go не подходит для программирования для встроенных систем по ряду причин. Этот раздел не посвящён критике Go, просто Go не проектировался для этого. Этот раздел — о заблуждениях людей, рекомендующих писать на Go для встроенных систем.
Проблема №1: куча и динамическое выделение памяти Куча — участок памяти, который может быть использован для хранения произвольного количества объектов, созданных во время исполнения. Использование кучи называется динамическим выделением памяти.Как правило, неразумно использовать кучу при программировании для встроенных систем. Основная причина в том, что куча, как правило, требует немалых дополнительных издержек памяти и некоторых весьма сложных структур для управления, ни одна из которых не подходит, когда пишешь под восьмимегагерцевый контроллер с двумя килобайтами оперативной памяти.
Ещё, конечно, неразумно использовать кучу в системах реального времени (системах, которые могут отказать, если операция занимает слишком много времени), потому что количество времени, требуемого для выделения и освобождения памяти в куче, не детерминировано. Например, если ваш микроконтроллер управляет ракетным двигателем, он соснёт, если вы попытаетесь выделить некоторое количество памяти в куче и это займёт на несколько сотен миллисекунд больше, чем обычно, и это приведёт к несвоевременной регулировке клапана и сильному взрыву.
Есть и другие стороны динамического выделения памяти, которые делают его использование непригодным для эффективного программирования под встроенные системы. Например, многие языки, которые используют кучу, полагаются на сборщик мусора, который во время работы обычно приостанавливает выполнение программы, чтобы найти и удалить объекты, которые больше не используются. Это делает работу программы ещё более непредсказуемой, чем просто c использованием динамической памяти.
Правильный подход: сделать динамическую память необязательной В стандартной библиотеке языка Rust есть построенная на динамической памяти функциональность — например, boxes. Однако, компилятор поддерживает флаги для полного отключения всей использующей динамическую память функциональности и принудительной статической проверки того, что эта функциональность не используется в коде. Rust действительно позволяет писать программы вообще без использования кучи.Подход Go: отсутствует Go очень сильно завязан на использовании динамической памяти. Нет ни одного практичного способа заставить код на Go использовать только стек, но для Go это не проблема — конечно, в областях, для которых Go и предназначен.Go также не язык реального времени. Совершенно невозможно дать жёсткие гарантии времени исполнения любой достаточно большой программы. Это может немного озадачить, поэтому я объясню: Go относительно быстр, но этого недостаточно для реального времени. Есть большая разница: скорость важна для реального времени, но гораздо важнее возможность гарантировать максимальное время отклика, которое не может быть легко предсказано в случае с Go — разумеется, из-за сильного использования кучи и из-за сборки мусора.
Аналогичные проблемы возникают и в случае языков вроде Haskell, непригодных для задач реального времени или для программирования встроенных систем из-за столь же большой завязки на куче. Однако, я никогда не видел кого-нибудь, пропагандирующего Haskell в качестве языка для программирования роботов, поэтому нет необходимости это обсуждать.
Проблема №2: написание небезопасного низкоуровневого кода Когда приходится писать программы для встроенных систем, практически невозможно избежать написания небезопасного кода (небезопасно приводящего типы или использующего адресную арифметику). В C или C++ делать небезопасные вещи очень просто. Пусть мне нужно включить светодиод, записав 0xFF по адресу 0×1234, тогда я просто могу сделать следующее:(C/C++):
* (uint8_t *) 0×1234 = 0xFF; Это исключительно опасно и имеет смысл только в очень низкоуровневом системном программировании, поэтому ни Go, ни Haskell не позволяют легко делать это; это не языки для системного программирования.
Правильный подход: изоляция небезопасного кода Rust, ориентированный как на безопасность, так и на системное программирование, даёт хороший способ инструмент — блоки небезопасного кода [ориг. «unsafe code blocks» — прим. переводчика], хороший способ явного отделения опасного кода от безопасного. Вот тот же пример с записью 0xFF по адресу 0×1234 на языке Rust:(Rust):
unsafe{ * (0×1234 as * mut u8) = 0xFF; } Если бы мы попытались сделать это вне блока небезопасного кода, компилятор бы громко возмутился. Это позволяет нам делать все те нерадостные, но необходимые опасные операции, присущие программированию для строенных систем, при этом максимально сохраняя безопасность кода.
Подход Go: отсутствует Go не заточен под такие вещи и не имеет для них встроенной поддержки.TL; DR Вы всё ещё можете сказать: «Но почему ж Go нехороший? Это просто список жалоб; жаловаться можно вообще на любой язык!». Это правда; нет совершенного языка. Однако, надеюсь, моё нытьё всё же немного показало, что: Go не делает ничего нового; Go не был великолепно спроектирован с нуля; Go — шаг назад по сравнению с другими современными языками.