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


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


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


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



Хороший


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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 игнорирует достижения современного проектирования языков


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


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


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


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


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


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


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


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


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


Вы можете подумать, что это нужно для реализации интерфейсов в других пакетах, а не в типе, к которому они относятся, по аналогии с расширениями классов в 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, начинается с проверки инициализации файла.

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


В стандартной библиотеке есть множество типов, подобных этому, и некоторые даже пытаются делать что-то полезное со своими нулевыми значениями. Вызовите любой метод применительно к нулевому значению 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 нет исключений. Хотя погодите… они есть!


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


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


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


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


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


Злой


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


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


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


— JBD (@rakyll) March 21, 2018


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


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


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


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


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


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


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


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


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


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


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


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


Однако при этом не будут скопированы значения, на которые ссылаются указатели. И поскольку встроенные коллекции (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", если слайс перенарезать, то ради сохранения производительности массив скопирован не будет. Причина достойная, но это означает, что подслайсы какого-то слайса будут являться всего лишь представлениями (view), повторяющими изменения исходного слайса. Так что не забудьте применить к слайсу copy(), если хотите отделить его от оригинала.


Если вы забудете применить copy(), то ситуация станет опаснее в связи с функцией append: добавление значений к слайсу приведёт к изменению массива, если у того не хватит ёмкости для хранения новых значений. То есть в зависимости от исходной ёмкости результат 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 невозможно создать неизменяемые структуры данных. Поэтому когда мы отправляем указатель в канал, для него всё кончено: мы поделились изменяемыми данными между параллельными процессами. Конечно, канал структур (а не указателей) копирует отправленные в него значения, но, как мы видели, не выполняется глубокое копирование ссылок, включая слайсы и map, которые изменяемы по своей сути. То же самое касается полей struct интерфейсного типа: это указатели, и любой метод изменения, определённый интерфейсом, является приглашением к состоянию гонки.


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


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


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


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


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

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


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


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


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


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


Обратите внимание, что если написать 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"`
}

Это теги struct, которые спецификация называет строковыми. Они «видимы через рефлексивный интерфейс (reflection interface) и участвуют в идентификации 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"`
}

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


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


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


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


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


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


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


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


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


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


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


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 так мало структур данных: их трудно использовать по сравнению со встроенными слайсами и map. И ещё одна причина — в Go есть две категории структур данных:


  • аристократия, встроенные слайсы, 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, который должен реализовывать три метода, чтобы соединить обобщённый (в смысле «многократно используемый») алгоритм сортировки и типизированный слайс.


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


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


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


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


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


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


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


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

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


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


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


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


Заключение


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


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


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


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


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


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


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


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


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


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


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


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


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

Комментарии (48)


  1. tangro
    20.04.2018 10:54
    -1

    Люди зачем-то пытаются писать вне Гугла на языке, который Гугл придумал исключительно сам для себя. И удивляются, что получается плохо.


    1. aPiks
      20.04.2018 12:27

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


      1. anurbol
        21.04.2018 14:05

        за что заминусовали интересно? за ангулар?


        1. aPiks
          22.04.2018 20:12

          Тут сидят молодые разработчики на Реакте, которым сказали на гурсах гикбрейнса, что Реакт — совершенство, а Ангулар — зло. Теперь они ходят по комментариям и минусуют все записи, где фигурирует Ангулар. Ну-ка товарищи, которые минусуют, скажите альтернативу первому Ангулар, когда он только вышел?


    1. JekaMas
      20.04.2018 12:43

      Потому что неплохо так получается. Да и разве в языке дело, а не в инженерных задачах и подходах к их решению?


      1. tangro
        23.04.2018 12:25
        +1

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


        1. JekaMas
          23.04.2018 12:28

          Написано: сервис массового импорта товаров, обработки изображений под highload, мобильный back для мессенджера, протокол обмена сообщениями в Ethereum, куски самого Ethereum, всякие мониторинги до кучи.
          Где что-то из этого совпадает с Гуглом, кроме мониторингов?


          1. tangro
            23.04.2018 15:58

            Думаете у гугла нет сервисов массового импорта, обработки изображений или бекендов мессенджеров? Именно для такого го и делался. Мне вот было бы интересно посмотреть на его применение в чём-то десктопном (с UI), в системном программировании, в мобильной разработке. Но таких кейсов нет, всё сводится к «принять 2 байта по сети, отправить 2 байта по сети».


            1. JekaMas
              23.04.2018 16:47
              -1

              Повторюсь, ну вот я сейчас в мобильной разработке пишу back. То есть гошка крутится к клиента на телефоне и подключается к фронту.


  1. Busla
    20.04.2018 10:54

    не для создания API <...>, а для реализации бизнес-логики

    странное противопоставление: API же не существует само по себе


    1. TheKnight
      20.04.2018 11:39

      Не совсем. Есть узкий спектр задач, когда необходимо реализовать внешнее API как обертку над существующим сервисом. С развязкой через tcp/rest/etc. При этом логики в таких обвязках довольно таки мало. Конвертация параметров, конвертация представления данных в запросе, etc.


  1. helgihabr
    20.04.2018 11:57

    > Главное отличие в том, что var позволяет объявлять без инициализации (потом приходится объявлять тип)
    Можно пример без инициализации? Т.к. инициализая все равно происходит.
    Считаю, что отличие в «non-declaration statement outside function body».
    Нельзя написать a := «test» вне функции.


    1. powerman
      22.04.2018 01:25

      Всё несколько глубже. Вот более-менее полный список нюансов про := и var.


      Факты:


      • явно указать тип можно только в var
      • наглядно сгруппировать (в скобках, с выравниванием) несколько переменных можно только в var
      • создать глобальные переменные может только var
      • создать переменные локальные для if, for, switch может только :=
      • задать смесь новых и существующих можно только в :=
        • использование := в этом случае избавляет от лишних строк объявляющих
          переменные да ещё и с обязательным указанием типа (что не всегда
          возможно — в переменной может хранится значение не экспортируемого
          типа возвращаемое некоторыми конструкторами)

      Выводы:


      • var более функционален для объявления новых переменных
      • var более нагляден для объявления новых переменных
      • var защитит от случайного изменения существующих переменных вместо объявления новых (go vet -shadow, go-nyet и т.п. могут детектить shadowing, что может снять претензию к := — а плюс в том, что не нужно заранее объявлять и указывать тип)

      И пара цитат из книжки Кернигана:


      • var name type или var := value в зависимости от важности инициализации начальным значением.
      • if err := ...; err != nil {} для уменьшения области видимости err.


  1. KirEv
    20.04.2018 12:47
    +3

    странно, когда в языке, вроде как с строгой типизацией, дают возможность делать много неявных вещей…

    и казалось бы, язык прост, но в ходе написания реального кода для production, простым вещам требуется много уделять внимания, что позволяет выстрелить в ногу, теми же интерфейсами, а ресурсоемкую рефлексию использовать не хочется… а про надстройки работы с типами в go мне ничего не известно…

    добавили бы уже опцию компилятора\макрос к функции: если string пытаешься свести к int и panic — возвращать 0, один фиг явность в go условная…


    1. Edison
      20.04.2018 14:48
      +2

      не сильно понял пример, в чем проблема там? Интерфейсная переменная содержит тип и указатель на значение. Конечно же когда пытаетесь сделать type assertion интерфейсной переменной в другой тип, а не в тот тип, который переменная содержит, получаете панику.


  1. beduin01
    20.04.2018 14:34
    -2

    Да давно уже понятно, что Go это тупо хайп, который уже сейчас начал спадать. Практически все кто писал на серьезных языках типа С#/Java от Go просто чертыхаются. Ибо он реально ущербный какой-то.


    1. Vadem
      22.04.2018 11:49

      Ну я, например, долгое вермя писал(и пишу) на C#/Java и мне Go нравится.


  1. wOvAN
    20.04.2018 15:06

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


    1. humbug
      20.04.2018 19:51

      Имейте уважение к автору, который утверждает обратное:


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


  1. Gizmich
    20.04.2018 19:16

    Прямо видно как комьюнити сильно разделилось на тех кто пишет на Go, но жалуется на его недостатки и тех кто хвалит Rust за его эффективность, но фактически язык не востребован на рынке. С моей точки зрения Rust конечно эффективнее и лучше задуман, но читать код на нем мне просто больно. И это как борьба асемблера с Си, Си менее эффективен, но он удобнее. п.с. может Go 2.0 спасет нас всех? ))


    1. humbug
      20.04.2018 19:52

      Я за 3 дня нашел работу на Rust. Рынок бурлит!


  1. Sergunka
    20.04.2018 19:28
    +1

    Они получили высокую производительность и небольшое потребление ресурсов памяти/процессора/диска.


    Прекрасная статья. Go решает на мой взгляд очень узкую проблему как метко заметил автор статьи. У меня была задача запустить тысячу нодов кластер и опа-на! я обнаружил, что тот же ТомКет просит под сервер с апп 380М памяти, а Го скромно укладывается и на 60М. Пришлось ударными темпами переписывать под Го.


    1. Ivanhoe
      21.04.2018 10:28

      TomCat просит 380, но сколько просил бы, скажем, Netty?


  1. Viknet
    20.04.2018 19:57

    В Rust такая же проблема: поскольку в нём нет исключений (действительно нет, в отличие от Go)

    Но ведь в Rust есть точно такой же panic и его так же можно поймать и отменить…


    1. humbug
      20.04.2018 20:01

      Подскажи как отменить панику, если в настройках стоит panic = abort?


      1. Viknet
        20.04.2018 20:09

        Никак, но это и не поведение по-умолчанию.
        Я-то в курсе, что panic в rust это совсем не то, чем кажется, просто в статье слишком безаппеляционно сказано, что исключения в Go есть, а в Rust нет.


  1. TheShock
    20.04.2018 20:30

    Автор так старался поддерживаться нейтралитета, что записал два недостатка в преимущества.

    Прекрасная стандартная библиотека
    Это скорее в недостатки. Библиотека как для нового языка — непоследовательная и непродуманная. Чего только стоит совершенно разные подходы к парсингу в flag (через ссылочную муть с тонной копипасты и никакой декларативностью) и json/xml (через теги). При это flag — совершенно не критична к производительности, ведь парсинг запускается лишь однажды!

    Стандартизированный тестовый фреймворк
    А это в «Ужасный». Серьезно, фреймворк настолько отвратительный, что лучше его бы не было. В нем просто никаких преимуществ, даже банального Assert нету, а из-за отсутствия Generic написать свой, адекватный — крайне сложно. То есть банальный Assert из C# в Go выглядит так (в синтаксисе мог ошибиться, т.к. давно не писал):
    a := fn()
    if a != 10 {
      t.Fail(fmt.Sprintf("Expect a to be (%v), actual: (%v)", 10, a);
    }



    1. powerman
      22.04.2018 01:38

      Библиотека в целом отличная. Да, там есть проблемы, как упомянутые Вами так и другие, и да, если бы вся библиотека была написана исключительно гениями из альфа-центавра, не допустившими ни одной ошибки проектирования в её коде — было бы лучше. К несчастью для нас — её писали люди. Тем не менее, в среднем код стандартной библиотеки заметно лучше среднего, намного лучше поддерживается (исключая не очень удачные пакеты, от которых решили отказаться — вроде net/rpc), и очень помогает то, что весь этот функционал в принципе есть из коробки.


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


      Тестовый фреймворк отличный. И свою задачу он выполнил — отсутствие assert-ов стимулировало попробовать писать тесты в табличном стиле, и помогло оценить этот подход. А потом assert-ы элементарно добавляются поверх, например: https://godoc.org/github.com/powerman/check


      1. TheShock
        22.04.2018 17:29

        К несчастью для нас — её писали люди
        Можете привести похожие примеры в стандартной библиотеке, к примеру, C#?

        тесты в табличном стиле
        Которые, кстати, тоже вручную пишутся. Класс. Так зачем такая надобиблиотека? И вы называете её классной просто потому что из-за неудобства вам приходится искать способ занять позу поудобнее? Очень похоже на Стокгольмский Синдром: «насильник был хороший, потому что я сам мог выбрать позу»


    1. anjensan
      23.04.2018 13:07
      +1

      Вот зря вы так про стандартную библиотеку. Она очень мощная.

      Сравниим, к примеру, банальную сортировку.
      Для Python всего то есть sorted, да у списков метод list.sort имеется. Весьма и весьма куцо. А вот у Go есть целый отдельный модуль sort. Тут и сортировка строк, и сортировка целых, и сортировка флоатов!

      Или, например, в Go есть полезнейший пакет errors с инструментами для упрощения работы с ошибками. Он даже в отдельной директории находится, настолько он важен. В Python банально нету аналога для такого модуля.


      1. poxvuibr
        23.04.2018 14:46
        +3

        А вот у Go есть целый отдельный модуль sort. Тут и сортировка строк, и сортировка целых, и сортировка флоатов!

        Я же правильно понял, что это такая издевательская ирония?


        1. anjensan
          23.04.2018 15:26
          +2

          Т.е. «не имеющий аналогов» «полезнейший пакет errors» не смутило? :)


          1. anjensan
            23.04.2018 15:52
            +2

            Кстати, «из коробки» начиная с 1.8 можно ведь сортировать и slice-ы! А чтобы это было быстро (рефлексия ведь), добавили специальную функцию reflect.Swapper. Так что библиотека не только мощная, но еще и гибкая!


  1. bgnx
    20.04.2018 23:13

    Здесь применяется мантра «Не взаимодействую с помощью общей памяти, делай память общей с помощью взаимодействия».

    Этот принцип не подходит для одной из самой важной части бэкенда — базы данных или разного рода кешей. Когда есть большая структура-граф объектов в оперативной памяти то нет никакого смысла хранить копию этой структуры в разных горутинах или потоках не говоря уже о необходимости синхронизации частичных обновлений этой структуры между ними и опасности race condition. Для таких задач нужна именно общая память и параллельный доступ к памяти из разных потоков


    1. powerman
      22.04.2018 01:42

      Похоже, Вы просто не понимаете суть подхода. Структура хранится в одной горутине — выделенном "менеджере" этой структуры. А все остальные горутины получают доступ к ней общаясь по каналам с горутиной-менеджером. Ничего копировать и синхронизировать не нужно. Единственная ситуация, в которой этот подход не очень хорошо работает — когда жёстко не хватает производительности, и замена каналов на один (или группу) мьютексов позволяет заметно всё ускорить.


      1. bgnx
        22.04.2018 12:48

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

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


        1. powerman
          22.04.2018 13:21

          Если эта структура — god object, который хранит все данные приложения — то да. Не надо так делать. А если таких структур куча (и у каждой своя горутина-менеджер) — то нет, не превратится в однопоточное.


          Насчёт RWMutex Вы правы, он позволит распараллелить доступ. Но обычный Mutex точно так же выстроит всех в одну очередь, как и горутина-менеджер.


  1. bgnx
    20.04.2018 23:34

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

    Наследование нужно для того чтобы оптимизировать логику декораторов которые в случае композиции будут создавать отдельные объекты в рантайме. Допустим у нас есть класс DBConnection объект которого представляет собой соединение с базой данных. Применяя композицию обычно создают отдельный класс Repository который представляет собой crud-операции с таблицами базы данных, который в конструкторе создает new DBConnection(...) и использует его для взаимодействия c базой данных. А вот применяя наследование вместо композиции класс Repository отнаследуется от DBConnection и добавит нужный код работы с crud. И здесь принципиальное отличие — в случае композиции при создании объекта Repository будет создано два объекта в рантайме (сам Repository и объект DBConnection) а применяя наследование — только один объект. А в случае если у на будет цепочка из 10 различных сущностей которые что-то добавляют и переопределяют то с композицией это уже 10 рантайм-объектов а с наследованием только один вне зависимости от длины этой цепочки (да хоть тысячу сущностей). В это и суть наследования — оно позволяет вынести в compile-time много работы экономя cpu-циклы и память


  1. deep_orange
    21.04.2018 07:03
    -8

    Все Ваши проблемы синтетические. Как меня задолбал Хабр подобными постами.


  1. powerman
    22.04.2018 01:46
    -1

    В общем и целом статья отличная! Немного комментариев:


    так что без проблем можно иметь сотни, и даже тысячи горутин

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


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

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


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

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


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

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


    Синтаксис := позволяет случайно «затенить» переменную.

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


    Так что исключения в Go есть, он использует их внутри себя, но вам не разрешает.

    Чушь (может просто перевод не очень удачный?). Исключения для обработки ошибок рекомендуется использовать исключительно внутри своего пакета, как в вышеупомянутом json — чтобы на тех, кто просто использует ваш пакет, это никак не отражалось и в их код случайные паники не вылетали.


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

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


    1. prospero78su
      22.04.2018 13:41

      Очень много полярных комментариев про Go, и меня даже сейчас смущает эта политизированность.
      Но, всё же, я решил взять Go как рабочий язык.
      1. Будем считать, что я слез с python (Go по духу близок, типизация это ооочень сильное послабление мне — невнимательному погроммисту)
      2. Go (на мой исключительно субъективный взгляд) — это испорченный Oberon. А Oberon я действительно люблю. Имхо, очень продуманная вещь.
      3. Стандартная библиотека почти во всём заточена под веб. Шифрование, шаблоны, обработка строк, изображения. Всё это работает с сетью, отображает результат в браузере (что тоже кросплатформенно, как и сам бинарник на выходе Go).
      4. Производительность, всё-таки, не последнее, на что смотрит владелец ресурса. 20 серверов на python или один на Go — разница есть. Конкурентность вносит свою сложность (и коллеги из лагеря Erlanga убедительно показали, что в Go она сделана совсем не лучшим образом), но всё же, эффективность исполнения кода заставляет принять Go к сведению.
      5. У Go таки есть странные решения. Типа, интерфейсов. Имхо, опасная штука с их автоматическим удовлетворением. Но стандартные типы никто не запрещал. Я пока профан в Go, так что посмотрим. Крики про отсутствие ООП мягко говоря — сильно преувеличены. Сделано своеобразно, но оно там есть.
      6. В плане разработки у Go есть явные преимущества по причине наличия интересных IDE. LiteIDE, к слову. Качество контроля кода на Go я бы оценил где-то на 50-70% выше, чем в python на этапе разработки.

      Ничего не скажу в духе книги Саммерфильда «ах, какая прелесть, ну просто паром писаю», но по сравнению с python — да, это безусловно шаг вперёд.


    1. rraderio
      23.04.2018 13:24

      Если честно, я тоже считал это проблемой
      А как быть с рефакторингом? Как IDE узнает нодо изменить имя метода или нет если в интерфейсе мы поменяли?


      1. prospero78su
        23.04.2018 15:47

        Если поменяли в интерфейсе, то менять надо однозначно. Интерфейс — это аналог абстрактного класса для методов.


        1. rraderio
          23.04.2018 16:01

          Если поменяли в интерфейсе, то менять надо однозначно
          А если Я нигде не передаю эту структуру там где ожидают этот интерфейс?



  1. rraderio
    23.04.2018 15:56

    не та ветка


  1. guai
    23.04.2018 19:54
    -2

    hype driven development как он есть :)


    1. ozkriff
      23.04.2018 19:56
      +2

      Аргументация? :)