В курсе рассматриваются базовые концепции языка 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
Все приведённые выше развилки имеют достоинства и недостатки с обоих сторон. Нельзя сказать, что компилируемые языки лучше или хуже интерпретируемых. Для некоторых задач что-то больше подходит, что-то меньше. Из своего опыта могу лишь сказать, что в долгосрочной перспективе компиляция и строгая статическая типизация помогают уберечься от глупых ошибок, хоть по началу и требуют больше времени для написания кода.
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-файла должна задавать имя пакета, которому этот файл принадлежит. Более подробно о пакетах мы поговорим соответствующей части, пока же упомянем лишь следующее:
-
Пакетом является директория, то есть все файлы, расположенные в одной директории (за исключением тестов), должны принадлежать одному пакету. Таким образом не нужно явно подключать файл, находящийся рядом, как в том же Python или NodeJS.
-
Внутри пакета доступны все объявления. То есть нельзя в двух соседних файлах объявлять функции, типы или константы с одинаковыми именами.
Также как в языке Си, исполняемая программа должна содержать функцию main
, с которой и начинается исполнение программы. Инструкции за пределами функций, за исключением объявления глобальных переменных, запрещены. Но есть одно ограничение: функция main
должна располагаться в пакете main
. При этом хорошим тоном считается держать весь код этого пакета в одном файле, так чтобы было удобнее запускать и компилировать программу. На первых порах мы будем оперировать только такими файлами-пакетами, позже мы научимся разбивать код на пакеты и библиотеки.
В строке 3 подключается стандартный пакет fmt
. Этот пакет содержит функции по работе с вводом/выводом и другие вспомогательные функции. В данном случае в 6-й строке мы используем функцию Println
из этого пакета, позволяющую вывести на экран текст.
Переменные
Переменные можно объявлять следующим способом:
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"
В результате будут объявлены три переменные
-
x
типаint
со значением 17; -
y
типаfloat64
со значением 19.5; -
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
, а rune
— int32
. Но есть и менее предсказуемые типы 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
, после чего возможно несколько вариантов:
-
именованная функция уровня пакета;
-
анонимная функция;
-
метод (в данном разделе мы пропустим этот вариант и вернёмся к нему в разделе, посвящённом ООП и методам);
Определение именованной функции уровня пакета мы с вами уже встречали:
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
}
Анонимные функции
В 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 .
|
Теперь можно использовать эту функцию в комбинации с различными функциями проверки:
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))
}
Но иногда не хочется объявлять функции в глобальном пространстве имён. Тогда можно воспользоваться анонимными функциями, функциями без имени:
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
). При этом в замыкании можно как читать, так и изменять контекст порождающей функции. С помощью замыканий можно реализовывать различные полезные паттерны проектирования. Например, с помощью замыкания можно создать итератор (объект возвращающий по запросу следующее значение из некоторой последовательности). Для примера напишем генератор чисел Фибоначчи:
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))
Этот синтаксис имеет ряд ограничений, но часто позволяет улучшить публичный интерфейс пакета. Старайтесь не злоупотреблять подобным синтаксисом.
Задачи
-
Что будет результатом
--x
? Почему? -
Предложите минимальное исправление для следующей программы:
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)))
}
-
Чему будет равна константа
Qux
при следующем определении:
const (
Foo uint32 = iota * iota
Bar
Baz = iota + 2
Qux
)
-
Напишите генератор квадратов натуральных чисел.
Сложные типы данных
Важной отличительной чертой скалярных типов является то, что при передаче их в качестве аргументов в функцию, на самом деле в функцию попадает копия объекта. Для наглядности рассмотрим пример:
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{"Иванов", "Иван", "Иванович"}
Теперь мы также можем передать переменную такого типа в функцию. И в этом случае поведение структуры похоже на поведение скалярных типов, создаётся копия переменной:
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
|
Также при присваивании одной переменной другой будет происходить копирование:
|
Для изменения данных структуры внутри функции можно воспользоваться указателями.
Указатели
Указатель это адрес данных в памяти. Если мы создадим копию этого адреса, он всё равно будет указывать на ту же область памяти. Таким образом при передаче указателя в качестве аргумента мы не создаём копию данных, копия указателя ссылается на те же данные. В 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 являются типизированными. Подробнее об этом мы поговорим при обсуждении интерфейсов
|
Массивы
Массивы в Go похожи на тип array в Pascal. При описании типа мы должны указать количество и тип элементов:
type Point [2]int
x := Point{3, 12}
С точки зрения хранения данных массив очень похож на структуру, где все поля имеют одинаковый тип и вместо имён полей используются индексы от 0 до n-1 (где n это количество элементов массива). Поэтому и ведут себя они также как структуры.
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 последовательных байт в оперативной памяти.
При этом память вокруг этих 40 байт может быть занята другими данными. Поэтому при добавлении даже одного элемента с помощью команды append
может потребоваться зарезервировать новый участок памяти, перенести в него имеющиеся данные и дописать новый элемент. Операции аллокации памяти и копирования данных занимают много времени, поэтому резервировать необходимо с запасом. Обычно используется удвоение размера, то есть, даже если мы захотим дописать к срезу из 4 элементов ещё один, выгоднее зарезервировать 96 байт, вместо 48.
Таким образом, у среза помимо данных есть две характеристики: длина и вместимость. Длина означает сколько фактически элементов лежит в памяти, а вместимость — под сколько элементов зарезервировано место. Узнать длину и вместимость среза можно с помощью функций 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]
Переменные параметры для функции
Помните, что последний параметр функции с типом срез можно определить с использованием 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
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, если на неё не осталось больше ссылок. |
Тип ключей должен поддерживать операции сравнения на равенство, к таким типам относятся все числовые типы и основанные на них, булевые, строки, указатели, каналы, интерфейсы, а также структуры и массивы, содержащие указанные типы. То есть нельзя использовать в качестве ключей функции, срезы и другие хеш-таблицы.
Итерирование
При итерировании по таблице возможны два варианта цикла:
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
}
Многомерные хеш-таблицы
Часто возникает задача разграничивать данные по нескольким признакам. Пусть, например, мы пишем систему групповых чатов, и нам необходимо кешировать счётчики непрочитанных сообщений. Для каждого пользователя и для каждого чата. Первое, что приходит в голову это сделать хеш-таблицу хеш-таблиц:
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
, с помощью которого можно сортировать любые срезы и массивы. Однако, этот пример нам ещё пригодится.
Заметим, что сортировка не добавляет новых элементов в срез, так что мы можем передать срез в функцию сортировки как есть, переставить его элементы местами, и это будет применено к тому участку памяти, на который ссылается оригинальный срез.
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]
}
Задачи
-
В примере сдвиг отсортированной части массива при вставке производится в цикле (строки
[10:12]
). Оптимизируйте этот сдвиг при помощи встроенной функцииcopy
. -
Какую потенциальную проблему может вызвать следующий код?
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 подходом «табличные тесты». В этом подходе создаётся массив структур в которых описываются входные и выходные параметры, а потом в цикле для каждого элемента этого массива выполняется проверка результата при заданных аргументах.
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
|
В данном примере используется синтаксис объявления массива через три точки. По факту эти три точки в момент компиляции будут заменены на количество элементов в объявлении. Например выражение
будет заменено на
Такой синтаксис удобно использовать потому, что при добавлении или удалении одного из элементов не нужно переписывать количество в квадратных скобках. Конечно, в данном случае можно было бы воспользоваться и срезом, но массив занимает меньше места и не использует указатели, что более дружелюбно к сборщику мусора. |
Работа этого нехиторого теста вполне очевидна, за исключением двух строк:
-
на 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
. С помощью этого же флага можно «заставить» компилятор заново выполнить закешированный тест.
С помощью флага -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 мс. Но как понять — много это или мало? В данном случае мы можем сравнить это со стандартной библиотекой. Для этого расширим тест, сделав два вложенных теста: для нашей функции сортировки и для стандартной:
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.
Не старайтесь скрывать логику программы за сложными конструкциями, оставляйте код максимально чистым и понятным.
Методы
Первая ложка сахара, предоставляемая языком — методы. К любому пользовательскому типу (кроме интерфейсов, но об этом позже) можно добавить метод. Для этого в том же пакете, где объявлен тип нужно определить функцию следующего вида:
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.
Правила этикета интерфейсов
Как и всё, что связано с ООП, тема интерфейсов неоднозначна и периодически вызывает холивары. О видении самих разработчиков можно прочитать в статье 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
}
Задания
-
Написать 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
Эта команда выполнит следующее:
-
создаст директорию
$GOPATH/src/golang.org/x/tools/cmd/stringer
; -
склонирует туда дефолтную ветку этого репозитория;
-
если в пакете есть подпакет
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.
Наибольшую популярность язык 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/