Хабрахабр

[Перевод] Go: Хороший, плохой, злой

Но когда речь заходит о применении этого языка не для создания API или сетевых серверов (для чего он и был разработан), а для реализации бизнес-логики, то я считаю Gо слишком неуклюжим и неудобным. У Go есть некоторые замечательные свойства, которым посвящён раздел «Хороший». Хотя даже в рамках сетевого программирования найдётся немало подводных камней как в архитектуре языка, так и в реализации, что делает Go опасным, несмотря на его кажущуюся простоту.

Я активно использовал этот язык в предыдущем проекте при написании прокси (HTTP и TCP) для SaaS-сервиса. Я решил написать эту статью после применения Go в одном из второстепенных проектов. Мой второстепенный проект представлял собой простой API, и мне казалось, что с помощью Go я смогу быстро его написать. Работа над сетевой частью мне понравилась (я попутно изучал язык), но бухгалтерская и биллинговые части дались мне тяжело. Мне пришлось реализовать обработку данных для обсчёта статистики, и я снова столкнулся с недостатками Go. Но, как вы знаете, многие проекты в результате оказываются сложнее, чем предполагалось. Эта статья — рассказ об испытанных мной неприятностях.

Мои первые значимые программы были написаны на Pascal. Немного о себе: мне нравятся статически типизированные языки. Также я написал большое количество кода на JavaScript, потому что до недавнего времени только этот язык был доступен в браузерах. В начале 1990-х использовал Ada и C/C++, затем перешёл на Java, потом на Scala (между ними было немного Go), и недавно начал изучать Rust. Мне не нравятся императивный, функциональный и объектно-ориентированный подходы. Я чувствую себя неуютно при работе с динамически типизированными языками и стараюсь ограничить их использование простыми скриптами.

Статья длинная, так что можете ориентироваться по содержанию:

Хороший

Go прост в изучении

Почитайте Effective Go, изучите стандартную библиотеку, поиграйтесь с веб-инструментами Gorilla или Go kit, и станете весьма приличным разработчиком на Go. Это факт: если вам знакомы все виды языков программирования, вы можете с помощью "Tour of Go" изучить синтаксис Go за пару часов, а через пару дней начать писать настоящие программы.

Когда я начал изучать Go, это напомнило мне времена моего знакомства с Java: тоже простой и богатый язык, стандартная библиотек без излишеств. Всё дело во всеобъемлющей простоте языка. Благодаря простоте языка, код на Go очень легко читается, даже если блоки обработки ошибок несколько усложняют листинг (об этом ниже). Изучение Go стало приятным опытом на фоне современной тяжёлой среды Java.

Как сказал Роб Пайк: «простота сложна», и ниже мы увидим, что вас ожидает большое количество подводных камней, и что простота и минимализм препятствуют написанию DRY-кода. Но эта простота может оказаться ложной.

Простое многопоточное программирование с помощью горутин и каналов

Это небольшие потоки вычисления, отделённые от потоков вычисления ОС. Пожалуй, горутины — лучшая особенность Go.

Тем временем составляется очередь исполнения других горутин. Когда Go-программа выполняет то, что выглядит как блокирующая операция ввода-вывода, среда исполнения Go приостанавливает горутину, и возвращается к ней при возникновении события, сигнализирующего о доступности какого-то результата. Это даёт нам возможности масштабирования, характерные для асинхронного программирования, в рамках программирования синхронного.

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

Процесс безо всяких причин потреблял 90 % ресурсов процессора, а при изучении expvars выяснилось, что сейчас простаивает 600 тысяч горутин! Однажды я столкнулся с утечкой горутин в приложении: прежде чем завершиться, они ожидали закрытия канала, а тот не закрывался (стандартная проблема дедлока). Полагаю, процессор занимал их диспетчер.

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

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

Ниже мы увидим, что из-за отсутствия неизменяемости в Go использование каналов не предотвращает состояния гонки. Однако использовать каналы нужно обдуманно, поскольку при неправильном выборе размера (каналы по определению не буферизуются) могут возникать дедлоки.

Прекрасная стандартная библиотека

Есть даже парсер HTML и довольно мощный движок шаблонов, что позволяет создавать текст и HTML с автоматическим экранированием (automatic escaping) для защиты от XSS (к примеру, используется в Hugo). Стандартная библиотека Go действительно великолепна, особенно применительно к разработке сетевых протоколов или API: в ней есть HTTP-клиент и сервер, шифрование, форматы архивирования, сжатие, отправка писем и так далее.

Хотя иногда они могут выглядеть чрезмерно упрощёнными: отчасти из-за модели горутин, то есть нам нужно заботиться об операциях, «кажущихся синхронными», а отчасти потому, что несколько универсальных функций могут заменить много специализированных, как я недавно обнаружил при вычислениях времени. Различные API в целом просты и легки для понимания.

Производительность

Многие программисты приходят в Go из Python, Ruby или Node.js. Go компилируется в нативные исполняемые файлы. То же самое можно сказать про тех, кто переходит с интерпретируемых языков без распараллеливания (Node.js) или глобальной блокировки интерпретатора. Им просто сносит крышу от такой возможности, поскольку сервер способен обрабатывать огромное количество одновременных запросов. В сочетании с простотой языка это способствует популярности Go.

Зато Go лучше Java по использованию памяти и сборке мусора. Но по сравнению с Java ситуация в бенчмарках производительности не столь однозначна.

Он может потреблять больше ресурсов процессора, но в горизонтально масштабируемой архитектуре это легко решается добавлением машин. Сборщик мусора в Go спроектирован с учётом приоритетности задержки и избегания больших пауз, что особенно важно для серверов. Не забывайте, что Go создавался в Google, которой едва хватает ресурсов!

Также maps в Go используют маленькие массивы в качестве блоков памяти (bucket). По сравнению с Java, сборщик мусора в Go выполняет меньше работы: слайсы структур представляют собой смежные массивы структур, а не массивы указателей, как в Java. В результате сборщику мусора приходится выполнять меньше работы, что улучшает локальность кэша процессора.

Также Go лучше Java при использовании через командную строку: учитывая нативность исполняемых файлов, у программы на Go нет расходов на запуск, в отличие от Java, которой сначала приходится загружать и компилировать байткод.

Формат исходного кода определяется языком

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

Нравится вам это или нет, gofmt решает, как должен быть отформатирован код на Go, и эта проблема решена для всех раз и навсегда!

Стандартизированный тестовый фреймворк

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

Программы на Go очень удобны в эксплуатации

Конечно, это вовсе не такая большая проблема с учётом всё более широкого использования Docker, но отдельные исполняемые файлы ещё и уменьшают размер контейнеров. По сравнению с Python, Ruby или Node.js, установка единственного исполняемого файла — мечта инженеров по эксплуатации.

Но будьте внимательны, потому что статусы и метрики автоматически отображаются — незащищённые — в обработчике HTTP-запросов по умолчанию. Также в Go есть некоторые встроенные возможности по наблюдению с помощью пакета expvar, позволяющего публиковать внутренние статусы и метрики, и облегчающего их добавление. В Java для тех же целей есть JMX, но они гораздо сложнее в использовании.

Выражение defer помогает не забыть об очистке

Любопытно, что defer не связано с блоком кода и может появляться в любое время. Выражение defer играет ту же роль, что и finally в Java: в конце текущей функции исполняет код очистки, вне зависимости от того, как эта функция вышла. Это позволяет писать код очистки как можно ближе к коду, который создаёт то, что нужно вычистить:

file, err := os.Open(fileName)
if err != nil { return
}
defer file.Close() // use file, we don't have to think about closing it anymore

Конечно, try-with-resource в Java получается менее многословно, а в Rust ресурсы автоматически забираются, когда их владелец дропается, но поскольку Go требует явной очистки ресурсов, то и наличие соответствующего кода рядом с выделением ресурсов идёт на пользу.

Новые типы

Обычно мы кодируем тип идентификатора в имени параметра, но когда в функции в качестве параметров есть несколько идентификаторов, это становится причиной мелких багов, а некоторые вызовы путают порядок параметров. Мне нравятся типы, и меня раздражает и пугает, когда, к примеру, мы где угодно передаём идентификаторы сохранённых объектов (persisted object identifiers) в виде string или long.

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

type UserId string // <-- new type
type ProductId string func AddProduct(userId UserId, productId ProductId) func main() { userId := UserId("some-user-id") productId := ProductId("some-product-id") // Right order: all fine AddProduct(userId, productId) // Wrong order: would compile with raw strings AddProduct(productId, userId) // Compilation errors: // cannot use productId (type ProductId) as type UserId in argument to AddProduct // cannot use userId (type UserId) as type ProductId in argument to AddProduct
}

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

Плохой

Go игнорирует достижения современного проектирования языков

Также в Go есть много отсылок к Plan9, распределённой ОС, которую авторы Go создали в Bell Labs в 1980-х. В статье Less is exponentially more Роб Пайк объясняет, что Google создавал Go в качестве замены для С и С++, и его предшественником был язык Newsqueak, написанный в 1980-х.

Почему нельзя было использовать LLVM, который из коробки предоставляет большое количество целевых архитектур? Даже ассемблер Go создавался под впечатлением от Plan9. Если тебе нужно написать ассемблер, чтобы воспользоваться всеми возможностями процессора, то разве ты не будешь напрямую использовать процессорный ассемблер? Возможно, я что-то упускаю, но зачем нужно было так делать?

Или словно Go создавался системными программистами, которые ещё и компилятор смогли написать. Создатели Go заслуживают уважения, но выглядит так, словно архитектура языка создавалась в параллельной вселенной (или в лаборатории Plan9?), где не было ничего из того, что реализовали в компиляторах и архитектурах языков в 1990-х и 2000-х.

Даже не вспоминайте. Функциональное программирование? Они вам не нужны, посмотрите, какой из-за них бардак в С++! Обобщённые типы? И это несмотря на то, что слайсы, map и каналы являются обобщёнными типами, как мы увидим ниже.

Их надежды не сбылись. Go создавался как замена для С и С++, и очевидно, что его авторы не слишком смотрели по сторонам. Низкоуровневые С-разработчики яростно отвергают управляемую память, поскольку не контролируют, что и когда в ней происходит. Думаю, что главная причина заключается в сборщике мусора. Любопытно, что в Rust применён совершенно иной подход с автоматическим управлением памяти без сборщика мусора. Им нравится контролировать ситуацию, даже если это всё усложняет и открывает возможность утечек памяти и переполнений буфера.

Они получили высокую производительность и небольшое потребление ресурсов памяти/процессора/диска. С точки зрения эксплуатационных инструментов, Go нравится пользователям скриптовых языков вроде Python и Ruby. Убойным приложением для Go стал Docker, обеспечивший широкое распространение этого языка в мире DevOps. И заодно больше статичной типизации, что для них было в новинку. А расцвет Kubernetes усилил эту тенденцию.

Интерфейсы и структурные типы

Интерфейсы Go похожи на интерфейсы Java или трейты Scala и Rust: они определяют поведение, которое позднее реализуется типом (не буду называть здесь это «классом»).

Так что интерфейсы Go фактически относятся к структурной типизации. Но, в отличие от интерфейсов Java и трейтов Scala и Rust, типу не нужно явно определять, что он реализует интерфейс: он просто обязан реализовывать все функции, определённые в интерфейсе.

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

Go — не единственный язык, использующий структурную типизацию, но я нашёл у него несколько недостатков:

  • Трудно понять, какие типы реализуют конкретный интерфейс, поскольку это зависит от соответствия определения функции (function definition matching). В Java и Scala я часто встречаю интересные реализации, когда ищу классы, реализующие интерфейс.
  • Добавляя метод в интерфейс, находишь типы, которые нужно обновить, только когда они используются в качестве значения этого интерфейсного типа. И довольно долго о них просто не вспоминаешь. Чтобы избежать такой ситуации, рекомендуется использовать маленькие интерфейсы с очень небольшим количеством методов.
  • Тип может случайно реализовать интерфейс из-за соответствующих методов. Однако случайность этого события может привести к тому, что семантика реализации будет отличаться от того, что вы ожидаете от контракта интерфейса.

Дополнение: что касается недостатков интерфейсов, почитайте главу «Интерфейсные nil-значения».

Отсутствие перечислений

В Go нет перечислений, и я считаю это упущенной возможностью.

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

Это также означает, что компилятор не может проверить, является ли выражение switch исчерпывающим, и нет возможности описать разрешённые в типе значения.

Дилемма := / var

Зачем? В Go есть два способа объявления переменной и присвоения ей значения: var x = "foo" и x := "foo".

Думаю, что := изобрели для существенного упрощения обработки ошибок: Главное отличие в том, что var позволяет объявлять без инициализации (потом приходится объявлять тип), как в случае с var x string, а := требует присваивания и позволяет смешивать существующие и новые переменные.

С var:
var x, err1 = SomeFunction()
if (err1 != nil) { return nil
} var y, err2 = SomeOtherFunction()
if (err2 != nil) { return nil
}
C:=:
x, err := SomeFunction()
if (err != nil) { return nil
} y, err := SomeOtherFunction()
if (err != nil) { return nil
}

Я несколько раз попадался на этом, поскольку := (объявить и присвоить) слишком похоже на = (присвоить): Синтаксис := позволяет случайно «затенить» переменную.

foo := "bar"
if someCondition { foo := "baz" doSomething(foo)
}
// foo == "bar" even if "someCondition" is true

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

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

Давайте рассмотрим объект io. На практике, многие типы не могут быть полезны без соответствующей инициализации. File, который взят из Effective Go:

type File struct { *file // os specific
} func (f *File) Name() string { return f.name
} func (f *File) Read(b []byte) (n int, err error) { if err := f.checkValid("read"); err != nil { return 0, err } n, e := f.read(b) return n, f.wrapErr("read", e)
} func (f *File) checkValid(op string) error { if f == nil { return ErrInvalid } return nil
}

Что мы видим?

  • Вызов Name() применительно к нулевому значению File приведёт к панике, поскольку поле file содержит nil.
  • Функция Read, как и почти все остальные методы File, начинается с проверки инициализации файла.

Вам придётся использовать одну из функций-конструкторов вроде Open или Create. Так что, по сути, File с нулевым значением не только бесполезен, но и может привести к панике. А проверка правильной инициализации — это дополнительные расходы, на которые придётся идти при каждом вызове функции.

Вызовите любой метод применительно к нулевому значению html. В стандартной библиотеке есть множество типов, подобных этому, и некоторые даже пытаются делать что-то полезное со своими нулевыми значениями. Template: все будут паниковать.

Также есть серьёзная проблема с нулевым значением map: вы можете его запросить, но если попытаетесь в нём что-то сохранить, возникнет паника:

var m1 = map[string]string{} // empty map
var m0 map[string]string // zero map (nil) println(len(m1)) // outputs '0'
println(len(m0)) // outputs '0'
println(m1["foo"]) // outputs ''
println(m0["foo"]) // outputs ''
m1["foo"] = "bar" // ok
m0["foo"] = "bar" // panics!

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

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

В Go нет исключений. Хотя погодите… они есть!

Я могу с этим согласиться, трудно работать с исключениями в условиях асинхронного программирования или функционального стиля, наподобие потоков Java (не будем уточнять, что в Go первое не нужно благодаря горутинам, а последнее просто невозможно). В статье "Why Go gets exceptions right" подробно рассказано, чем плохи исключения и в чём преимущество подхода Go, который требует возврата error. В статье верно говорится, что panic «всегда фатальна для вашей программы, это конец».

В статье "Defer, panic and recover" объясняется, что делать в случае паники (нужно её ловить), и говорится: «реальный пример паники и работы с ней можно посмотреть в JSON-пакете из стандартной библиотеки Go».

Возникшая паника нейтрализуется с помощью верхнеуровневой функции unmarshal, которая проверяет тип паники и возвращает её как ошибку, если это «локальная паника», либо повторяет панику в случае ошибки иного рода (попутно теряя трассировку стека исходной паники). Действительно, в JSON-декодере есть стандартная функция обработки ошибок, которая просто паникует.

Так что исключения в Go есть, он использует их внутри себя, но вам не разрешает. Для любого Java-разработчика это выглядит как try / catch (DecodingException ex).

Любопытный факт: недавно сторонний разработчик исправил JSON-декодер, чтобы тот использовал обычное информирование об ошибках.

Злой

Кошмар управления зависимостями

Сначала процитирую Джаану Доган (Jaana Dogan, aka JBD), известную гофершу из Google, которая недавно вылила своё разочарование в Twitter:

Проблемы с управлением зависимостями регулярно портят всё удовольствие от использования этого языка. Если через год ситуация с управлением зависимостями не разрешится, я откажусь от Go и никогда к нему не вернусь.

— JBD (@rakyll) March 21, 2018

Все текущие решения — это хаки и ухищрения. Скажу просто: в Go нет управления зависимостями.

Им не нужно версионирование модулей, не нужны репозитории сторонних модулей, просто собирай всё подряд из своей текущей ветки. Здесь нужно вспомнить о том, что язык создан в Google, которая для всех своих исходных кодов использует гигантский единый репозиторий. К сожалению, в остальном интернете такой подход не работает.

Какая ещё версия? В Go добавление зависимости подразумевает клонирование репозитория исходного кода этой зависимости в ваш GOPATH. А если разным проектам нужные разные версии зависимости? Всё делается в текущую на момент клонирования мастер-ветку, вне зависимости от её содержимого. Отсутствует даже само понятие «версии». Ничего не поделаешь.

Хотите, чтобы проект был аккуратно организован в отдельной директории? Кроме того, ваш проект должен жить в GOPATH, иначе компилятор просто не найдёт его. Придётся хакать предпроектный GOPATH или мошенничать с символьными ссылками.

Пакеты инструментов управления внедряют вендоринг, и что бы вы ни клонировали, файлы блокировки (lock files) содержат Git sha1, обеспечивая воспроизводимость сборок. Сообщество разработало большое количество инструментов для создания обходных путей.

6 внедрили официальную поддержку директории vendor. Наконец, в Go 1. Также нет решения проблемы конфликта импортирований из транзитивных зависимостей, которые обычно решаются с помощью семантического версионирования. Но это вендоринг того, что вы клонировали, а не нормальное управление версиями.

Он поддерживает версии (git-теги) и содержит средство разрешения версий (version solver), соблюдающее соглашения о семантическом версионировании. Но всё же ситуация улучшается: недавно был представлен dep, официальный инструмент управления зависимостями для вендоринга. Да, и проекты всё ещё должны находиться в GOPATH. Работает пока нестабильно, но направление выбрано верное.

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

Его трудно настраивать, и о нём не вспоминаешь, пока ситуация не взорвётся при новом импортировании или когда просто захочешь запулить ветку коллеги в свой GOPATH... Управление зависимостями в Go кошмарное.

Но вернёмся к коду.

Изменяемость жёстко прописана в языке

Однако в Go можно легко скопировать всю структуру с простым присваиванием, так что можно подумать, что для реализации неизменяемости достаточно передать аргументы по значениям, лишь потратив ресурсы на копирование. В Go нельзя определить неизменяемые структуры: поля struct являются изменяемыми, а ключевое слово const к ним не применяется.

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

Чтобы было понятнее:

type S struct { A string B []string
} func main() { x := S{"x-A", []string{"x-B"}} y := x // copy the struct y.A = "y-A" y.B[0] = "y-B" fmt.Println(x, y) // Outputs "{x-A [y-B]} {y-A [y-B]}" -- x was modified!
}

Так что будьте очень аккуратны и не думайте, что получили неизменяемость, если передали параметр по значению.

Так что трудно будет организовать защитное копирование в надежде избежать состояния гонки, поскольку это потребует большого количества шаблонного кода. Есть библиотеки глубокого копирования, которые пытаются решить эту проблему с помощью (медленной) рефлексии (reflection), но толку от этого не так много, поскольку обращаться к приватным полям с помощью рефлексии нельзя. В Go даже нет интерфейса Clone, который позволил бы это стандартизировать.

Подвохи слайсов

Как объясняется в "Go slices: usage and internals", если слайс перенарезать, то ради сохранения производительности массив скопирован не будет. Со слайсами вас поджидает несколько подводных камней. Так что не забудьте применить к слайсу copy(), если хотите отделить его от оригинала. Причина достойная, но это означает, что подслайсы какого-то слайса будут являться всего лишь представлениями (view), повторяющими изменения исходного слайса.

То есть в зависимости от исходной ёмкости результат append может указывать на исходный массив, а может и не указывать. Если вы забудете применить copy(), то ситуация станет опаснее в связи с функцией append: добавление значений к слайсу приведёт к изменению массива, если у того не хватит ёмкости для хранения новых значений. В результате возможно появление трудно выявляемых, недетерминированных багов.

В этом коде показано, как влияние функции, добавляющей значения в подслайс, зависит от ёмкости исходного слайса:

func doStuff(value []string) { fmt.Printf("value=%v\n", value) value2 := value[:] value2 = append(value2, "b") fmt.Printf("value=%v, value2=%v\n", value, value2) value2[0] = "z" fmt.Printf("value=%v, value2=%v\n", value, value2)
} func main() { slice1 := []string{"a"} // length 1, capacity 1 doStuff(slice1) // Output: // value=[a] -- ok // value=[a], value2=[a b] -- ok: value unchanged, value2 updated // value=[a], value2=[z b] -- ok: value unchanged, value2 updated slice10 := make([]string, 1, 10) // length 1, capacity 10 slice10[0] = "a" doStuff(slice10) // Output: // value=[a] -- ok // value=[a], value2=[a b] -- ok: value unchanged, value2 updated // value=[z], value2=[z b] -- WTF?!? value changed???
}

Изменяемость и каналы: легко придти к состоянию гонки

Здесь применяется мантра «Не взаимодействую с помощью общей памяти, делай память общей с помощью взаимодействия». Согласованность в Go основана на CSP, использующих каналы, что делает координирование горутин гораздо проще и безопаснее по сравнению с синхронизацией общих данных. Это желаемый подход, но в реальности его невозможно применять безопасно.

Поэтому когда мы отправляем указатель в канал, для него всё кончено: мы поделились изменяемыми данными между параллельными процессами. Как мы уже видели, в Go невозможно создать неизменяемые структуры данных. То же самое касается полей struct интерфейсного типа: это указатели, и любой метод изменения, определённый интерфейсом, является приглашением к состоянию гонки. Конечно, канал структур (а не указателей) копирует отправленные в него значения, но, как мы видели, не выполняется глубокое копирование ссылок, включая слайсы и map, которые изменяемы по своей сути.

И её вероятность возрастает из-за принципиальной изменяемости слайсов и map. Так что, хотя каналы и облегчают согласованное программирование, они не предотвращают состояние гонки применительно к общим данным.

Но этот режим позволяет определять проблемы с гонкой только когда они уже возникли, то есть по большей части во время интеграции или нагрузочного тестирования, в надежде, что они спровоцируют гонку. Раз уж мы заговорили об этом: в Go есть режим определения состояния гонки, при котором в коде ищется несинхронизированный общий доступ. В production этот режим включать нельзя из-за высоких runtime-расходов, разве только временно, для отладки.

Неудобное управление ошибками

В Go вы быстро столкнётесь с ошибкой шаблона обработки ошибок, которая повторяется до умопомрачения:

someData, err := SomeFunction()
if err != nil { return err;
}

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

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

Reader: Также вас подстерегает несколько подводных камней: результирующая ошибка может быть номинальной, как, например, при чтении из вездесущего io.

len, err := reader.Read(bytes)
if err != nil { if err == io.EOF { // All good, end of file } else { return err }
}

Я считаю их довольно опасными: В статье "Error has values" Роб Пайк предлагает несколько подходов к уменьшению многословности обработки ошибок.

type errWriter struct { w io.Writer err error
} func (ew *errWriter) write(buf []byte) { if ew.err != nil { return // Write nothing if we already errored-out } _, ew.err = ew.w.Write(buf)
} func doIt(fd io.Writer) { ew := &errWriter{w: fd} ew.write(p0[a:b]) ew.write(p1[c:d]) ew.write(p2[e:f]) // and so on if ew.err != nil { return ew.err }
}

В результате любая операция, необходимая для записывающего кода, при работе которого возникла ошибка, будет исполняться, хотя мы знаем, что этого делать не следует. По сути, здесь признаётся, что постоянная проверка на ошибки создаёт трудности, и предлагается игнорировать ошибки до самого окончания последовательности записи. Мы просто теряем ресурсы из-за неудобной обработки ошибок в Go. А если это будет ещё дороже, чем получение слайса?

Поэтому в Rust 1. В Rust такая же проблема: поскольку в нём нет исключений (действительно нет, в отличие от Go), функции, которые могут сбоить, возвращают Result<T, Error> и требуют шаблонного сопоставления результат. В результате получается лаконичный код с корректной обработкой ошибок. 0 внедрили макрос try!, и учитывая его востребованность, макрос стал одним из главных свойств языка.

Перенести этот подход из Rust в Go, к сожалению, невозможно, потому что в Go нет ни обобщённых типов, ни макросов.

Интерфейсные nil-значения

Поясню: Один пользователь Reddit jmickeyd заметил странное поведение nil и интерфейсов, которое определённо можно считать недостатком языка.

type Explodes interface { Bang() Boom()
} // Type Bomb implements Explodes
type Bomb struct {}
func (*Bomb) Bang() {}
func (Bomb) Boom() {} func main() { var bomb *Bomb = nil var explodes Explodes = bomb println(bomb, explodes) // '0x0 (0x10a7060,0x0)' if explodes != nil { explodes.Bang() // works fine explodes.Boom() // panic: value method main.Bomb.Boom called using nil *Bomb pointer }
}

Почему? Код проверяет, чтобы explodes не был nil, но паники возникают в Boom, а не в Bang. Всё дело в строке println: указатель bomb ссылается на 0x0, по сути — nil, однако explodes не является nil (0x10a7060,0x0).

Первый элементы этой пары — указатель на таблицу назначения методов (method dispatch table) для реализации интерфейса Bomb типом Explodes, второй элемент — адрес реального объекта Explodes, который является nil.

Метод Boom применяется к значению, и поэтому вызов приводит к разыменованию указателей, что вызывает панику. Вызов Bang успешен, потому что он применяется к указателям на Bomb: для вызова метода нет нужды разыменовывать указатель.

Обратите внимание, что если написать var explodes Explodes = nil, тогда != nil не будет успешно выполнено.

Нужно проверить на nil оба интерфейсных значения, и если они не nil, тогда… с помощью рефлексии проверить значение, на которое ссылается объект интерфейса! Как же написать безопасный тест?

if explodes != nil && !reflect.ValueOf(explodes).IsNil() { explodes.Bang() // works fine explodes.Boom() // works fine
}

В Tour of Go целая страница посвящена объяснению этого поведения, и там ясно сказано: «Обратите внимание, что интерфейсное значение, содержащее конкретное nil-значение, само по себе не является nil». Баг или фича?

Я считаю это большим упущением в архитектуре языка, возникшим из-за упрощения реализации. Нехорошая ситуация, которая может привести к очень хитрым багам.

Теги полей struct: runtime DSL в строковых

Если вы использовали JSON в Go, то наверняка сталкивались с чем-то подобным:

type User struct { Id string `json:"id"` Email string `json:"email"` Name string `json:"name,omitempty"`
}

Они «видимы через рефлексивный интерфейс (reflection interface) и участвуют в идентификации struct’ов, но в остальном игнорируются». Это теги struct, которые спецификация называет строковыми. И паникуйте во время runtime, если синтаксис ошибочный. Так что помещайте в эти строковые что угодно, и во время runtime парсите с помощью рефлексии.

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

Почему в Go решили использовать обычную строковую, которую любая библиотека может использовать с любым DSL, парсящимся во время runtime?

Вот пример из буфера протокола из документации Go: Всё становится ещё сложнее, когда вы используете несколько библиотек.

type Test struct { Label *string `protobuf:"bytes,1,req,name=label" json:"label,omitempty"` Type *int32 `protobuf:"varint,2,opt,name=type,def=77" json:"type,omitempty"` Reps []int64 `protobuf:"varint,3,rep,name=reps" json:"reps,omitempty"` Optionalgroup *Test_OptionalGroup `protobuf:"group,4,opt,name=OptionalGroup" json:"optionalgroup,omitempty"`
}

Потому что в публичных полях в Go нужно использовать UpperCamelCase, или хотя бы начинать с заглавной буквы, в то время как соглашение по именованию полей в JSON подразумевает использование lowerCamelCase или snake_case. Примечание: почему эти теги столь часто применяются с JSON? В результате приходится применять утомительное тегирование.

Вероятно, этим объясняется, почему все поля в Docker API именованы с помощью UpperCamelCase: его разработчикам не приходится писать громоздкие теги для своих больших API. Стандартный кодировщик/декодер JSON не разрешает использовать стратегию именования для автоматизации преобразования, как это делает Jackson в Java.

Обобщённых типов нет… по крайней мере, для вас

И как мы увидим, это ещё хуже, чем если бы их не было вовсе. Трудно представить себе современный, статически типизированный язык без обобщённых типов, но именно таким и является Go: в нём нет обобщённых типов… или, точнее, почти нет.

Объявление map [string]MyStruct ясно свидетельствует об использовании обобщённого типа с двумя параметрами. Встроенные слайсы, map, массивы и каналы являются обобщёнными типами. И это хорошо, потому что допускает типобезопасное программирование с поимкой ошибок всех видов.

Это означает, что вы не можете типобезопасным способом определить многократно используемые абстракции, способные работать с любыми типами. Однако в Go отсутствуют определяемые пользователями обобщённые структуры данных. Любая ошибка будет поймана только в runtime и приведёт к панике. Придётся использовать нетипизированный interface{} и приводить значения к соответствующему типу. 0 2004 года. Для Java-разработчиков эта ситуация аналогична JSE 5.

Прекрасно, ты можешь не любить наследование (я пишу много кода на Scala и стараюсь избегать наследования), но обобщённые типы помогают решать другую задачу: многократное использование с сохранением типобезопасности. В статье "Less is exponentially more" Роб Пайк почему-то относит обобщённые типы и наследование к «типизированному программированию» и говорит, что предпочитает композицию, а не наследование.

Как мы увидим дальше, разделение на встроенные типы с обобщёнными и пользовательские без обобщённых влияет не только на «комфорт» разработчиков и типобезопасность при компилировании — это влияет на всю экосистему Go.

В Go мало структур данных помимо slice и map

В свежих версиях Go добавлены пакеты контейнеров, которые чуть улучшили ситуацию. В экосистеме Go мало структур данных, предоставляющих дополнительную или какую-то иную функциональность из встроенных slice и map. И у всех одно слабое место: они работают со значениями interface{}, поэтому вы теряете типобезопасность.

Map — это согласованная map с более низкой конкуренцией за поток исполнения (thread contention) по сравнению с защитой обычной map с помощью мьютекса: Разберём пример с sync.

type MetricValue struct { Value float64 Time time.Time
} func main() { metric := MetricValue{ Value: 1.0, Time: time.Now(), } // Store a value m0 := map[string]MetricValue{} m0["foo"] = metric m1 := sync.Map{} m1.Store("foo", metric) // not type-checked // Load a value and print its square foo0 := m0["foo"].Value // rely on zero-value hack if not present fmt.Printf("Foo square = %f\n", math.Pow(foo0, 2)) foo1 := 0.0 if x, ok := m1.Load("foo"); ok { // have to make sure it's present (not bad, actually) foo1 = x.(MetricValue).Value // cast interface{} value } fmt.Printf("Foo square = %f\n", math.Pow(foo1, 2)) // Sum all elements sum0 := 0.0 for _, v := range m0 { // built-in range iteration on map sum0 += v.Value } fmt.Printf("Sum = %f\n", sum0) sum1 := 0.0 m1.Range(func(key, value interface{}) bool { // no 'range' for you! Provide a function sum1 += value.(MetricValue).Value // with untyped interface{} parameters return true // continue iteration }) fmt.Printf("Sum = %f\n", sum1)
}

И ещё одна причина — в Go есть две категории структур данных: Прекрасная иллюстрация, почему в экосистеме Go так мало структур данных: их трудно использовать по сравнению со встроенными слайсами и map.

  • аристократия, встроенные слайсы, map, массивы и каналы: типобезопасные и обобщённые, удобные в использовании с range,
  • и весь остальной код на Go: нет типобезопасности, неудобно использовать из-за необходимости приведения значений (casts).

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

Вот пример сортировки слайса из пакета sort стандартной библиотеки: Дуализм встроенных структур и остального кода вредит и тогда, когда мы хотим писать многократно используемые алгоритмы.

import "sort" type Person struct { Name string Age int
} // ByAge implements sort.Interface for []Person based on the Age field.
type ByAge []Person func (a ByAge) Len() int { return len(a) }
func (a ByAge) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByAge) Less(i, j int) bool { return a[i].Age < a[j].Age } func SortPeople(people []Person) { sort.Sort(ByAge(people))
}

Мы вынуждены определять новый тип ByAge, который должен реализовывать три метода, чтобы соединить обобщённый (в смысле «многократно используемый») алгоритм сортировки и типизированный слайс. Погодите… Серьёзно?

Всё остальное — шум и шаблонный код, необходимые лишь потому, что в Go нет обобщённых типов. Единственное, что должно заботить нас, разработчиков, — функция Less, которая сравнивает два объекта и предметно-зависима (domain-dependent). И для каждого компаратора. И всё это приходится повторять для каждого типа, который мы хотим сортировать.

Slice. Обновление: мне указали на упущенный мной sort. Выглядит лучше, хотя под капотом использует рефлексию (ой!) и для сортировки требует наличия завершения слайса в виде функции-компаратор, что выглядит уродливо.

Когда утверждают, что Go не нуждается в обобщённых типах, это всегда объясняют «путём Go», который позволяет иметь многократно используемые алгоритмы, избегая приведения к дочернему типу (downcasting) interface{}...

Тогда для облегчения ситуации было бы неплохо иметь макросы, способные генерировать этот нелепый шаблонный код, верно? Ну ладно.

go generate: неплохо, но...

4 появилась команда go:generate для запуска генерирования кода из аннотаций исходного листинга. В Go 1. Если вставите пробел, ни один инструмент вас об этом не предупредит. Ну, под «аннотациями» подразумеваются волшебные комментарии //go:generate со строгими правилами: «комментарий должен начинаться в начале строки и не иметь пробелов между // и go:generate».

Таким образом решаются две задачи:

  • Генерирование Go-кода из других источников: схем ProtoBuf / Thrift / Swagger, языковых грамматик (language grammars) и так далее.
  • Генерирование Go-кода, дополняющего существующий код, вроде stringer, который генерирует метод String() для ряда типизированных констант.

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

Stringer импортирует парсер компилятора Go для прохождения AST. Что касается второго случая, то многие языки, в том числе Scala и Rust, поддерживают макросы (упомянутые в документации по архитектуре), которые обращаются к AST исходного кода во время компилирования. В Java такого макроса нет, но ту же роль играют обработчики аннотаций.

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

Кстати, вы знали, что в компиляторе Go есть аннотации/прагмы и условное компилирование, использующие этот синтаксис комментариев?

Заключение

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

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

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

Думаю, эти утверждения становятся всё менее верными. Что касается технических аспектов, то в сети есть статьи, утверждающие, что Rust и Go не конкурируют друг с другом, что Rust — это системный язык, поскольку в нём нет сборщика мусора, и тому подобное. Он наделяет приятной уверенностью, что «если код скомпилировался, то ошибки связаны с написанной мной логикой, а не особенностями языка, про которые я забыл». Rust поднимается всё выше в списке замечательных веб-фреймворков и хороших ORM’ов.

Компания Buoyant (разработчик Linkerd) создаёт новый Kubernetes-service mesh Conduit, в котором Go используется на уровне управления (вероятно, благодаря доступным Kubernetes-библиотекам), а Rust, благодаря своей эффективности и надёжности, — на уровне работы с данными. В сфере контейнеров/service mesh сегодня наблюдаются интересные изменения, связанные с прокси Sozu, написанным на Rust.

Хотя его экосистема всё ещё слишком Apple-центрична, несмотря на доступность языка под Linux и на развитие серверных API и фреймворка Netty. Swift тоже начинает рассматриваться как альтернатива C и C++.

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

Несколько дней спустя...

Она попала на главные страницы Hackernews (доходила до третьего места) и /r/programming (доходила до пятого места), а также получила поддержку в Twitter. Через три дня после публикации: реакция на статью оказалась невероятной.

Конечно, людям на /r/rust понравился мой интерес к Rust. Комментарии, в целом, положительные (даже на /r/golang/), или хотя бы отмечают сбалансированность статьи и стремление к честности. Спасибо за все ваши усилия». Мне даже написал какой-то незнакомец: «Хочу лишь сказать, что ваш текст — самый лучший.

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

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

Делая серьёзную и честную работу, ты получаешь много хороших отзывов в интернете (если игнорировать несколько троллей и вечно недовольных). Я писал статью по вечерам две недели, но это было интересно. Очень мотивирует писать более глубокие статьи!

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть