Как стать автором
Обновить

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

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

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


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


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

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


Что по-поводу пары Java/C#? Go ей ни разу не конкурент, по крайней мере пока он молод (речь про версию Go 1.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, ибо сначала сравнивается тип интерфейсов, а он разный. И если в данном случае ошибка явно бросается в глаза, в реальности она может быть сильно размыта.

powerman поделился ещё один примером ложных ожиданий:
func returnsError(t bool) error {
	var p *MyError = nil
	if t {
		p = ErrBad
	}
	return p // Will always return a non-nil error.
}
err := returnsError(false)
if err != nil {
  # Истина
}

Интерфейс с nil не равен просто nil, будьте осторожны. В FAQ языка этот момент есть.

Ну и завершая про безопасность. Разыменование в языке убрано, а вот спецэффекты в зависимости от вида доступа от доступа (по указателю или по копии) остались. Поэтому следующий код:
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)
    }
    
    Благодаря
    defer file.Close()
    мы сразу сообщаем рантайму, что, в независимости от того каким и где будет выход из функции, в конце надо выполнить определённый код. Это сразу частично решает проблему с отсутствием деструкторов и почти полностью решает проблему отсутствующих контекстов (например питоновский with).


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


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

Резюме


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


Библиотеки для тестов


  • github.com/vizor-games/golang-unittest — нормальные человеческие assert и check для тестов, похоже на питон, вдохновлялось им же. С нормальным выводом строчек, где именно тест повалился.
  • godoc.org/github.com/powerman/check — библиотека догружающая стандартный тестовый интерфейс, привнося в неё полезные методы типа: Equal, Less,…. Поделился powerman.
  • github.com/stretchr/testify — ещё одна хорошая библиотека для тестов. Поделился esata.
  • github.com/onsi/ginkgo — ещё одна хорошая библиотека для тестов. Поделился tyderh.
  • github.com/smartystreets/goconvey — BDD подход к тестированию, выглядит очень непривычно, но может кому пригодится. Поделился negasus.

Что почитать


Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
+69
Комментарии479

Публикации

Истории

Работа

Go разработчик
132 вакансии

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн