Как работают ссылки, указатели, переменные?

Переменная - это определенный именованный участок памяти(адрес), в котором хранится значение. Так вот, я не понимаю, как она работает, потому что выводя переменную на экран, мы получаем ее значение. Но допустим мы объявляем ссылку. Неужели мы в качестве rvalue подставляем это самое значение? Допустим имя переменной все таки хранит под собой адрес, но тогда зачем мы при объявлении указателя берем адрес этой самой пременной, если она и так содержит адрес? Мне очень интересно узнать, что из себя для компилятора вообще представляет идентификатор - адрес, значение и т. д.


Ответы (2 шт):

Автор решения: gbg

Не следует ставить этот вопрос в такой формулировке и вот почему: компилятор С++ очень хорошо оптимизирует программу.

Это значит, что допущение о том, что переменная - "именованный участок памяти" в общем случае, верно не всегда ( иногда это регистр, иногда переменная вовсе оказывается выброшена за ненадобностью)

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

"ссылка" не является указателем который не может быть null. При компиляции с ней происходят совсем другие вещи (в какой-то момент компилятор перестает отличать внутри себя ссылку и саму переменную, если может это сделать).

А что будет при векторизации при помощи SIMD? А у вас несколько переменных попадут в один 128-бит или 256-бит (или 512-бит, если у вас крутой проц) регистр.

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

Закладываться на то, что компилятор выдаст в ответ на какой-то код на C++ какой-то конкретный ассемблер можно было году так в 1990м.

→ Ссылка
Автор решения: nick_n_a

Ссылки указатели переменные.

Явные переменные. Обычно под переменной понимают запись int a (вместо int может быть любой другой тип кроме void, имеется ввиду тип, который не содержит ссылку или указатель т.к. типы можно переопределять). Это называется явное обьявление переменной типа int. Такая переменная всегда имеет размерность. Явная переменная может иметь или не иметь адрес. Если компилятор упрощает код - то переменная может попасть в регистр процессора - и быть без адреса (попытка взять адрес в дебагере - покажет что адреса нету). Если компилятор видит что переменная не используется, он может вообще не выдать ни адрес ни регистр и даже не выдать имя. Увидеть что случилось с переменной можно а) в дебаггере б) дизассемблером. в) в map файле. Для глобальных переменных - адрес постоянный (оговорка если компилятор не упростил, если imagtbase постоянный и т п), для локальных - как правило в стеке (если компилятор не упростил). Поведение компилятора может менятся, например без создания ссылки на переменную - у переменной может не быть адреса, а при использовании ссылки - компилятор сделает сборку где у переменной будет адрес. Есть дополнительные подсказки компилятору - их рассматривать не буду. Явная переменная может иметь любой размер от 1 байта до достаточно большого числа байти, которое разрешает сделать компилятор (даже 1 Мб можно).

Неявные переменные. Неявные переменные могут быть получены с помощью malloc, GlobalAlloc и подобных, с помощью вспомагательных библиотек (если создаётся обьект), с помощью ассемблерных операций (как обычные операции, так и SSE MMX и т п), путём прямого назначения адреса. Неявная переменная не может быть обьявлена явно, но их может быть очень много. Неявные переменные к которым есть доступ - могут существовать только в виде ссылки или указателя. Если к адресу нету доступа через ссылку или указатель, то можно считать, что переменная a) упакована (или хранимая в памяти), её всегда можно получить выполнив опредёленные операции б) потеряная. Потеряные - те которые потерялись в результате ошибок - пропали, и их невозможно найти, именно из таких получается "утечка памяти" (в упрощённом виде).

Ссылка. Запись f(int & b) или запись int & b = выражение; позволяет создать ссылку на обьект. Ссылка не может быть неинициализирована. Тип ссылки должен совпадать с типом обьекта (без & ссылки будет более гибкое преобразование), на который она ссылается и фактически "повторяет" тот объект, который ей присвоен изначально, все операции с новым именем будут иметь те же последствия которые наступили бы при операциях с оригинальным обьектом. Объект начинает обладать новым именем. Гарантировано отличить програмно ссылку от не ссылки - не получится. В ссылку может быть вложен указатель (int *& a; имеется ввиду, наоборот - нельзя), двойная ссылка int && a - недопутима. Константная ссылка const int &a - недопустима. За "широмой" выражение инициализирует ссылку путём вычисления адреса ссылке "присваивается" адрес. Технически ссылка хранится в памяти и занимает столько же места склько занимает указатель, что иногда условно даёт возможность сократить расход памяти при вызове функции если вместо явной переменной - ссылка. Хотя возможен случай когда компилятор упрощает код выбрасывая из памяти место под ссылку. Обычно ссылка содержит валидный обьект(адрес), но присвоить невалидный тоже можно через невалидную ссылку. При обращении к невалидному обьекту будет или исключение или фатальная ошибка или неопределённое поведение UB. Поэтому если идет преобразование указателя в ссылку желательно проверить валидность адреса. В некоторых языках (делфи) ссылка называется var. Ссылки имеют поведение очень схожее 99.9% с обычными именоваными переменными, поэтому их иногда используют.

Ссылка на явную переменную может быть записана так int & b = a;.

Ссылка на неявную переменную или косвенная ссылка на явную переменную может быть получена a)с указателя на переменную (явную/неявную) через оператор * int & b = *p; Но есть исключение. Если переопредёлен оператор operator *() то будет получена та ссылка которую вернёт оператор. Можно через malloc - сначало получаем указатель, потом приводим к нужному типу указателя, потом через * получаем нужный тип ссылки. Например так int & z1 = *(int*)malloc*(sizeof(int)); б)через другую ссылку, которая уже задана.

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

Указатель Указатель - именованная переменная типа указатель. Указатель содержит адрес. Обьект указатель позволяет: а) получать доступ к обьекту, адрес которого взят. б) проверять адрес на 0 (null) или не ноль в) получить адрес обьекта в абсолютном (двоично-десятичном представлении, т.е. как обычное число в случае с линейной памятью) г) позволяет использовать адрес как типизированый массив, и перемещаться по массиву вперёд (или назад) д) позволяет менять адрес разными способами, задавать нулевой, задавать значение литералами (цифровое), увеличивать, уменьшать. Указатель может быть как одинарным так и двойным (похож на двумерный массив) и более. Бывает пустой указатель (с нулевым адресом), указатель на явную/неявную переменную, указатель на элемент массива, неинициализированый указатель может сожержать "мусор" его использование может вызвать ошибки или UB - перед использованием такому указателю нужно присвоить значение. Обычно указатель задаётся выражением int *p; звёздочкой, но есть ещё несколько более сложных форм записи (для методов и методов класса, и ещё есть случаи). Указатель можно перепутаь с оператором *(). Указатель может быть как константным (дополнительно поставить модификатор const), так и таким который можно менять в процессе.

Есть указатель, а есть оператор тип operator *(). Указатель задаётся в секции обьявления типа, во всех других случаях - это оператор разименования указателя (т.е. преобразование в значение или ссылку). Обычно этот оператор не переопределяют, но некоторые программисты ради запутывания кода могут переопределить его.

Указатель не даёт доступ к обьекту на который он указывает напрямую. Для того что бы получить доступ к обьекту, нужно использовать один из 3-х операторов * -> [] . Если выражение слева - ссылка - то * [] дают значение или ссылку. Если выражение слева - не ссылка, то происходит копирование всего значения в тот обьект, который слева. Эти три оператора работают для обьекта указатель, для других обьектов нет, но могут работать для отдельных обьектов, если данные операторы для них заданы.

Оператор * operator *() (без операндов, умножение - с операндами, получение сслыки - без) - это оператор получения значения или ссылки из указателя. Пример использования *p, (*p).field (для классов). Принято использовать для не массивов и не классов. Но не запрещено использование для даных случаев.

Оператор стрелка operator ->() - получает доступ к полям Пример использования p->fied. Оператор доступен только для структур и классов.

Оператор взятия элемента массива operator [](тип index) - позволяет расскрывать указатель как ссылку на элемент масса. Приемр p[0], p[0].field. Обычно этот оператор используется с ссылками на массив и с массивами, но нулевой элемент будет аналогичен как * (если операторы не переопределены). Для массива - можно получить длинну массива, для указателя - нет. Неаккуратное ображение с этим оператором может приводить к сбоям, зато ввиду отсутствия проверки на выхода за пределы масиива - повышается быстродействие.

Обычно первые два оператора не принято менять, но все три оператора могут быть изменены для запутывания кода, или для создания "кастомного" указателя. Оператор [] обычно переопределяют, когда хотят сделать "улучшеный" или "виртуальный" массив.

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

Размер ссылки, размер указателя. Физически и указатель и ссылка имеют одинаковый размер. Поэтому подмена внутри функции аргумента ссылкой или указателем - может приводить к сокращению расхода оперативной памяти. Но встроеное sizeof() покажет разный размер, для ссылки - будет всегда размер обьекта на который ссылается ссылка, а для указателя - будет размер адреса. Для обычного линейного x86 с 32-битным режимом адресации размер будет равнятся 4 байта, для x64 - 8 байт. Но адрес может быть другого размера от 1 байт (для некоторых микроконтроллеров), 2 байта (16-битный режим), бывают сегмент-офсетные "двойные" адреса (метка far). Проэтому если вы пишите под кросс-платформу или под нестандартнуюю платформу - то надо учитывать что размер адреса может быть другим. И в некоторых случаях можно создать два указателя с разной размерностью адреса (при написании драйвера или загрузчика например). И опять же, если компилятор сократит ссылку или указатель - то физически указатель/ссыка могут быть без адреса (но адрес будет хранится или в регистре ЦП или две "переменных" будут хранится по одному адресу).

Пример идеинтичного кода с указателем и ссылкой

По сути, с точки зрения ассемблера 
- ссылка и указаель - это почти одно и то же. 
Отличие только в том, что с точки зрения с++ 
это разные обьекты с которыми совсем по разному 
нужно работать, хоть и по функциональности они похожи. 
Если не использовать все возможности указателя 
- то ассемблерный код будет идеинтичным. Следующие примеры сформируют одинаковый ассемблерный код 
(в 99% случаев).<code>

    void SetupA_ptr(char * a){// код с указателем этого метода будет
      *a = 'x';            // идеинтичный коду следующего метода
      }                    // Но тут можно проверить ссылку на null

    void SetupA_ref(char & a){// код с ссылкой
      a = 'x'; // А тут нельзя сделать проверку на null 
      }        // (т.к. при преобразованиях может возникнуть UB)
    //----------
    char z;
    SetupA_ptr(&z); // И даже вызов тут код вызова будет одинаковым
    SetupA_ref(z);  // И вызов тут
</code>

Пример проброса адреса

Если выражение слева готово принять ссылку - происходит передача ссылки. Если выражение слева не готово принять ссылку, то происходит копирование данных. lvalue - означает left-value - преобразование по значению слева. Например (скомпилировано в старом BCC 5.0):

int a0 = 1;    /*  mov [ebp-180h],1; */ 
// Делаем ссылку на a0
int & a1 = a0; /*  lea edx, [ebp-180h]; mov [ebp-184h],edx; */ 
// Берём адрес с a1, точнее получаем указатель на a0, на обьект на который ссылается ссылка
int * a2 = &a1 /*  mov ecx, [ebp-184h]; mov [ebp-188h],ecx; */
// Ниже делаем ссылку по полученому адресу, в итоге на обьект a0
int & a3 = *a2 /*  mov eax, [ebp-188h]; mov [ebp-18Ch],eax; */
// Берём адрес обьекта из ссылки a3, т.е. фактически адрес a0
int * a4 = &a3;/*  mov edx, [ebp-18Ch]; mov [ebp-190h],edx; */
printf("%X ",a1); /*Вывод: 1*/
printf("%X ",a2); /*Вывод: 18FDD0 адрес a0*/
printf("%X ",a3); /*Вывод: 1*/
printf("%X ",a4); /*Вывод: 18FDD0 адрес a0*/
                     
   Но если мы ссылку или звездочка-указатель присваиваем в int - то он поменяет свой адрес.

int b0 = 1;
int * b1 =  &b0;  // Тут адрес b0
int b2 = *b1;    // Тут мы теряем ссылку, b2 не умеет хранить адрес.
int * b3 =  &b2; // А тут будет адрес b2
int & b4 = b0;  // Тут адрес b0
int   b5 = b4;  // Тут мы теряем ссылку, b5 не умеет хранить адрес.
int * b6 =  &b5; // Тут адрес b5 

Но если мы пишем

int a = 1;
int b = 2;
int * pa = &a;
int * pb = &b;
*pa = *pb; // тут в а скопируется значение b, присваивание ссылки не  будет
int & c = *pb; // тут будет скопирован адрес b, т.к. инициализация
// Менять ссылку ещё раз - запрещено,  int& - слева запрещён.
с = a; // Скопирует значение а, 1  в переменную b.

Аналогичо, если мы передаём аргумент в функцию 
f1(int arg1) - то `arg1` получает копию переменной и 
для arg1 резервируется отдельное место для хранения, 
изменения в arg1 не повлияют на значение переменной в вызвавшей функции, 
а если мы передаём аргумент в функцию 
f2(int & arg2) - то arg2 сохранит адрес переменной, 
а если передали ссылку - то адрес ссылки, 
если передали *p  (от `int * p`) - то сохранит адрес записаный в p. 
И изменения в arg2 коснуться той переменной, 
на которую создана ссылка изначально.

Упрощенный пример "умного" массива

class MyArray {
    int * source;
    int max_index;
    int noValue; // Если адрес не верный.
   public:               
  void Init(int * src, int maxidx) {
       source = src;
       max_index = maxidx;        
       }

   int& operator[](int index) {
      if ((index < 0)||(index >= max_index)) return noValue; 
          // Вернём что-нибудь, 
          //но можно a) вызвать исключение
          //можно    б) вызвать printf и вывести ошибку на экран
      return source[index];   
      // Будет передана ссылка на элемент, 
      // Поэтому при изменении результата, который вернул оператор []
      // будет меняться оригинальный массив.
      // если убрать & - то получится "readonly" массив.
      // Ссылка позволяет аккуратно сделать "проброс"
      }             
   }

  MyArray arr;
  int array[10];
  arr.Init(array,10);
  arr[1] = 10;// сохранится
  arr[20] = 20; // не сохранится, сбоя не будет

  P.S. В STL есть хорошая реализация массива std::array 

Полный map-файл может дать ответы, какие адреса каким переменным ссылкам и указателям дал компилятор. Если конечно уровень детализации позволяет это увидеть. Так же дизассемблированый код покажет что компилятор сделал с вашими переменными и ссылками. Использование переменных и ссылок позволяет оптимизировать код и более гибко управлять памятью.

→ Ссылка