Указатели и ссылки в C и C++
Немного о памяти
Память можно представить по-разному.
Объяснение для военных на примере взвода. Есть взвод солдат. Численность — 30 человек. Построены в одну шеренгу. Если отдать им команду рассчитаться, у кажого в этой шеренге будет свой уникальный номер. Обязательно у каждого будет и обязательно уникальный. Этот взвод — доступная нам память. Всего нам здесь выделено для работы 30 ячеек. Можно использовать меньше. Больше — нельзя. К каждой ячейке можно обратиться и быть уверенным, что обратился именно к ней. Любому солдату можно дать что-то в руки. Например, цветы. То есть поместить по адресу данные.
Объяснение для Маленького Принца. Здравствуй, Маленький Принц. Представим, что твоему барашку стало одиноко. И ты попросил нарисовать ему друзей. Ты выделил для барашков целую планету (точнее, астероид) по соседству. Эта планета — доступная память. Вся она уставлена коробочками, в которых будут жить барашки. Чтобы не запутаться, все коробочки пронумерованы. Коробочки — это ячейки памяти. Барашек в коробочке — это данные. Допустим, что попался какой-то особо упитанный барашек. Ему понадобится две коробочки. Или даже больше. Барашек — неделимая структура (для нас с тобой, Маленький Принц, это точно так), а коробочки идут подряд. Нет ничего проще. Мы вынимает стенки между двумя рядом стоящими коробочками и кладем туда барашка. Места в коробочке не очень много. И барашек не может свободно развернуться. Поэтому мы всегда знаем, где его голова, а где хвост. И если нам что-то нужно будет сказать барашку, мы обратимся к той коробочке, где у него голова.
Объяснение для хулиганов. Есть забор. Забор из досок. Забор — доступная память. Доска — ячейка памяти. Забор длинный. И чтобы потом похвастаться друзьям, где ты сделал надпись, надо как-то обозначить место. Я знаю, о уважаемый хулиган, что ты нашел бы что-то поинтереснее, чем нумеровать каждую доску. Но в программировании не такие выдумщики. Поэтому доски просто пронумерованы. Возможно, твоя надпись поместится на одну доску. Например, %знак футбольного клуба%. Тогда ты просто скажешь номер и друзья увидят серьезность твоего отношения к футболу. А возможно, что одной доски не хватит. Ничего, главное, чтобы хватило забора. Пиши подряд. Просто потом скажи, с какой доски читать. А что если не подряд? Бывает и не подряд. Например, ты хочешь признаться Маше в любви. Ты назначаешь ей встречу под доской номер 40. Если все пройдет хорошо, ты возьмешь Машу и поведешь ее к доске 10, где заранее написал «Хулиган + Маша = любовь». Если что-то пошло не так, ты поведешь Машу к доске 60, на которой написано все нехорошее, что ты думаешь о Маше. Примерно так выглядит условный переход. То есть оба его исхода помещаются в память заранее. На каком-то этапе вычисляется условие. Если условие выполнилось — переходим к одному месту памяти и начинаем идти дальше подряд. Если условие не выполнилось — переходим к другому месту, с другими инструкциями. И тоже продолжаем выполнять их подряд. Инструкции всегда выполняются одна за другой, если только не встретился переход (с условием или без условия). Ну, или что-то поломалось.
Модель взаимодействия программы с памятью компьютера может быть разной. Будем считать, что для каждой программы выделяется своя обособленная область памяти. Даже если запущены два экземпляра одной программы — память у них будет разная.
В памяти хранятся числа. Ни с чем кроме чисел компьютер работать не умеет. Если вы поместили в память какую-то комплексную структуру, она все равно будет представлена числами. Даже если вы работаете с ней как со структурой. Примером комплексной структуры в терминах языков C и C++ может быть, например, экземпляр структуры или объект класса.
Наименьшей адресуемой величиной в памяти типового компьютера является байт. Это означает, что каждый байт имеет собственный адрес. Для того, чтобы обратиться к полубайту, придется обратиться сначала к байту, а затем выделить из него половину.
Возможно, подобные объяснения кажутся очевидными и даже смешными. Но в действительности имеет значение только формализация. И то, что кажется привычным, в определенных случаях может быть совсем иным. Например, запросто можно задать условие, при котором байт не будет равен 8 битам. И такие системы существуют.
Раз уж мы договорились, что минимальная адресуемая величина — байт, то всю доступную программе память можно представить в виде последовательности байтов.
Система в компьютере двоичная (хотя есть и тернарные машины). В 1 байте 8 бит. Английское bit означает binary digit, то есть двоичный разряд. Получается, что байт может принимать числовые значения от 0 до 2 в 8 степени без единицы. То есть от 0 до 255. Если представлять числа в шестнадцатеричной системе, то от 0x00 до 0xFF.
Представим область памяти.
0x01 | 0x02 | 0x03 | 0x04 |
0x05 | 0x06 | 0x07 | 0x08 |
0x09 | 0x0A | 0x0B | 0x0C |
0x0D | 0x0E | 0x0F | 0x10 |
В ней лежат числа от 1 до 16. Направление обхода обычно задается слева направо и сверху вниз. Помните, что никакой таблицы на самом деле нет (почти как ложки в Матрице). Она нужна человеку для удобства восприятия. Каждая такая ячейка описывается двумя величинами: значением и адресом. В приведенной таблице значение и адрес совпадают.
Понятие указателя
Указатель — это переменная. Такая же, как и любая другая. Со своими «можно» и со своими «нельзя». У нее есть свое значение и свой адрес в памяти.
Значение переменной-указателя — адрес другой переменной. Адрес переменной-указателя свой и независимый.
Переменная-указатель (далее будем говорить просто — указатель) объявляется также, как и любые другие переменные, но после имени типа ставится звездочка.
int *pointerToInteger;
Здесь объявляется переменная pointerToInteger. Ее тип — указатель на переменную типа int.
Немного лирики.
Как следует писать звездочку относительно типа и имени переменной? Встречаются, например, такие формы записи, и все они имеют право на существование:
int* p1; int * p2; int *p3;
Аргументы за первую форму. Чтобы объявить переменную следует указать ее тип, а затем имя. Звездочка является частью типа, а не частью имени. Это также подтверждается тем, что при привидении типов пишется тип со звездочкой, а не тип отдельно. Следовательно, должна писаться слитно с типом. Минус в том, что при объявлении нескольких переменных после объявления int*, только первая из них будет указателем, а вторая будет просто переменной типа int. Не объявляйте несколько указателей в одной строчке. Это не очень хороший стиль.
Аргументы за вторую форму. Есть люди, которым нравится «когда код дышит» Они ставят пробел до скобок и после скобок. И здесь тоже ставят. Возможно, это просто такой компромисс.
Аргументы за третью форму. Если писать так, то с объявлением нескольких указателей в одной строчке проблем быть не должно (хотя это все равно плохой тон). Некоторая идеология нарушается. Но этот стиль — самый распространенный, так как точно видно, что переменная — указатель.
И помните, что компилятору все это безразлично.
Адрес переменной и значение переменной по адресу
Рассмотрим две переменные: целочисленную переменную x и указатель на целочисленную переменную.
int x; int *p;
Чтобы получить адрес переменной, нужно перед ее именем написать амперсанд.
p = &x;
Данная конструкция будет выполняться справа налево. Сначала с помощью оператора &, примененного к переменной x, будет получен адрес x. Затем адрес x будет сохранен в указателе p.
Есть и обратная операция. Чтобы получить значение переменной по ее адресу, следует написать звездочку перед именем указателя.
int y = *p;
Такая операция в русском языке называется не слишком благозвучным словом «разыменование». В английском — dereference.
В данном примере с помощью оператора * мы получим то значение, которое находится в памяти по адресу p. Затем мы сохраним его в переменную y. В итоге получится, что значения x и y совпадают.
Все это несложно увидеть на экране.
#include <stdio.h> int main(void) { int x; int y; int *p; x = 13; y = 0; p = &x; y = *p; printf("Value of xt%d", x); printf("Address of xt%p", &x); printf("n"); printf("Value of pt%p", p); printf("Address of pt%p", &p); printf("n"); printf("Value of yt%d", y); printf("Address of yt%p", &y); printf("n"); return 0; }
В указанном примере значение x и y будут одинаковы. А также адрес x и значение p.
Адресная арифметика
К указателям можно прибавлять числа. Из указателей можно вычитать числа. На основе этого сделана адресация в массиве. Этот код показывает несколько важных вещей.
int array[5] = {1, 2, 3, 4, 5}; int *p = &array[0]; p++;
Первая строка простая и понятая. Объявлен массив и заполнен числами от 1 до 5.
Во второй строке объявляется указатель на int и ему присваивается адрес нулевого элемента массива. Некоторые компиляторы разрешают писать такое присвоение так, считая, что имя массива означает адрес его нулевого элемента.
int *p = array;
Но если вы хотите избежать неоднозначности, пишите явно. Таким образом в p лежит адрес начала массива. А конструкция *p даст 1.
Третья строчка увеличивает значение p. Но не просто на 1, а на 1 * sizeof(int). Пусть в данной системе int занимает 4 байта. После увеличения p на 1, p указывает не на следующий байт, а на первый байт из следующей четверки байтов. Программисту не нужно думать в данном случае о размере типа.
С вычитанием ситуация такая же.
Последний важный момент этого кода в том, как преобразуется обращение к элементу массива. Имя массива — это указатель на его начало. Точка отсчета. Индекс, переданный в квадратных скобках, — смещение относительно начала массива.
Конструкция array[i] будет преобразована компилятором к *(array + i). К начальному адресу массива будет прибавлено число с учетом размерности типа данных. А затем будет взято значение по вычисленному адресу. Обратите внимание, что никто не запрещает написать и так i[array]. Ведь конструкция будет преобразована к виду...
С указателем можно складывать число, представленное переменной или целочисленной константой. Вычесть можно не только число, но и указатель из указателя. Это бывает полезно. А вот сложить два указателя, умножить или разделить указатель на число или на другой указатель — нельзя.
С указателями разных типов нельзя обходиться легкомысленно.
char c[10]; char *pc = &c[0]; int *pi = pc;
С точки зрения языка C все корректно. А вот в C++ будет ошибка, потому что типы указателей не совпадают.
int *pi = (int*)pc;
Вот такая конструкция будет принята C++.
Небольшое резюме.
int x; //объявление переменной целого типа int *p; //объявление указателя на переменную целого типа p = &x; //присвоить p адрес переменной x x = *p; //присвоить x значение, которое находится по адресу, сохраненному в p
Применение указателей
Обычно функция возвращает одно значение. А как вернуть больше одного? Рассмотрим код функции, которая меняет местами две переменные.
int swap(double a, double b) { double temp = a; a = b; b = temp; }
Пусть есть переменные x и y с некоторыми значениями. Если выполнить функцию, передав в нее x и y, окажется, что никакого обмена не произошло. И это правильно.
При вызове этой функции в стеке будут сохранены значения x и y. Далее a и b получат значения x и y. Будет выполнена перестановка. Затем функция завершится и значения x и y будут восстановлены из стека. Все по-честному.
Чтобы заставить функцию работать так, как нужно, следует передавать в нее не значения переменных x и y, а их адреса. Но и саму функцию тогда нужно адаптировать для работы с адресами.
void swap(double* a, double* b) { double temp = *a; *a = *b; *b = temp; }
Не стоить забывать о том, что и вызов функции теперь должен выглядеть иначе.
swap(&x, &y);
Теперь в функцию передаются адреса. И работа ведется относительно переданных адресов.
Если функция должна вернуть несколько значений, необходимо передавать в нее адреса.
Если функция должна менять значение переменной, нужно передавать ей адрес этой переменной.
У тех, кто только начинает программировать на C, есть одна распространенная ошибка. При вводе с клавиатуры с помощью функции scanf() они передают значение переменной, а не ее адрес. А ведь scanf() должна менять значение переменной.
Еще один важный случай, когда указатели крайне полезны — это передача большого объема данных.
Немного посчитаем.
Пусть нам нужно передать в функцию целое число типа int. Таким образом мы передаем в функцию sizeof(int) байт. Обычно это 4 байта (размер будет зависеть от архитектуры компьютера и компилятора). 4 байта — не так много. 4 байта уйдут в стек. Потому что имеет место передача по значению.
Теперь нам нужно передать 10 таких переменных. Это уже 40 байт. Тоже невелика задача.
Вообразим себя проектировщиками Большого Адронного Коллайдера. Вы отвечаете за безопасность системы. Именно вас окружают люди с недобрыми взглядами и факелами. Нужно показать им на модели, что конца света не будет. Для этого нужно передать в функцию collaiderModel(), скажем, 1 Гб данных. Представляете, сколько информации будет сохранено в стек? А скорее всего программа не даст вам стек такого объема без специальных манипуляций.
Когда нужно передать большой объем данных, его передают не копированием, а по адресу. Все массивы, даже из одного элемента, передаются по адресу.
Указатели — это мощный инструмент. Указатели эффективны и быстры, но не слишком безопасны. Потому как вся ответственность за их использования ложится на разработчика. Разработчик — человек. А человеку свойственно ошибаться.
Представим ситуацию.
int x; int *p;
В большинстве компиляторов C и С++ неинициализированные локальные переменные имеют случайное значение. Глобальные обнуляются.
Если мы захотим разыменовать указатель и присвоить ему значение, скорее всего, будет ошибка.
*p = 10;
Неинициализированный указатель p хранит случайный адрес. Мы честно можем попытаться получить значение по этому адресу и что-то туда записать. Но совсем не факт, что нам можно что-то делать с памятью по этому адресу.
Указатели можно и нужно обнулять. Для этого есть специальное значение NULL.
int *p = NULL;
Это запись больше соответствует стилю C. В C++ обычно можно инициализировать указатель нулем.
int *p = 0;
Ловкость рук и никакого мошенничества. На самом деле, если изучить библиотечные файлы языка, можно найти определение для NULL.
#define NULL (void*)0
Для C NULL — это нуль, приведенный к указателю на void. Для C++ все немного не так. Стандарт говорит: «The macro NULL is an implementation-defined C++ null pointer constant in this International Standard. Possible definitions include 0 and 0L, but not (void*)0». То есть это просто 0 или 0, приведенный к long.
Предлагаю вам такую задачку. Папа Карло дал Буратино 5 яблок. Злой Карабас Барабас отобрал 3 яблока. Сколько яблок осталось у Буратино?
Ответ: неизвестно. Так как нигде не сказано, сколько яблок у Буратино было изначально.
Мораль: обнуляйте переменные.
Ссылки
В языке C++ появился новый механизм работы с переменными — ссылки. Функция swap() была хороша, только не слишком удобно применять разыменование. С помощью ссылок функция swap() может выглядеть аккуратнее.
#include <stdio.h> void swap(double& a, double& b) { double temp = a; a = b; b = temp; }
А вызов функции тогда будет уже без взятия адреса переменных.
swap(x, y);
Конструкция double& объявляет ссылку на переменную типа double. При таком объявлении функции в стек будут положены не значения переменных, а их адреса.
Ссылка — это указатель, с которым можно работать, как с обычной переменной.
Ссылка не может быть равна NULL. Указатель может. Ссылка не может быть непроинициализирована. Указатель может.
Для взятия адреса переменной и для объявления ссылки используется одинаковый символ — амперсанд. Но в случае взятия адреса & стоит в выражении, перед именем переменной. А в случае объявления ссылки — в объявлении, после объявления типа.
Использование ссылок и указателей — это очень широкая тема. Описание основ на этом закончим.
За мысли и замечания спасибо Юрию Борисову, @vkirkizh, @vitpetrov.
Комментарии
Чумаров Илья
Спасибо большое!
Дима
Спасибо...очень помогло, долго пытаюсь раз и навсегда разобраться с этой темой,после прочтения вроде бы все уже окончательно стало на свои места.
Влад
Благодарю вас за столь замечательный и хороший блог!
ЖЕНЯ
Отлично
Виктор
Утверждается, что ссылки относятся именно к С++, а не к С. Даже вики так говорит. Вопос: почему тогда gcc их совершенно спокойно компилирует? И верно ли, что в чистом С запись int& х должна являтся не корректной?
Статься отличная, разговорно написана, но висьма глубоко вникая в вопросы!
Антон Куликов
Было бы неплохо пояснить еще о физической адресации памяти, указав на отличия с указателями Си. Чтобы не было путаницы. Особенно момент с байтом, имеющий адрес 0.
Алейник Андрей
"
int swap(double a, double b) {
double temp = a;
a = b;
b = temp;
}
"
А Вы не забыли return случаем (или тип возвращаемого функцией значения сделать void.
Александр
Спасибо огромное за статью, все просто, понятно и доступно!
Игорь
Спасибо! Очень доступно!!
Григорий
void swap(double* a, double* b) {
double temp = *a;
*a = *b;//1
*b = temp;//2
}
в первом случае присвоение объекту,другого объекта.
во втором присвоение объекту ,значение ,которое хранилось в другом объекте ?правильно понимаю?
Олег
Большое спасибо за материал. Мне, как начинающему изучать С++, было крайне полезно прочитать то, что я не слишком хорошо понял в книге.
Ал ле
Спасибо! Очень внятно и доступно!
Sir_Veldosya
ВЫ МНЕ ПАМАГЛИ СПОСосиба ета було самае найкраще ликция
Юра
Спасибо огромное!
Ник
очень здорово про указатели. но мысль оборвалась на полуслове на ссылках. Если "double& объявляет ссылку на переменную типа double. При таком объявлении функции в стек будут положены не значения переменных, а их адреса. ", то в строке "double temp = a;" в temp записывается адрес "a", но тип переменной при этом не указатель, а просто double. Вопрос: почему?
Евгений Медведев
Здравствуйте!!!
Заранее спасибо за помощь!!!
А как вывести на экран содержимое массива с помощью ссылок??? С помощью указателей???.
Таисия К
Спасибо за хорошее и доступное объяснение! )
NiOl
Спасибо!
Есть вопрос по последнему примеру "void swap(double& a, double& b)":
внутри функции мы оперируем с непосредственным содержимым "a" и "b", или меняем адреса, где расположены "a" и "b"?
aлександр бородин
большое спасибо , доходит наконец!