Хабрахабр

Если вы подумываете начать писать на Go, то вот что вам следует знать

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

Го - это портируемый Си

Для кого эта статья

Я сам Си++/Python разработчик и могу сказать, что это сочетание является один из оптимальнейших для освоения Go. И вот почему:

  • Go очень часто используется для написания backend-сервисов и очень редко для всего остального. Существует ещё две популярные пары для этого же: Java/C# и Python/Ruby. Go, на мой взгляд, нацелен именно на то, чтобы забрать долю у пары Python/Ruby.
  • Go наследует своё странное поведение именно из нюансов синтаксиса Си, неочевидно спрятанных в языке. Поскольку в Go есть чёткие моменты отторжения до такой степени, что порой хочется удалить компилятор Go и забыть, то понимание принципов Си и того, что Go в каком-то смысле является надмножеством Си, позволяет их существенно сгладить.

Go ей ни разу не конкурент, по крайней мере пока он молод (речь про версию Go 1. Что по-поводу пары Java/C#? 11).

Чего не будет в статье

  • Мы не будем говорить о том, что Go плох, так как в нём нет фичи X, как в языке Y. У каждого языка свои правила игры, свои подходы и свои поклонники. Хотя кого я обманываю, конечно же об этом нам придётся поговорить.
  • Мы не будем сравнивать напрямую интерпретируемые и компилируемые языки.

Только конкретные случаи дискомфорта, которые доставляет язык в работе. А что будет?

Начало работы

Хорошим вводным по языку мануалом является короткая онлайн книга Введение в программирование на Go. Читая которую вы довольно быстро наткнётесь на странные особенности. Приведём для начала первую партию из них:

Странности компилятора

Поддерживаются только египетские скобки

Поддерживаются только египетские скобки, то есть следующий код не компилируется:

package main func main() // Не компилируется

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

Многострочные перечисления должны заканчиваться запятой

a := []string{ "q" // Нет запятой, не компилируется
}

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

Не использовал переменную? Не компилируется!

Нет, это не шутка.

package main func main() { a := []string{ "q", } // Не компилируется, переменная не использована
}

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

Неиспользуемые параметры приходится заглушать и это смотрится странно, хотя в питоне так тоже можно:

for _, value := range x { total += value
}

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

«Безопасный» язык

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

Вот цитата одного из создателей языка:

Они, как правило, весьма молоды, идут к нам после учебы, возможно изучали Java, или C/C++, или Python. «Ключевой момент здесь, что наши программисты (прим.пер.: гуглеры) не исследователи. Именно поэтому язык должен быть прост для понимания и изучения.» Они не в состоянии понять выдающийся язык, но в то же время мы хотим, чтобы они создавали хорошее ПО.

Спионерено отсюда: Почему дизайн Go плох для умных программистов.

Так значит вы говорите безопасный язык?

var x map[string]int
x["key"] = 10

и после запуска программы получаем:

panic: runtime error: assignment to entry in nil map

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

Таким образом, подобные ошибки программистов не способны привести к появлению уязвимостей.
Хабраюзер tyderh замечает, что:
Безопасность заключается в том, что при выполнении отлавливается ошибка, а не происходит неопределённое поведение, способное произвольным образом изменить ход выполнения программы.

Следующий пример:

var i32 int32 = 0 var i64 int64 = 0 if i64 == i32 { }

Вызовет ошибку компиляции, что как бы нормально. Но поскольку в Go пока (пока!) нет шаблонов, то очень часто они эмулируются через интерфейсы, что может рано или поздно вылиться в такой код:

package main import ( "fmt"
) func eq(val1 interface{}, val2 interface{}) bool { return val1 == val2
} func main() { var i32 int32 = 0 var i64 int64 = 0 var in int = 0 fmt.Println(eq(i32, i64)) fmt.Println(eq(i32, in)) fmt.Println(eq(in, i64))
}

Этот код уже компилируется и работает, но не так как ожидает программист. Все три сравнения выдадут false, ибо сначала сравнивается тип интерфейсов, а он разный. И если в данном случае ошибка явно бросается в глаза, в реальности она может быть сильно размыта.

Разыменование в языке убрано, а вот спецэффекты в зависимости от вида доступа от доступа (по указателю или по копии) остались. Ну и завершая про безопасность. Поэтому следующий код:

package main import "fmt" type storage struct { name string
} var m map[string]storage func main() { m = make(map[string]storage) m["pen"] = storage{name: "pen"} if data, ok := m["pen"]; ok { data.name = "-deleted-" } fmt.Println(m["pen"].name) // Output: pen
}

Выведет pen. А следующий:

package main import "fmt" type storage struct { name string
} var m map[string]*storage func main() { m = make(map[string]*storage) m["pen"] = &storage{name: "pen"} if data, ok := m["pen"]; ok { data.name = "-deleted-" } fmt.Println(m["pen"].name) // Output: -deleted-
}

Выведет "-deleted-", но пожалуйста, не ругайте сильно программистов, когда они на эти грабли наступят, от этого в «безопасном» языке их не спасли.

В чём же отличие в этих чёртовых кусках?

В одном примере:

m = make(map[string]storage)

а в другом:

m = make(map[string]*storage)

Ха, вы думали всё? Я тоже так думал, но неожиданно напоролся ещё на одни грабли:

Наступить на грабли

package main import "fmt" var globState string = "initial" func getState() (string, bool) { return "working", true
} func ini() { globState, ok := getState() if !ok { fmt.Println(globState) }
} func main() { ini() fmt.Println("Current state: ", globState)
}

Возвращает initial и это верно ибо оператор := создаёт новые локальные переменные. А его мы вынуждены были использовать из-за переменной ok. Опять таки всё верно, но изначально строчка

globState, ok := getState()

могла выглядеть как

globState = getState()

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

А это значит, что теперь нам надо у PVS просить статический анализатор для языка Go.

Краткий вывод: безопасность присутствует, но она не абсолютна от всего.

«Единообразный» язык

Выше в разделе странности компилятора было указано, что при неверном форматировании кода, компилятор упадёт. Я предположил, что это было сделано для единообразия кода. Посмотрим насколько код единообразный.
Вот например, два способа выделить память:

make([]int, 50, 100)
new([100]int)[0:50]

Ну да, ну да, это просто фишка функции new, которую мало кто использует. Ладно будем считать это не критичным.

Вот например, два способа создать переменную:

var i int = 3
j := 6

Ладно, ладно, var используется реже и в основном для резервирования под именем определённого типа или для глобальных переменных неймспейса.

Ладно, с натяжкой будем считать Go единообразным языком.

«Колбасный» код

А вот ещё частая проблема, конструкция вида:

result, err := function()
if err != nil { // ...
}

Это типичный кусок кода на Go, назовём его условно колбасой. Среднестатистический код на Go состоит на половину из таких колбас. При этом первая колбаса сделана так result, err := function(), а все последующие так result, err = function(). И в этом не было бы проблемы, если бы код писался только один раз. Но код — штука живая и постоянно приходиться менять местами колбасы или утаскивать часть колбас в другое место и это вынуждает постоянно менять оператор := на = и наоборот, что напрягает.

«Компактный» язык

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

Сейчас ноябрь 2018 и все Go программисты ожидают версию 2. И в первую очередь из-за «колбас», о которых я упоминал чуть выше. Рекомендую статью по ссылке выше, в ней суть проблемы «колбасного» кода разъяснена наглядно. 0, потому что в нём будет новая обработка ошибок, которая наконец покончит с колбасами в таком количестве.

По прежнему будет не хватать конструкций in и not in. Но новая обработка ошибок не устранит все проблемы компактности. На текущий момент проверка нахождения в map значения выглядит так:

if _, ok := elements["Un"]; ok {
}

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

Молодой язык и бедный синтаксис

К Go существует очень много написанного кода. И есть просто потрясающие вещи. Но не редко вы выбираете между очень плохой библиотекой и просто приемлемой. Например SQL JOIN в одном из лучших ORM в GO (gorm) выглядит так:

db.Table("users").Select("users.name, emails.email").Joins("left join emails on emails.user_id = users.id").Scan(&results)

А в другом ORM вот так:

query := models.DB.LeftJoin("roles", "roles.id=user_roles.role_id"). LeftJoin("users u", "u.id=user_roles.user_id"). Where(`roles.name like ?`, name).Paginate(page, perpage)

Что ставит пока под сомнение вообще необходимость использовать ORM ибо нормальной поддержки защиты от переименования полей не везде просто нет. И ввиду компилируемой природы языка может и не появиться.

А вот один из лучших образцов компактного роутинга в вебе:

a.GET("/users/{name}", func (c buffalo.Context) error { return c.Render(200, r.String(c.Param("name")))
})

Не то чтобы здесь было что-то плохое, но в динамических языках код обычно выглядит более выразительным.

Спорные недостатки

Публичные функции

Угадайте, как сделать функцию публичной для использования в других пакетах? Здесь есть два варианта: либо вы знали или никогда бы не угадали. Ответ: зарезервированного слова нет, нужно просто назвать функцию с большой буквы. В это вляпываешься ровно один раз и потом привыкаешь. Но как питонист помню про правило «явное лучше неявного» и предпочёл бы отдельное зарезервированное слово (хотя если вспомнить про двойное подчёркивание в питоне, то чья бы корова мычала).

Многоэтажность

Если вам нужен словарь объектов, то вы напишите что-то такое:

elements := map[string]map[string]string{ "H": map[string]string{ "name": "Hydrogen", "state": "gas", }, }

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

elements := map[string](map[string]string){ }

Но это всё, что позволит вам форматтер go fmt, который почти наверняка будет использоваться в вашем проекте для переформатирования кода при сохранении. Все остальные вспомогательные пробелы будут выпилены.

Атомарные структуры

Их нет. Для синхронизации надо явно использовать мьютексы и каналы. Но «безопасный язык» не будем вам пытаться мешать писать одновременно из разных потоков в стандартные структуры и получать падение программы.
helgihabr любезно напомнил, что в 1.9 появился sync.Map.

Тестирование

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

if result != 1 { t.Fatalf("result is not %v", 1) }

Понимая ущербность данного подхода, мы сразу нашли в сети библиотеку, реализующую assert и доработали её до вменяемого состояния. Можно брать и использовать: https://github.com/vizor-games/golang-unittest.

Теперь тесты выглядят так:

assert.NotEqual(t, result, 1, "invalid result")

Две конвертации типов

В языке сущность интерфейса используется, чтобы заткнуть «бедность» синтаксиса языка. Выше уже был пример с реализацией шаблонов через интерфейсы и неявным вредным спецэффектом, порождённым этим случаем. Вот ещё один пример из этой же серии.
Для преобразования типов можно использовать обычную конструкцию в Си-стиле:

string([]byte{'a'})

Но не пытайтесь применить её к интерфейсам, ибо для них синтаксис другой:

y.(io.Reader)

И это довольно долго будет вас путать. Я для себя нашёл следующее правило для запоминания.
Преобразование слева называется conversion, его корректность проверяется при компиляции и в теории для констант может производится самим компилятором. Такое преобразование аналогично static_cast из Си++.
Преобразование справа называется type assertion и выполняется при выполнении программы. Аналог dynamic_cast в Си++.

Исправленные недостатки

Пакетный менеджер

vgo одобрен, поддерживается JetBrains GoLand 2018.2, для остальных IDE как временное решение подойдёт команда:

vgo mod -vendor

Да, это выглядит как небольшой костыль сбоку, но это отлично работает и просто реализует ваши ожидания по версионированию. Возможно в go2 этот подход будет единственным и нативным.
В версии 1.11 эта штука уже встроена в сам язык. Так что верной дорогой идут товарищи.

Достоинства

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

  • Единый бинарник — скорее всего весь ваш проект скомпилится в единый бинарник, что очень удобно для упаковки в минималистичный контейнер и отправки на деплой.
  • Нативная сборка — скорее команда go build в корне вашего проекта соберёт этот самый единый бинарник. И вам не потребуется возиться с autotools/Makefile. Это особенно оценят те, кто регулярно возится с ошибками Си компиляторов. Отсутствие заголовочных файлов — дополнительное преимущество, которое ценишь каждый день.
  • Многопоточность из коробки — в языке не просто сделать многопоточность, а очень просто. Настолько просто, что очень часто просто импорт библиотеки в проект и использование какого-либо её примера уже может содержать явно или неявно в себе работу с многопоточностью и при этом в основном проекте ничего от этого не ломается.
  • Простой язык — обратная сторона бедности синтаксиса — возможность освоить язык за 1 день. Даже не за 1 день, а за 1 присест.
  • Быстрый язык — в виду компилируемой природы и ограниченности синтаксиса вам будет сложно выжирать много памяти и процессорного времени в ваших программах.
  • Строгая типизация — очень приятно, когда IDE в любой момент знает тип переменной и переход по коду работает как часы. Это не преимущество именно Go, но в нём оно тоже есть.
  • Защита от расширения структур — ООП в Go эмулируется структурами и методами для структур, но правило такое, что это должно лежать в одном файле. И это очень хорошо в плане анализа чужого кода, в Ruby есть паттерн подмешивания и иногда чёрт ногу сломит.
  • Отложенная деинициализация. Лучше всего иллюстрируется примером:

    package main import ( "fmt" "os" "log"
    ) func main() { file, err := os.Open("file.txt") if err != nil { log.Fatal(err) } defer file.Close() b, err := ioutil.ReadAll(file) fmt.Print(b)
    }

    Благодаря

    Close() defer file.

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

Почему так получилось

Go выглядит как надмножество Си. Об этом говорит очень многое: и похожесть синтаксиса и понимание того, как это может быть легко преобразовано в Си код. Конечно же горутины, сборка мусора и интерфейсы (а вместе с ним RTTI) нетипичны для Си, но весь остальной код легко конвертируется практически регулярками.
И вот эта природа, на мой взгляд, и диктует почти все приведённые выше странности.

Резюме

  • Go отлично подходит для быстрого написания экономных и быстрых микросервисов, при этом для этой работы годятся любые опытные разработчики с других языков. Именно в этом вопросе ему мало равных.
  • Go молод. Как верно было отмечено кем-то из комментаторов: «Идея на 5, реализация на 3». Да, как универсальный язык — на три, а чисто для микросервисов на 4. Плюс язык развивается, в нём вполне можно исправить половину описанных недостатков и он станет существенно лучше.
  • Первый месяц работы вы будете бороться с компилятором. Потом поймёте его характер и борьба пройдёт. Но этот месяц придётся пережить. Половина хейтеров языка месяц не протянули. Это надо чётко понимать.
  • Любителям STL надо сказать, что пока придётся собирать с миру по нитке. Ибо пока доступных контейнера три, не считая встроенных map и array. Остальное придётся эмулировать или искать в сторонних библиотеках.

Библиотеки

  • github.com/vizor-games/golang-unittest — нормальные человеческие assert и check для тестов, похоже на питон, вдохновлялось им же. С нормальным выводом строчек, где именно тест повалился.
  • github.com/stretchr/testify — ещё одна хорошая библиотека для тестов. Поделился esata.
  • github.com/onsi/ginkgo — ещё одна хорошая библиотека для тестов. Поделился tyderh.

Что почитать

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

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

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

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

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