
В этой статье я хочу погрузиться в то, как работают некоторые структуры (далее ниже) в ГО. Хотя я и работаю с ГО уже 3й год, все равно есть вещи, в которые интересно погружаться. Хочу отметить, что я не буду погружаться прям сильно в реализацию того как устроены map и slice, скорее на столько, что бы понимать как они ведут себя и почему. Такое часто могут спрашивать на собеседованиях или это поможет писать более качественный и безопасный код.
Итак на сколько мы знаем (я надеюсь, что и вы читаете статью уже со знанием ГО) в ГО можно разделить типы переменных глобально на 2 группы
Value types - это простые типы такие как int, float, array, struct, string,...
Reference type - это сcылочные типы, такие как chan, map, slice
Для value type мы можем использовать ключевое слово new или литералы для создания
foo := "Some string"
bar := new(int) // zero value is 0 + heap memory allocation
Для reference type мы должны использовать ключевое слово make для создания или литерал, иначе вы получите panic при попытки что-то сделать с nil
myMap := make(map[string]string)
// myMap := map[string]string{} - literal for create a map
myMap["foo"] = "foo" // all good here
var myBMap map[string]string
myBMap["bar"] = "bar" // panic, don't do this!
Одна из интересных механик в ГО это как ведут себя переменные, которые мы прокидываем в функции. Для обычных типов (value type). Если мы передадим по значению и сделаем какие то действия с ней, то оригинальная переменная не поменяет свое значение, потому, что создается копия объекта в стеке функции (область памяти, которая очищается при завершения функции). Однако, когда мы передаем по ссылке, тогда любые манипуляции будут отражаться и на оригинальной переменной.
type User struct {
Name string
}
func makeChangesWithVal(user User) {
user.Name = "Peter"
}
func main() {
user := User{ Name: "Ivan" }
makeChangesWithVal(user)
fmt.Println(user.Name) // "Ivan"
}
type User struct {
Name string
}
func makeChangesWithVal(user *User) {
user.Name = "Peter"
}
func main() {
user := User{ Name: "Ivan" }
makeChangesWithVal(user)
fmt.Println(user.Name) // "Peter"
}
Однако ссылочные переменные (reference type) ведут себя по другому. Потому, что для их при создании выделяется память и создается дескриптор (специальная структура, которая содержит метаданные и ссылку на выделенную область памяти для мастер данных).
func DoSomeWithMap(myMap map[string]string) {
myMap["foo"] = "foo value"
}
func main() {
fooMap := make(map[string]string)
DoSomeWithMap(fooMap)
fooKey, exists := fooMap["foo"]
fmt.Println(exists, fooKey) // true, "foo value"
}
Ого! даже когда мы передаем "по значению" и добавляем новый ключ к map, мы видим изменения оригинальной переменной. Давайте посмотрим на еще один интересный пример с передачей "по значению"
func DoSomeWithMap(myMap map[string]string) {
myMap = make(map[string]string)
myMap["foo"] = "foo value"
}
func main() {
fooMap := make(map[string]string)
DoSomeWithMap(fooMap)
fooKey, exists := fooMap["foo"] // fooKey is empty string here
fmt.Println(exists, fooKey) // false
}
Интересно мы сделали ремейк (make) той же самой переменной в функции, однако она не поменялась. Но почему так ?
Как я писал выше когда мы создаем экземпляр класса ссылочных структур таких как map, slice, chan, данные кладутся в область памяти и создается дескриптор с ссылкой на эту область, и когда мы передаем эти переменные в функцию (по значению), то мы передаем дескриптор, при этом в функции создается новый дескриптор, но ссылка на область памяти остается той же(ссылается на туже область памяти) и когда мы меняем что-то то меняется именно данные по ссылке, при этом после завершении, дескриптор очищается(удаляется из памяти) но ссылка остается.
Но что происходит когда мы делаем ремейк и почему оригинальная переменная не меняется в этом случае ? Итак, у нас есть новый дескриптор, когда переменная попадает в функцию с ссылкой на оригинальную область в памяти, но когда мы делаем новый make мы создаем новую переменную, данные которой кладутся в новую область памяти и новый дескриптор с ссылкой на нее. После завершения функции дескриптор очищается вместе с этой новой ссылкой, тогда как оригинальная ссылка и данные остаются не тронутые.
Для slice работает тоже со своей логикой
func DoSomeWithSlice(mySlice []string) {
mySlice[0] = "foo value"
}
func main() {
fooSlice := make([]string, 3)
DoSomeWithSlice(fooSlice)
fmt.Println(fooSlice, cap(fooSlice), len(fooSlice)) // [foo value ] 3 3
}
func DoSomeWithSlice(mySlice []string) {
mySlice = append(mySlice, "foo value")
}
func main() {
fooSlice := make([]string, 3)
DoSomeWithSlice(fooSlice)
fmt.Println(fooSlice, cap(fooSlice), len(fooSlice)) // [ ] 3 3
}
Во втором случае нужно точно знать как работает append, что оставлю вам в качестве самостоятельного исследования.
Жду ваших комментариев и критики
Комментарии (18)
xxxphilinxxx
23.05.2025 14:00Для value type мы можем использовать ключевое слово new или литералы для создания
Поведение разное, если что: new размещает дефолтное значение типа в куче и возвращает указатель.
Для reference type мы должны использовать ключевое слово make для создания или литерал,
new тоже можно использовать, просто в куче по новому адресу будет создан объект с валидным, но не всегда удобным дефолтным значением nil
иначе вы получите panic при попытки что-то сделать с nil
Не во всех случаях: попытка чтения из nil map валидна, как и некоторые операции с nil-слайсами вроде append и т.д.
Однако ссылочные переменные (reference type) ведут себя по другому. Потому, что для их при создании выделяется память и создается дескриптор (специальная структура, которая содержит метаданные и ссылку на выделенную область памяти для мастер данных).
Т.к. это поведение обусловлено копированием вложенных указателей, то и для обычных struct/array оно тоже воспроизводимо, пусть они и записаны в value types. А вот для string нет, т.к. они иммутабельны по стандарту языка. Кстати, сам указатель тоже можно бы отнести к reference types, пусть это в go и не совсем тип.
А вообще ссылочных типов в терминологии го не существует уже 12 лет как ¯\_(ツ)_/¯
naHDop Автор
23.05.2025 14:00new почти никогда не используют для chan, map, slice. Да есть случаи но это оч редко и очень специфически
с нил слайсами можно делать почти все кроме взятии по индексу или записи по индексу, я тут не стал вдаваться в подробности, и надеялся, что это и так понятно читателю.
для array и struct я же привел пример, ели передавать по значению а не ссылку то оригинальный массив или структура не измениться
func makeSomething(s [3]string) {
s[0] = "first"
}func main() {
ar := [3]string{"f", "s", "t"}
makeSomething(ar)
fmt.Println(ar) // [f s t]
}Я их называю ссылочными переменными, потому много кто так делает и все понимают о чем я говорю, и все понимают когда я говорю передать по ссылки, это значит я передаю ссылку и ничто другое потому что там указан явно астериск *
Спасибо за коменты
xxxphilinxxx
23.05.2025 14:00для array и struct я же привел пример, ели передавать по значению а не ссылку то оригинальный массив или структура не измениться
Верно, оригинальный массив или структура не изменятся, но могут измениться данные, на которые они ссылаются, т.к. при копировании в другую переменную или при передаче в функцию по значению deep copy не выполняется: я на это хотел обратить внимание, т.к. тут можно запутаться. Slice внутри, например, является обычной структурой, где вся магия ссылочности -- это вложенный указатель на массив. Поведение легко воспроизводится и очень часто используется:
type A struct { V *int } change := func(a A) { *a.V = 2 } a := A{V: new(int)} *a.V = 1 fmt.Println(*a.V) // 1 change(a) // передаем по значению, но внутри есть поле-указатель fmt.Println(*a.V) // 2
qrKot
23.05.2025 14:00new почти никогда не используют для chan, map, slice. Да есть случаи но это оч редко и очень специфически
Давайте будем честными и сократим до "new почти никогда не используют. Да есть случаи но это оч редко и очень специфически"
для array и struct я же привел пример, ели передавать по значению а не ссылку то оригинальный массив или структура не измениться
Ну, собственно, в случае со слайсом - тоже не изменится. Я вам больше скажу, слайс в принципе не может измениться, он иммутабельный. Аппенд всегда новый слайс создает.
a := make([]int, 0, 3) a = append(a, 1) b = append(a, 2) fmt.Println(a,b) // внезапно [1] и [1,2]
Я их называю ссылочными переменными
Ну, собственно, зря.
"Ссылочные типы данных в программировании – это типы, которые хранят не само значение, а адрес (ссылку) на место в памяти, где хранится это значение"
Из перечисленных вами slice, map и channel вышеуказанное верно только для map, которая буквально алиас на *hMap. Слайс и канал - иммутабельные структуры, со всеми вытекающими.
Для value type мы можем использовать ключевое слово new или литералы для создания
Для reference type мы должны использовать ключевое слово make для создания или литерал, иначе вы получите panic при попытки что-то сделать с nil
Вот это откровенная неправда.
Возьмем слайс: make - запросто, литералом - да не вопрос, var a []int - вообще по барабану. nil-слайс - валидная структура.
Нил-мапа? Ну, при попытке чтения, например, ничего страшного не случится. А вот при попытке записать - паника будет. Но я вам интереснее скажу: при инициализации литералом попытка записи вызывала панику до одной из последних версий.
Канал... А как вы его литералом инициализировать собираетесь?
Да и вообще, в кучу кони и люди. Литералы ортогональны ссылочности/нессылочности. Они вообще вещь в себе - просто сокращенная запись для "создать переменную, проинициализировать ее начальным значением". Для каких-то типов есть, для каких-то не придуманы.
я тут не стал вдаваться в подробности, и надеялся, что это и так понятно читателю
Ну т.е. вы не стали в принципе вдумываться в то, что написали, и решили, что читателю и так понятно:
а) что фраза "в го есть 3 ссылочных типа: мапа, слайс и канал" означает "в го не используется терминология ссылочных типов"
б) что фраза "слайс должен быть явно инициализирован словом make, иначе вы получите панику" на самом деле означает "слайс можно инициализировать как угодно, паники от него не добьешься"Ну и примерно вся статья такая....
apevzner
23.05.2025 14:00Для value type мы можем использовать ключевое слово new или литералы для создания
Довольно скользкое утверждение, поскольку new возвращает не значение, а указатель на значение.
иначе вы получите panic при попытки что-то сделать с nil
Слайсы довольно осмысленно ведут себя, если они - nil. У них нулевая длина и к ним можно append.
naHDop Автор
23.05.2025 14:00да со слайсами nil можно делать все кроме взятия значения по индексу и сета нового значения по индексу, все остальное будет работать. Я расчитывал на то, что читатель понимает, что если я пишу манипуляции с nil, то понятно о чем я говорю и не надо разъяснять.
Для new да я не уточнил, что возвращается указатель. Но я говорил образно, что происходит. у ок, тут можно было расписать получше, спасибо
apevzner
23.05.2025 14:00да со слайсами nil можно делать все кроме взятия значения по индексу и сета нового значения по индексу, все остальное будет работать
Ровно как и со слайсами нулевой длинны. и len() от nil-слайса возвращает 0.
Для new да я не уточнил, что возвращается указатель
Все-таки, я бы поспорил с Вашей классификацией на reference/value types. Указатель - определенно reference, создаётся вызовом new (или литералом). Указатель на функцию - определенно reference, создаётся путем описания анонимной функции или упоминанием имени неанонимной функции (или метода объекта). Может прихватывать данные, в случае замыкания.
ilving
23.05.2025 14:00Замечание: слайсы не то чтоб прямо ссылочный тип. Он скорее как морская свинка, которая не свинка, и не морская.
Для примера могу автору попробовать сделать append к слайсу, переданному как аргумент функции, потом поменять какой-то элемент по индексу и объяснить себе разницу в поведении в зависимости от capacity
naHDop Автор
23.05.2025 14:00если ты значение аппенд сохранишь в ту же переменную или неважно в какую, то оригинальный слайс никак не поменяется.
func makeSomething(s []string) {
s = append(s, "item")
s[1] = "meti"
}func main() {
ar := []string{"f", "s", "t"}
makeSomething(ar)
fmt.Println(ar) // [f s t]
}ilving
23.05.2025 14:00Да, но...
func makeSomething(s []string) {s = append(s, "item"); s[1] = "meti"}
ar := make([]string,3,4)
ar[0], ar[1], ar[2]. = "f", "s", "t"
makeSomething(ar)
unreal_undead2
Тут вещи достаточно понятные. Но для меня после плюсов было странно, что простые вещи типа использования массива переменной длины в качестве ключа в map или модификация поля хранящейся там структуры требуют некоторых приседаний.
naHDop Автор
Ну тут скорее всего это связанно с реализацией мапы в ГО, и как они решают проблему коллизии ключей.
ilving
Гошные слайсы по традиции не являются Comparable типами (для скорости..? Не знаю), а ключ мапы требует именно Comparable
0xBAB10
для консистентности в первую очередь
как только вы сделаете ключ мутабельным, то при изменениях поменяется хэшкод и потеряете доступ к тому бакету в котором лежат данные. физически они там лежат, но другой хэш адресует другой бакет и приплыли
naHDop Автор
Comparable ? Я не очень понимаю что вы имеете ввиду под этим, приведите пожалуйста пример.
ilving
https://go.dev/ref/spec#Comparison_operators
dikorn
Залогинился только чтобы написать, что рановато вам писать статьи, если не знаете, что такое Comparable типы
naHDop Автор
Я спрашивал что он имеет ввиду, когда говорит про компарабл в рамках своего коммента, а не что такое именно в ГО. Я статью пишу прежде всего для того, что материал усвоить и получить критику.