Перейти к содержимому

Лекция 6. Maps, строки, байты и Unicode

Цели лекции

После лекции студент должен:

  • создавать и изменять maps;
  • проверять наличие ключа через форму value, ok;
  • понимать поведение отсутствующего ключа и nil map;
  • использовать map как множество и частотный словарь;
  • не зависеть от порядка обхода map;
  • понимать, что строка Go содержит байты;
  • различать string, byte, rune, []byte и []rune;
  • корректно обрабатывать UTF-8;
  • применять пакеты strings, unicode, unicode/utf8;
  • избегать лишних конкатенаций и некорректной индексации текста.

1. Ассоциативные данные

Slice хранит значения по целочисленным индексам. Во многих задачах доступ нужен по смысловому ключу:

  • оценка по имени студента;
  • пользователь по идентификатору;
  • количество слов по самому слову;
  • настройка по её названию.

Для этого в Go используется map.

scores := map[string]int{
"Анна": 95,
"Борис": 82,
}

Тип читается как «map из string в int».

2. Тип ключа и значения

Общая форма:

map[KeyType]ValueType

Ключ должен быть сравнимым типом. Подходят:

  • строки;
  • числа;
  • логические значения;
  • указатели;
  • массивы и структуры, если все их элементы сравнимы.

Slices, maps и функции нельзя использовать как ключи, потому что они не сравниваются через ==.

Допустимые примеры:

map[int]string
map[string]float64
map[UserID]User
map[[2]int]string

Значением может быть практически любой тип, включая slice или другую map.

3. Создание map литералом

capitals := map[string]string{
"Россия": "Москва",
"Франция": "Париж",
"Япония": "Токио",
}

Пустая, но готовая к записи map:

capitals := map[string]string{}

4. Создание через make

scores := make(map[string]int)
scores["Анна"] = 95
scores["Борис"] = 82

Можно подсказать ожидаемое число элементов:

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

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

5. Чтение и запись

scores := map[string]int{
"Анна": 95,
}
fmt.Println(scores["Анна"])
scores["Анна"] = 97
scores["Борис"] = 82

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

Количество элементов:

fmt.Println(len(scores))

Вместимость map через cap получить нельзя.

6. Отсутствующий ключ и zero value

Чтение отсутствующего ключа возвращает zero value типа значения:

scores := map[string]int{
"Анна": 95,
}
fmt.Println(scores["Виктор"]) // 0

Но нулевой балл может быть реальным значением. Чтобы отличить отсутствие ключа, используют дополнительный bool:

score, ok := scores["Виктор"]
if !ok {
fmt.Println("Студент не найден")
return
}
fmt.Println("Балл:", score)

Эту форму называют comma-ok idiom.

Короткая инструкция:

if score, ok := scores["Анна"]; ok {
fmt.Println("Балл:", score)
}

7. Удаление и очистка

Удаление ключа:

delete(scores, "Анна")

Удаление отсутствующего ключа безопасно и ничего не делает.

Очистка всех элементов:

clear(scores)

После clear сама map остаётся готовой к повторной записи.

Можно также присвоить новую map:

scores = make(map[string]int)

Старая map будет освобождена сборщиком мусора, когда на неё не останется ссылок.

8. nil map

Zero value map — nil:

var scores map[string]int

Читать из неё можно:

fmt.Println(scores["Анна"]) // 0
fmt.Println(len(scores)) // 0

Удаление и clear также безопасны. Но запись вызывает panic:

// scores["Анна"] = 95 // panic: assignment to entry in nil map

Перед записью map нужно создать:

scores = make(map[string]int)
scores["Анна"] = 95

Функция, которая только читает map, может корректно принимать nil. Функция, которая записывает, должна получить созданную map или создать и вернуть её.

9. Перебор map

for name, score := range scores {
fmt.Println(name, score)
}

Порядок обхода map не определён. Он может различаться между запусками. Нельзя:

  • сравнивать текстовый вывод, рассчитывая на порядок;
  • выбирать «первый» элемент как особенный;
  • использовать порядок в бизнес-логике.

Для стабильного вывода сортируют ключи:

names := make([]string, 0, len(scores))
for name := range scores {
names = append(names, name)
}
slices.Sort(names)
for _, name := range names {
fmt.Println(name, scores[name])
}

10. Map присваивается как ссылка на общие данные

first := map[string]int{"one": 1}
second := first
second["two"] = 2
fmt.Println(first) // содержит one и two

Присваивание не создаёт независимую копию. Для клонирования можно использовать пакет maps:

clone := maps.Clone(first)

Клонирование поверхностное. Если значениями являются slices или maps, вложенные данные остаются общими.

Maps нельзя сравнивать через ==, кроме сравнения с nil.

11. Передача map в функцию

func increment(counter map[string]int, key string) {
counter[key]++
}
func main() {
counter := make(map[string]int)
increment(counter, "go")
fmt.Println(counter["go"]) // 1
}

Функция получает копию небольшого описателя map, но обе копии обращаются к одной внутренней структуре.

Если функция может заменить map целиком, новую map следует вернуть:

func reset() map[string]int {
return make(map[string]int)
}
counter = reset()

12. Частотный словарь

Map удобно считает повторения:

words := []string{"go", "python", "go", "swift", "go"}
frequency := make(map[string]int)
for _, word := range words {
frequency[word]++
}
fmt.Println(frequency["go"]) // 3

Отсутствующий ключ возвращает 0, поэтому отдельная проверка перед инкрементом не нужна.

13. Map как множество

В стандартной библиотеке нет отдельного типа set. Множество часто представляют как map[T]struct{}:

languages := map[string]struct{}{
"go": {},
"python": {},
}

Добавление:

languages["swift"] = struct{}{}

Краткая форма:

languages["swift"] = struct{}{}

Проверка:

_, exists := languages["go"]

Удаление:

delete(languages, "swift")

struct{} не содержит полей и не требует места для полезных данных. Иногда используют map[T]bool, но тогда false и отсутствие ключа требуют внимания.

14. Вложенные maps и slices

Map со slice значений:

courses := map[string][]string{
"backend": {"Go", "PostgreSQL", "Docker"},
"mobile": {"Swift", "SwiftUI"},
}

Добавление:

courses["backend"] = append(courses["backend"], "Redis")

Вложенная map требует создания каждого уровня:

grades := make(map[string]map[string]int)
grades["Анна"] = make(map[string]int)
grades["Анна"]["Go"] = 95

Попытка записать во внутреннюю nil map вызовет panic.

Часто структура данных понятнее вложенных maps. Структуры рассматриваются в следующей лекции.

15. Строка как последовательность байтов

Строка Go — неизменяемая последовательность байтов:

text := "Go"
fmt.Println(len(text)) // 2
fmt.Println(text[0]) // 71 — байт символа 'G'

Индексирование строки возвращает byte, а не строку и не обязательно целый Unicode-символ.

text := "Мир"
fmt.Println(len(text)) // 6 байт в UTF-8
fmt.Println(text[0]) // только первый байт буквы 'М'

Нельзя заменить отдельный байт строки:

// text[0] = 'м' // ошибка: строки неизменяемы

16. byte и rune

Псевдонимы:

type byte = uint8
type rune = int32

byte хранит один байт. rune хранит Unicode code point.

var latin rune = 'G'
var cyrillic rune = 'Ж'
fmt.Printf("%c %U\n", latin, latin)
fmt.Printf("%c %U\n", cyrillic, cyrillic)

Один Unicode-символ кодируется в UTF-8 одним–четырьмя байтами.

Важно: rune соответствует code point, но не всегда тому, что пользователь воспринимает как один видимый символ. Например, буква и комбинируемый знак могут состоять из нескольких runes, а emoji — из последовательности code points.

17. Перебор строки через range

text := "Go, мир!"
for index, symbol := range text {
fmt.Printf("index=%d rune=%c code=%U\n", index, symbol, symbol)
}

index — байтовое смещение начала символа. Для ASCII он увеличивается на 1, для кириллицы обычно на 2.

Если нужны только runes:

for _, symbol := range text {
fmt.Printf("%c ", symbol)
}

18. Длина строки

Число байтов:

len(text)

Число UTF-8 code points:

utf8.RuneCountInString(text)

Или:

len([]rune(text))

Второй способ создаёт slice runes, поэтому для простого подсчёта utf8.RuneCountInString выражает намерение точнее.

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

19. Преобразование между string, []byte и []rune

text := "Привет"
bytes := []byte(text)
runes := []rune(text)
fmt.Println(bytes)
fmt.Println(runes)

Обратное преобразование:

textFromBytes := string(bytes)
textFromRunes := string(runes)

Для байтовой обработки файлов, сети и кодировок используют []byte. Для изменения Unicode code points удобен []rune:

runes := []rune("Кот")
runes[0] = 'Р'
fmt.Println(string(runes)) // Рот

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

20. Некорректный UTF-8

Строка может содержать произвольные байты, не только корректный UTF-8:

text := string([]byte{0xff, 0xfe, 'A'})
fmt.Println(utf8.ValidString(text)) // false

При переборе некорректные последовательности заменяются на utf8.RuneError — символ �.

Проверка внешних данных:

if !utf8.Valid(data) {
return fmt.Errorf("input is not valid UTF-8")
}

21. Пакет strings

Частые операции:

text := " Go, Python, Swift "
fmt.Println(strings.TrimSpace(text))
fmt.Println(strings.ToUpper(text))
fmt.Println(strings.ToLower(text))
fmt.Println(strings.Contains(text, "Python"))
fmt.Println(strings.HasPrefix(text, " Go"))
fmt.Println(strings.HasSuffix(text, " "))

Разбиение:

parts := strings.Split("go,python,swift", ",")

Объединение:

joined := strings.Join(parts, " | ")

Разбиение по последовательностям пробельных символов:

words := strings.Fields(" Go\tPython\nSwift ")

Замена:

result := strings.ReplaceAll("go go go", "go", "Go")

Сравнение без учёта регистра:

fmt.Println(strings.EqualFold("GO", "go"))

EqualFold учитывает Unicode лучше, чем ручное сравнение двух результатов ToLower.

22. Пакет unicode

Классификация runes:

for _, symbol := range "Go 2026!" {
switch {
case unicode.IsLetter(symbol):
fmt.Println(symbol, "letter")
case unicode.IsDigit(symbol):
fmt.Println(symbol, "digit")
case unicode.IsSpace(symbol):
fmt.Println(symbol, "space")
default:
fmt.Println(symbol, "other")
}
}

Также доступны unicode.ToUpper, unicode.ToLower и другие функции.

23. Конкатенация строк

Для нескольких частей оператор + понятен:

message := "Привет, " + name + "!"

Для большого количества частей повторная конкатенация в цикле создаёт лишние строки:

result := ""
for _, word := range words {
result += word
}

Используйте strings.Builder:

var builder strings.Builder
for index, word := range words {
if index > 0 {
builder.WriteString(", ")
}
builder.WriteString(word)
}
result := builder.String()

После передачи непустого strings.Builder не следует копировать по значению.

24. Нормализация пользовательского ввода

Для командного интерфейса часто достаточно:

command := strings.TrimSpace(input)
command = strings.ToLower(command)

Теперь строки " Add ", "ADD" и "add" обрабатываются одинаково.

Для полноценной лингвистической нормализации Unicode могут потребоваться дополнительные библиотеки. ToLower не решает все задачи поиска и сравнения во всех языках.

25. Практический пример: частота слов

package main
import (
"fmt"
"slices"
"strings"
"unicode"
)
func normalizeWord(word string) string {
word = strings.TrimFunc(word, func(symbol rune) bool {
return !unicode.IsLetter(symbol) && !unicode.IsDigit(symbol)
})
return strings.ToLower(word)
}
func wordFrequency(text string) map[string]int {
frequency := make(map[string]int)
for _, rawWord := range strings.Fields(text) {
word := normalizeWord(rawWord)
if word == "" {
continue
}
frequency[word]++
}
return frequency
}
func main() {
text := "Go прост. Go быстр, а Go-код форматируется автоматически."
frequency := wordFrequency(text)
words := make([]string, 0, len(frequency))
for word := range frequency {
words = append(words, word)
}
slices.Sort(words)
for _, word := range words {
fmt.Printf("%-20s %d\n", word, frequency[word])
}
}

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

26. Практический пример: уникальные теги

func uniqueTags(tags []string) []string {
seen := make(map[string]struct{}, len(tags))
result := make([]string, 0, len(tags))
for _, tag := range tags {
normalized := strings.ToLower(strings.TrimSpace(tag))
if normalized == "" {
continue
}
if _, exists := seen[normalized]; exists {
continue
}
seen[normalized] = struct{}{}
result = append(result, normalized)
}
return result
}

Map обеспечивает быструю проверку повторов, а slice сохраняет порядок первого появления тегов.

27. Типичные ошибки

Запись в nil map

Перед записью создайте map через литерал или make.

Проверка наличия только по zero value

Используйте value, ok, если нулевое значение допустимо.

Зависимость от порядка map

Для стабильного вывода сортируйте ключи.

Ожидание независимой копии

Присваивание map сохраняет общие данные. Используйте maps.Clone, если нужна отдельная верхнеуровневая map.

Индексация строки как символов

text[index] возвращает байт. Для Unicode используйте range или []rune.

Ожидание, что len возвращает количество букв

Для строки len возвращает байты.

Квадратичная конкатенация

При построении большой строки применяйте strings.Builder или strings.Join.

Наивная обработка Unicode

Один rune не всегда равен одному визуальному символу, а перевод регистра не является полной нормализацией текста.

28. Вопросы для самопроверки

  1. Какие типы можно использовать как ключ map?
  2. Что возвращает чтение отсутствующего ключа?
  3. Зачем нужна форма value, ok?
  4. Какие операции безопасны для nil map?
  5. Почему порядок обхода map нельзя считать заданным?
  6. Создаёт ли присваивание map копию данных?
  7. Как представить множество в Go?
  8. Что возвращает индексирование строки?
  9. Чем byte отличается от rune?
  10. Что возвращает len для строки?
  11. Как корректно перебрать Unicode code points?
  12. Когда использовать []byte, а когда []rune?
  13. Для чего нужен strings.Builder?
  14. Почему число runes не всегда равно числу видимых символов?

Итоги

Map связывает сравнимые ключи со значениями и возвращает zero value при отсутствующем ключе; форму value, ok используют, когда наличие нужно отличить от нулевого значения. Строка Go является неизменяемой последовательностью байтов, обычно содержащей UTF-8. Корректная текстовая обработка требует понимать границы байтов и runes и применять range, strings, unicode и unicode/utf8.