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

Мы используем жаргонный термин "мэпа" (она же "мапа") вместо того чтобы писать по-английски "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]

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

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

Разработчики 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.

Заключение

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

Комментарии (5)


  1. anaxita
    29.10.2024 09:35

    Закончился очередной курс вайтишников?


  1. denisskin
    29.10.2024 09:35

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

    m := map[[2]any]string{}
    
    m[[2]any{"user", 111}] = "Alice"
    m[[2]any{"user", 222}] = "Bob"
    
    println(m[[2]any{"user", 222}])

    а если еще добавить в качестве мини-утилиты функцию

    func key2(a, b any) [2]any {
    	return [2]any{a, b}
    }

    то можно писать так: m[key2("user", 111)] = "Alice"

    иногда бывает полезным.

    а то частенько встречал код, где делают мапы мапов %)



    1. RodionGork Автор
      29.10.2024 09:35

      и тут я замечаю кое-что интересное :) не думал что ключом может быть массив из any - т.к. any ведь может быть и не сравнимым типом. попробовал заменить 222 на слайс - обламывается в рантайме. т.е. он не только при компиляции проверяет что ключ валидного типа. любопытно, спасибо!

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

      m[x .. ":" .. y] = 100


  1. hello_b0ss
    29.10.2024 09:35

    Про порядок ключей и печать мапы - можно уточнить, пакет fmt сам сортирует мапу по ключу при формировании mapString, поэтому обычный fmt.Print(m) всегда будет печатать "отсортированную" мапу. Видел подобный вопрос на каком-то из моков, возможно кому-то пригодится)


    1. RodionGork Автор
      29.10.2024 09:35

      да, спасибо, подписал под примером а то действительно выглядит неясно зачем он там :)