Как стать автором
Обновить

Как не наступать на грабли в Go

Время на прочтение10 мин
Количество просмотров95K

Этот пост является версией моей же англоязычной статьи "How to avoid gotchas in Go", но слово gotcha не переводится на русский, поэтому я буду использовать это слово как без перевода, так и немного непрямой вариант — "наступать на грабли".


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

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


Но один вопрос меня мучал долгое время — почему я сам никогда не делал этих ошибок? Серьезно, самые популярные из них, вроде путаницы с nil-интерфейсом или непонятного результата при append()-е слайса — в моей практике никогда не были проблемой. Каким-то образом мне повезло обойти эти подводные камни с первых дней своей работы с Go. Что же мне помогло?


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


Давайте вернёмся к определению, "gotcha… это корректная конструкция… которая контринтуитивна...". В этом вся соль. У нас есть, на самом деле, два варианта:


  • "починить" язык
  • починить интуицию

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


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


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



Указатели


Go, имея в генеалогическом дереве язык С, на самом деле довольно близок к железу. Если вы создаете переменную типа int64 (целочисленной значение 64-бита) вы точно можете быть уверены в том, сколько именно места она занимает в памяти, и всегда можете использовать unsafe.Sizeof(), чтобы узнать это для любого другого типа.


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


Например, давайте начнём с простейших базовых типов в Go:


image


Скажем, в такой визуализации видно, что переменная типа int64 будет занимать в два раза больше "места", чем int32, а int занимает столько же, сколько int32 (подразумевая, что это 32-битная машина).


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


image


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


И тут мы подходим к одной из gotchas в Go, с которой сталкиваются люди, у которых не было опыта работы с указателями в других языках — это путаница в понимании что такое "передача по значению" параметров в функции. Как вы, наверняка знаете, в Go всё передаётся "по значению", тоесть буквально копируется. Давайте попробуем это визуализировать для функций, в которых параметр передаётся как есть и через указатель:


image


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


Но теперь, имея это наглядное представление, как передаются указатели в функцию, вы естественным образом можете "увидеть", что в первом случае изменяя переменную p в функции Foo(), вы будете работать с копией и не измените значение оригинальной переменной (p1), а втором — измените, поскольку указатель будет ссылаться на оригинальную переменную. Хотя и в том и другом случае, при передаче параметров происходит копирование данных.


Окей, разогрев окончен, давайте копнём глубже и посмотрим вещи чуть сложнее.


Массивы и слайсы


Слайсы поначалу принимают за обычный массив. Но это не так, и, на самом деле, это два разных типа в Go. Давайте сначала посмотрим на массивы.


Массивы


var arr [5]int
var arr [5]int{1,2,3,4,5}
var arr [...]int{1,2,3,4,5}

Массив это просто последовательный набор блоков памяти и если мы посмотрим на исходники Go (src/runtime/malloc.go), то увидим, что создание массива это по сути просто выделение куска памяти нужного размера. Старый добрый malloc, только чуть умнее:


// newarray allocates an array of n elements of type typ.
func newarray(typ *_type, n int) unsafe.Pointer {
    if n < 0 || uintptr(n) > maxSliceCap(typ.size) {
        panic(plainError("runtime: allocation size out of range"))
    }
    return mallocgc(typ.size*uintptr(n), typ, true)
}

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


image


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


var arr [5]int
arr[4] = 42

То просто берем пятый (4+1) элемент и изменяем значение этого блока в памяти:


image


Окей, теперь разберёмся со слайсами.


Слайсы


На первый взгляд, они похожи на массивы. Ну вот прям очень похожи:


var foo []int

Но если мы посмотрим на исходники Go (src/runtime/slice.go), то увидим, что слайс это, на самом деле, структура из трёх полей — указателя на массив, длины и вместимости (capacity):


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

Когда вы создаёте новый слайс, рантайм "под капотом" создаст новую переменную этого типа, с нулевым указателем (nil) и длиной и ёмкостью равными нулю. Это нулевое значение для слайса. Давайте попробуем визуализировать его:


image


Это не очень интересно, поэтому давайте инициализируем слайс нужного нам размера с помощью встроенной команды make():


foo = make([]int, 5)

Эта команда создаст сначала массив из 5 элементов (выделит память и заполнит их нулями), и установит значения len и cap в 5. Cap означает ёмкость и помогает зарезервировать место в памяти на будущее, чтобы избежать лишних операций выделения памяти при росте слайса. Можно использовать чуть более расширенную форму — make([]int, len, cap), чтобы указать ёмкость изначально. Чтобы уверенно работать со слайсами, важно понимать разницу между длиной и ёмкостью.


foo = make([]int, 3, 5)

Давайте посмотрим на оба вызова:


image


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


foo = make([]int, 5)
foo[3] = 42
foo[4] = 100

image


Это было легко. Но что будет, если мы создадим новый подслайс из foo и изменим какой-нибудь элемент? Давайте посмотрим:


foo = make([]int, 5)
foo[3] = 42
foo[4] = 100

bar  := foo[1:4]
bar[1] = 99

image


Видно же? Модифицируя слайс bar, мы, на самом деле, изменяем массив, но это тот же самый массив, на который указывает и слайс foo. И это, на самом деле, реальная штука — вы можете написать код вроде этого:


var digitRegexp = regexp.MustCompile("[0-9]+")

func FindDigits(filename string) []byte {
    b, _ := ioutil.ReadFile(filename)
    return digitRegexp.Find(b)
}

И, скажем, считав 10МБ данных в слайс из файла, найти 3 байта, содержащих цифры, но возвращать вы будете слайс, который ссылается на массив размером 10МБ!


image


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


Добавление к слайсу (append)


Следом за хитрой ошибкой со слайсами, идёт не очень очевидное поведение встроенной функции append(). Она, в принципе, делает одну простую операцию — добавляет к нему элементы. Но под капотом там делаются довольно сложные манипуляции, чтобы выделять память только при необходимости и делать это эффективно.


Взглянем на следующий код:


a := make([]int, 32)
a = append(a, 1)

Он создаёт новый слайс из 32 целых чисел и добавляет к нему ещё один, 33-й элемент.


Помните про cap — ёмкость слайсов? Ёмкость означает количество выделенного места для массива. Функция append() проверяет, достаточно ли у слайса места, чтобы добавить туда ещё элемент, и если нет, то выделяет больше памяти. Выделение памяти это всегда дорогая операция, поэтому append() пытается оптимизировать это, и запрашивает в данном случае памяти не для одной переменной, а для ещё 32х — в два раза больше, чем начальный размер. Выделение памяти пачкой один раз дешевле, чем много раз по кусочкам.


Неочевидная штука тут в том, что по различным причинам, выделение памяти обычно означает выделение её по другому адресу и перемещение данных из старого места в новое. Это означает, что адрес массива, на который ссылается слайс также изменится! Давайте визуализируем это:


image


Легко увидеть два массива — старый и новый. Вроде бы ничего сложного, и сборщик мусора просто освободит место, занимаемое старым массивом при следующем проходе. Но это, на самом деле, одна из тех самых gotchas со слайсами. Что будет если, мы сделаем подслайс b, затем увеличим слайс a, подразумевая, что они используют один и тот же массив?


a := make([]int, 32)
b := a[1:16]
a = append(a, 1)
a[2] = 42

Мы получим вот это:


image


Именно так, мы получим два различных массива, и два слайса будут указывать на совершенно разные участки памяти! И это, мягко говоря, довольно контринтуитивно, согласитесь. Поэтому, как правило, если вы вы работаете с append() и подслайсами — будьте осторожны и имейте ввиду эту особенность.


К слову, append() увеличивает слайс удвоением только до 1024 байт, а затем начинает использовать другой подход — так называемые "классы размеров памяти", которые гарантируют, что будет выделяться не более ~12.5%. Выделять 64 байта для массива на 32 байта это нормально, но если слайс размером 4ГБ, то выделять ещё 4ГБ даже если мы хотим добавить лишь один элемент — это чересчур дорого.


Интерфейсы


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


Как обычно, давайте обратимся к исходному коду Go. Что из себя представляет интерфейс? Это обычная структура из двух полей, вот её определение (src/runtime/runtime2.go):


type iface struct {
    tab  *itab
    data unsafe.Pointer
}

itab означает interface table и тоже является структурой, в которой хранится дополнительная информация об интерфейсе и базовом типе:


type itab struct {
    inter  *interfacetype
    _type  *_type
    link   *itab
    bad    int32
    unused int32
    fun    [1]uintptr // variable sized
}

Мы сейчас не будем углубляться в то, как работает приведение типа в интерфейсах, но что важно понимать, что по своей сути интерфейс это всего лишь набор данных о типах (интерфейса и типа переменной внутри него) и указатель на, собственно, саму переменную со статическим (конкретным) типом (поле data в iface). Давайте посмотрим, как это выглядит и определим переменную err интерфейсного типа error:


var err error

image


То, что мы видим на этой визуализации — это нулевой интерфейс (nil interface). Когда мы возвращаем nil в функции, возвращающей error, мы возвращаем именно вот этот объект. В нём хранится информация про сам интерфейс (itab.inter), но поля data и itab.type пустые — равны nil. Сравнение этого объекта с nil вернёт true в условии if err == nil {}.


func foo() error {
    var err error // nil
    return err
}

err := foo()
if err == nil {...} // true

Теперь, взгляните на вот этот случай, который также является известной gotcha в Go:


func foo() error {
    var err *os.PathError // nil
    return err
}

err := foo()
if err == nil {...} // false

Эти два куска кода очень похожи, если вы не знаете, что из себя представляет интерфейс. Но давайте посмотрим, как выглядит интерфейс error, в который "завернута" переменная типа *os.PathError:


image


Мы чётко видим тут саму переменную типа *os.PathError — это вот кусок памяти, в котором записано nil, потому что это нулевое значение для любого указателя. Но тот объект, что мы возвращаем из функции foo() — это уже более сложная структура, в которой хранится не только информация об интерфейсе, но и информация о типе переменной, и адрес в памяти на блок, в котором лежит nil указатель. Чувствуете разницу?


В обоих случаях мы как бы видим nil, но есть большая разница между "интерфейс с переменной внутри, чьё значение равно nil" и "интерфейс без переменной внутри". Теперь, понимая эту разницу, попробуйте спутать вот эти два примера:


image


Теперь вам должно быть сложно натолкнуться на такую проблему в вашем коде.


Пустой интерфейс (empty interface)


Несколько слов о так называемом пустом интерфейсе — interface{}. В исходниках Go он реализован отдельной структурой — eface (src/runtime/malloc.go):


type eface struct {
    _type *_type
    data  unsafe.Pointer
}

Легко заметить, что эта структура похожа на iface, но в ней нет таблицы интерфейса (itab). Что логично, потому что, по определению, любой статический тип удовлетворяет пустому интерфейсу. Поэтому, когда вы "заворачиваете" какую-либо переменную — явно или неявно (передавая, как аргумент или возвращая из функции, например) — в interface{}, вы на самом деле работаете с вот этой структурой.


func foo() interface{} {
    foo := int64(42)
    return foo
}

image


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


func foo() []interface{} {
    return []int{1,2,3}
}

Комплиятор вполне недвусмысленно ругнётся:


$ go build
cannot use []int literal (type []int) as type []interface {} in return argument

Поначалу это сбивает с толку. Мол, что за дела — я могу привести одну переменную любого типа в пустой интерфейс, почему же нельзя сделать тоже самое со слайсом? Но, когда вы знаете, что из себя представляет пустой интерфейс и как устроены слайсы, то вы должны интуитивно понять, что это "приведение слайса" на самом деле — довольно дорогая операция, которая будет подразумевать проход по всей длине слайса и выделение памяти прямо пропорционального количеству элементов. А, поскольку один из принципов в Go это — хотите сделать что-то дорогое — делайте это явно, то такая конвертация отдана на откуп программисту.


Давайте попробуем визуализировать, что собой представляет приведение []int в []interface{}:


image


Надеюсь, теперь этот момент имеет смысл и для вас.


Заключение


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


Если вам интересно почитать больше о внутренностях Go, вот небольшая подборка статей, которые помогли в своё время мне:



Ну, и конечно, как же без этих ресурсов :)



Удачного кодинга!

Теги:
Хабы:
Всего голосов 46: ↑38 и ↓8+30
Комментарии9

Публикации

Истории

Работа

Go разработчик
152 вакансии

Ближайшие события

19 августа – 20 октября
RuCode.Финал. Чемпионат по алгоритмическому программированию и ИИ
МоскваНижний НовгородЕкатеринбургСтавропольНовосибрискКалининградПермьВладивостокЧитаКраснорскТомскИжевскПетрозаводскКазаньКурскТюменьВолгоградУфаМурманскБишкекСочиУльяновскСаратовИркутскДолгопрудныйОнлайн
3 – 18 октября
Kokoc Hackathon 2024
Онлайн
24 – 25 октября
One Day Offer для AQA Engineer и Developers
Онлайн
25 октября
Конференция по росту продуктов EGC’24
МоскваОнлайн
26 октября
ProIT Network Fest
Санкт-Петербург
7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань