Указатели и ссылки в C++
В чём сходство и разница ссылок и указателей в C++?
Рассмотрим пример, в котором функции возвращают сумму аргументов:
struct STRUCT {int member;};
int ptr_fun (int* op1, STRUCT* op2) {
return *op1 + op2 -> member;
}
int ref_fun (int& op1, STRUCT& op2) {
return op1 + op2 . member;
}
Функция «ptr_fun» использует только указатели, а «ref_fun» — только ссылки.
С первого же взгляда заметно, что ссылки приятнее на глаз: они не требуют разыменования,
да и «.» короче, чем «->».
Почему со ссылками возможна точечная нотация, а с указателями — нет?
Ссылки не могут ссылаться на объекты ссылочного типа: т.е. на указатели, массивы, и т.д.
Ссылки всегда указывают на «конечный» объект. Причина в этом.
Интересно, это небольшие плюшки бесплатны или за это надо платить
дополнительным кодом при его генерации?
Смотрим код функции на ассемблере «ptr_fun»:
push ebp
mov ebp,esp
mov eax,dword ptr [ebp+8]
mov eax,dword ptr [eax]
mov edx,dword ptr [ebp+12]
add eax,dword ptr [edx]
pop ebp
ret
А вот во что компилируется «ref_fun»:
push ebp
mov ebp,esp
mov eax,dword ptr [ebp+8]
mov eax,dword ptr [eax]
mov edx,dword ptr [ebp+12]
add eax,dword ptr [edx]
pop ebp
ret
Разницы нет вообще! Может, их вызов организован как-то по-другому? Смотрим вызов:
int a = ptr_fun (&X, &Y);
компилируется в
lea eax,dword ptr [ebp-8]
push eax
lea edx,dword ptr [ebp-4]
push edx
call @@ptr_fun$qpip6STRUCT
add esp,8
mov edi,eax
Вызов
b = ref_fun (X, Y);
компилируется в то же самое (с поправкой на вызов разных функций):
lea eax,dword ptr [ebp-8]
push eax
lea edx,dword ptr [ebp-4]
push edx
call @@ref_fun$qrir6STRUCT
add esp,8
mov edi,eax
Оказывается, ссылки и указатели ничем не отличаются с точки зрения процессора. Мы платим за них одинаковую цену. Но если это так, то может быть они «взаимозаменяемы»? Попробуем в место вызова «ptr_fun» подсунуть адрес «ref_fun» и наоборот. Заметят ли это компьютер и ОС? Немного подправим вышеописанные функции, добавив в них вывод диагностики для «сигнализации», чтобы нас не обманули. Ну и вторая функция будет вычитать вместо сложения:
#include
struct STRUCT {int member;};
int ptr_fun (int* op1, STRUCT* op2) {
cout << "Pointers\n";
return *op1 + op2 -> member;
}
int ref_fun (int& op1, STRUCT& op2) {
cout << "References\n";
return op1 - op2 . member;
}
typedef int (*PF)(int*, STRUCT*);
typedef int (*RF)(int&, STRUCT&);
main() {
int X = 3;
STRUCT Y;
Y . member = 2;
int a, b;
a = ptr_fun (&X, &Y); // вызываем как обычно
cout << a << "\n";
b = ref_fun (X, Y); // вызываем как обычно
cout << b << "\n";
PF pf = (PF) ref_fun; // подмена
RF rf = (RF) ptr_fun; // подмена
a = (*pf)(&X, &Y); // вызов ref_fun как ptr_fun
cout << a << "\n";
b = (*rf)(X, Y); // вызов ptr_fun как ref_fun
cout << b << "\n";
return 0;
};
Запускаем и получаем на выходе:
Pointers
5
References:
1
References:
1
Pointers
5
Да, подмена прошла незамеченной.
В чём же тогда разница между ними?
Разницы на уровне процессора не существует,
но она есть на уровне синтаксиса и семантики языка и компилятора.
Концепция указателей Си и семантика их работы позволяет обойти
некоторые концептуальные недостатки языка.
Мало того, что указатели могут быть инициализированы неправильным значением
или не инициализированы вовсе.
Значение указателя может быть подправлено на любое число:
char* ptr = "abc";
ptr -= 16; // компилятор не проверяет правильность
ptr += 64; // и тут тоже
Хорошо, что компилятор не пропустит явно абсурдные операции:
ptr *= 2; // ошибка при компиляции
Но ничто не мешает обойти такую «защиту»:
ptr = (char*) (2*(int)ptr); // то же, что и ptr *= 2;
С изменением значения указателя связана ещё одна проблема.
Когда указатель связан с динамически выделенным участком памяти,
то при изменения указателя этот участок становится недоступным.
Ведь уже никто «не помнит», где находится этот участок, поэтому его невозможно освободить.
Вот почему к обычным указателям приклеилось название «диких указателей».
На другой, «светлой» стороне (или «культурной»?) мы видим растущую
популярность всякого рода «умных» указателей, которые призваны дать
безопасные способы работы с памятью.
Но это опять борьба с последствиями, а причина — то, как был задуман Си.
Использование «правильных» средств не отменяет возможности использования опасных:
последние по-прежнему законны.
Ссылки более безопасны, нежели указатели.
Они перед использованием должны быть инициализированы,
что уже создаёт какие-то гарантии.
Адрес, содержащийся в ссылке, нельзя увеличивать и уменьшать арифметичекими операциями,
единственный способ его изменить — присвоить ссылке адрес другого существующего объекта.
Ссылки вносят единообразие при обращении к членам объектов:
obj1 . member = x; // obj1 — ссылка
obj2 . member = y; // obj2 — собственно объект
Но если obj1— указатель, то вносится разнобой:
obj1 -> member = x;
obj2 . member = y;
Со ссылками труднее вытворять всяческие трюки, недаром тип «void*» существует,
а тип «void&» — нет.
Конечно, это ограничивает неограниченный полёт творческой мысли отдельных
одарённых личностей.
Но есть ли такие области в программировании,
где бы ссылки оказались бессильны в решении задач?
Когда функциональность ссылок недостаточна?
Иногда в системном программировании надо обратиться по абсолютному адресу.
Если ссылками пользоваться «по-честному», без union, например, то они
не позволят этого сделать.
Однако, недостаточность возможностей ссылок этим не исчерпывается.
Ссылки нужно инициализировать адресом существующего объекта.
Это требование не всегда можно выдержать.
Рассмотрим такую структуру данных, как список.
Он состоит из элементов примерно такого типа:
struct element {
<какой-то тип> value; // собственно элемент
element* prev; // указатель на предыдущий элемент
element* next; // указатель на следующий элемент
};
Когда элемент списка первый в этом списке, то указатель «prev» имеет значение NULL.
Последний же элемент имеет указатель «next» так же равным NULL.
Конечно, можно завести отдельные поля для того, чтобы отличать первый и
последний элементы от остальных, но это расточительно.
Поэтому соответствующие указатели просто устанавливаются в NULL в качестве сигнала.
А теперь вопрос: а можем ли мы в структуре «element» заменить указатели на ссылки?
struct element {
<какой-то тип> value; // собственно элемент
element& prev; // указатель на предыдущий элемент
element& next; // указатель на следующий элемент
};
Увы, но...
- Мы теперь не можем полям «prev» и «next» присваивать значение NULL.
- Мы не можем изменять значение этих полей,
ведь они инициализируются адресом единожды.
А если мы в список вставляем новый элемент и удаляем старый —
мы же должны поменять адреса у соседей, но как теперь это сделать?
Являются ли ссылка указателем с урезанной функциональностью?
В принципе, такое толкование сути ссылок недалеко от истины. Но...
Учебники внушают мысль, что ссылка — это лишь синоним имени какого-то объекта
и она не занимает места в памяти.
Но можно ли верить учебникам, что этот сыр достанется совсем бесплатно?
Я вас умоляю:
printf("sizeof(int*) = %d\n", sizeof(int*));
printf("sizeof(int&) = %d\n", sizeof(int&));
Этот код выдаёт совершенно одинаковую цену на сыр.
Так что надо быть поосторожнее с утверждением «не занимает место в памяти».
Рассмотрим ещё пример:
int& func (int& k) { return k;}
func(i) = func(i+1);
Здесь мы видим чудо чудное и диво дивное, невообразимое в Си, но возможное в C++.
Функция употреблена в левой части выражения!
Чудеса стали возможными благодаря ссылкам.
Это как раз тот случай, когда функциональность ссылок превосходит
функциональность указателей.
Ну и нельзя обойти вниманием вопрос, зачем в C++ вообще появились ссылки.
Они делают возможным перезагрузку операторов:
T& operator+= (T&, T&);
Выводы
Создавая язык для надёжного, безопасного программирования,
следует приветствовать некоторый прогресс ссылок относительно указателей.
Для редких же случаев, когда обращение по абсолютному адресу действительно необходимо,
следует иметь специальные функции в специальных системных библиотеках.
Большинству же программистов прямая работа с памятью не нужна.
Однако функциональность ссылок всё-таки недостаточна для полноценного программирования.
Некоторая небезопасность указателей (указатели со значением NULL)
должна быть ликвидирована обязательностью («зашитой» в синтаксис языка) проверок на NULL.
Опубликовано: 2015.12.31, последняя правка: 2022.06.15 12:44
Отзывы
✅ 2016/01/21 21:31, rst256 #0
Естественно что операции над ссылками и над указатели порождают идентичный код, ссылки это указатели с урезанным функционалом, введенные в язык отнюдь не с целью сделать его удобней/безопасней/круче, а по совсем иным причинам (см. статью о ссылках с++ на вики)...
Фирменный ход с++ — любой косяк/костыль выдавать за очень полезную фичу! Здесь это тупейшая "защита от дурака", просто за счет урезания ненужного "особо одаренным" программистам функционала.✅ 2016/01/25 15:57, Автор сайта #1
Да, костылей хватает, но было бы неправильно говорить, что они есть только в C++. К примеру, функция malloc всегда возвращает результат типа void*. Тут хоть убейся, но без костыля в виде приведения типов ну никак не обойтись:double* ptr = (double*) malloc (1); В этом примере есть ещё изъян: нет никакой связи между размером запрашиваемой памяти и типом указателя в левой части. Мы точно видим, сколько нам надо памяти — sizeof(double), а malloc не видит, ей можно подсунуть всё, что угодно.
А вот C++:double* ptr = new double; И костыль для приведения типа не нужен, да и с размером памяти ошибиться труднее. Однако, ни Си, ни C++ не могут служить нам примером того, как в новом языке реализовать адресную арифметику, чтобы было и просто, и ясно, и безопасно.✅ 2016/04/12 23:42, rst256 #2
К примеру, функция malloc всегда возвращает результат типа void*. Все правильно, так и должно быть.double* ptr = (double*) malloc (1); Ну и зачем вам это "приведение"? К чему вы собрались приводить свеже-выделенный блок память?Мы точно видим, сколько нам надо памяти, а malloc ... Должна делать в точности что ему скажут, что и применяется при создании всевозможных компиляторов, операционок и библиотек.А вот C++: double* ptr = new double; И костыль для приведения типа не нужен, ... Придумать и решить несуществующую проблему, крестоносцы тут всегда были вне конкуренции.✅ 2016/04/12 23:55, rst256 #3
Причиной введения ссылок в язык С++ в основном являлась необходимость перегрузки операторов, применяемых к объектам пользовательских типов (классов). Как упоминалось выше, передача по значению громоздких объектов в качестве операндов вызывала бы лишние затраты на их копирование. С другой стороны, передача операндов по адресу с использованием указателей приводит к необходимости взятия адресов операндов в выражениях. Однако, выражение &y - &z уже имело определённый смысл в языке С. Источник: https://ru.wikipedia.org/wiki/Ссылка_(C++)✅ 2016/04/13 08:00, rst256 #4
Мне кажется, или лепить бессмысленный тайп-кастинг к маллоку вас заставляет давно устаревший компилятор TCC? Ни один из современных компиляторов Си подобной фигней не страдает, "Under the C standard, the cast is redundant."(https://en.wikipedia.org/wiki/C_dynamic_memory_allocation#cite_note-4) уж не обессудьте, что без перевода, в русской версии статьи этого нет. Так какие же это костыли, если приведение там не требуется? Самое смешное, что на Си легко получить то, что вы хотите банальным макросом:#ifdef ___CHECK_MALLOC_RESULTS
inline void *___check_malloc_results(size_t size){ void * ptr = malloc(size); if (ptr == NULL){ fprintf(stderr,"malloc(%zu) failed in file %s at line # %d", size, __FILE__, __LINE__); exit(EXIT_FAILURE); } return ptr; } #define _NEW(T, N, ...) (T *)___check_malloc_results(sizeof(T)*(N)); #else
#define _NEW(T, N, ...) (T *)malloc(sizeof(T)*(N)); #endif
#define NEW(...) _NEW(__VA_ARGS__, 1)
#define _INIT(V, N, ...) V = NEW(typeof(*(V)), N) #define INIT(...) _INIT(__VA_ARGS__, 1) Пример использования: double * d = NEW(double); double * d_x_6 = NEW(double, 6); double * d2, *d3; INIT(d2); INIT(d3, 10); d_x_6[5]=.5; Один в один с++, я бы даже отказался от повторного указания типа в операторе new, макросы такое позволяют легко, нужно помнить и чтить принцип DRY!✅ 2016/04/15 12:58, Автор сайта #5
К чему вы собрались приводить свеже-выделенный блок память? ... Придумать и решить несуществующую проблему... лепить бессмысленный тайп-кастинг к маллоку вас заставляет давно устаревший компилятор TCC?... Так какие же это костыли, если приведение там не требуется?... Вы будете долго смеяться, но очень давно приучил себя указатели типа void* приводить к указателям на другие типы. void* vp; int* ip; vp = ip; // вполне законно ip = vp; // ошибка при компиляции ip = (int*) vp; // теперь правильно, необходимо приведение Тупо заставил себя всегда делать приведение («Надо или не надо — не хочу думать, зато никогда не ругается при компиляции, а лишний код не генерится»), а теперь стандарт «для себя» машинально переношу на стандарт языка. Компиляторы в этом не виноваты, виноват я. Признаю ошибку.
Но строгая типизация всё-таки необходима, она помогает бороться с ошибками. Насаждать строгую типизацию и давать лазейки для её обхода в виде void* как-то не по фэншую.✅ 2016/08/07 04:29, rst256 #6
Некоторая небезопасность указателей (указатели со значением NULL) должна быть ликвидирована обязательностью («зашитой» в синтаксис языка) проверок на NULL. Ви наверное шутите? NULL одно из самых безопасных значений указателя, пожалуй даже самое безопасное из всех! Какое тогда по вашему значение будет указывать на то, что он пустой? Раз NULL нельзя, то, наверное, будет какой-то иной способ отмечать сие, пусть и с незначительными потерями производительности. Это не важно, важно то что пустой указатель, у которого не выставлено значение NULL, это НЕБЕЗОПАСНО. Тем более, что это значение скорее всего окажется внутри доступного программе сегмента. Всегда есть вероятность, что по указателю обратятся, и лучше если в тот момент его значение будет либо валидным, либо NULL. Ошибка сегментации всегда приятнее, чем разрушение данных.Здесь мы видим чудо чудное и диво дивное, не вообразимое в Си, но возможное в C++. Функция употреблена в левой части выражения! Вы издеваетесь? Я даже не припоминаю? было ли когда нибудь в Cи такое недоступно. Вся разница в том, что Си требует выполнить разадресацию явно. Вот пример: лямбда-функция в левой части выражения, обычные функции, разумеется, работают также.*({ char* self (int i) { return &(sss[i]); } &self; })(10)='@'; P.S. А вот это как раз тот реальный случай, когда функциональность указателей превосходит функциональность ссылок: ((((mode *)(list = (char *) ((((int)list + (__alignof__(mode)<=4?3:7)) & (__alignof__(mode)<=4?-4:-8))+sizeof(mode))))[-1] Но это, конечно, же не для прикладного программирования, хотя va_arg там иногда используют.✅ 2017/09/21 19:37, Comdiv #7
int& func (int& k) { return k;}
func(i) = func(i+1);
Хотите сказать, что такой код у Вас компилируется?✅ 2017/09/30 23:57, Автор сайта #8
Код#include <stdio.h> int& func (int& k) { return k;} main() { int i = 99; func(i) = func(i+1); printf("i = %i\n", i); func(i) = 999; printf("i = %i\n", i); } не только компилируется, но и выполняется и выводит "100", а потом "999". А почему Вы этим интересуетесь?✅ 2018/05/23 23:06, Александр Коновалов aka Маздайщик #9
Некоторая небезопасность указателей (указатели со значением NULL) должна быть ликвидирована обязательностью («зашитой» в синтаксис языка) проверок на NULL. Кстати, да. Во многих прикладных языках без адресной арифметики вроде C# или Java ссылочные переменные могут хранить или действительную ссылку на объект, или NULL (причём «неинициализированного» значения нет, подразумевается инициализация нулём). И этот факт некоторых теоретиков языков программирования напрягает (где-то читал на Хабре). Дескать, правильнее проектировать язык, в котором объектные ссылки по умолчанию всегда хранят действительный указатель, а для «обнуляемых» указателей нужно явно использовать тип вроде Nullable<T> с методами вроде bool has_value() и T get(). Последний для нуля выбрасывает исключение.
Так, кстати, сделано и в Rust’е.✅ 2018/05/24 15:27, Автор сайта #10
В Rust’е нет исключений, поинтересуйтесь на досуге этим. Там другие механизмы. А вот языки, которые делают вывод типов (ML был первым), интересны тем, что объект инициализируется не только типом, но и значением (как бы побочный эффект от вывода типов). Т.е. в ML и его последователях типа Haskell нет такого понятия, как «неинициализированное значение». Правда, есть значения типа «ошибка» (NULL для указателей — это и есть «ошибка»), но в Haskell это отслеживается монадой Maybe и родственными.✅ 2018/05/24 23:51, Александр Коновалов aka Маздайщик #11
Спасибо, я в курсе, что в Rust’е нет исключений. Там другой, более интересный механизм, который складывается из монады Error (не помню, как она в Расте точно называется), макросов и приведения типов. Про Rust я вспомнил в связи с тем, что в нём указатели (вне unsafe-кода) всегда ссылаются на актуальный объект, а для временно отсутствующего объекта применяется аналог монады Maybe. Я мог бы вспомнить Haskell, но он из «второй категории» по Вашей терминологии. А Раст позиционируется как из «первой».
В C# есть тип Nullable<T>, но он может добавлять значение NULL только типам значений. А мы рассуждаем об обратном: об отбирании значения NULL от объектных ссылок.
За все языки семейства ML не скажу, но OCaml позволяет создавать мутабельные поля в классах и имеет встроенный (в стандартной библиотеке, доступен по умолчанию) тип данных ref — изменяемую ячейку. Но в обоих случаях, как я понимаю, создать такие объекты, не проинициализировав их начальным значением, нельзя. Чисто синтаксически.✅ 2018/05/26 01:01, Автор сайта #12
Этот механизм в Rust не производит впечатления завершённости. Можно проверять значения на NULL, но можно и не проверять. Т.е. нет обязательности такой проверки. Нехорошо иметь такое в хорошем языке. Ну и плюс там используются макросы. А что такое макросы? Это надстройка над языком, которая говорит, что сам язык несовершенен, но за счёт костылей мы его маленечко ремонтируем. Если же язык хорош, то и макросы ему не нужны.✅ 2018/05/27 16:21, Александр Коновалов aka Маздайщик #13
Освежил в памяти обработку ошибок в Rust, Вам эта статья известна).
Как я понимаю, проверка указателя (завёрнутого в Option или Result) выполняется всегда. Её можно выполнять явно при помощи match, можно использовать комбинаторы разнообразные, можно использовать макрос try!, и можно использовать метод unwrap или expect, которые являются своего рода assert’ами, вызывающими panic. В любом случае разыменования нуля не происходит.
Под «необязательностью проверки» Вы имели ввиду unwrap()?
Или речь идёт об unsafe-блоках, где можно использовать нулевые указатели? Ну так на то они и unsafe.✅ 2018/05/27 23:42, Автор сайта #14
Надо почитать эту статью. Такое впечатление, что она прошла мимо меня.✅ 2018/05/27 23:47, Александр Коновалов aka Маздайщик #15
В этой статье есть несколько Ваших комментариев от 2015 года.
Я и сам её давно читал. Оттуда узнал, что в Расте есть аналог монад Хаскеля Maybe и Error. И что красиво обработка ошибок сделана через макрос try! и приведение типов (типаж From). Но вот название макроса и названия типов Option и Result (соответственно) я уже забыл. Сегодня специально искал эту статью и немножко завис на Хабре.✅ 2018/05/29 21:52, Автор сайта #16
Чем хорош склероз — тем, что каждый день новости :) Я уж и забыл про эту статью. Вроде бы те неясности, ради которых задавал вопросы, прояснились и уложились в голове должным образом. Надо найти время и внимательно перечитать эту статью.✅ 2020/10/30 15:33, Бурановский дедушка #17
Разнообразие задач программирования вынуждает искать лазейки, если какая-то реальная потребность не вписывается в парадигмы языка. Какая бы хорошая типизация ни была, но на вопрос «а как читать и писать по абсолютному адресу» отвечать придётся.
Не всем понадобится читать и писать по абсолютному адресу. Это делают, например, разработчики ОС и драйверов. Их можно вооружить чем-то похожим на PEEK и POKE из Бейсика. Собрать такие функции в системной библиотеке. Для остального можно делать строгую и красивую типизацию — и остальные разработчики будут работать в её рамках.давно приучил себя указатели типа void* приводить к указателям на другие типы Это называется «синтаксическая соль». Добавить свой отзыв
Написать автору можно на электронную почту mail(аt)compiler.su
|