Важной отличительной чертой скалярных типов является то, что при передаче их в качестве аргументов в функцию, на самом деле в функцию попадает копия объекта. Для наглядности рассмотрим пример:
link:examples/05_scalar_args/main.go[role=include]
По факту переменные 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 последовательных байт в оперативной памяти.
+-----+-------+-------+-------+-------+-------+-----+ |cPNK |cGRE 1 |cGRE 1 |cGRE 2 |cGRE 4 |cGRE 9 |cPNK | +-----+-------+-------+-------+-------+-------+-----+
При этом память вокруг этих 40 байт может быть занята другими данными. Поэтому при добавлении даже одного элемента с помощью команды append
может потребоваться зарезервировать новый участок памяти, перенести в него имеющиеся данные и дописать новый элемент. Операции аллокации памяти и копирования данных занимают много времени, поэтому резервировать необходимо с запасом. Обычно используется удвоение размера, то есть, даже если мы захотим дописать к срезу из 4 элементов ещё один, выгоднее зарезервировать 96 байт, вместо 48.
+-----+-------+-------+-------+-------+-------+--------+-----+-----+-----+-----+ |cPNK |cGRE 1 |cGRE 1 |cGRE 2 |cGRE 4 |cGRE 9 |cGRE 21 |cYEL |cYEL |cYEL |cPNK | +-----+-------+-------+-------+-------+-------+--------+-----+-----+-----+-----+
Таким образом, у среза помимо данных есть две характеристики: длина и вместимость. Длина означает сколько фактически элементов лежит в памяти, а вместимость — под сколько элементов зарезервировано место. Узнать длину и вместимость среза можно с помощью функций 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]
Несмотря на столь скромный набор встроенных методов по работе со срезами (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.
Задача. Необходимо уметь хранить множество пар ключ-значение, так чтобы максимально эффективно реализовывались три операции:
-
поиск по ключу;
-
добавление пары или изменение значения по ключу;
-
удаление пары по ключу;
Эту задачу можно решить различными структурами данных. Например, если ключи можно упорядочить, то применимы деревья поиска, которые помимо вышеописанных операций могут добавлять эффективные взятие диапазона ключей, слияние и другие. Но наиболее эффективно эти операции реализует хеш-таблица.
В памяти выделяется монолитный участок для хранения \$N\$ пар. Доступ к конкретной паре осуществляется по индексу. После этого для хеш-таблицы создаётся хеширующая функция, с помощью которой из ключа можно получить индекс в диапазоне \$[0,N-1]\$. Таким образом для всех операций ключ преобразовывается в индекс и работа ведётся как с массивом.
Конечно, возможны коллизии хеширующей функции, когда два разных ключа отображаются в один индекс. Существует несколько способов разрешения этих конфликтов. Подробнее про эти способы вы можете прочитать в книге Н. Вирта[virt]. А в курсе Школы Анализа Данных[shad] подробно разбирается устройство хеширующей функции.
Работу с хеш-таблицами продемонстрируем на примере нахождения самой часто-встречаемой буквы в тексте:
link:examples/06_most_frequent_rune/main.go[role=include]
Note
|
Тип rune используется для многобайтных символов, например, в кодировке utf8 . Собственно строка на самом деле представляет из себя срез байт, с ограничением на изменение. Но при итерировании по строке она ведёт себя как срез рун.
|
Для объявления переменной типа хеш-таблица используется выражение map[<тип ключа>]<тип значения>
. Для чтения и записи по ключу используется синтаксис с указанием ключа в квадратных скобках. Важно отметить, что при чтении в таблице может не быть пары с данным ключом, тогда в качестве значения вернётся пустое значение, соответствующего типа. Если нам важно различать: есть ли ключ и по нему лежит пустое значение или пары с таким ключом нет, можно использовать расширенный синтаксис чтения: val, ok := m[key]
, для первой ситуации переменной ok
будет присвоена истина, для второй — ложь. Для удаления пары по ключу используется встроенная функция delete(m, key)
.
Note
|
В Go нет встроенного механизма для очистки всей таблицы. Для этого просто создайте новую пустую таблицу и присвойте её переменной. Старая таблица будет собрана GC, если на неё не осталось больше ссылок. |
Тип ключей должен поддерживать операции сравнения на равенство, к таким типам относятся все числовые типы и основанные на них, булевые, строки, указатели, каналы, интерфейсы, а также структуры и массивы, содержащие указанные типы. То есть нельзя использовать в качестве ключей функции, срезы и другие хеш-таблицы.
Дело в том, что хеш-таблицы своего рода задают сопоставление множества ключей и множества значений, то есть, говоря математическим языком отображение множества ключей в множество значений. Частным случаем отображений являются хорошо знакомые нам математические функции. В английском языке для отображений используют термин 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
, с помощью которого можно сортировать любые срезы и массивы. Однако, этот пример нам ещё пригодится.
Заметим, что сортировка не добавляет новых элементов в срез, так что мы можем передать срез в функцию сортировки как есть, переставить его элементы местами, и это будет применено к тому участку памяти, на который ссылается оригинальный срез.
link:examples/07_insert_sort/main.go[role=include]
-
В примере сдвиг отсортированной части массива при вставке производится в цикле (строки
[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
}