Зачем в Go амперсанд и звёздочка (& и *)?

  • Tutorial

Если вы хоть раз были сбиты с толку, что означает символ "амперсанд" (&) или "звёздочка" ("знак умножения", *) или запутывались, когда что использовать, то это статья для вас. Авторы Go старались сделать язык знакомым большинству программистов, и многие элементы синтаксиса заимствовали из языка С. Но в 2017м уже сложно понять, большинство программистов владеют С или нет, и смею полагать, что уже нет. Поэтому концепции хорошо знакомые прошлому поколению разработчиков, могут выглядеть совершенной абракадаброй для для нового поколения. Давайте немного копнём историю и расставим все точки над ї в вопросах указателей в Go и использования символов & и *.



Указатели


Про то, что такое и как устроены указатели я писал в статье "Как не наступать на грабли в Go", которую рекомендую к прочтению даже не новичкам в Go. Краткий повтор про указатели:


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

Вот визуализация из статьи:
image


Здесь Point{10, 20} это "литерал" — новая переменная, объявленная на месте, "блок памяти", а & — это "адрес этого блока памяти".


Тоесть в коде:


var a int
var b = &a
fmt.Println(a, b) //  выведет "0 0x10410020"

переменная b будет является указателем и содержать адрес a.


Тот же код, но запишем тип b явно:


    var a int
    var b *int = &a
    fmt.Println(a, b) //  выведет "0 0x10410020"

здесь звёздочка означает "тип указатель на число". Но, если она используется не перед типом, а перед самой переменной, то значение меняется на обратное — "значение по этому адресу":


    var a int
    var b *int = &a
    var c int = *b
    fmt.Println(a, b, c)  //  выведет "0 0x10410020 0"

Это может запутывать и сбивать с толку, особенно людей, никогда, не работавших с указателями, которых нет, например, в таких популярных языках как JavaScript или Ruby. Причём в языках вроде C и С++ есть ещё масса применений указателям, например "арифметика указателей", позволяющая вам прямо смещениями по сырой памяти бегать и реализовывать невероятно быстрые по современным меркам структуры данных. Ещё очень удобно переполнение буфера получать благодаря этому, создавая баги, приносящие ущерб на миллиарды долларов. Есть даже целые книги по тому, как понимать указатели в С.


Но если механика работы с указателями в Go относительно простая, остаётся открытым вопрос — почему "амперсанд" и "звёздочка" — что это вообще должно означать? Возможно это потому что символы рядом на клавиатуре (Shift-7 и Shift-8)? Ну а чтобы понять любую тему, нет способа лучше, нежели копнуть её историю.


История


А история такова. Одним из авторов Go был легендарный Кен Томпсон, один из пионеров компьютерной науки, подаривший нам регулярные выражение, UTF-8 и язык программирования B, из которого появился C, на базе которого, 35 лет спустя, появился Go. Вообще, генеалогия Go немного сложнее, но С был взят за основу по той простой причине, что это язык, который десятилетиями был стандартом для изучения программирования в университетах, ну и о его популярности в своё время, думаю, не нужно говорить.


И хотя сейчас Кен Томпсон отошёл немного от Go и летает на своём частном самолёте, его решения проникли в Go ещё задолго до Go. В юности он развлекался тем, что писал на завтрак новые языки программирования (слегка утрирую), и одним из языков, который был им создан вместе с ещё одной легендой компьютерной науки Денисом Ритчи, являлся язык программирования B (Би).


В то время Кен Томпсон написал операционную систему на ассемблере для компьютера PDP-7, который стоил 72000 долларов — а это примерно полмиллиона долларов сегодня — обладал памятью в 9 КБ (расширялась до 144КБ) и выглядел вот так:



Собственно, эта операционная система называлась Unics, и затем была переименована в UNIX. И когда зашла речь о переписывании её для нового крутого компьютера PDP-11, было принято решение писать на каком-то более высокоуровневом языке программирования. BCPL, который был предшественником B был слишком многословен — много букв. B был более лаконичен, но имел другие проблемы, которые делали его плохим кандидатом для портирования UNIX на PDP-11. Именно тогда Денис Ритчи и начал работать над новым языком, во многом основанном на B, специально для написания UNIX под PDP-11. Имя C было выбрано, как следующая буква алфавита после B.


Но вернёмся к теме об амперсанде и звёздочке. Звёздочка (*) была ещё в языке BCPL, и в B попала с тем же смыслом обозначения указателя, просто потому что так было в BCPL. Ровно по этой же причине перекочевали в С.


А вот амперсанд (&), означающий "адрес переменной", появился в B (и также перекочевал в С просто потому что), и был выбран по нескольким причинам:


  • нужен был один символ, а не два или целое слово
  • выбор символов был очень ограниченный (об этом чуть ниже)
  • как говорит сам Кен Томпсон, слово "амперсанд" звучало мнемонически похоже на "адрес" и было выбрано именно по этому.

Если я вас запутал, то вот нагляднее:


И тут нужно посмотреть внимательно на клавиатуры того времени. Чуть выше на картинке PDP-7 можно рассмотреть вводное устройство, коим являлся Телетайп 33. Стоит посмотреть на его клавиатуру повнимательнее, чтобы понять реалии того времени, и понять, с какими ограничениями сталкивались программисты и дизайнеры языков программирования в то время:



Как можно увидеть, ни тачбара, ни эмоджи не было :), и символы приходилось выбирать только из того набора, который был в телетайпе. Также, примечательно, что амперсанд и звёздочка тогда были не рядом, а на целых 4 клавиши порознь, что опровергает идею выбора амперсанда из-за близости клавиш. Собственно, из всех доступных клавиш, Кену Томпсону на тот момент больше всего приглянулся "амперсанд", похожий на "адрес".


Ну а дальше вы знаете — С стал языком века (прошлого), повлиял на огромное количество других языков, а книги по С стали настольными библиями программистов на несколько десятилетий. В таком же виде указатели вместе со звёздочкой и амперсандом попали и в С++ — ещё один язык мейнстримовый язык, на котором до Go писалась большая часть сетевого и серверного софта.


Поэтому решение включить указатели (без арифметики указателей, к счастью) в Go с тем же синтаксисом — было вполне логичным и естественным. Для С/C++ программистов это такие же базовые и простые понятия, как скобочки { и }.


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


Заключение


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


  • "Амперсанд" & звучит похоже на "Адрес" (ну, и то слово и другое на "А" начинается, по крайней мере)))), поэтому &x читается как "адрес переменной X"
  • звёздочка * ни на что не похоже на звучит, но может использоваться в двух случаях — перед типом (var x *MyType — означает "тип указателя на MyType") и перед переменной (*x = y — означает "значение по адресу")

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

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 32

    0
    А зачем вообще для типов и для переменных использовать разные символы, в Паскале и там и там используется "^" и всё работает, да и в B, вроде бы тоже, достаточно было только звёздочки (по мне, так достаточно одного амперсанда, раз уж звёздочка это умножение для переменных). Так что такое глубокое умозаключение про амперсанд, при введении двух знаков одновременно, мне вообще не понятно, ибо для меня вопрос «нафига» остаётся не раскрытым.
      +3
      Ничего не перепутали?

      В Паскале используется "&" для оператора взятия адреса, "^" для определения ссылочного типа, "^" для взятия значения по ссылке.

      В С используется "&" для оператора взятия адреса, "*" для определения ссылочного типа, "*" для взятия значения по ссылке.
        +1
        В Паскале используется "&" для оператора взятия адреса,

        Вообще-то '@'.

          0
          Да, верно, ошибся с копипастом :-)

          Но вопрос тот-же про «и там и там используется ^», что имеется ввиду?
            +1
            Ошибка. Символ каретки в Паскале так же используется, как и звездочка в сях, в зависимости от позиции — перед переменной/перед типом для разыменовывания или задания типа. В Паскале каретка до — тип, после — разыменовывание:
            PMyType = ^TMyType - указатель на TMyType
            var
            abc: PMyType;
            ...
            := abc^.field;
          0
          Ах, это Addr() ("@" я не использую), я просто не понял о чём речь, мне подумалось, что там разные знаки: для указания, что это ссылочный тип и для работы с переменными ссылочного типа.
            0
            В Си, кстати, звездочка намного логичнее — объявление вида «вот такое получится, если разыменовать», а в Паскале — нет, мнемоники никакой нет.
              +1
              Паскале — нет, мнемоники никакой нет.

              Почему нет? Вроде, есть. Не везде допустима, но есть.

              var
              abc: ^Integer;
              ...
              abc^ := 123;
                +1
                Вы не поняли.
                В сях будет так:
                int *a
                Т.е. «если дереференснуть а, будет int», потому что * перед — это разыменовывание. Так что такое объявление логично.
                А паскалевское такой мнемоники не имеет — каретка прыгает то перед переменной, то после.
                Если было бы ^abc = было бы так же мнемоничненько.
                  –2
                  Для меня то, что в паскале — логичнее. Есть переменная, есть ее тип.
                  Тип с крышкой (^) впереди — это ссылочный тип. Все просто и понятно.
                    –1
                    Вы все равно не поняли.
                    В сях есть мнемоника, которая не требует запоминания.
                    Тип с крышкой (^) впереди — это ссылочный тип. Все просто и понятно

                    Нет, каретка ставится перед тем, на что ссылку описываем, а не перед самим типом. Никакой логикой тут не пахнет.
                    PMyType = ^TMyType;
                    Ссылочный тип тут PMyType, а не TMyType. Было бы PMyType = TMyType^; было бы как в Си, логично. Но низя: требование однопроходности паскалевского компилятора. Не скажу, что я не доволен этим балансом, tradeoffом так сказать.
                      +1
                      В сях есть мнемоника, которая не требует запоминания.

                      Ну как не требует запоминания? Программисты на C святым духом что ли пользуются?

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

                      Ссылочный тип тут PMyType, а не TMyType.

                      Ссылочный тип здесь ^TMyType и, по счастливой случайности, PMyType.
                        0
                        Ну как не требует запоминания? Программисты на C святым духом что ли пользуются?

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

                        Вы все равно не поняли, или я выразился неточно. В С запоминается только одно: звездочка и потом переменная — это разыменовывание. Всё. В Паскале запоминается как синтаксис разыменовавания, так и синтаксис объявления, потому что он нелогичен. В С синтаксис объявления органично следует из синтаксиса разыменовывания.
                0
                Эта логика начинает резко хромать, когда определения усложняются:

                typedef int (*fun)(void*);
                


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

                А вот такое компиляторы уже не осиливают, выдавая ошибки:

                typedef int (*fun1)(int) (*fun2)(int);
                


                хотя, казалось бы, чего сложно — определить fun2 как указатель на функцию с int параметром и указателем на функцию int->int в результате…
                ан нет, случился зашкал сложности, и без промежуточных typedefʼов не выкрутиться.

                Стиль таких определений в Pascal, Go (*) и многих других — читаемее и не страдает такими ограничениями. Цена же за это — что надо, например, явно писать слова var — для переменных, func — для определений функций. Как по мне, цена вполне разумная, польза перевешивает.

                (*) Хоть я его и громко ругаю за кучу прочего.
                  0
                  А вот такое компиляторы уже не осиливают, выдавая ошибки:

                  typedef int (*fun1)(int) (*fun2)(int);

                  Компилятор как раз осилит, а вот мы, люди, такое можем в голове и не удержать. :)

                  … определить fun2 как указатель на функцию с int параметром и указателем на функцию int->int в результате

                  typedef int (*(*fun1)(int))(int);

                  Вот здесь дан очень хороший ответ:
                  stackoverflow.com/questions/10758811/c-syntax-for-functions-returning-function-pointers

                  От себя добавлю, что очень помогает typeof, если он определён компилятором:

                  typedef typeof(typeof(int(*)(int)) (*)(int)) fn2;

                  // Если нет аллергии на макросы:
                  #define FN(PARAMS, RET) typeof(RET(*) PARAMS)
                  typedef FN((int), FN((int), int)) fn3;
                    0
                    > typedef int (*(*fun1)(int))(int);

                    Вы определили fun1, а не fun2. Похоже, это опечатка — если написать fun2, то получится то, что ожидается (?)

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

                    А ещё — сколько времени прошло, прежде чем хоть кто-то заметил, где я ошибся?

                    > Вот здесь дан очень хороший ответ:

                    Угу, с тремя слоями вкручивания в определение…

                    > typedef typeof(typeof(int(*)(int)) (*)(int)) fn2;

                    и ещё одна путаница, и уже другого стиля (!) Итого — два разных стиля.

                    Спасибо за исправление моего примера. И всё это считаю безусловными примерами в пользу того, что синтаксис определений в том порядке, как Pascal/Ada/Go/etc., работает на пользу написания/чтения/сопровождения, а синтаксис C — против.
                      0
                      Спорить с тем, что в C не самый удачный синтаксис для указателей на функции и тем, что рядом с ними, никто не будет.

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

                      Вы определили fun1, а не fun2. Похоже, это опечатка — если написать fun2, то получится то, что ожидается (?)

                      Да, получится то, что вы описывали.

                      Это, кстати, не самый удивляющий пример.
                      Без typedef определить функцию, возвращающую функцию, которая возвращает и принимает функцию — вот тут в скобочках и звёздочках нормальные люди начнут «плавать».
                        0
                        и ещё одна путаница, и уже другого стиля (!) Итого — два разных стиля.

                        Это способ описания указателей на функции без надобности запоминать странные правила их определения.
                        Не уверен, что понял, о какой «путаннице» вы говорили.

                        С этим макросом стиль определения будет чем-то похож на тот, что в Go:

                        FN((int), int)
                        =
                        func(int) int

                        FN(FN((int, string), float), FN(int) int)
                        =
                        func(func(int, string) float) func(int) int


                        Если я допущу здесь какую-то опечатку или пропущу запятую — не вижу в этом катастрофы. Описана идея, а не исполняемые сниппеты.
            0

            Помнится, Спольски разорялся по поводу языков, в которых нет указателей (ну и рекурсию сюда же прицепил) http://local.joelonsoftware.com/wiki/Опасности_обучения_на_Java

              0
              дизайнеры

              Designer = разработчик, или в данном контексте «создатель» (языка программирования). Дизайнер — тот, кто создает одежду или интерьер для квартиры, такова коннотация в русском яыке.
                +1
                И чем людям адресная арифметика не нравится? Боишься накосячить — не юзай. Хотя, конечно, это для кода, который пишется навсегда. Менять в нем что-нибудь даже автор зачастую в силах
                  –1
                  Если очень захотеть, то получить адресную арифметику в Go можно.
                  Это будет выглядеть примерно как в Rust, или, если позволите, «не как в Си».

                  Вы ведь не придерживаетесь мнения, что для высокоуровневого кода арифметика указателей полезна?
                    0
                    `unsafe.Pointer` и `uintptr` в совокупности с возможностью преобразовывать их друг в друга дадут возможность читать по указателю со смещением.

                    Это может быть полезно при работе, например, с сишными библиотеками.

                    Point в том, что там, где реально нужно использовать арифметику указателей, делать это можно. Тезис «людям не нравится адресная арифметика» не обоснован,
                    корректнее сказать: «большинству не нравится адресная арифметика там, где можно было обойтись без неё».

                    P.S. — если кто-то этим воспользуется, стоит мониторить
                    proposal: spec: disallow T<->uintptr conversion for type T unsafe.Pointer
                    0

                    Это, как минимум, сильно усложняет жизнь Garbage Collector'у и не позволяет компилятору вставить за вас проверки на выходы за границы массива, например.

                    0

                    Подскажите пожалуйста одну вещь. В своё время я задавал вопрос, почему существует такая вещь как not-so-nil interface pointers — ситуация, когда nil-указатель на переменную конкретного типа неявно приводится к указателю на интерфейс, который этот тип реализует. И, как результат, у нас получается fat pointer с nil data pointer и валидным vtable pointer. Следствие — nil pointer dereference в рантайме, где его не ждали. Но вопрос не в этом. Вопрос в том, что ответ тогда был "потому что в Go указатели, а не ссылки". Как раз этот ответ я и не понял.

                      –1
                      Я тоже этот ответ не понял. Я бы сказал, что это становится понятным, если посмотреть как интерфейсы под капотом устроены (ну, мне, по крайней мере стало понятно): habrahabr.ru/post/325468/#interfeysy
                        0

                        Мне в общем-то тоже понятно, почему так происходит. Мне даже понятно, для чего используется эта фича. Что мне, к сожалению, осталось непонятно — почему это разрешено в виде неявного преобразования. Как по мне, сильно повышает шансы наступить на грабли. Было бы в виде явного тайп каста, так что неявный приводит к fully nil interface pointer — вопросов бы не было. Впрочем, как реализовали так реализовали. Может в v2 поменяют. Или давно есть линт, о котором я не знаю.

                          –1
                          А покажите пример, похоже я не до конца понял проблему.
                            0

                            Вырожденный пример будет какой-то такой


                            // Note: both FirstError and SecondError implement 'error' interface
                            // and use some data from value when their respective Error() is called
                            
                            // May return non-nil error, if code fails
                            func first(arg MyData) *FirstError { /* some code here */ }
                            // May also return non-nil error, if code fails
                            func second(arg MyData) *SecondError { /* some other code here */ }
                            
                            func third(arg MyHugeData) error {
                                if err := first(arg.DataField); err != nil {
                                    return err
                                }
                                return second(arg.DataField)
                            }
                            
                            func fourth(arg MyHugeData) {
                                if err := third(arg); err != nil {
                                    fmt.Println(err.Error())
                                }
                            }

                            Такой код КМК вполне может случиться — "просто верни статус последней операции дальше по стеку". Во-первых, результат всегда будет трактоваться как неуспех. Во-вторых, при попытке узнать причину мы получим nil-pointer panic из по сути ниоткуда. Пример с обработкой ошибок КМК просто будет самый типичный. Я в своё время нарвался на такое поведение в другом контексте, подробностей уже не помню.
                            Но суть была именно такой:


                            1. Есть несколько типов, приводимых к одному интерфейсу.
                            2. Каждый из типов требует не-нулевой инстанс чтобы корректно реализовывать указанный интерфейс
                            3. В некоем коде, допустим при передаче через канал, указатель на объект какого-либо из конкретных типов приводится к интерфейсу. При этом для канала nil pointer является абсолютно корректным положением вещей и обрабатывается на принимающей стороне как положено.
                            4. На принимающей стороне не-нулевой указатель на интерфейс неожиданно оказывается очень даже нулевым, т.к. сам по себе vtable бесполезен. Результат — NPE где не ждали.
                              –2
                              Ну да, так запутанно, конечно.
                              Ошибка тут, конечно очевидная — возврат ошибки по значению, а не с помощью интерфейса error из First и Second. Тоесть — это как бы и не проблема (с точки зрения компилятора), но это немного самодельный способ обработки ошибки — «А если first еще и другой тип ошибки может вернуть, а не только *FirstError)?».
                                +2

                                Пример с обработкой ошибок просто самый наглядный. Во второй половине комментария описан чуть более сложный случай. Добавьте туда пункт 2.5:


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


                                Проблема именно в том, что в сколь-нибудь нетривиальном коде вполне можно получить "битый интерфейс" на ровном месте.

                      0
                      Поэтому решение включить указатели (без арифметики указателей, к счастью) в Go с тем же синтаксисом — было вполне логичным и естественным.


                      Если нет арифметики указателей, по мне было бы логичнее использовать два ключевых слова — для типов по ссылке и для типов по значению. Скажем class и record.

                      Указатель на один тип данных можно разыменовывать как на другой в Go? Если да, то вообще решение убрать арифметику будет странным.

                      Если нет, то можно было бы определить типы «указателей на» и «значений» и все.

                      var a int
                      var b pInt
                      
                      pInt = class int

                      Only users with full accounts can post comments. Log in, please.