В моем последнем сообщении в блоге мы говорили о некоторых различиях между срезом и массивом. А именно, мы обсудили, что срез имеет как capacity, так и length, а массив имеет только длину. Мы также кратко рассмотрели, как слайс использует массив за кулисами как часть своей структуры данных. Если это все новости для вас, я предлагаю вам ознакомиться со статьей.

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

Почему срезы иногда изменяются при передаче по значению в Go?

Если быть честным, ответ на этот вопрос не ограничивается слайсами (подробнее об этом позже), но чаще всего он застает людей врасплох с помощью слайсов.

Я считаю, что самый простой способ проиллюстрировать «проблему» — начать с нескольких популярных викторин. Всего их будет три, и я настоятельно рекомендую вам проверить все три, прежде чем нажимать кнопку «Назад». Вы можете быть удивлены результатами.

Поп-викторина №1

Что выводит следующий код?

func main() {  
  var s []int
  for i := 1; i <= 3; i++ {
    s = append(s, i)
  }
  reverse(s)
  fmt.Println(s)
}

func reverse(s []int) {  
  for i, j := 0, len(s) - 1; i < j; i++ {
    j = len(s) - (i + 1)
    s[i], s[j] = s[j], s[i]
  }
}

Запустите его на Go Playground и проверьте свой ответ

Почему изменения в s видны после вызова функции, несмотря на то, что s передается по значению?

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

Поп-викторина #2

Мы собираемся немного изменить наш код, добавив один вызов append внутри функции reverse(). Как это меняет наш результат?

func main() {  
  var s []int
  for i := 1; i <= 3; i++ {
    s = append(s, i)
  }
  reverse(s)
  fmt.Println(s)
}

func reverse(s []int) {  
  s = append(s, 999)
  for i, j := 0, len(s) - 1; i < j; i++ {
    j = len(s) - (i + 1)
    s[i], s[j] = s[j], s[i]
  }
}

Запустите его на Go Playground и проверьте свой ответ

На этот раз, когда мы печатаем s, это в обратном порядке, но что случилось с 1?

Когда мы вызываем append, создается новый слайс. Новый срез имеет новый атрибут length, который не является указателем, но по-прежнему указывает на тот же массив. Из-за этого остальная часть нашего кода в конечном итоге обращается к тому же массиву, на который ссылается наш исходный срез, но исходный срез не имеет измененной длины.

Поп-викторина №3

Настало время нашей последней викторины. Мы собираемся внести относительно небольшое изменение — мы собираемся добавить несколько дополнительных чисел к нашему срезу внутри функции reverse().

func main() {  
  var s []int
  for i := 1; i <= 3; i++ {
    s = append(s, i)
  }
  reverse(s)
  fmt.Println(s)
}

func reverse(s []int) {  
  s = append(s, 999, 1000, 1001)
  for i, j := 0, len(s)-1; i < j; i++ {
    j = len(s) - (i + 1)
    s[i], s[j] = s[j], s[i]
  }
}

Запустите его на Go Playground и проверьте свой ответ

В нашем финальном тесте не только не сохраняется длина, но и не изменяется порядок среза. Почему?

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

Проверка того, что произошло с функцией cap

Мы можем проверить, что происходит, используя функцию cap для проверки емкости нашего среза, переданного в reverse().

func reverse(s []int) {  
  newElem := 999
  for len(s) < cap(s) {
    fmt.Println("Adding an element:", newElem, "cap:", cap(s), "len:", len(s))
    s = append(s, newElem)
    newElem++
  }
  for i, j := 0, len(s)-1; i < j; i++ {
    j = len(s) - (i + 1)
    s[i], s[j] = s[j], s[i]
  }
}

Запустить на Go Playground

Пока мы не выйдем за пределы возможностей нашего слайса, мы в конечном итоге увидим изменения функции reverse() в нашей функции main(). Мы по-прежнему не увидим изменения длины, но увидим перестановку элементов в массиве, поддерживающем срез.

Если мы добавим один вызов append() к s после того, как мы заполним наш слайс до предела, мы больше не увидим этих изменений в функции main(), потому что наш реверсивный код в конечном итоге будет работать с новым слайсом, который указывает на совершенно другой массив.

Также затрагиваются срезы, полученные из срезов или массивов.

Если в нашем коде будут созданы новые слайсы, производные от существующих слайсов или массивов, мы также увидим тот же эффект. Например, если вы вызовете s2 := s[:], а затем передадите s2 в нашу функцию reverse(), это все равно может повлиять на s, поскольку и s2, и s указывают на один и тот же резервный массив. Точно так же, если мы добавим новые элементы в s2, что в конечном итоге приведет к тому, что он перерастет резервный массив, мы больше не увидим изменения в одном фрагменте, влияющие на другой.

Это не совсем плохо. Не копируя базовый массив до тех пор, пока это не станет абсолютно необходимым, мы получаем гораздо более эффективный код, но это требует небольших умственных затрат, связанных с необходимостью учитывать это при написании кода.

Это не ограничивается ломтиками

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

type A struct {  
  Ptr1 *B
  Ptr2 *B
  Val B
}

type B struct {  
  Str string
}

func main() {  
  a := A{
    Ptr1: &B{"ptr-str-1"},
    Ptr2: &B{"ptr-str-2"},
    Val: B{"val-str"},
  }
  fmt.Println(a.Ptr1)
  fmt.Println(a.Ptr2)
  fmt.Println(a.Val)
  demo(a)
  fmt.Println(a.Ptr1)
  fmt.Println(a.Ptr2)
  fmt.Println(a.Val)
}

func demo(a A) {  
  // Update a value of a pointer and changes will persist
  a.Ptr1.Str = "new-ptr-str1"
  // Use an entirely new B object and changes won't persist
  a.Ptr2 = &B{"new-ptr-str-2"}
  a.Val.Str = "new-val-str"
}

Запустить на Go Playground

Как и в этом примере, срез определяется как:

type slice struct {  
  array unsafe.Pointer
  len   int
  cap   int
}

Заметили, что поле array на самом деле является указателем? Это означает, что срезы в конечном итоге будут вести себя так же, как и любые другие типы в Go, которые имеют вложенный указатель, и на самом деле они совсем не особенные. Просто так случилось, что очень немногие люди смотрят на внутренности.

Что это значит для гоферов?

Если вам понравился этот пост, рассмотрите вариант присоединиться к моему списку рассылки. Я отправляю примерно одно электронное письмо каждую неделю с информацией о том, что я пишу/записываю, а также своими мыслями и вдохновением, стоящим за статьями.

Обещаю, я не буду рассылать спам или делать что-либо еще нечестное с вашей информацией. Я ненавижу такое поведение так же сильно, как и вы.

В качестве дополнительного бонуса я пришлю вам БЕСПЛАТНЫЙ образец из моего предстоящего курса «Веб-разработка на Go». Образец может быть как в формате электронной книги, так и в формате скринкаста.

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

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

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

Если вы считаете, что что-то явно неправильно или вводит в заблуждение, напишите мне по электронной почте ([email protected]) или в чат Gopher (@jon), и я буду рад обсудить и обновить сообщение.

Первоначально опубликовано на www.calhoun.io 30 января 2017 г.