Shanraq.org Shanraq.org
Массивы и срезы в Go: список, который растёт, append, len и cap
IT

Go: с нуля до своего блога Урок 8 из 50

Массивы и срезы в Go: список, который растёт, append, len и cap

Восьмой урок курса по Go. Массив, из которого сделан срез, и сам срез — список, который растёт: []string, append и почему результат присваивают обратно. Чем массив отличается от среза при присваивании, чем длина от ёмкости и главная ловушка: кусок смотрит в ту же память, что и оригинал.

Зачем это нужно

До сих пор одно имя хранило одно значение. Но статья в блоге не одна, тегов у неё несколько, комментариев тоже. Нужен список, и в Go он называется срезом.

Заодно for получит вторую половину обещанного: range, который мы взяли для строки, теперь пройдёт по списку.

Сразу целиком

Новая папка, go mod init sabaq07, main.go:

package main

import "fmt"

func main() {
	var titles []string

	titles = append(titles, "Шанырак")
	titles = append(titles, "Язык Go")
	titles = append(titles, "Степь")

	fmt.Println("статей:", len(titles), "· место:", cap(titles))

	for i, t := range titles {
		fmt.Println(i+1, t)
	}

	fmt.Println("первые две:", titles[:2])
	fmt.Println("последняя:", titles[len(titles)-1])
}

Прежде чем запускать — не заглядывая ниже, скажите вслух, что она напечатает. Потом запустите и сверьте: расхождение и есть то место, где вы ещё не знаете языка.

go run . печатает:

статей: 3 · место: 4
1 Шанырак
2 Язык Go
3 Степь
первые две: [Шанырак Язык Go]
последняя: Степь

Обратите внимание: статей три, а места четыре.

Разбор

Сначала массив: то, из чего сделан срез

Слово «массив» только что прозвучало — договорим до конца, потому что без него срез объяснить нельзя.

Массив в Go — список, длина которого задана раз и навсегда и входит в сам тип. [3]string и [4]stringразные типы, и присвоить один другому нельзя. Поэтому массивы в обычном коде почти не пишут: заранее известное число элементов — редкость.

Одно отличие знать обязательно, потому что из него следует всё остальное. Массив при присваивании копируется, срез — нет:

titles := [3]string{"Шанырак", "Язык Go", "Степь"}
copyOf := titles
copyOf[0] = "Горы"
fmt.Println("массив:", titles[0], "·", copyOf[0])

list := []string{"Шанырак", "Язык Go", "Степь"}
same := list
same[0] = "Горы"
fmt.Println("срез:  ", list[0], "·", same[0])
массив: Шанырак · Горы
срез:   Горы · Горы

Массив присвоили — получилась вторая, независимая тройка строк, и правка первой её не касается. Срез присвоили — оба имени смотрят на один и тот же массив, поэтому правка через одно имя видна через другое.

Вот что такое срез на самом деле: три числа — адрес массива, длина и ёмкость. Данные лежат в массиве, срез только говорит, как до них добраться. Отсюда и append, который иногда «переезжает»: место в массиве кончилось, заводится массив побольше.

Считать элементы руками необязательно: [...]string{"Шанырак", "Язык Go", "Степь"} — тоже массив, длину посчитает компилятор.

Где массив всё-таки встречается: там, где длина — часть смысла. Хеш SHA-256 — это [32]byte, и другой длины у него быть не может. В таких местах массив сообщает размер прямо в типе.

[]string — срез строк

Квадратные скобки перед типом и есть срез. Внутри пусто: длина заранее не назначается, список растёт по мере надобности.

var titles []string

Такой срез пустой, и это законное состояние: len равен нулю, cap нулю, а само значение — nil. Добавлять в него можно сразу, никакой подготовки не нужно.

append добавляет — и возвращает новый срез

titles = append(titles, "Степь")

Слева и справа одно и то же имя, и это не описка. append не меняет старый срез, а возвращает новый. Если места хватило, он допишет элемент в тот же массив и вернёт срез с новой длиной; если не хватило — заведёт массив побольше, и срез окажется уже по другому адресу. Забудете присвоить обратно — добавление пропадёт, и программа даже не пожалуется.

Образ. Переезд. Вы просите положить ещё одну вещь, а вам отвечают: «поместилось, но пришлось переехать, вот новый адрес». Не записали адрес — ходите по старому и удивляетесь, что там всё по-прежнему.

Это ошибка, которую делают все и один раз. Дальше рука сама пишет x = append(x, …).

Длина и ёмкость

len — сколько элементов лежит. cap — сколько влезет, прежде чем понадобится переезд.

В примере их три и четыре: Go выделил место с запасом, чтобы не переселять список на каждом добавлении. Четвёрку не запоминайте: сколько именно запаса взять, решает реализация, и на другой версии Go число может оказаться иным. Проверять надо len, а cap — только наблюдать.

Образ. Коробка на полке. Длина — сколько книг в ней сейчас. Ёмкость — сколько войдёт, пока не придётся брать коробку побольше и перекладывать всё туда.

Ёмкость знать полезно, но управлять ею вручную почти никогда не нужно: Go наращивает её сам и с запасом — небольшой срез удваивает, дальше прибавляет осторожнее. Точный размер новой ёмкости язык не обещает, и полагаться на него в программе нельзя. Важно другое: тысяча добавлений стоит около десятка переездов, а не тысячи.

Индексы и границы

Первый элемент — titles[0], последний — titles[len(titles)-1]. Счёт с нуля.

Выход за границу не прощается: titles[5] при трёх элементах остановит программу с сообщением index out of range. Это лучше, чем тихо вернуть мусор, — вы узнаете об ошибке в ту же секунду, а не через неделю от читателя.

Кусок среза

titles[:2]    // первые два
titles[1:]    // со второго до конца
titles[1:3]   // со второго по третий

Первое число — откуда, второе — до какого не включая. Пропущенное значит «с начала» или «до конца».

Ловушка: кусок — не копия

Вот то, ради чего этот раздел:

two := titles[:2]
two[0] = "ИЗМЕНЕНО"
fmt.Println(titles[0])   // ИЗМЕНЕНО

Мы правили two, а изменился titles. Кусок среза — не отдельный список, а окно в ту же память.

Образ. Не копия документа, а окно в тот же склад. Через окно видно часть полок, и если что-то переставить через окно, оно переставлено на самом складе.

Нужна настоящая копия — делайте её явно:

two := make([]string, 2)
copy(two, titles[:2])

Пока держите в голове одно: срез, полученный из среза, разделяет с ним память. Большинству это не мешает, но когда однажды «непонятно почему изменилось» — вспомните этот абзац.

range по списку

for i, t := range titles {

То же, что и по строке, но проще: i — порядковый номер с нуля, без всяких байтов, t — сам элемент. Если номер не нужен, на его место ставят подчёркивание: for _, t := range titles.

Карта урока

Карта урока: длина три, ёмкость четыре

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Почему append пишут как x = append(x, v), а не просто append(x, v)?
  2. Чем длина отличается от ёмкости и зачем нужна вторая?
  3. Вы взяли part := titles[:2] и поправили part[0]. Что стало с titles[0] и почему?

Задание

Обязательное. Напишите функцию longer(titles []string, n int) []string, которая возвращает только те заголовки, в которых больше n букв. Считайте буквы, а не байты, — как в прошлом уроке. Вызовите её для списка из "Степь", "Шанырак", "Go", "Көш" при n = 3 и напечатайте результат.

По желанию.

  • Функция total(nums []int) int, складывающая все числа списка.
  • Добавьте в список десять заголовков в цикле и печатайте len и cap после каждого. На каком шаге ёмкость меняется?
  • Сделайте настоящую копию первых двух заголовков через make и copy, поправьте копию и убедитесь, что оригинал цел.

Куда это встанет в блоге

Список статей на главной, теги под заголовком, комментарии под текстом, результаты поиска — всё это срезы. В уроке про шаблоны вы отдадите такой список в HTML и он развернётся в карточки; в уроке про базу — получите его оттуда одним запросом.

Ответы

Показать ответы
  1. Потому что append возвращает новый срез, а не меняет старый. При нехватке места он переселяет данные, и адрес меняется; без присваивания вы остаётесь со старым значением, а добавленное теряется. Компилятор об этом не предупредит: он видит вызов функции, результат которой не взяли.
  2. Длина — сколько элементов сейчас, ёмкость — сколько влезет до переезда. Вторая нужна затем, чтобы добавление не переселяло список каждый раз: Go берёт место с запасом и наращивает его сам, поэтому тысяча добавлений обходится примерно десятком переселений. Насколько именно вырастет ёмкость, язык не обещает.
  3. titles[0] тоже стал «ИЗМЕНЕНО». Кусок среза не копирует данные, а смотрит в ту же память, поэтому правка через один виден через другой. Копия делается явно, через make и copy.

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

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

Пока нет комментариев. Будьте первым.