golang

Вопросы по мэпам (map) в Go

  • среда, 30 октября 2024 г. в 00:00:19
https://habr.com/ru/articles/854214/

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

Мы используем жаргонный термин "мэпа" (она же "мапа") вместо того чтобы писать по-английски "map" чисто ради того чтобы иметь возможность пользоваться свойственными русскому языку падежными окончаниями для большей связности текста.

Начнем с простого - Порядок Ключей

"В каком порядке range перебирает элементы мэпы?" и подобные вопросы.

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

A map is an unordered group of elements... (Мэпа - неупорядоченный набор элементов...)

и про range

The iteration order over maps is not specified and is not guaranteed to be the same (порядок итерирования по мэпам не определен и нет гарантии что он будет всегда одинаков...)

То есть он может отличаться в разных реализациях языка, может измениться от версии к версии и т.п. Программы не должны полагаться на какой-то определенный порядок, даже если показалось что он есть :)

fmt.Printf("%v\n", map[int]int{1: 10, 2: 20, 3: 30})
// prints map[1:10 2:20 3:30]

Объявление и Инициализация

Разработчики Go - ребята непростые. Они оставили нам только один for цикл и исключили тернарный оператор, утверждая что "не нужно предоставлять больше одного способа сделать что-либо". Невзирая на это, мэпу можно создать тремя способами:

var m1 map[int]int
m2 := map[int]int{}
m3 := make(map[int]int)

Какая разница между этими способами? В первом случае в переменной оказывается nil (или nil-map):

fmt.Printf("%v %v %v\n", m1, m2, m3)
// prints map[] map[] map[]
fmt.Println(m1 == nil, m2 == nil, m3 == nil)
// prints true false false

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

Встроенные функции и операции

Функцию make(...) для создания мэпы мы обсудили.

Запись в мэпу осуществляется присваиванием элемента (m[key] = value). Выбор из мэпы - тоже присваиванием (value = m[key]) причем как все мы знаем, отсутствие ключа приведёт к записи "дефолтного значения для данного типа" в value, ошибки не происходит. Если нужно различить случай отсутствия ключа, используется вторая переменная в операторе присваивания (value, ok = m[key]).

Для удаления элемента существует функция delete(m, key) - она не кидает ошибок если элемента нет или если мэпа вообще nil. В отличие от многих других функций эта ни для чего больше не используется.

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

Конечно, есть функция len(m) и она тоже nil-толерантна. Зато функции cap(...) для мэп нет. И для массивов нет. Она только для слайсов. Можно придумывать разные версии насчет того, почему решили так сделать.

Модификация в Цикле

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

	m := map[int]int{1: 10, 2: 20, 3: 30, 4: 40, 5: 50, 6: 60, 7: 70}
	for k, v := range m {
		println(k, v)
		if k%2 == 1 {
			m[k*3+1] = v * 2
		}
	}

Некоторые языки кидают ошибку чтобы предупредить о возможных сложных последствиях такого кода. Go так не делает - в спецификации сказано (в разделе про for ... range) что если элемент был удалён раньше чем цикл его достиг, то он обработан не будет - а если добавлен, то может будет, а может и нет. Можно было бы ожидать что код выше выведет 11 строк (т.к. в списке 4 нечетных ключа которые добавятся к 7 существующим) - но на данный момент в play.go.dev я вижу только 8 (дополнительно выводится только ключ 10 появившийся при обработке 3-ки).

Потокобезопасность

Мэпы никак не защищены на этот счет и не гарантируют консистентного поведения при использовании из нескольких потоков сразу. Можно либо лочиться с помощью мьютекса и обращаться к мэпе всегда "единолично", либо использовать sync.Map однако стоит внимательно почитать документацию (несколько туманную) по ней, т.к. она не во всех случаях несет выгоду (по крайней мере в текущей реализации) - предлагаются два "полезных" случая:

  • когда запись происходит редко или однократно а чтение часто (звучит так будто тут sync.Map не особо нужен)

  • когда запись, чтение и удаление происходят в непересекающихся подмножествах ключей (в смысле каждому потоку своё подмножество) - довольно узкий кейс

Кто может быть Ключом?

Значения каких типов могут быть ключами мэпы? Если вы не задумывались об этом раньше, вопрос может поставить в тупик. Логичное, что приходит на ум - иммутабельность. Нехорошо было бы добавить ключ и значение в мэпу, а потом ключ каким-то образом поменять так что он уже лежит "не на своём месте" (и например не найдётся по хэшу).

И это неправильно :)

В Go можно использовать в качестве ключей также изменяемые типы - в частности массивы и структуры!

Критерием является то что тип должен быть comparable - так называются типы для которых определены операции сравнения на равенство (но не на больше-меньше, как в Java).

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

Это означает что ключом мэпы может быть в частности структура и массив!

На первый взгляд может показаться - как так, а вдруг мы что-то в них поменяем? Но структуры и массивы в Go присваиваются c копированием, то есть мэпа в качестве ключа будет хранить копию а не оригинальный переданный ключ - и поменять эту самую копию вам просто не удастся - можете проверить на практике:

type Key struct {
	A int
	B string
}
//...
m := map[Key]string{}
k := Key{5, "bla"}
m[k] = "tin"
fmt.Printf("%v\n", m)
k.B = "mla"
fmt.Printf("%v\n", m)

Естественно и при получении ключа например с помощью for ... range нам достаётся копия, непригодная для модификации "внутреннего" ключа в мэпе.

Указатель на структуру конечно тоже можно использовать (он тоже comparable) - но как вы понимаете в таком случае ключом вообще будет адрес а не содержание - скорее всего это не то чего мы хотели достичь.

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

Присваивание и передача в параметры

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

Детали реализации

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

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

Если вы беседуете с программистами на C++ может быть полезно упомянуть что "наши мэпы" не такие как там (если не ошибаюсь, в C++ обычная мэпа сделана деревом, а для хэштаблиц есть "hashmap"). Но вообще такие подробности уже не про Go.

Заключение

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