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

Этот курс предполагает, что читатель уже знаком с каким-нибудь языком программирования, поэтому содержит много сравнений и отсылок к другим языкам. Если вы не знаете ни одного языка программирования, начните с изучения языка Си, тем более, что классическая книга по нему, написанная Керниганом и Ритчи, является примером прекрасного учебника, объясняющего сложные абстракции простым и доступным языком с иллюстрациями на лаконичных и понятных примерах[kr].

Для работы с курсом вам понадобится установленный компилятор языка Go, который можно установить, воспользовавшись инструкциями на официальном сайте https://golang.org/. Также необходимо умение пользоваться командной строкой. Данный учебный материал выложен в открытом доступе под лицензией MIT. Исходный код находится по адресу https://github.com/vporoshok/go-introduction, версия для чтения — https://vporoshok.me/go-introduction/. Если вы нашли ошибку, опечатку или неточность, откройте задачу или Pull Request в репозитории или свяжитесь со мной с помощью электронной почты vporoshok@gmail.com.

Базовый синтаксис

Начнём с избитой фразы: «Go является статически компилируемым в машинный код языком с сильной статической типизацией». А теперь давайте по порядку разбираться, что же всё это означает. Если вам и так всё понятно, смело переходите параграфу Hello, World!.

Вообще компьютер умеет работать только с так называемым машинным кодом, то есть инструкциями для центрального процессора, закодированных в понятные этому процессору коды. При этом одна инструкция языка, например, print("Hello, World") разворачивается в несколько инструкций для процессора. Программа, на каком бы языке она ни была написана в итоге должна превратиться в инструкции, понятные процессору. Но пути этого превращения различны. Языки могут быть компилируемые и интерпретируемые. У компилируемых языков этап превращения исходного кода в инструкции процессора, называемый компиляцией, отделён от этапа исполнения этих инструкций. В интерпретируемых языках это преобразование делается непосредственно перед исполнением. Стоит отметить, что в Go есть сокращённая форма для компиляции и запуска: go run main.go, выглядящая со стороны как запуск интерпретатора.

С другой стороны даже процесс компиляции может быть разделён на различные этапы. Так есть языки с виртуальными машинами, такие как Java, Erlang, JavaScript и многие другие. Программы, написанные на этих языках компилируются в инструкции виртуального процессора, который эмулируется соответствующей виртуальной машиной, которая на своём уровне преобразует эти инструкции в понятные физическому процессору. То есть вводится промежуточное представление программы, не зависящее от архитектуры конечного компьютера. С другой стороны промежуточное представление программы может быть и без виртуальной машины, например, многие языки используют промежуточное LLVM-представление.

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

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

var x int = 12
var y float64 = 2.2

var res float64 = float64(x) + y

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

Динамическая и статическая типизация также различается ограничениями, так при статической типизации тип приписывается переменной один раз и не может быть изменён. Например, следующий код допустим в Python (сильная динамическая типизация), но не допустим в Go:

x = "foo"
x = 5

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

Пара слов о рантайме

Стоит заметить также, что Go имеет так называемый рантайм (runtime), функциональность, которая добавляется в любую программу при компиляции. Поэтому даже просто hello world в скомпилированном виде имеет размер почти 2 МБ (1968 КБ для версии 1.12.10 linux/amd64). Для чего же нужен этот дополнительный багаж?

Во-первых, в рантайм встроен сборщик мусора, освобождающий память, от неиспользуемых переменных. Это значительно облегчает написание кода, в сравнении с низкоуровневыми языками, такими как Си и C++. разработчик не должен сам следить за использованием памяти, вызывая вручную примитивы типа malloc и mfree, хотя для оптимизации высоконагруженных мест Go позволяет переходить к ручному управлению памятью.

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

Hello, World!

Продолжим следовать избитым клише и первой программой будет «Привет, Мир!». Создайте файл с именем main.go со следующим содержимым:

01_hello_world/main.go
1
2
3
4
5
6
7
package main

import "fmt"

func main() {
	fmt.Println("Hello, World!")
}

Теперь, если выполнить в командной строке (в директории с файлом main.go) команду

go run main.go

вы должны увидеть вывод нашей программы:

Hello, World!
Note
Все примеры, используемые в данном курсе, можно найти в директории examples в репозитории проекта https://github.com/vporoshok/go-introduction.

Давайте разбираться с тем, что происходит в этой короткой программе. Первая не закомментированная строка любого go-файла должна задавать имя пакета, которому этот файл принадлежит. Более подробно о пакетах мы поговорим соответствующей части, пока же упомянем лишь следующее:

  1. Пакетом является директория, то есть все файлы, расположенные в одной директории (за исключением тестов), должны принадлежать одному пакету. Таким образом не нужно явно подключать файл, находящийся рядом, как в том же Python или NodeJS.

  2. Внутри пакета доступны все объявления. То есть нельзя в двух соседних файлах объявлять функции, типы или константы с одинаковыми именами.

Также как в языке Си, исполняемая программа должна содержать функцию main, с которой и начинается исполнение программы. Инструкции за пределами функций, за исключением объявления глобальных переменных, запрещены. Но есть одно ограничение: функция main должна располагаться в пакете main. При этом хорошим тоном считается держать весь код этого пакета в одном файле, так чтобы было удобнее запускать и компилировать программу. На первых порах мы будем оперировать только такими файлами-пакетами, позже мы научимся разбивать код на пакеты и библиотеки.

В строке 3 подключается стандартный пакет fmt. Этот пакет содержит функции по работе с вводом/выводом и другие вспомогательные функции. В данном случае в 6-й строке мы используем функцию Println из этого пакета, позволяющую вывести на экран текст.

Тулинг

С самой первой версии компилятор go содержит специальную команду: go fmt, с помощью которой можно привести код на Go к стандарту оформления. Это оказалось революционным решением, раз и навсегда положившим конец спорам Tab vs Space и исключившее написание условий и циклов без фигурных скобок. Часть сообщества, конечно, восприняли идею общего стиля кода в штыки, но к настоящему моменту большинство одобряет такой подход. Помимо встроенной команды форматирования со временем появились и более продвинутые утилиты, которые не только форматируют код, но и сами добавляют недостающие импорты и даже пропущенные пустые значения в инструкциях выхода из функций, например, goimports и goreturns.

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

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

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

  • Отступ строк формируется табуляцией;

  • Название переменных, типов и функций принято писать в camelCase;

  • Фигурные скобочки обязательны, и их принято расставлять в египетском стиле:

func declaration, for or if {
    body
}
  • Висящая запятая обязательна:

x := []int{
    1,
    2,
    3,
}

Без запятой после 3 компилятор будет ругаться. Аналогично при переносе любых скобок в определении или вызове функции. Вообще, если сомневаетесь — ставить запятую или нет — ставьте, если она не нужна, то go fmt её удалит.

Переменные

Переменные можно объявлять следующим способом:

var x int

Это создаст переменную с именем x типа int (то есть целое знаковое число). Эту строку можно прочитать как: «пусть x — переменная типа int». Если необходимо объявить несколько переменных, то можно сгруппировать определения с помощью скобок:

var (
    x int
    s string
)

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

var x, y, z int

Переменные будут сразу же инициализирована пустыми значением, соответствующими их типам (в данном случае 0). Можно задать другое значение для инициализации:

var x int = 17

Эта строка будет аналогична строкам:

var x int
x = 17

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

var x = 17

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

x := 17

Операция := объявляет и инициализирует переменную слева от себя значением правой части. В Go запрещено дважды объявлять переменную, поэтому последующие присвоения необходимо делать с помощью оператора =.

Также как обычное присвоение = операция объявления может сопоставлять кортежи:

x, y, s := 17, 19.5, "hello"

В результате будут объявлены три переменные

  1. x типа int со значением 17;

  2. y типа float64 со значением 19.5;

  3. s типа string со значением "hello";

Особенностью такого объявления является то, что в левой части могут быть уже объявленные переменные (кроме первой):

var s string
x, y, s := 17, 19.5, "hello"

В данном случае переменные x и y будут объявлены во второй строке, а переменной s просто будет присвоено значение, то есть этот код эквивалентен следующему:

var s string
x := 17
y := 19.5
s = "hello"

Скалярные типы данных

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

Числа int8, int16, int32, int64, uint8, unit16, uint32, uint64, float32, float64. Как видно все типы имеют в названии размер, занимаемой памяти в битах. Для всех чисел пустое значение — 0. Также любой числовой тип можно привести к любому другому числовому типу, использовав тип как функцию:

var x int32
y := 19.5
x = int32(y)

С числами можно производить следующие бинарные арифметические операции: сложение (+), вычитание (-), умножение (*), деление (/), а для целых чисел также доступна операция деления по модулю (%). Кроме того для знаковых типов можно инвертировать знак с помощью приписывания к числу слева знака -. Для всех бинарных операций есть краткая форма записи в случае, если результат необходимо присвоить переменной, являющейся первым операндом:

x += 2 // эквивалентно x = x + 2
x %= 3 // эквивалентно x = x % 3

Также существуют операции инкрементирования и декрементирования:

x++ // эквивалентно x += 1
x-- // эквивалентно x -= 1
Note
Операция присваивания в Go не имеет собственного результата, поэтому нельзя использовать присваивание как часть другой инструкции. Это же относится и к сокращённым формам бинарных операций.

Есть также типы алиасы к числовым типам. Например, тип byte является тем же типом int8, а runeint32. Но есть и менее предсказуемые типы int и uint, являющиеся алиасами к типам int32 и uint32 или int64 и uint64 соответственно в зависимости от битности операционной системы, на которой выполняется программа.

Булевый тип bool может принимать значение true или false, последнее является пустым значением для этого типа. Для булевых переменных и констант доступны следующие операции: инверсия (!), логическое и (&&) и логическое или (||).

И, наконец, скалярным типом в Go является строка (тип string). Вообще говоря строка представляет из себя срез (slice), о которых мы будем говорить позже. Но компилятор не позволяет модифицировать отдельные символы строки, так что она подходит под определение скалярного типа. Пустым значением для переменных типа строка является пустая строка. Для объявления строковых констант можно использовать либо двойные кавычки ("), либо обратные кавычки (`), при этом в первых можно использовать специальные символы, такие как \n, \t и так далее, а в обратных кавычках можно использовать непосредственно переносы строк. То есть следующие две константы равны:

s1 := "foo\nbar"
s2 := `foo
bar`

Строки можно складывать (конкатенировать), используя операцию +. А также можно использовать синтаксис срезов для получения подстроки:

fmt.Println("Hello, World!"[3:5] + "l")

Константы

Для всех скалярных типов можно также определить константу с помощью ключевого слова const. Объявление аналогично определению с ключевым словом var, но полученный объект нельзя использовать в левой части оператора присваивания. Также во время объявления констант доступно ключевое слово iota:

const (
    Foo = iota
    Bar
    Baz
    Qux
)

В результате будет объявлены следующие константы типа int: Foo равная 0, Bar равная 1, Baz равная 2 и Qux равная 3. При этом iota в рамках одного блока определений будет принимать значения от 0 до максимума типа int, увеличиваясь для каждого следующего определения. Для констант можно задать тип, отличный от int, а с iota можно составить выражение, допустимое в определении констант:

const (
    Foo uint32 = iota * iota
    Bar
    Baz
    Qux
)

Такой встроенный итератор можно использовать как некоторую замену отсутствующему в языке enum'у, но в придачу к iota понадобится Генерация кода

Область видимости

Областью видимости переменной называется область кода, где доступно значение этой переменной. Всегда есть глобальная область видимости пакета, туда попадают константы, функции, типы и переменные, объявленные вне функций. Глобальную переменную можно объявить только с использованием ключевого слова var. То есть можно написать

var x = 17

но нельзя использовать оператор :=.

Более узкую область видимости определяют функции. В теле функции доступны переменные, объявленные в глобальной области, а также переменные, объявленные в этом же теле функции выше. При этом локальные переменные могут перекрывать внешние, то есть следующий код

var s = "foo"

func main() {
    var s = "bar"
    fmt.Println(s)
}

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

Помимо функций, вложенные области видимости задают все конструкции, управляющие потоком выполнения.

Управление потоком выполнения

Для управления потоком выполнения в Go предусмотренны следующие конструкции:

Условия

if <condition 1> {
    <body 1>
} else if <condition 2> {
    <body 2>
} else {
    <body 3>
}

Если выполнено условие <condition 1>, то будут выполнены инструкции, перечисленные в <body 1>, если не выполнено условие <condition 1>, но выполнено условие <condition 2>, то будут выполнены инструкции из <body 2>, наконец, если ни одно условие не выполнено, то будут выполнены инструкции из блока else (<body 3>). Блоков else if может быть сколько угодно от 0 и до бесконечности. Блок else может быть пропущен.

В качестве условий могут быть использованы любые выражения, результатом которых имеет тип bool, например:

if x < 0 || x > 100 {
    fmt.Println("Неверно задано значение x")
} else if x == 0 {
    fmt.Println("x равен нулю")
} else {
    fmt.Println("x равен", x)
}
Note
Вообще некоторые считают плохим тоном использование ключевого слова else, предпочитая делать ранний выход из функции. Это не особо относится к изучению синтаксиса языка, но может качественно сказаться на читаемости вашего кода. В любом случае рекомендуем прочитать книгу «Чистый код»[cc].

В Go есть особая форма конструкции if, позволяющая выполнить инструкцию прямо внутри условия. Для примера рассмотрим следующую задачу: дана хеш таблица m, необходимо проверить наличие в ней ключа, если ключ есть, то вернуть значение по этому ключу, иначе вернуть defaultValue (аналог метода get словарей в Python). Подробнее о хеш-таблицах мы поговорим в соответствующем разделе, здесь отметим лишь то, что при получении значения по ключу из хеш-таблицы можно использовать синтаксис

val, ok := m[key]

Тогда, если ключ есть в хеш-таблице, то val будет присвоено значение, хранящееся по этому ключу, а в ok будет записано true. Если же ключ не найден, то val будет присвоено пустое значение, соответствующее типу значений хеш-таблицы, а в ok будет записано false.

Таким образом, задачу можно решить следующим способом:

val, ok := m[key]
if ok {
    return val
}
return defaultValue

Но в Go можно объединить первые две строки:

if val, ok := m[key]; ok {
    return val
}
return defaultValue

Это особенно удобно потому, что во втором случае область видимости переменных val и ok будет ограничена условием, в первом же случае они оказываются объявлены за пределами условия.

Switch

Для сопоставления значения выражения возможным значениям можно использовать оператор switch:

switch <expression> {
case <value 1>:
    <body 1>
case <value 2>:
    <body 2>
default:
    <body 3>
}

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

Знакомые с такими языками как Си или JavaScript могут удивиться отсутствию ключевых слов break в конце блоков. В Go поведение по умолчанию противоположно поведению этих языков. По умолчанию выполняется только один блок. Если необходимо выполнить также и следующий блок, необходимо использовать ключевое слово fallthrough. Например,

switch command {
case "save and continue":
    save()
    fallthrough
case "continue":
    next()
default:
    save()
    stop()
}

В данном примере, если значение переменной command равно строке "continue", то будет выполнена только функция next, если значение равно "save and continue", то будет выполнена функция save, после чего будет выполнена следующая ветка, то есть "continue", состоящая из вызова функции next. При любой другой команде будет выполнен блок default.

Если для нескольких значений необходимо выполнить одну и ту же последовательность инструкций, то можно записать это следующим образом:

switch command {
case "save and continue", "save and next":
    save()
    fallthrough
case "continue", "next":
    next()
default:
    save()
    stop()
}

Часто конструкцию из множественных блоков else if переписывают в виде:

switch true {
case <condition 1>:
    <body 1>
case <condition 2>:
    <body 2>
default:
    <body 3>
}

В Go в такой конструкции можно опустить выражение true вообще:

switch {
case <condition 1>:
    <body 1>
case <condition 2>:
    <body 2>
default:
    <body 3>
}
Note
В отличии от других языков в Go использование конструкции switch не является анти-паттерном, однако длинных и, тем более, повторяющихся switch-ей стоит избегать, используя полиморфизм или хеш-таблицы.

Циклы

Одной из «фишек» Go является минимизация ключевых слов языка. Именно поэтому все циклы в нём определяются одним ключевым словом for. Но имеются следующие виды циклов:

Классический Си-подобный
for i := 0; i < 10; i++ {
    // body
}

Здесь перед входом в цикл выполняется первая инструкция (i := 0), перед каждой итерацией цикла, в том числе и первой проверяется условие (i < 10) и, если условие выполнено, то выполняется тело цикла, после чего выполняется вторая инструкция (i++), если же условие не выполнено, то цикл завершается и программа переходит к следующим инструкциям. Инструкции и условия могут быть более сложными, например

for i, a, b := 1, 1, 1; i < 100; i, a, b = i+1, b, a+b {
    // body
}
Аналог цикла while
for x < y {
    // body
}

По сути этот цикл можно написать как частный случай предыдущего, где обе инструкции пустые:

for ; x < y; {
    // body
}

Но Go позволяет в этом случае не писать лишние точки с запятыми.

Итерирование
for i, x := range m {
    // body
}

Это особый вид цикла, где после ключевого слова range может стоять выражение, результатом которого является строка, массив, срез, хеш-таблица или канал. Принцип действия для строк, массивов и срезов похож: для всех элементов этих объектов будет выполнено тело цикла, а переменная i будет принимать последовательно значения от 0 до длинны объекта без единицы, а x — значения хранящиеся в данном объекте по индексу i. Более подробно действие циклов такого типа будет рассмотрено в разделах, посвящённых срезам, хеш-таблицам и каналам.

Бесконечный цикл
for {
    // body
}

Наконец, цикл с пустым условием аналогичен циклу с всегда истинным условием. Для выхода из такого цикла необходимо воспользоваться ключевыми словами break или return.

Также как и в других языках, в Go есть ключевые слова continue и break. Первое позволяет преждевременно завершить текущую итерацию цикла и перейти к следующей, например:

sumOfPrimes := 0
for i := 1; i < 100; i++ {
    if !isPrime(i) {
        continue
    }
    sumOfPrimes += i
}

С помощью ключевого слова break можно прекратить выполнение цикла. Однако, надо помнить, что это ключевое слово используется не только для прерывания цикла, но и для прерывания рассмотренной конструкции switch и конструкции из следующей главы select. Например, следующий код выведет все числа от 0 до 9:

for i := 0; i < 10; i++ {
    switch i {
    case 5:
        break
    }
    fmt.Println(i)
}

Функции

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

Определение именованной функции уровня пакета мы с вами уже встречали:

01_hello_world/main.go
1
2
3
4
5
6
7
package main

import "fmt"

func main() {
	fmt.Println("Hello, World!")
}

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

// Вычисление квадрата числа
func square(x float64) float64 {
    return x * x
}

// Сумма чисел
func sum(x, y float64) float64 {
    return x + y
}

// Выравнивание строки по правому краю по заданной длине
func leftPad(s string, n int) string {
    pad := ""
    for i := len(s); i < n; i++ {
        pad += " "
    }
    return pad + s
}

В общем виде определение функции можно описать так:

func <имя>(<описание аргументов>) <описание результата> {
    <тело>
}

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

Кортеж результатов

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

func swap(x, y float64) (float64, float64) {
    return y, x
}

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

func stripLeftSpaces(s string) (result string, trimmedSpaceCount int) {
    result := s
    for result != "" && result[0] == ' ' {
        result = result[1:]
        trimmedSpaceCount++
    }
    return
}

При этом совсем не обязательно использовать для возврата именно именованные результаты:

func stripLeftSpaces(s string) (result string, trimmedSpaceCount int) {
    for ; trimmedSpaceCount < len(s) && s[trimmedSpaceCount] == ' '; trimmedSpaceCount++ {
    }
    return s[trimmedSpaceCount:], trimmedSpaceCount
}
Присваивание в никуда

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

trimmedString, _ := stripLeftSpaces(s)

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

Анонимные функции

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

func findMinimal(check func(x int) bool) int {
    i := 1
    for ; !check(i); i++ {
    }
    return i
}
Note
Функция findMinimal принимает один аргумент check с типом func(x int) bool. Под этот тип подойдут любые функции, принимающие один аргумент с типом int и возвращающие булево значение. Конечно, что то, как аргумент этой функции будет называться нем не важно. Поэтому имя аргумента можно опустить, сократив определение типа до func(int) bool.

Теперь можно использовать эту функцию в комбинации с различными функциями проверки:

02_find_minimal/main.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import "fmt"

func findMinimal(check func(int) bool) int {
	i := 1
	for ; !check(i); i++ {
	}
	return i
}

func greatThan100(x int) bool {
	return x > 100
}

func dividedBy11(x int) bool {
	return x%11 == 0
}

func dividedBy11And13(x int) bool {
	return x%11 == 0 && x%13 == 0
}

func main() {
	fmt.Println("Минимальное число, больше 100:", findMinimal(greatThan100))
	fmt.Println("Минимальное число, делимое на 11:", findMinimal(dividedBy11))
	fmt.Println("Минимальное число, делимое на 11 и 13:", findMinimal(dividedBy11And13))
}

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

03_find_minimal_anonymous/main.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import "fmt"

func findMinimal(check func(int) bool) int {
	i := 1
	for ; !check(i); i++ {
	}
	return i
}

func main() {
	fmt.Println("Минимальное число, больше 100:", findMinimal(func(x int) bool {
		return x > 100
	}))
	fmt.Println("Минимальное число, делимое на 11:", findMinimal(func(x int) bool {
		return x%11 == 0
	}))
	fmt.Println("Минимальное число, делимое на 11 и 13:", findMinimal(func(x int) bool {
		return x%11 == 0 && x%13 == 0
	}))
}

Мы просто объявили функции в месте передачи их как аргументов, не давая им имена. Анонимную функцию можно присвоить переменной и даже вернуть как результат другой функции.

Замыкания

func dividedBy(d int) func(int) bool {
    return func(x int) bool {
        return x % d == 0
    }
}

fmt.Println("Минимальное число, делимое на 11:", findMinimal(dividedBy(11)))

Получившаяся функция также называется замыканием, потому что ссылается на область видимости функции, которая её порождает (аргумент d). При этом в замыкании можно как читать, так и изменять контекст порождающей функции. С помощью замыканий можно реализовывать различные полезные паттерны проектирования. Например, с помощью замыкания можно создать итератор (объект возвращающий по запросу следующее значение из некоторой последовательности). Для примера напишем генератор чисел Фибоначчи:

04_fibonacci/main.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package main

import "fmt"

func fibonacci() func() int {
	a, b := 0, 1
	return func() int {
		a, b = b, a+b
		return a
	}
}

func main() {
	iter := fibonacci()
	for i := 0; i < 10; i++ {
		fmt.Println(iter())
	}
}

Рекурсия

Некоторые задачи требуют рекурсивного вызова функции. Например, вычисление факториала числа:

func factorial(x int) int {
    if x == 1 {
        return 1
    }
    return x * factorial(x - 1)
}
Warning
Никогда не используйте приведённый выше способ вычисления факториала ни в одном языке. Используйте возможности стандартной библиотеки или используйте нерекурсивный вариант.

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

factorial := func(x int) int {
    if x == 1 {
        return 1
    }
    return x * factorial(x - 1)
}

Такой вариант не скомпилируется, потому что компилятор сначала разбирает определение функции, а уже только после этого определение переменной factorial, то есть на момент разбора определения функции переменная factorial ещё не объявлена и мы не можем её использовать. Это можно обойти, разделив определение и присвоение:

var factorial func(int) int
factorial = func(x int) int {
    if x == 1 {
        return 1
    }
    return x * factorial(x - 1)
}

Теперь к моменту разбора тела функции переменная factorial уже объявлена и её можно использовать.

Функции с переменным количеством аргументов

Иногда возникает необходимость определить функцию, которая может принимать переменное число аргументов. Например, функция, суммирующая все переданные в неё аргументы. Вообще говоря мы можем передать в такую функцию один аргумент с типом срез (см. Срезы):

func sum(args []float64) float64 {
    res := 1
    for _, x := range args {
        res += x
    }
    return res
}

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

fmt.Println(sum([]float64{4, 5}))

В Go есть синтаксический сахар, позволяющий объявить последний аргумент функции как остаточный (rest):

func sum(args ...float64) float64 {
    res := 1
    for _, x := range args {
        res += x
    }
    return res
}

Внутри функции это будет такой же срез, как и был, зато снаружи можно будет передавать аргументы обычным кортежем:

fmt.Println(sum(4, 5))

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

Задачи

  1. Что будет результатом --x? Почему?

  2. Предложите минимальное исправление для следующей программы:

package main

import "fmt"

func sum(x, y float64) float64 {
    return x + y
}

func square(x int) int {
    return x * x
}

func main() {
    fmt.Println(square(sum(25, 9)))
}
  1. Чему будет равна константа Qux при следующем определении:

const (
    Foo uint32 = iota * iota
    Bar
    Baz = iota + 2
    Qux
)
  1. Напишите генератор квадратов натуральных чисел.

Сложные типы данных

Важной отличительной чертой скалярных типов является то, что при передаче их в качестве аргументов в функцию, на самом деле в функцию попадает копия объекта. Для наглядности рассмотрим пример:

05_scalar_args/main.go
package main

import "fmt"

func increment(x int) {
	x++
}

func main() {
	n := 12
	increment(n)
	fmt.Println(n) // 12
}

По факту переменные n и x будут указывать на разные участки оперативной памяти. В этой части мы рассмотрим типы данных, для которых это не так однозначно.

Структуры

Структуры в Go, как и в Си представляют из себя связанный набор переменных. Например, если в программе используется ФИО, будет полезно иметь сгруппированную тройку переменных, отвечающих за фамилию, имя и отчество. Для этого можно объединить их с помощью структуры:

var fio struct {
    Last, First, Middle string
}

Далее к полям этой структуры можно обращаться через точку:

fio.Last = "Иванов"
fio.First = "Иван"
fio.Middle = "Иванович"

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

Note
Пустым значением для переменной такого типа будет структура с пустыми значениями всех полей.

Если в нашей программе тройка ФИО встречается не единожды, удобно объявить новый тип данных, описывающий такую структуру:

type FIO struct {
    Last, First, Middle string
}

fio := FIO{
    Last:   "Иванов",
    First:  "Иван",
}

Обратите внимание, что при создании объекта порядок полей может отличаться от порядка в объявлении типа, также могут быть перечисленны не все поля. Но можно не указывать имена полей, в таком случае порядок должен быть полностью совпадать с порядком объявления и пропуски запрещены:

fio := FIO{"Иванов", "Иван", "Иванович"}
Пустая структура

Иногда в коде, написанном на Go, встречается следующая конструкция:

type myType struct{}

или

m := map[string]struct{}{}

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

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

type Animal struct {
    Kind string
    Name string
    Age  int
}

func incrementAge(animal Animal) {
    animal.Age++
}

func main() {
    Jackie := Animal{
        Kind: "dog",
        Name: "Джеки",
        Age:  17,
    }
    incrementAge(Jackie)
    fmt.Println(Jackie.Age) // 17
}
Note

Также при присваивании одной переменной другой будет происходить копирование:

Copy := Jackie
Copy.Age++
fmt.Println(Copy.Age) // 18
fmt.Println(Jackie.Age) // 17

Для изменения данных структуры внутри функции можно воспользоваться указателями.

Указатели

Указатель это адрес данных в памяти. Если мы создадим копию этого адреса, он всё равно будет указывать на ту же область памяти. Таким образом при передаче указателя в качестве аргумента мы не создаём копию данных, копия указателя ссылается на те же данные. В Go существую только типизированные указатели, то есть они указывают на область памяти, где располагаются данные определённого типа. Этим типом может быть любой другой тип (кроме интерфейса, но об этом позже). Для указания того, что переменная является указателем перед типом добавляется символ *. Например:

func incrementAgeByPointer(animal *Animal) {
    animal.Age++
}

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

func incrementByPointer(x *int) {
    (*x)++
}

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

incrementAgeByPointer(&Jackie)
fmt.Println(Jackie.Age) // 18

Также при определении структуры можно вместо данных сразу получить указатель:

fio := &FIO{"Иванов", "Иван", "Иванович"}

Для других констант это невозможно, так что необходимо создавать переменную и получать указатель именно на неё:

s := "some string"
p := &s
Note
Пустым значением для указателя является nil. Необходимо помнить, что nil-ы в Go являются типизированными. Подробнее об этом мы поговорим при обсуждении интерфейсов
Пара слов о памяти

Стоит отметить, что указатель далеко не бесплатная переменная. Во-первых, в зависимости от системы он занимает в памяти 4 или 8 байт для 32- и 64-битных систем соответственно. Во-вторых, наличие указателя на определённые данные повышает вероятность того, что память для этих данных будет выделена в куче вместо стека, а соответственно это добавит работы сборщику мусора. Однако для больших структур вероятность выделения памяти в куче и так велика, а дополнительные 8 байт на указатель могут в несколько раз быть меньше памяти, необходимой на копию. Использовать или не использовать указатель сильно зависит от контекста и нет единого правила. Если вы не уверены как поступить, положитесь на здравый смысл и линтер, например, go-critic по умолчанию считает переломным моментом 80 байт.

Массивы

Массивы в Go похожи на тип array в Pascal. При описании типа мы должны указать количество и тип элементов:

type Point [2]int

x := Point{3, 12}

С точки зрения хранения данных массив очень похож на структуру, где все поля имеют одинаковый тип и вместо имён полей используются индексы от 0 до n-1 (где n это количество элементов массива). Поэтому и ведут себя они также как структуры.

О равенстве

Заметим, что все скалярные типы можно сравнивать на равенство. Если оба операнда имеют один тип и данные, хранящиеся в них равны, то отношение == вернёт истину. То же самое применимо и к структурам, и к массивам:

ivan1 := FIO{"Иванов", "Иван", "Иванович"}
ivan2 := FIO{"Иванов", "Иван", "Иванович"}
fmt.Println(ivan1 == ivan2) // true

p1 := Point{3, 15}
p2 := Point{3, 15}
fmt.Println(p1 == p2) // true

Однако, указатели на эти объекты не будут равны, потому что адреса в памяти у них разные:

fmt.Println(&ivan1 == &ivan2) // false
fmt.Println(&p1 == &p2) // false
Note
Пустым значением для массива является массив пустых значений соответствующих типу элементов этого массива.

Итерирование

Как упоминалось ранее, для массивов, строк и срезов есть особая форма цикла, позволяющая итерироваться по элементам (байтам в случае строки). Существует две формы этого цикла:

a := [5]int{1, 1, 2, 4, 9}

for i := range a {
    // i последовательно принимает значения от 0 до 4
    fmt.Printf("key=%v, value=%v", i, a[i])
}

for i, x := range a {
    // i последовательно принимает значения от 0 до 4
    // x копия i-го элемента
    fmt.Printf("key=%v, value=%v", i, x)
}

Срезы

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

var a []int

Можно объявить срез, уже наполненный какими-то элементами:

a := []int{1, 1, 2, 4, 9, 21, 51, 127, 323, 835}

Но можно также добавить элементы в конец среза:

a = append(a, 2188, 5798, 15511)

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

Note
Если вы знакомы с C++, то, скорее всего работали с таким типом данных, как vector. Срезы Go очень похожи по устройству и поведению на этот тип.

Все элементы среза лежат в памяти друг за другом также, как и в массиве. За счёт этого при работе с этими данными они легко кешируются процессором. Однако с этим связана проблема переноса данных. Представим, что у нас есть срез типа []int64, в котором 5 элементов. Каждый элемент занимает 8 байт, значит весь набор занимает 40 последовательных байт в оперативной памяти.

5 item slice

При этом память вокруг этих 40 байт может быть занята другими данными. Поэтому при добавлении даже одного элемента с помощью команды append может потребоваться зарезервировать новый участок памяти, перенести в него имеющиеся данные и дописать новый элемент. Операции аллокации памяти и копирования данных занимают много времени, поэтому резервировать необходимо с запасом. Обычно используется удвоение размера, то есть, даже если мы захотим дописать к срезу из 4 элементов ещё один, выгоднее зарезервировать 96 байт, вместо 48.

12 item slice

Таким образом, у среза помимо данных есть две характеристики: длина и вместимость. Длина означает сколько фактически элементов лежит в памяти, а вместимость — под сколько элементов зарезервировано место. Узнать длину и вместимость среза можно с помощью функций len и cap соответственно:

a := []int{1, 1, 2, 4, 9}
fmt.Println(len(a), cap(a)) // 5, 5

a = append(a, 21)
fmt.Println(len(a), cap(a)) // 6, 12

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

Пустой срез

Одной из важных особенностей срезов является то, что срез может быть пустым, а может быть nil:

var a []int
b := []int{}
fmt.Println(a == nil) // true
fmt.Println(b == nil) // false

Дело в том, что пустым значением для среза является nil. Но если мы объявим срез с пустым списком элементов, то получим не nil, а срез без элементов. При этом функция append ведёт себя одинаково.

Предварительное резервирование памяти

Итак, при добавлении элементов в срез возможен перенос данных. Это, как уже упоминалось, достаточно долгая операция. Часто мы можем предсказать сколько будет элементов или по крайней мере знаем верхнюю границу их количества. В этом случае можно создать срез с заранее выделенной памятью под данные (в дальнейшем такой срез ничем не будет отличаться от любого другого, по необходимости также будет выделяться новая память и перенос данных). Для этого можно воспользоваться встроенной функцией make, доступной в двух вариантах записи:

make([]<type>, <len>)
make([]<type>, <len>, <cap>)

В первом случае будет создан срез с элементами типа <type> с длинной и вместимостью <len>, а во втором с длинной <len> и вместимостью <cap>.

a := make([]int, 0, 12)
fmt.Println(len(a), cap(a)) // 0, 12
a = append(a, 1, 1, 2, 4, 9, 21)
fmt.Println(len(a), cap(a)) // 6, 12

Копирование среза

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

a := []int{1, 1, 2, 4, 9}
b := a
b[1] = 12
fmt.Println(a) // [1 12 2 4 9]

А при расширении среза всё ещё куда менее предсказуемо:

a := []int{1, 1, 2, 4, 9}
b := a // копируем срез без запаса вместимости
a = append(a, 21)
b = append(b, 51)
fmt.Println(a) // [1 1 2 4 9 21]
a := []int{1, 1, 2, 4, 9}
a = append(a, 21)
b := a // копируем срез с запасом вместимости
a = append(a, 51)
b = append(b, 127)
fmt.Println(a) // [1 1 2 4 9 21 127]

Копирование данных

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

a := []int{1, 1, 2, 4, 9}
a = append(a, 21)
b := make([]int, len(a)) // создаём новый срез длинны a
for i := 0; i < len(a) && i < len(b); i++ {
    b[i] = a[i]
}
a = append(a, 51)
b = append(b, 127)
fmt.Println(a) // [1 1 2 4 9 21 51]
fmt.Println(b) // [1 1 2 4 9 21 127]

Вместо написания цикла можно воспользоваться встроенной функцией copy(<dst>, <src>), которая копирует данные из <src> в <dst>:

a := []int{1, 1, 2, 4, 9}
a = append(a, 21)
b := make([]int, len(a)) // создаём новый срез длинны a
copy(b, a)

Важно помнить, что функция copy копирует элементов не больше, чем есть в <src> и для которых есть место в <dst>, то есть для среза <dst> необходимо заранее выделить память, например, с использованием функции make.

Срез среза

Любопытный читатель может спросить, почему же срезы называются срезами, ведь пока они выглядят скорее как динамические массивы. Такое название определилось особым синтаксисом для получения части среза. Во многих языках есть функция или метод массива slice(<i>[, <j>]), которая возвращает кусочек оригинального массива от i-го элемента до j-1-го. В Go вместо функции для подобных выборок используется специальный синтаксис:

a := []int{1, 1, 2, 4, 9}
fmt.Println(a[1:4]) // [1, 2, 4]

Можно не указать начало среза, тогда срез будет взят от 0. Аналогично, если не указать конец среза, то срез будет взят до конца оригинального среза.

fmt.Println(a[:4]) // [1, 1, 2, 4]
fmt.Println(a[1:]) // [1, 2, 4, 9]
fmt.Println(a[:]) // [1, 1, 2, 4, 9]

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

a := []int{1, 1, 2, 4, 9}
b := a[1:4]
b[0] = 12
fmt.Println(a) // [1, 12, 2, 4, 9]
Режем всё

На самом деле синтаксис среза можно применять также к строкам и массивам. При этом срез строки останется строкой, а срез массива станет непосредственно срезом:

fmt.Printf("%T\n", "test"[:]) // string

arr := [4]int{0, 1, 2, 3}
fmt.Printf("%T\n", arr[:]) // []int

Переменные параметры для функции

Помните, что последний параметр функции с типом срез можно определить с использованием rest-синтаксиса. Мы также можем передать его с использованием подобного rest-синтаксиса:

func sum(args ...int) int {
    var res int
    for i := range args {
        res += args[i]
    }
    return res
}

a := []int{1, 1, 2, 4, 9}
fmt.Println(sum(a...)) // 17

Похожий интерфейс имеет функция append:

a := []int{1, 1, 2, 4, 9}
b := []int{21, 51, 127, 323, 835}

fmt.Println(append(a, b...)) // [1 1 2 4 9 21 51 127 323 835]

Slice tricks

Несмотря на столь скромный набор встроенных методов по работе со срезами (append, [i:j], copy), с их помощью можно производить достаточно сложные манипуляции, например, вставка элемента в i-ю позицию среза:

a = append(a, x)
copy(a[:i+1], a[:i])
a[i] = x

Или удаление i-го элемента:

a = append(a[:i], a[i+1:]...)

Другие операции над срезами вы можете найти в вики языка: https://github.com/golang/go/wiki/SliceTricks.

Хеш-таблицы

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

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

  1. поиск по ключу;

  2. добавление пары или изменение значения по ключу;

  3. удаление пары по ключу;

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

Как работают хеш-таблицы

В памяти выделяется монолитный участок для хранения \(N\) пар. Доступ к конкретной паре осуществляется по индексу. После этого для хеш-таблицы создаётся хеширующая функция, с помощью которой из ключа можно получить индекс в диапазоне \([0,N-1]\). Таким образом для всех операций ключ преобразовывается в индекс и работа ведётся как с массивом.

Конечно, возможны коллизии хеширующей функции, когда два разных ключа отображаются в один индекс. Существует несколько способов разрешения этих конфликтов. Подробнее про эти способы вы можете прочитать в книге Н. Вирта[virt]. А в курсе Школы Анализа Данных[shad] подробно разбирается устройство хеширующей функции.

Работу с хеш-таблицами продемонстрируем на примере нахождения самой часто-встречаемой буквы в тексте:

06_most_frequent_rune/main.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package main

import "fmt"

func main() {
	text := `Welcome to a tour of the Go programming language. The tour is
divided into a list of modules that you can access by clicking on A Tour of Go
on the top left of the page. You can also view the table of contents at any
time by clicking on the menu on the top right of the page. Throughout the tour
you will find a series of slides and exercises for you to complete.`
	counts := map[rune]int{}
	for _, c := range text {
		switch c {
		case ' ', '.':
			// не будем учитывать эти символы в статистике
		default:
			counts[c] += 1
		}
	}
	var (
		res rune
		max int
	)
	for c, count := range counts {
		if count > max {
			res = c
			max = count
		}
	}
	fmt.Println(string(res)) // преобразуем руну в строку, иначе будет выведен код
}
Note
Тип rune используется для многобайтных символов, например, в кодировке utf8. Собственно строка на самом деле представляет из себя срез байт, с ограничением на изменение. Но при итерировании по строке она ведёт себя как срез рун.

Для объявления переменной типа хеш-таблица используется выражение map[<тип ключа>]<тип значения>. Для чтения и записи по ключу используется синтаксис с указанием ключа в квадратных скобках. Важно отметить, что при чтении в таблице может не быть пары с данным ключом, тогда в качестве значения вернётся пустое значение, соответствующего типа. Если нам важно различать: есть ли ключ и по нему лежит пустое значение или пары с таким ключом нет, можно использовать расширенный синтаксис чтения: val, ok := m[key], для первой ситуации переменной ok будет присвоена истина, для второй — ложь. Для удаления пары по ключу используется встроенная функция delete(m, key).

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

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

Почему хеш-таблицы называются map

Дело в том, что хеш-таблицы своего рода задают сопоставление множества ключей и множества значений, то есть, говоря математическим языком отображение множества ключей в множество значений. Частным случаем отображений являются хорошо знакомые нам математические функции. В английском языке для отображений используют термин map. Отсюда и название этой структуры.

Итерирование

При итерировании по таблице возможны два варианта цикла:

for key := range m {
    // ...
}
for key, value := range m {
    // ...
}

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

m := map[string]int{
    "foo": 5,
    "bar": 6,
    "baz": 7,
}
for _, value := range m {
    value++
}
fmt.Println(m) // map[foo:5 bar:6 baz:7]

Для модификации необходимо использовать доступ по ключу или обновлять значение в таблице принудительно.

m := map[string]int{
    "foo": 5,
    "bar": 6,
    "baz": 7,
}
for key := range m {
    m[key]++
}
fmt.Println(m) // map[foo:6 bar:7 baz:8]

Создание хеш-таблиц

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

var m map[string]int
m["foo"] = 1

Это произойдёт потому, что таблицы также как и срезы являются ссылочным типом и при простом объявлении переменные этих типов оказываются указателями в никуда, то есть nil. При этом операции чтения будут валидны для этой переменной, в том числе итерирование будет выполнено ноль раз, как и ожидается, а взятие по ключу вернёт пустое значение и ложь в качестве второго возврата. Для того чтобы инициализировать таблицу для записи необходимо объявить её одним из следующих образов:

m := map[string]int{}

или

m := make(map[string]int)

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

Если же вы заранее знаете о предполагаемом количестве пар, то можно дать рантайму подсказку:

m := make(map[string]int, 100)

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

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

Передача хеш-таблиц качестве параметров

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

Использование хеш-таблиц как множеств

Часто возникает потребность в получении множества каких-то объектов. Например, для удаления дубликатов. В этом случае можно использовать хеш-таблицу, используя объекты в качестве ключей. При этом значения нам не важны, важно понимать — есть уже объект в виде ключа в множестве или нет. Для удобства будем хранить там булево значение, записывая истину для добавляемых элементов:

func unique(a []int) []int {
    set := make(map[int]bool, len(a))
    res := make([]int, 0, len(a))
    for _, x := range a {
        if !set[x] {
            set[x] = true
            res = append(res, x)
        }
    }
    return res
}

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

if _, ok := set[x]; !ok {
    // ...
}

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

func unique(a []int) []int {
    set := make(map[int]struct{}, len(a))
    for _, x := range a {
        set[x] = struct{}{}
    }
    res := make([]int, 0, len(set))
    for x := range set {
        res = append(res, x)
    }
    return res
}
Пара слов о порядке

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

Многомерные хеш-таблицы

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

counts := map[int]map[int]int{}

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

func inc(counts map[int]map[int]int, userID, chatID int) {
    userCounts, ok := counts[userID]
    if !ok {
        userCounts = make(map[int]int)
        counts[userID] = userCounts
    }
    userCounts[chatID]++
}

С другой стороны есть решение этой задачи с использованием одного уровня таблицы. Для этого в качестве ключа используем пару: идентификатор пользователя и идентификатор чата:

type CountKey struct {
    UserID, ChatID int
}
counts := map[CountKey]int{}

Тогда увеличение счётчика больше не требует дополнительных проверок:

func inc(counts map[CountKey]int, userID, chatID int) {
    counts[CountKey{userID, chatID}]++
}

Каналы

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

Каналы, также как срезы и таблицы, создаются с помощью встроенной функции make. Так как каналы обладают фиксированной длинной, то её необходимо задать при создании канала:

ch := make(chan int, 2)

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

Добавление и извлечение элемента производится с помощью оператора

ch <- 4
ch <- 5
fmt.Println(<- ch) // 4
fmt.Println(<- ch) // 5

При закрытии канала попытка записи в него приведёт к падению программы:

close(ch)
ch <- 4 // unrecovered panic

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

ch <- 4
ch <- 5
close(ch)
val, ok := <- ch
fmt.Println(val, ok) // 4 true
val, ok = <- ch
fmt.Println(val, ok) // 5 true
val, ok = <- ch
fmt.Println(val, ok) // 0 false

Также есть специальная форма цикла, выполняющаяся до тех пор, пока канал не будет закрыт:

ch <- 4
ch <- 5
close(ch)
for val := range ch {
    fmt.Println(val)
}

Что же такого особенного в каналах? Особенностью каналов является их дружественность к конкурентному коду. Подробнее об использовании каналов обсуждается в соответствующей главе.

Пример: сортировка вставками

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

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

07_insert_sort/main.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package main

import "fmt"

func InsertSort(a []int) {
	for i := 1; i < len(a); i++ {
		j := i
		for ; j > 0 && a[i] < a[j-1]; j-- {
		}
		for ; i > j; i-- {
			a[i], a[i-1] = a[i-1], a[i]
		}
	}
}

func main() {
	a := []int{12, 8, 22, 11, 1, 3}
	InsertSort(a)
	fmt.Println(a) // [1 3 8 11 12 22]
}

Задачи

  1. В примере сдвиг отсортированной части массива при вставке производится в цикле (строки [10:12]). Оптимизируйте этот сдвиг при помощи встроенной функции copy.

  2. Какую потенциальную проблему может вызвать следующий код?

func unique(a []int) []int {
    set := make(map[int]bool, len(a))
    for i := len(a)-1; i >= 0; i-- {
        if set[a[i]] {
           copy(a[i:], a[i+1:])
           a = a[:len(a)-1]
        }
        set[a[i]] = true
    }
    return a
}

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

Практический любой современный язык из коробки предоставляет инструментарий для написания и запуска тестов. Так и в Go есть встроенная библиотека для написания тестов и команда для запуска. Видов тестов существует достаточно много: модульные, интеграционные, функциональные, end-to-end и так далее. Также существуют различные подходы к написанию тестов: BDD, табличные, golden, property-based, fuzzy и много других. Стандартная библиотека не навязывает какой-то стиль или подход к написанию тестов, но как и многие стандартные пакеты Go даёт базовый инструментарий, с помощью которого можно реализовать практически любой из подходов на любом уровне.

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

  • создать файл с именем, заканчивающимся на _test.go;

  • описать в этом файле функцию, название которой начинается с Test, принимающую один аргумент testing.T и ничего не возвращающую.

func TestSomething(t *testing.T) {
    // ...
}

Внутри функции пишется код вызова тестируемых методов и проверки результатов. Если ожидаемый результат неверен, то необходимо пометить тест как проваленный, для этого можно воспользоваться методом t.Fail. Также можно писать в лог о причине провала теста, для этого можно использовать функцию t.Log или вариант с подстановками t.Logf. Также можно воспользоваться формой t.Error или t.Errorf, совмещающей запись в лог и пометку теста как проваленного.

Простой тест

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

07_insert_sort/main_test.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package main

import (
	"testing"
)

func TestSort(t *testing.T) {
	cases := [...]struct {
		name   string
		input  []int
		result []int
	}{
		{
			"already sorted",
			[]int{1, 2, 3, 4, 5, 6},
			[]int{1, 2, 3, 4, 5, 6},
		},
		{
			"inversed",
			[]int{6, 5, 4, 3, 2, 1},
			[]int{1, 2, 3, 4, 5, 6},
		},
		{
			"duplicates",
			[]int{6, 5, 2, 3, 2, 1},
			[]int{1, 2, 2, 3, 5, 6},
		},
	}
	for _, c := range cases {
		c := c
		t.Run(c.name, func(t *testing.T) {
			InsertSort(c.input)
			CompareIntSlices(t, c.result, c.input)
		})
	}
}

func CompareIntSlices(t *testing.T, expected, actual []int) {
	t.Helper()
	if len(expected) != len(actual) {
		t.Errorf("Invalid slice length. Expected %d instead of %d",
			len(expected), len(actual))
		return
	}
	for i := range expected {
		if expected[i] != actual[i] {
			t.Errorf("Invalid element at position %d. Expected %d instead of %d",
				i, expected[i], actual[i])
		}
	}
}
Note

В данном примере используется синтаксис объявления массива через три точки. По факту эти три точки в момент компиляции будут заменены на количество элементов в объявлении. Например выражение

a := [...]int{1, 2, 3, 4, 5}

будет заменено на

a := [5]int{1, 2, 3, 4, 5}

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

Работа этого нехиторого теста вполне очевидна, за исключением двух строк:

  • на 29-й строке выполняется запуск вложенного теста. С помощью вложенных тестов удобно организовывать сложные сценарии тестирования. Идея подтестов заключается в том, что при провале такого теста остальные тесты выполнятся независимо, но родительский тест также будет помечен как проваленный. При этом t.Run возвращает false при провале вложенного теста, так что с использованием этой концепции легко построить и сценарные тесты, отделив логические шаги друг от друга, но сохранив последовательность шагов и прервать выполнение теста при провале одного из шагов.

  • на 37-й строке вызывается специальный метод t.Helper(). Этот метод исключает эту функцию из трейса ошибки. Таким образом, если, например, у срезов будет разная длинна, то в логах будет показано, что ошибка возникла не на 39 строке, а на 31. В данном случае это не особо меняет дело, но если такая вспомогательная функция используется в нескольких местах, то удобно видеть в каком именно месте использования она вернула ошибку.

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

PASS
ok      github.com/vporoshok/go-introduction/examples/07_insert_sort    0.442s

Параметры запуска тестов

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

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

$ go test main_test.go
# command-line-arguments [command-line-arguments.test]
./main_test.go:30:4: undefined: InsertSort
FAIL    command-line-arguments [build failed]
FAIL

Но можно включить все необходимые файлы для запуска теста: go test main.go main_test.go.

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

$ go test -v -run Sort/inversed
=== RUN   TestSort
=== RUN   TestSort/inversed
--- PASS: TestSort (0.00s)
    --- PASS: TestSort/inversed (0.00s)
PASS
ok      github.com/vporoshok/go-introduction/examples/07_insert_sort    0.099s

Некоторые тесты не всегда с первого раза выявляют ошибку, особенно, если ошибка проявляется только при определённой нагрузке на сервис или при написании внешних тестов на АПИ. Такие тесты удобно запускать многократно, для этого можно воспользоваться флагом -count. С помощью этого же флага можно «заставить» компилятор заново выполнить закешированный тест.

Кеширование

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

$ go test main.go main_test.go
ok      command-line-arguments  (cached)

Для того, чтобы тест всё же выполнился, можно добавить флаг -count 1.

С помощью флага -failfast можно прервать выполнение тестов при первом проваленном тесте. Этот флаг может оказаться полезным для CI систем, если тесты выполняются значительное время.

Также в библиотеку тестирования встроена концепция коротких тестов. При запуске тестов можно добавить флаг -short, после чего функция testing.Short() возвращать истину. Таким образом можно пропустить долгие тесты:

func TestSomethingLong(t *testing.T) {
    if testing.Short() {
        t.Skip("Long test skipped")
    }
    // do something
}

Тесты производительности

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

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

const size = 10000
data := [size]int{}
for i := range data {
    data[i] = rand.Int()
}
Note
В данном случае используется библиотека math/rand в которой реализован алгоритм генерации случайных чисел с помощью вихря Мерсена. При этом мы не будем задавать зерно для этого генератора, так что при каждом запуске мы будем получать абсолютно одинаковые данные. Это сделано специально, потому что наша задача в данном случае не проверить корректность работы алгоритма, а оценить его время работы. И при этом сделать эту проверку повторяемой.

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

func BenchmarkInsertSort(b *testing.B) {
	const size = 10000
	data := [size]int{}
	for i := range data {
		data[i] = rand.Int()
	}
	args := make([]int, len(data))
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		b.StopTimer()
		copy(args, data[:])
		b.StartTimer()
		InsertSort(args)
	}
}

В данном примере мы специально убрали из подсчёта времени вспомогательные операции по генерации и копированию данных. Для этого мы использовали функцию b.ResetTimer с помощью которой занулили таймер теста, исключив таким образом время, затраченное на генерацию данных. А также использовали функции StopTimer и StartTImer, чтобы не считать время на копирование данных.

Для запуска этого теста необходимо выполнить команду go test -bench InsertSort, после чего мы получим примерно такой отчёт:

$ go test -bench InsertSort
goos: darwin
goarch: amd64
pkg: github.com/vporoshok/go-introduction/examples/07_insert_sort
BenchmarkInsertSort-8                 18          65151932 ns/op
PASS
ok      github.com/vporoshok/go-introduction/examples/07_insert_sort    1.678s

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

В целом получилось 65 мс. Но как понять — много это или мало? В данном случае мы можем сравнить это со стандартной библиотекой. Для этого расширим тест, сделав два вложенных теста: для нашей функции сортировки и для стандартной:

07_insert_sort/main_benchmark_test.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package main

import (
	"math/rand"
	"sort"
	"testing"
)

func BenchmarkSort(b *testing.B) {
	const size = 10000
	data := [size]int{}
	for i := range data {
		data[i] = rand.Int()
	}
	args := make([]int, len(data))
	b.Run("insert", func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			b.StopTimer()
			copy(args, data[:])
			b.StartTimer()
			InsertSort(args)
		}
	})
	b.Run("standard", func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			b.StopTimer()
			copy(args, data[:])
			b.StartTimer()
			sort.Ints(args)
		}
	})
}

На что получим примерно следующий отчёт:

$ go test -bench Sort
goos: darwin
goarch: amd64
pkg: github.com/vporoshok/go-introduction/examples/07_insert_sort
BenchmarkSort/insert-8                16          68174407 ns/op
BenchmarkSort/standard-8             940           1272137 ns/op
PASS
ok      github.com/vporoshok/go-introduction/examples/07_insert_sort    2.729s

Пожалуй лучше использовать стандартную библиотеку сортировки.

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

Примеры и главный тест

При написании библиотеки оказывается полезно дать пример использования. Для этого в тестовых файлах можно добавлять примеры. Описываются они как обычные функции в тестовых файлах, с названием, начинающимся на Example. Например, мы можем переделать в пример нашу функцию main

func ExampleInsertSort() {
    a := []int{12, 8, 22, 11, 1, 3}
    InsertSort(a)
    fmt.Println(a)
    // Output: [1 3 8 11 12 22]
}

Такой пример будет не только примером, но при запуске тестов он будет также выполняться как тест и результат вывода будет сравниваться со строкой в комментарии // Output. В некоторых случаях такой пример вполне достаточен как единственный тест.

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

func TestMain(m *testing.M) {
    // setup flags if needed
    flag.Parse()
    // setup
    res := m.Run()
    // tear down
    os.Exit(res)
}

Если тестовые файлы содержат такую функцию, то при выполнении команды go test будет выполнена только эта функция. Метод m.Run выполнит все тесты в пакете (по фильтру, если таковой указан). Если все тесты пройдены успешно, то m.Run() вернёт 0, в другом случае вернётся код выхода, отличный от нуля. Этот результат необходимо использовать в команде os.Exit.

Стоит помнить, что при наличии функции TestMain флаги запуска не будут разобраны автоматически, необходимо явно вызвать функцию flag.Parse().

Задача: оптимизация сортировки вставками бинарным поиском

Добавьте оптимизации в функцию сортировки InsertSort и сравните производительность:

  • используйте функцию copy;

  • используйте бинарный поиск места вставки;

  • используйте экспоненциальный поиск места вставки;

ООП в Go

Вопрос о том является ли Go объектно ориентированным или нет достаточно спорный. Кто-то скажет, что да, кто-то — нет. В целом можно сказать, что Go всё же процедурный язык, а все концепции ООП в нём являются лишь синтаксическим сахаром. Именно из-за этого многие шаблоны, распространённые в ООП языках не всегда применимы в Go. Разрабатывая на Go нужно помнить о том, что

Clear is better than clever.
— Rob Pike

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

Методы

Первая ложка сахара, предоставляемая языком — методы. К любому пользовательскому типу (кроме интерфейсов, но об этом позже) можно добавить метод. Для этого в том же пакете, где объявлен тип нужно определить функцию следующего вида:

type PowerInt int

func (x PowerInt) Power(n int) (res PowerInt) {
    for res = 1; n > 0; n-- {
        res *= x
    }
    return res
}

Полученная функция будет методом типа PowerInt и вызвать её можно будет следующим образом:

x := PowerInt(2)
fmt.Println(x.Power(8)) // 256

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

Помимо методов, объявленных на самом типе, можно определить метод на указателе:

type PowerInt int

func (x *PowerInt) Power(n int) {
    for y := *x; n > 1; n-- {
        *x *= y
    }
}

Тогда этот метод можно вызвать как на указателе, так и на переменной типа PowerInt, при этом в момент вызова в функцию будет передан указатель на эту переменную, а значит изменения в методе изменят эту переменную:

x := PowerInt(2)
x.Power(8)
fmt.Println(x) // 256

Наследование

Конечно, говоря объекты, мы подразумеваем совокупность данных и методов. Чаще всего для хранения данных используется структура, к которой добавлены методы. Приведём стандартный пример:

type Monster struct {
    HitPoints   int
    AttackBonus int
    ArmorClass  int
}

func (m *Monster) Attack() int {
    return rand.Intn(20) + 1 + m.AttackBonus
}

func (m *Monster) Defence() int {
    return 10 + m.ArmorClass
}

Для структур также поддерживается некоторое подобие наследования:

type Armadillo struct {
    Monster
    HanukkahBonus bool
}

Мы просто включили тип Monster в описании структуры Armadillo. Таким образом с одной стороны мы добавили в описание поле с типом Monster и именем Monster. При этом, к полям этого поля можно обращаться без указания Monster (если, уровнем выше нет полей с таким же именем):

a := Armadillo{}
a.ArmorClass = 3 // ~ a.Monster.ArmorClass = 3

Аналогично можно вызывать и методы вложенного типа:

a.Attack() // ~ a.Monster.Attack()

С другой стороны можно переопределить методы в «наследнике»:

func (a Armadillo) Defence() int {
    defence := a.Monster.Defence()
    if a.HanukkahBonus {
        defence += 10
    }
    return defence
}

Но, это всего лишь сахар, никакой дополнительной магии здесь не может произойти. Так, например, если мы определим на типе Monster другой метод, использующий метод Defence, то вызов его на переменной типа Armadillo вызовет не переопределённый метод:

func (m Monster) CMD() int {
    return m.Defence() + m.AttackBonus
}
a.HanukkahBonus = true
a.Defence() // 23
a.CMD() // 13

Таким образом это больше похоже на композицию, а не наследование. Однако, с помощью интерфейса можно сделать полиморфизм.

Интерфейсы и полиморфизм

Итак, уже много раз упомянутые интерфейсы. Интерфейс это специальный тип, который описывает какие методы должны быть у объекта, для того, чтобы объект удовлетворял интерфейсу. Описывается он следующим образом:

type Creature interface {
    Attack() int
    Defence() int
}

Это означает, что у объекта, удовлетворяющего этому интерфейсу есть два метода: Attack и Defence. Оба не принимают никаких аргументов и возвращают int. То есть описанные выше типы Monster и Armadillo удовлетворяют этому интерфейсу. Отлично! Что дальше? Теперь мы можем в определении переменных (в том числе и аргументов функции использовать этот тип). Соответственно функцию расчёта Combat Maneuver Defense мы можем описать как внешнюю функцию, добавив в интерфейс и реализации дополнительный метод:

type Creature interface {
    Attack() int
    Defence() int
    GetAttackBonus() int
}

func (m Monster) GetAttackBonus() int {
    return m.AttackBonus
}

func CMD(c Creature) int {
    return c.Defence() + m.GetAttackBonus()
}

Таким образом реализуется полиморфизм в языке Go.

Наследование интерфейсов

Описание интерфейсов напоминают описание структур. И возникает вопрос: а можно ли включить один интерфейс в другой, также как мы делали это со структурами? Ответ можно:

type Reader interface {
    Read([]byte) (int, error)
}

type Writer interface {
    Write([]byte) (int, error)
}

type ReadWriter interface {
    Reader
    Writer
}

Получившийся интерфейс будет описывать объекты с двумя методами: Read и Write. Правда до версии 1.14 нельзя объединить пересекающиеся по методам интерфейсы, то есть код

type Closer interface {
    Close() error
}

type ReadCloser interface {
    Reader
    Closer
}

type ReadWriteCloser interface {
    ReadWriter
    ReadCloser
}

Выдаст ошибку на этапе компиляции: duplicate method Read.

Правила этикета интерфейсов

Как и всё, что связано с ООП, тема интерфейсов неоднозначна и периодически вызывает холивары. О видении самих разработчиков можно прочитать в статье Go Code Review Comments в вики проекта на https://github.com/golang/go. Мы же рассмотрим имеющиеся ограничения как данность.

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

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

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

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

func NewArmadillo() Creature {
    // ...
}

пишите

func NewArmadillo() *Armadillo {
    // ...
}

Особенно, если интерфейс лежит в другом пакете.

interface{}

Конструкция interface{} описывает объект без методов. Такому интерфейсу удовлетворяет всё, что угодно. Это некоторая замена any или unknown из других языков. Такой тип часто используют для определения аргументов функций, которые могут обрабатывать несколько типов. Например, функция fmt.Println принимает сколько угодно каких угодно аргументов и имеет объявление

func Println(args ...interface{}) {
    // ...
}

Кастинг типа

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

x := y.(float64)

Однако, если под y окажется спрятана переменная другого типа, то программа упадёт с ошибкой. Для того, чтобы проверить, действительно ли переменная имеет этот тип, можно воспользоваться формой

if x, ok := y.(float64); ok {
    // do something with x
}

Если же для разных типов необходимо вести себя по разному, можно воспользоваться специальной формой конструкции switch:

switch x := y.(type) {
case float64:
    // do something with x like float64
case int:
    // do something with x like int
}

Например, можно сделать суммирование любых чисел следующим образом:

func sum(args ...interface{}) (res float64) {
    switch x := args.(type) {
    case float64:
        res += x
    case int:
        res += x
    }
    return res
}

Инструкция fallthrough в данной конструкции не работает по очевидным причинам.

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

type MyType int

var x interface{} = MyType(10)

j, ok := x.(MyType)
fmt.Println(j, ok) // 10, true

i, ok := x.(int)
fmt.Println(i, ok) // 0, false

Для более глубокого анализа значений и их типов необходимо использовать рефлексию.

Почему nil != nil?

Ещё одна проблема связана со значением nil. Проблема заключается в том, что под капотом каждая переменная описывается значением и типом. Таким образом даже пустое значение, даже nil имеет тип. Чтобы было понятнее приведём пример:

var a []int
fmt.Println(a == nil) // true
var x interface{} = a
fmt.Println(x == nil) // false

В первом случае переменная a имеет тип срез и nil приводится к этому типу, соответственно сравнение оказывается истинным. Во втором же случае переменная x имеет тип интерфейс, но при этом значение всё тот же неинициализированный срез, правая часть сравнения приводится к типу интерфейсу и сравнение оказывается ложным. Сложности с этим часто возникают при создании кастомных ошибок на основе срезов, например:

type MyError []string

func (merr MyError) Error() string {
	return strings.Join(merr, "\n")
}

func Foo(throwError bool) error {
	var merr MyError
	if throwError {
		merr = append(merr, "Something went wrong")
	}
	return merr
}

if err := Foo(false); err != nil {
    fmt.Println("Unexpected error:", err)
}

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

func Foo(throwError bool) error {
	var merr MyError
	if throwError {
		merr = append(merr, "Something went wrong")
	}
    if merr == nil {
        return nil
    }
	return merr
}

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

func (merr MyError) ToError() error {
    if len(merr) == 0 {
        return nil
    }
    return merr
}

Задания

  1. Написать HTTP эхо сервис. На любой POST запрос он должен возвращать тело запроса как ответ.

Пакеты и модули

Помните, что первой строкой каждого файла с кодом на Go является строка вида

package main

Эта строка декларирует имя пакета. Весь код, лежащий в одной папке должен принадлежать одному пакету. Это означает, что во всех файлах внутри папки должна быть одинаковая строка объявления пакета. Кроме файлов с тестами, но об этом немного позже. Внутри пакета все функции, типы и глобальные переменные и константы доступны в любом файле без префиксов как в Erlang, то есть нет нужды импортировать рядом лежащий файл, как в NodeJS или Python. С одной стороны это удобно, с другой стороны с непривычки может путать, но при наличии редакторов с поддержкой перехода к определению к этому достаточно легко привыкнуть и приспособиться.

Стандартной практикой является называть пакет именем папки, в которой он лежит. Это, конечно, не относится к пакетам main, которые являются точками входа и вряд ли будут импортироваться в другие пакеты. К именам пакетов, также как и к переменным, golint предъявляет требования соответствовать camelCase. Но при учёте того, что некоторые файловые системы нечуствительны к регистру, лучше вообще называть пакеты строчными буквами, не прибегая к другим символам.

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

Модули

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

Вначале был $GOPATH

С самой первой версии для работы с языком было необходимо настроить переменную окружения $GOPATH. Принцип работы этой переменной вызывало максимальное непонимание для новичков в языке, так что в итоге в версии 1.8 для этой переменной ввели значение по умолчанию (~/go), а начиная с версии 1.12 можно работать вообще не зная ничего об этой переменной. Однако, давайте всё же разберёмся — как она работает.

Переменная $GOPATH должна содержать путь к директории, в которой будет хранится информация обо всех сторонних пакетах, в том числе и разрабатываемых. Внутри этой директории будет созданы три директории: src, pkg и bin. В последнюю будут автоматически попадать скомпилированные исполняемые файлы, при установке пакетов, собирающихся в таковые. Например, если установить пакет stringer, из него будет собран исполняемый файл stringer. В директории pkg будет собираться кеш версий различных библиотек, а также промежуточные артефакты компиляций. Наконец, директория src содержит исходный код пакетов и модулей. Начиная с версии 1.13, установка пакета сохраняет его исходные коды только в pkg, так что в src остались только разрабатываемые вами исходные коды. И полное имя пакета для импорта совпадает с путём до пакета относительно директории src. Таким образом, если в директории src создать директорию foo/bar, в которую положить go-файлы, то для импорта этого пакета в другом пакете в директиве import будет необходимо прописать строку "foo/bar".

Сторонние библиотеки

Для установки сторонних библиотек с самой первой версии существует команда go get <имя пакета>. В качестве источников пакетов используются git-репозитории, например, https://github.com или https://golang.org. Собственно для установки того же пакета stringer в консоли необходимо выполнить

go get golang.org/x/tools/cmd/stringer

Эта команда выполнит следующее:

  1. создаст директорию $GOPATH/src/golang.org/x/tools/cmd/stringer;

  2. склонирует туда дефолтную ветку этого репозитория;

  3. если в пакете есть подпакет main, скомпилирует исполняемый файл и положит его в $GOPATH/bin;

Для обновления можно использовать дополнительный флаг -u. А после внесения изменений можно пересобрать исполняемый файл с помощью команды

go install golang.org/x/tools/cmd/stringer

При этом src будет лежать полноценный git-репозиторий, как есть.

Если одна библиотека использовала другую, то при установке устанавливались и зависимости. Конечно без всякого версионирования, просто последний коммит из master-ветки. Но что хорошо для монорепозитория, плохо для большого распределённого сообщества. Так в версии 1.5 появилась поддержка специальной папки vendor, содержащей в себе зависимости. Также правилом хорошего тона стало включение этой папки в репозитории. При сборке зависимость в первую очередь ищется в папках vendor рекурсивно от текущего пакета до $GOPATH/src, после чего ищется уже в $GOPATH/src. Но не смотря на поддержку этой папки во время компиляции, не было никакого официального тулинга для работы с содержимым этой папки. Так появились инструменты, разрабатываемые сообществом.

А потом пришли модули

В конце февраля 2018 года один из корневых разработчиков языка Расс Кокс выпустил серию статей и прототип того, что в последствии стало называться go-модули. Начиная с версии языка 1.10 поддержка модулей появилась в наборе команд go mod.

Модули позволяют версионировать библиотеки и их зависимости, а также, при необходимости управлять директорией vendor. Для того, чтобы создать модуль, достаточно в любой директории выполнить команду go mod init. После этого в директории появится файл go.mod, в котором будут описываться зависимости текущего модуля. Первой строкой в таком файле указано название модуля. Это название важно в том смысле, что работать с модулем можно за пределами $GOPATH. При разрешении зависимостей, если зависимость начинается с названия модуля, то она будет искаться относительно этого модуля.

При выполнении команды go get внутри модуля будет подтянута в кеши последняя версия библиотеки. В качестве версий библиотек используются тэги git-репозитория. При этом используется строгий semver, то есть тэг должен иметь вид

v{major:\d+}.{minor:\d+}.{patch:\d+}[-.*]

Если в репозитории нет ни одного тэга, подходящего под это определение, то будет взята голова master-ветки. Информация об устанавливаемых версиях будет накапливаться в файле go.mod. При необходимости включения зависимости целиком можно выполнить команду go mod vendor, которая соберёт все зависимости с нужными версиями и скопирует их в директорию vendor.

Если две или более зависимости ссылаются на разные версии одной библиотеки, то для минорных версий будет выбрана максимальная из требуемых, а мажорные версии могут работать параллельно, используя суффиксы в импортах v2. Более подробно о разрешении зависимостей и других особенностях поведения модулей можно прочитать в серии статей Расса Кокса.

Публичные и приватные сущности

Разделение публичных и приватных сущностей в Go сделано очень просто. Если имя сущности начинается с заглавной буквы, то она публичная. Это относится к глобальным переменным, функциям, типам, полям структур и методам. То есть, если вы объявите

type User struct {
    ID       int
    Email    string
    password []byte
}

то для внешних пакетов этот тип будет доступен, а у объектов этого типа будут доступны поля ID и Email, поле password будет приватным.

Циклические зависимости

Одной из частых проблем, с которой можно столкнуться при разработке на Go — циклические зависимости. То есть пакет A импортирует пакет B, который импортирует пакет C, импортирующий в свою очередь пакет A. Циклы могут быть разной длинны, сути это не меняет. Компилятор не может разрешить такой граф и соответственно скомпилироать код. Для разрешения таких зависимостей чаще всего приходится пересматривать архитектуру проекта, используя такие подходы как чистая архитектура, предложенная Робертом Мартином или восьмиугольную, луковую, любую другую позволяющую разделить абстракции. Взаимодействие же между уровнями абстракции строить на стандартных типах или на общих чистых сущностях (моделях), а также прибегая к интерфейсам. Один из вариантов организации проекта предложен в статье «Чистая архитектура на Go»[cag]. С другой стороны при использовании интерфейсов циклическая зависимость может возникнуть в тестах. Для разрешения таких зависимостей можно вынести тесты в отдельный модуль.

Тесты в отдельном пакете

Для тестов допустимо использовать имя пакета с добавлением суффикса _test, не перемещая их файлы в другую директорию. Это оказывается удобным для написания функциональных тестов по типу чёрного ящика. Тесты своего рода оказываются во внешнем пакете, поэтому в них доступны только публичные свойства и методы. Дополнительным плюсом оказывается, что примеры (функции Example…​ из тестовых файлов) будут более приближены к реальному использованию:

package sort_test

import (
    "fmt"

    "github.com/superSorter/sort"
)

func ExampleFlashSort() {
    a := []int{12, 22, 11, 0, 9}
    sort.FlashSort(a)
    fmt.Println(a)
    // Output: [0 9 11 12 22]
}

Соглашения

В сообществе сложилось несколько правил к именованию директорий и пакетов.

Первое правило поддерживается компилятором и тулингом. Пакеты располагаемые внутри директории internal доступны только в родительском пакете. То есть, если у нас есть такая структура директорий:

foo/
  bar/
  internal/
    baz/
qux/

то пакет foo/internal/baz можно импортировать в пакетах foo и foo/bar, но нельзя в пакете qux. Это особенно полезно для того, чтобы не смущать такие инструменты как goimports. Если у вас есть 10 сервисов, в каждом из которых есть пакет model, то утилита goimports может подставить импорт из соседнего сервиса вместо того, чтобы использовать модели текущего. Если же пакеты моделей спрятать внутри internal, то вопрос о том откуда импортировать пакет не возникнет.

Остальные правила не поддерживаются тулингом или компилятором, однако хорошо закрепились в правилах хорошего тона. Для точек входа (пакетов main) использовать подкатологи cmd. Часто возникает необходимость уметь собирать из одного кода несколько артефактов, например, сервис и терминальный клиент к сервису или утилиту для миграций. Тогда структура проекта может выглядеть примерно так

module/
  cmd/
    service/main.go
    client/main.go
    migrate/main.go
  internal/
    ...
  go.mod

При разработке библиотеки иногда возникает необходимость вводить экспериментальные функции, которые работают нестабильно или их интерфейс может измениться. Такие функции принято выносить в пакет x или его подпакеты. Ярким примером такого подхода является стандартная библиотека Go. Так для стандартного пакета net есть пакет экспериментальных функций golang.org/x/net.

Конкурентное программирование

Concurrency is not parallelism.
— Rob Pike

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

Go-рутины

Конкурентный доступ к памяти

atomic, mutex, Map

CSP (Communicating sequential processes)

Ожидание завершения

WaitGroup, ErrGroup

Прерывание пула go-рутин

Context

Дополнительно

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

fmt

time

http

Обработка ошибок

Потоки (reader и writer)

Рефлексия

Различные подходы к тестированию

Генерация кода

Makefile для Go-проектов

Линтеры и другие инструменты

Особые комментарии

Сборка мусора

Литература и ссылки

  • [kr] Брайан Керниган и Деннис Ритчи. Язык программирования C. Второе издание, переработанное и дополненное. Вильямс, 2019. — 288 с.

  • [cc] Роберт Мартин. Чистый код. Создание, анализ и рефакторинг. СПб.: Питер, 2018. — 464 с.

  • [virt] Никлаус Вирт. Алгоритмы и структуры данных. М.: ДМК Пресс, 2010. – 272 с.

  • [shad] Максим Бабенко. Алгоритмы и структуры данных. Учебный курс (видеолекции). — М.: Яндекс, 2012. https://yandexdataschool.ru/edu-process/courses/algorithms

  • [cag] Евгений Бастрыков. Чистая архитектура на Go. Статья в блоге. — 2018. https://vporoshok.me/post/2018/04/clean-architect/