Интересный пример кода на Go и зашоренность мышления
Данная история началась с того, что как-то коллега скинула в телеграмм чат команды пример (на Go)
type field struct {
name string
}
func (p *field) print() {
fmt.Println(p.name)
}
func main() {
data := []field{{"one"},{"two"},{"three"}}
for _,v := range data {
go v.print()
}
time.Sleep(3 * time.Second)
// горутины выводят: three, three, three
}
и попросила объяснить, почему если поменять массив структур на массив указателей на структуру начинают выводиться значения этих структур.
Я сходу (особо не размышляя над примером) подумал, что такого поведения быть не должно или это может быть как-то связано с версией языка, так как когда начинаешь разбирать данный пример, то первое что приходит в голову это очень похожие примеры-ловушки, когда в горутине запускается лямбда функция внутри которой мы производим какие-то действия. Например,
for _,v := range data {
go func() {
v.print()
}()
}
в этом случае до версии 1.22 в переменной v почти всегда будет храниться последнее значение из последовательности data
. Так происходит поскольку горутины (еще раз подчеркну до версии 1.22) при запуске берут текущее значение переменной из той области видимости (в нашем случае это цикл for) в которой они создаются, а запускаются они при этом почти всегда после отработки цикла for. Вот и получается что выводится одно и то же значение (в версии 1.22 данное поведение было поправлено), которое к моменту запуска горутин хранится в переменной v
.
Мы помним что в случае с лямбдой мы можем исправить этот пример явно передавая значение в лямбду
for _,v := range data {
go func(v field) {
v.print()
}(v)
}
В этом случае передаваемое значение скопируется в аргумент лямбды и мы получим ожидаемый вывод, что-то вроде three
, one
, two
(при каждом новом запуске кода порядок вывода значений может отличаться).
А как исправить изначальный код без лямбды? Пока что не понятно. Тогда я решил поэксперементировать с кодом; вернулся к предложенному изменению слайса, которое исправляет поведение: сделал из слайса структур слайс ссылок на структуры
data := []*field{{"one"}, {"two"}, {"three"}}
и сравнил что выведут три варианта: вариант с прямой передачей v.print()
в горутину
for _,v := range data {
go v.print()
}
вариант с передачей лямбды в горутину
for _,v := range data {
go func() {
v.print()
}()
}
и исправленная версия с передачей лямбды в горутину
for _,v := range data {
go func(v field) {
v.print()
}(*v)
}
И вот после запуска всех трех вариантов я обнаружил, что совпадут результаты первого и третьего варианта. Что на первый взгляд кажется противоречивым и нелогичным результатом.
И вот когда мы сталкиваемся с такими вроде бы похожими ситуациями мы очень часто не думая пытаемся ее интерпретировать их исходя из предыдущего опыта решения подобных задач (невольно в таких ситуациях сразу в памяти всплывает отрывок из фильма «Трасса 60» про фокус с картами https://www.youtube.com/watch? v=G1PkH-tf5fY). Но в результате оказывается, что наш опыт подсказывает нам неправильный ход мыслей. Но почему так? Почему мы получаем не тот же результат что и в случае с «неисправленной» лямбдой? В чем существенное отличие?
По сути главное отличие в том, что в первом случае в горутину на исполнение передается сам метод v.print
, а в других случаях на исполнение передается лямбда, внутри которой собственно и вызывается метод v.print
. Вроде бы на первый взгляд отличие не столь существенно, но в чем же тогда дело?
Для того чтобы понять ответы на эти вопросы я попытался воспользоваться советом Аккофа, который говорил, что, как правило, мы не можем решить какую-нибудь сложную задачу из-за тех ограничений, которые сами же себе установили. А какие ограничения в ситуации с v.print
мы могли установить сами себе? Для этого порассуждаем над тем что вообще такое v.print()
. Поскольку я как и многие другие переключались на Go, как правило, с ООП языков, то посмотрим на этот вызов с точки зрения этих языков. А с точки зрения большинства этих языков это вызов метода объекта. То есть у нас имеется какой-то объект, у которого есть некоторое множество методов одним из которых является наш метод print
. Так же мы можем вспомнить что в большинстве ООП языков, чтобы в методе получить доступ к объекту нужно использовать какую-нибудь специальную переменную по типу переменной $this
в PHP или специальное ключевое слово this
как в Java или C++. Таким образом, у нас в методе всегда (естественно за исключением статических методов) каким-то магическим образом появляется переменная указывающая на текущий экземпляр класса. Итого получается что связь объекта с методом (если смотреть только на сам метод) не так очевидна.
А теперь подумаем что такое v.print()
в Golang? Мы помним, что с точки зрения Golang v.print()
это вызов функции, которая привязана к какому-то типу. Данный тип называется «получателем» и может передаваться по указателю или по значению. И в отличии от большинства ООП языков связь с получателем (экземпляром) здесь кажется более очевидной за счет того, что получатель явно указывается после ключевого слова func
. И вот здесь стоит задуматься, а что же такое получатель по своей сути? А по сути это еще один — можно сказать «нулевой» — аргумент функции (аналогично тому как self
является первым аргументом в методах классов в Python). То есть вот так наше старое восприятие получателя, которые мы принесли с собой из другого языка (ов) мешало нам взглянуть на получателя по другому, под другим углом. Это было нашим ограничением мешавшим посмотреть на данную ситуацию по другому.
А если получатель это по сути такой же аргумент как и остальные, то мы можем переписать исходный пример следующим образом и он должен отработать так же как и исходный пример, что мы и сделаем:
type field struct {
name string
}
func print(p *field) {
fmt.Println(p.name)
}
func main() {
data := []field{{"one"},{"two"},{"three"}}
for _,v := range data {
go print(&v) // ссылка на переменную v
}
time.Sleep(3 * time.Second)
// горутины выводят: three, three, three
}
В итоге после запуска данного кода мы увидим тот же самый результат, что и в исходном примере. Тогда опишем что же в данной ситуации произошло. В этом случае мы передаем в аргумент p
(горутины) указатель на значение хранящееся в переменной v
. А как мы помним на каждой новой итерации цикла значение хранящееся в переменной v
перезатирается очередным значением из data
. В итоге функция вызывается три раза для одного и того же значения {"three"}
. В случае с v.print()
ситуация аналогичная.
Попробуем проверить наше предположение о получателе на втором примере: еще раз сделаем из массива структур — массив указателей на структуры.
type field struct {
name string
}
func (p *field) print() {
fmt.Println(p.name)
}
func main() {
data := []*field{{"one"},{"two"},{"three"}} // указатели
for _,v := range data {
go v.print()
}
time.Sleep(3 * time.Second)
// горутины выводят: three, one, two
}
Пример, где p
это аргумент, а не получатель
type field struct {
name string
}
func print(p *field) {
fmt.Println(p.name)
}
func main() {
data := []*field{{"one"},{"two"},{"three"}} // указатели
for _,v := range data {
go print(v)
}
time.Sleep(3 * time.Second)
// горутины выводят: three, one, two
}
В этом случае мы получаем ожидаемый ответ three
, one
, two
(при каждом новом запуске кода порядок вывода значений может отличаться). Но почему изменение типа переменной приводит к изменению вывода в консоль? Все дело в том, что в данном случае в функцию print
в аргумент p
мы передаем указатель не на значение хранящееся в переменной v
, а указатель на саму структуру, значение которого просто в момент итерации лежит в переменной v
. Звучит возможно сложно, поэтому попробуем разобраться в деталях как это происходит.
Что по сути собой представляет []*field{{"one"},{"two"},{"three"}}
? Это слайс указателей на структуры. Т.е. предположим что адресом в памяти (первой ячейкой) в котором храниться {"one"}
является, например, 0x0001
. Тогда пусть условно для {"two"}
адресом будет 0x0002
;, а для {"three"}
адресом будет 0x0003
. В итоге в базовом массиве на который ссылается слайс хранящийся в data
у нас будет храниться что-то вроде [3]{0x0001, 0x0002, 0x0003}
(массив из указателей).
Далее пройдемся по циклу и посмотрим как это будет все выглядеть в действии.
1 итерация цикла
в переменную
v
копируется значение указателя, которое лежит в первом элементе среза; т.е.v = 0x0001
;в метод
print
в качестве аргументаp
передается указатель на адрес структуры. Но поскольку мы знаем, что значения копируются в аргументы функции, то можем сделать вывод что в переменнойp
будет копия указателя, который лежал на тот момент в переменнойv
. Т.е. вp
скопируется значение0x0001
(адрес в памяти, где лежит наше значение) изv
.
2 итерация цикла
в переменную
v
копируется значение указателя, которое лежит во втором элементе среза; т.е.v = 0x0002
;в метод
print
в качестве аргумента передается указатель на адрес второй структуры; т.е.p = 0x0002
.
3 итерация цикла
в переменную
v
копируется значение указателя на третий элемент среза; т.е.v = 0x0003
;в метод
print
в качестве аргумента передается указатель на адрес третьей структуры; т.е.p = 0x0003
.
Далее по указателям на адреса в памяти Go выводит значения полей name
для этих структур. Аналогичная ситуация и для go v.print()
.
Попробуем внести еще одно изменение в наши примеры: сделаем получателя не по указателю, а по значению.
type field struct {
name string
}
func (p field) print() { // получатель по значению
fmt.Println(p.name)
}
func main() {
data := []*field{{"one"},{"two"},{"three"}}
for _,v := range data {
go v.print()
}
time.Sleep(3 * time.Second)
// горутины выводят: three, one, two
}
type field struct {
name string
}
func print(p field) { // аргумент по значению
fmt.Println(p.name)
}
func main() {
data := []*field{{"one"},{"two"},{"three"}}
for _,v := range data {
go print(*v)
}
time.Sleep(3 * time.Second)
// горутины выводят: three, one, two
}
Вспоминаем в чем существенное различие получателя метода по значению от получателя по указателю. Получатель по значению всегда работает с копией значения типа (так же как и в случае передачи в функцию любого аргумента по значению), а получатель по указателю работает непосредственно с самим значением типа, т. к. в его случае копируется не само значение, а указатель на адрес в памяти, где лежит значение.
Таким образом в данной версии нам нужно передавать не указатель, а само значение. А для того чтобы передать само значение нужно разыменовать указатель, что мы и делаем. Т.е. мы передаем само значение, от которого просто в момент выполнения функции/метода print
берется копия.
Для большего понимания пройдемся по циклу более подробно.
1 итерация цикла
в переменную
v
копируется значение указателя, которое лежит в первом элементе среза; т.е.v = 0x0001
;в метод
print
путем разыменования указателя передается само значение (структура), которое в момент выполнения функции/метода копируется; т.е.v = {"one"}
.
2 итерация цикла
в переменную
v
копируется значение указателя, которое лежит во втором элементе среза; т.е.v = 0x0002
;в метод
print
передается само значение, которое потом копируется; т.е.v = {"two"}
.
3 итерация цикла
в переменную
v
копируется значение указателя на третий элемент среза; т.е.v = 0x0003
;в метод
print
передается само значение, которое потом копируется; т.е.v = {"three"}
.
И в завершение внесем еще одно изменение в наши примеры: обратно переделаем из массива указателей на структуры на массив структур.
type field struct {
name string
}
func (p field) print() {
fmt.Println(p.name)
}
func main() {
data := []field{{"one"},{"two"},{"three"}} // структуры
for _,v := range data {
go v.print()
}
time.Sleep(3 * time.Second)
// горутины выводят: three, one, two
}
type field struct {
name string
}
func print(p field) {
fmt.Println(p.name)
}
func main() {
data := []field{{"one"},{"two"},{"three"}} // структуры
for _,v := range data {
go print(v)
}
time.Sleep(3 * time.Second)
// горутины выводят: three, one, two
}
Здесь совсем все просто. В переменную v
в цикле записываются копии структур, которые потом передаются в функцию/метод print
, которые так же копируются при вызове этой функции/метода.
Логика работы программы.
1 итерация цикла
в переменную
v
копируется значение первой структуры; т.е.v = {"one"}
;в функцию/метод
print
передается структура, которая копируется в аргументp
; т.е.p = {"one"}
.
2 итерация цикла
в переменную
v
копируется значение второй структуры; т.е.v = {"two"}
;в метод
print
передается структура, которая копируется; т.е.p = {"two"}
.
3 итерация цикла
в переменную
v
копируется значение третьей структуры; т.е.v = {"three"}
;в метод
print
передается структура, которая копируется; т.е.p = {"three"}
.
В итоге получаем вот такую непростую, но безусловно интересную ситуацию с вызовом метода структуры в горутине в Golang. Но благодаря этому мы смогли по другому взглянуть на получателя метода как на аргумент функции, что и помогло нам в конечном итоге в понимании сути происходящего в рассмотренных примерах. А так же напомнило еще раз что не все ситуации стоит сходу пытаться решать исходя из предыдущего опыта и иногда все-таки стоит подумать :)
В заключение хочу привести цитату приведенную в самом начале книги Акоффа «Искусство решения проблем»:
«Между истинным и ложным представлениями об окружающем нас мире лежит целый ряд образов, которые мы склонны выдавать за действительность. Стремление сохранить свою приверженность этим образам сковывает наше воображение и мысль.
Мы должны стремиться отходить от привычных концепций и учиться смотреть на мир по-новому; только в этом случае возможны творческий рост личности и совершенствование самого процесса познания.»