Если вы подумываете начать писать на 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

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

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

  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: test
}


Выведет 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: test
}


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

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


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

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


Во всех не очень безопасных языках безопасность хорошо реализуется через тестирование с хорошим покрытием. В 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 для тестов, похоже на питон, вдохновлялось им же. С нормальным выводом строчек, где именно тест повалился.
  • github.com/stretchr/testify — ещё одна хорошая библиотека для тестов. Поделился esata.


Что почитать


© Habrahabr.ru