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

Интересный пример кода на Go и зашоренность мышления

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

Данная история началась с того, что как-то коллега скинула в телеграмм чат команды пример (на 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 итерация цикла

  1. в переменную v копируется значение указателя, которое лежит в первом элементе среза; т.е. v = 0x0001;

  2. в метод print в качестве аргумента p передается указатель на адрес структуры. Но поскольку мы знаем, что значения копируются в аргументы функции, то можем сделать вывод что в переменной p будет копия указателя, который лежал на тот момент в переменной v. Т.е. в p скопируется значение 0x0001 (адрес в памяти, где лежит наше значение) из v.

2 итерация цикла

  1. в переменную v копируется значение указателя, которое лежит во втором элементе среза; т.е. v = 0x0002;

  2. в метод print в качестве аргумента передается указатель на адрес второй структуры; т.е. p = 0x0002.

3 итерация цикла

  1. в переменную v копируется значение указателя на третий элемент среза; т.е. v = 0x0003;

  2. в метод 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 итерация цикла

  1. в переменную v копируется значение указателя, которое лежит в первом элементе среза; т.е. v = 0x0001;

  2. в метод print путем разыменования указателя передается само значение (структура), которое в момент выполнения функции/метода копируется; т.е. v = {"one"}.

2 итерация цикла

  1. в переменную v копируется значение указателя, которое лежит во втором элементе среза; т.е. v = 0x0002;

  2. в метод print передается само значение, которое потом копируется; т.е. v = {"two"}.

3 итерация цикла

  1. в переменную v копируется значение указателя на третий элемент среза; т.е. v = 0x0003;

  2. в метод 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 итерация цикла

  1. в переменную v копируется значение первой структуры; т.е. v = {"one"};

  2. в функцию/метод print передается структура, которая копируется в аргумент p; т.е. p = {"one"}.

2 итерация цикла

  1. в переменную v копируется значение второй структуры; т.е. v = {"two"};

  2. в метод print передается структура, которая копируется; т.е. p = {"two"}.

3 итерация цикла

  1. в переменную v копируется значение третьей структуры; т.е. v = {"three"};

  2. в метод print передается структура, которая копируется; т.е. p = {"three"}.

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

В заключение хочу привести цитату приведенную в самом начале книги Акоффа «Искусство решения проблем»:

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

Теги:
Хабы:
+8
Комментарии7

Публикации

Истории

Работа

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

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

AdIndex City Conference 2024
Дата26 июня
Время09:30
Место
Москва
Summer Merge
Дата28 – 30 июня
Время11:00
Место
Ульяновская область