Указатели обладают массой преимуществ и представить себе программирование без них, скажем так, не очень приятно. Но у них есть минусы, один из них (по крайней мере, для меня) может заключаться в том, что они могут вызвать путаницу и даже сломать код, если их не использовать с осторожностью. Я думаю, что все мы сталкивались с ситуациями, когда что-то просто не работало должным образом, и после сеанса отладки выяснилось, что указатель вызвал неожиданное поведение. Но ведь принять решение о том, передавать (или вообще использовать) указатель, мне не так-то просто. Поэтому я решил учиться и учиться у людей намного умнее и опытнее меня. Надеюсь, вам тоже будет интересно поделиться этими выводами.

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

Эта статья во многом вдохновлена ​​Хоссейном Назари в их бесценной статье под названием Использовать или не использовать указатели при передаче структур, опубликованной на Gocast (к вашему сведению: она на персидском языке).

Один из основных выводов, сделанных первоначальным автором, был следующим:

Установите по умолчанию не использование указателей, если вы точно не знаете, что его поля будут меняться.

Передача по «значению» против «ссылки»

Во-первых, давайте взглянем на некоторые детали, связанные с указателями. В одной интересной статье подчеркивается тот факт, что go является передачей по значению, но бывают случаи, когда это не похоже на правду. Существуют примитивные типы, например. int, string, byte, rune, bool считаются типами значения, тогда как типы pointer являются ссылочными. Но есть определенные типы, которые имеют несколько иную природу, как написано в статье:

Однако map, slice и channel являются специальными типами, которыеявляютсяилисодержатссылки.

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

package main

import "fmt"

func changeKey(m map[string]string) {
    m["key"] = "new_value"
}

func main() {
    m := map[string]string{"key": "value"}
    fmt.Println(m) // map[key: value]
    changeKey(m)
    fmt.Println(m) // map[key: new_value]
}

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

package main 

import "fmt"

func new(m map[string]string) {
    m = make(map[string]string)
}

func main() {
    var m map[string]string
    new(m)
    fmt.Println(m == nil) // true
}

Если бы карта m была ссылочной переменной стиля C++, m, объявленная в main, и m, объявленная в new, занимали бы одно и то же место хранения в памяти. Но поскольку присвоение m внутри new не влияет на значение m в main, мы видим, что карты не являются ссылочными переменными.

Ссылка map — это указатель на структуру заголовка карты (типа hmap) в памяти, которая содержит различные поля, в том числе количество ячеек и некоторые другие вещи.

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

Конечный результат тоже изображен здесь:

Во-первых, технически Go имеет только передачу по значению. При передаче указателя на объект вы передаете указатель по значению, а не объект по ссылке. Разница тонкая, но иногда актуальная. Например, вы можете перезаписать значение указателя, которое не влияет на вызывающую программу, вместо того, чтобы разыменовывать его и перезаписывать память, на которую он указывает.

Производительность

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

Подкрепленный надлежащим бенчмаркингом, Марио Масиас предлагает интересный момент о возврате структур по сравнению с указателями:

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

Также в этой статье указывается, что для того, чтобы оправдать совместное использование указателя на структуру, она должна быть особенно большой (насколько большой, спросите вы? Я не уверен, может быть, несколько сотен байт?) и ее жизненный цикл должен быть достаточно большой. И есть еще один бенчмарк, предполагающий, что вам редко нужно делиться структурой по ее указателю.

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

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

Стек быстрее, потому что шаблон доступа упрощает выделение и освобождение памяти из него (указатель/целое число просто увеличивается или уменьшается), в то время как куча имеет гораздо более сложную бухгалтерию, связанную с выделением или освобождением памяти. Кроме того, каждый байт в стеке, как правило, используется повторно очень часто, что означает, что он имеет тенденцию сопоставляться с кешем процессора, что делает его очень быстрым. Еще одним ударом по производительности для кучи является то, что куча, будучи в основном глобальным ресурсом, обычно должна быть многопоточной, т. е. каждое выделение и освобождение должны быть — как правило — синхронизированы со «всеми» другими обращениями к куче в программе.

Так как это касается нас? Что ж, оказывается, то, что мы делаем с возвращаемыми значениями или входными параметрами функции (т. е. решаем, использовать указатель или нет), может привести к выделению памяти. Этот процесс подробно и красиво описывает Джеймс Кирк в своей статье:

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

Мы увидим больше о стеке и куче в следующем разделе.

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

Меня довольно часто спрашивают о том, когда и когда не использовать указатели в Go. Проблема большинства людей заключается в том, что они пытаются принять это решение, основываясь на том, каким, по их мнению, будет компромисс производительности. Отсюда проблема: не принимайте решения о кодировании на основе необоснованных мыслей о производительности. Принимайте решения о кодировании, основываясь на том, что код идиоматичен, прост, удобочитаем и разумен.

Позже он добавляет, что в общем случае, если мы хотим сгруппировать реализацию либо как «примитивное значение данных», либо как «подлежащее изменению», целесообразно разделить изменяющееся с указателем:

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

А также:

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

Кстати, я не совсем уверен, что могу полностью понять самое последнее предложение. «двойственность природы» не кажется мне четко определенной вещью. Честно говоря, большинство структур с «изменяющейся природой», которые я видел до сих пор, также имели свое собственное последовательное и неизменное внутреннее состояние. Чтобы получить пример этого, рассмотрим структуру, объясняющую транспортное средство на пути из пункта отправления в пункт назначения (извините, если этот пример оказался полусырым и не совсем верным, это просто пример):

package main

type Point struct {
    Lat float64
    Long float64
}

type Vehicle struct {
    id string
    Origin Point
    Destination Point
    GasolineLeft float64
}

Я не говорю, что он не прав, вовсе нет, я лишь говорю, что эта двойственность природы может наблюдаться то здесь, то там, и это нормально (вы можете возразить, что приведенный выше замысел может быть изменен в любой момент). таким образом, возможно, origin и destination хранятся где-то вместе с id или чем-то еще, но вы поняли). Мой собственный подход к этой статье заключается в использовании указателя для структур, которые будут изменяться даже незначительно.

А ссылочные типы (например, значения map, slice, chan, interface и function) используются совместно с указателем только в редких случаях, и если вы не уверены, просто не используйте указатель.

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

Стек против кучи

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

Этот раздел вдохновлен Джейкобом Уокером в их видео The Stack and the Heap — GopherCon SG 2019 на YouTube.

Существует два типа памяти: стек и куча, и с помощью go у нас есть несколько стеков (т. е. стек для каждого goroutine) и куча, в которой в основном хранится все остальное которого нет в стеках.

Хотя мы сами не можем точно сказать, будет ли переменная находиться в стеке или в куче, главный вопрос: «Имеет ли это значение?».

Для корректности программы НЕТ. Но это действительно влияет на производительность программы, потому что все в куче управляется сборщиком мусора, что вызывает некоторую задержку для всей программы.

Прежде всего, давайте удостоверимся, что мы не тратим слишком много времени на разработку и оптимизацию нашей программы, в то время как мы не видим реальной необходимости или теста, доказывающего низкую производительность. Другими словами, если программа «достаточно быстра», пусть так и будет, вероятно, есть другие более важные вещи, над которыми можно поработать.

Не оптимизируйте в темноте.

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

— — —
Отказ от ответственности:
- ВЕРСИЯ: go1.20.1
- GOOS: linux
- GOARCH: amd64
- Встроенная оптимизация отключена
> — — —

Рассмотрим пример ниже, где не используются указатели:

package main

func main() {
    n := 4
    n2 := square(n)
    println(n2)
}

func square(x int) int {
  return x * x
}

Почему я использовал println вместо fmt.Println? Просто продолжайте читать!

При запуске программы создается «кадр» стека для func main со значениями n = 4 и n2 = 0. Когда вызывается func square, для этой функции создается другой фрейм, внутри которого находится x = 4. После возврата func square результат n2 = 16 будет установлен в первом кадре стека. Go не очищает себя после себя, второй фрейм стека все еще доступен в стеке, и что делает go, так это отслеживает, что является допустимым, а что недействительным (черная линия, которую вы можете видеть на изображении ниже). Наконец, когда вызывается println, для него создается новый фрейм с a = 16, и этот новый стек помещается в допустимую секцию. Линия перемещается вверх и вниз, чтобы определить допустимую и недопустимую области. Вот почему стеки считаются «самоочищающимися», любая переменная в стеке очищается, поскольку это пространство повторно используется (т. е. адреса x и a могут быть одинаковыми, и на самом деле это так). .

Теперь добавим несколько указателей:

package main

func main() {
    n := 4
    inc(&n)
    println(n)
}

func inc(x *int) {
    *x++
}

Опять же, просматривая этот код в памяти и пропуская интересную часть, когда вызывается func inc, для него создается новый кадр стека, содержащий x = 0xc000044780, который фактически указывает на n = 4 в первом стеке. Когда указатель на n разыменовывается и увеличивается, значение n в первом стеке обновляется, а второй стек просто помещается в недопустимый раздел. При достижении println эта функция освобождает пространство, ранее принадлежавшее func inc, и процесс продолжается, как и прежде. Здесь мы используем указатели, но он смог остаться в стеке.

Совместное использование обычно остается в стеке.

Возвращать указатели?

package main

func main() {
    n := answer()
    println(*n/2)
}

func answer() *int {
    x := 42
    return &x
}

Когда мы впервые вызываем main, он устанавливает n = nil для начала (в исходном фрейме стека), поскольку вызывается func answer, компилятор уже знает, что небезопасно помещать переменную x (созданную func answer) в фрейм стека, поэтому вместо этого x объявляется где-то в куче. "Почему?" Вы могли бы спросить, если бы переменная собиралась быть объявлена ​​в кадре стека, тогда n должно было бы указывать на значение в недопустимом разделе (это значение равно x = 42, а n будет чем-то вроде 0xc000044770, и это вызовет проблемы. Мы говорим что «x сбежал в кучу», но на самом деле он не перемещается во время выполнения, это происходит во время компиляции.Компилятор изначально знает, что эта переменная будет построена в куче.

Совместное использование обычно уходит в кучу.

Слово «обычно» и здесь, и в приведенной выше цитате означает, что на самом деле знает только компилятор.

Согласно Часто задаваемым вопросам по Golang:

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

То, о чем мы здесь говорим, называется «анализом побега». Это то, что делает компилятор, просматривая наш код, чтобы увидеть, должна ли какая-либо из этих переменных находиться в куче.

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

Так что, если только компилятор знает, давайте спросим его!

go help build
      usage: go build [-o output] [build flags] [packages]
      ...
      -gcflags '[pattern=]arg list'
             arguments to pass on each go tool compile invocation.

Мы используем go build для построения наших программ, но на самом деле компилятором является go tool compile.

go tool compile -h
        usage: compile [options] file.go...
        ...
        -l disable inlining
        ...
        -m print optimization decisions
        

# Example 1
go build -gcflags "-m -l" example1.go
# no output, since inlining is disabled

# Example 2
go build -gcflags "-m -l" example2.go
./example2.go:9:10: x does not escape

# Example 3
go build -gcflags "-m -l" example3.go
./example3.go:9:2: moved to heap: x

Здесь мы видим, что компилятор решает, помещать переменную в кучу или нет, во время компиляции.

Но есть загвоздка! Вы могли заметить, что вместо fmt.Println, который, возможно, является распространенным способом вывода переменной, я решил использовать println. "Почему это?" Вы можете спросить, ну, играя с fmt.Println, я заметил кое-что странное (чтобы воспроизвести эти результаты, просто замените println на fmt.Println в приведенных выше примерах):

# Example 1
go build -gcflags "-m -l" example1.go
./example1.go:8:13: ... argument does not escape
./example1.go:8:14: n2 escapes to heap

# Example 2
go build -gcflags "-m -l" example2.go
./example2.go:11:10: x does not escape
./example2.go:8:13: ... argument does not escape
./example2.go:8:14: n escapes to heap

# Example 3
go build -gcflags "-m -l" example3.go
./example3.go:11:2: moved to heap: x
./example3.go:7:13: ... argument does not escape
./example3.go:7:17: *n / 2 escapes to heap

Кажется, это открытая проблема на Golang GitHub:

Итак, давайте завершим.Когда значения создаются в куче?

  1. Когда на значение можно было бы сослаться после возврата функции, создавшей значение.
  2. Когда компилятор определяет, что значение слишком велико для размещения в стеке.
  3. Когда компилятор не знает размер значения во время компиляции (например, в случае slice).

Некоторые часто выделяемые значения (не исчерпывающие):

  • Ценности, которыми поделились с pointer.
  • Переменные хранятся в interface переменных.
  • func литеральные переменные (или анонимные функции).
  • Резервные данные для map, channel, slices и string (строки фактически являются специальным фрагментом байтов).

Какой выбрать?

После всех рассуждений выше может остаться неясным, скажем, передавать ли копию структуры или указатель на нее, использовать приемник указателя или нет. Итак, давайте рассмотрим некоторые мнения из разных источников.

Общий вывод отлично изображен здесь:

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

Еще одно информативное обсуждение можно найти здесь:

Является ли структура достаточно большой? Преждевременная оптимизация редко приносит пользу, но если у вас есть структура с несколькими полями и/или полями, содержащими большие строки или массивы байтов (например, тело уценки), преимущества возврата указателя вместо копии становятся более очевидными.< br /> Каков риск того, что возвращающая функция мутирует структуру (или объект) после ее возврата? (т. е. какая-то длительная задача или задание)
Я почти всегда возвращаю указатель из конструктора, так как конструктор должен запускаться один раз.

Также в том же обсуждении Уильям Кеннеди отметил:

У меня немного другая философия. Мой основан на принятии решения о типе, который вы определяете. Задайте себе один-единственный вопрос. Если кому-то нужно добавить или удалить что-то из значения этого типа, должен ли я создать новое значение или изменить существующее значение. Эта идея пришла из изучения стандартной библиотеки.

Есть определенные ситуации, в которых мы (ошибочно) предполагаем, что низкая производительность в основном связана с неправильным использованием указателей, но с небольшой дополнительной оценкой мы находим новый способ делать вещи (например, лучший дизайн), чтобы повысить производительность программного обеспечения в удобочитаемой и идиоматической форме, не добавляя сложности и путаницы в код путем введения ненужных указателей. Также, как предлагает Zen of Go:

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

Краткое содержание

В этой статье (на самом деле и моей первой!) я попытался исследовать загадочную область указателей и ответить на вопрос, использовать ли указатели.

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

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

  • Вы имеете дело с изменяющейся структурой.
  • Вы имеете дело с mutex, что в какой-то мере является той же идеей, что и изменяющаяся структура (т. е. она постоянно блокируется и разблокируется).
  • Ваша структура особенно велика (возможно, в случае структуры config или чего-то еще, с воображаемым порогом в несколько сотен байтов).

Мы видели, что, несмотря на «эффективный по памяти» характер указателей (поскольку они предотвращают копирование всей структуры, вместо этого копируются только сами указатели), при неправильном использовании они имеют тенденцию генерировать много накладных расходов и снижать производительность. деградации, а также введения ненужной сложности и путаницы.

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