Что такое адрес памяти в c

Обновлено: 04.07.2024

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

Для определения указателя надо указать тип объекта, на который указывает указатель, и символ звездочки *. Например, определим указатель на объект типа int:

Пока указатель не ссылается ни на какой объект. Теперь присвоим ему адрес переменной:

Указатель хранит адрес объекта в памяти компьютера. И для получения адреса к переменной применяется операция & . Эта операция применяется только к таким объектам, которые хранятся в памяти компьютера, то есть к переменным и элементам массива.

Что важно, переменная x имеет тип int, и указатель, который указывает на ее адрес тоже имеет тип int. То есть должно быть соответствие по типу.

Какой именно адрес имеет переменная x? Для вывода значения указателя можно использовать специальный спецификатор %p :

В моем случае машинный адрес переменной x - 0060FEA8. Но в каждом отдельном случае адрес может быть иным. Фактически адрес представляет целочисленное значение, выраженное в шестнадцатеричном формате.

То есть в памяти компьютера есть адрес 0x0060FEA8, по которому располагается переменная x. Так как переменная x представляет тип int , то на большинстве архитектур она будет занимать следующие 4 байта (на конкретных архитектурах размер памяти для типа int может отличаться). Таким образом, переменная типа int последовательно займет ячейки памяти с адресами 0x0060FEA8, 0x0060FEA9, 0x0060FEAA, 0x0060FEAB.

Указатели в Си

И указатель p будет ссылаться на адрес, по которому располагается переменная x, то есть на адрес 0x0060FEA8.

Но так как указатель хранит адрес, то мы можем по этому адресу получить хранящееся там значение, то есть значение переменной x. Для этого применяется операция * или операция разыменования, то есть та операция, которая применяется при определении указателя. Результатом этой операции всегда является объект, на который указывает указатель. Применим данную операцию и получим значение переменной x:

Используя полученное значение в результате операции разыменования мы можем присвоить его другой переменной:

И также используя указатель, мы можем менять значение по адресу, который хранится в указателе:

Так как по адресу, на который указывает указатель, располагается переменная x, то соответственно ее значение изменится.

Создадим еще несколько указателей:

В моем случае я получу следующий консольный вывод:

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

На данном уроке мы рассмотрим интересную тему. Это указатели и адреса.

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

Вернее, мы, конечно же, данную тему не рассмотрим. Мы её только легонько коснёмся. Рассмотреть в рамках одного урока такую серьёзную тему не получится. По ней будет как минимум 4 урока. Мы лишь проведём сегодня первое знакомство. Но это будет самое главное. Так как понимание указателей и адресов начинается именно здесь.

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

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

Также, думаю, практически все в курсе, что у меня есть некоторое количество уроков по приобретающим всё большую популярность микроконтроллерам. Вот там как раз мы работаем напрямую с памятью физической и в отладке видим именно её. Но это не важно. Урок данный будет вполне актуален и для МК. Нам же абсолютно нет дела до того, какую память мы видим. А видим мы именно ту память, с которой мы работаем.

Организована память, предоставленная программе, примерно вот таким образом


У каждой ячейки памяти существует адрес.

Почему адреса следуют друг за другом не подряд, а пропускаются по 4 байта?

Можно конечно показать и подряд, но я показал именно с учётом того, что мы пишем приложение под 32-разрядную систему. Хотя у нас практически у большинства установлены операционные системы 64-разрядные, но приложения, написанные под 32-битные системы, там прекрасно работают. У нас даже компилятор mingw предназначен для 32-разрядных систем. Пока мы будем писать с расчётом именно на 32-разрядные системы, так как, во-первых, они легче для понимания, во-вторых, из соображений совместимости, так как 32-рязрядные системы пока ещё существуют и хочется, чтобы наша программа запускалась и прекрасно работала и на них. Также у нас получается то, что наш урок актуален и для 32-разрядных МК, например для тех же stm32.

Так вот к чему я это всё?

А к тому, что считаю, что удобнее работать также с 32-битными ячейками памяти и зачастую к ним идёт приравнивание.

Ну что ж. Представим, что мы объявили переменную типа int, которая скорей всего занимает в памяти 32 бита (но не факт, это иногда проверять надо), назвав её, например a. Затем присвоили ей какое-то значение. В данном случае нам операционная система выделит какую-то ячейку памяти, ну пусть, например вот эту


Теперь у нашей переменной появился адрес, так как операционная система предоставила ей определённую ячейку памяти. Операционная система не знает имя этой переменной, оно ей не нужно, даже и процессор при выполнении программы не знает имени переменной, он работает с её значением и обращается к ней именно по адресу. А как же мы можем узнать адрес этой переменной?

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

Чтобы нам как-то запомнить адрес нашей переменной, то есть тот, который в нашем случае 0x0061FF14, то мы можем создать для нашей переменной указатель.

Для этого мы можем заранее объявить переменную-указатель типа int. Указатель такого типа будет указывать на переменные именно такого типа.

Объявляется переменная-указатель какого-либо типа данных вот таким образом


Как это всё будет выглядеть практически?

Сначала давайте объявим нашу обычную переменную a и присвоим ей какое-нибудь значение. Пусть она будет даже беззнаковая

unsigned int a;

a = 0xFE340056;

А теперь мы объявим переменную-указатель такого же типа

unsigned int *p_a;

Имя нашей переменной p_a. Я специально дал такое имя, чтобы показать то, что указатель будет у нас хранить адрес переменной a. Пока мы этот указатель только объявили, мы ему не присваивали никаких адресов. Это обычная переменная, которой также выделена ячейка памяти, но она пока не хранит никакой информации, пока там какое-то случайное значение.

Вообщем, в памяти у нас пока примерно вот такая картина


То есть, у нас есть переменная типа указателя на unsigned int, которая пока не хранит практически ничего, а переменная a имеет уже значение, хранящееся по определённому адресу.

Как же присвоить адрес нашей переменной a нашему указателю p_a?

А делается это вот таким вот образом.

Существует специальная операция, называемая операцией взятия адреса.

Оператор данной операции выглядит в виде амперсанда


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


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

Давайте теперь присвоим адрес нашей переменной a переменной-указателю p_a

p_a = &a;

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

Вот теперь переменная p_a хранит в себе адрес переменной a


Получается, что p_a знает теперь адрес a. То есть если, как в жизни, у нас есть такой человек, который знает адрес другого человека.

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

Ну конечно же любой ответит: Да как! Спросить у него надо!

Ну это в жизни. А здесь как? Да вообще элементарно. У нас p_a его и хранит, он является его значением. Просто присваиваем значение этого p_a любым переменным.

Тогда будет другой вопрос. А можем ли мы узнать у p_a не адрес, а значение нашей переменной a, адрес которой он хранит? Ну это типа спросить у человека, знающего адрес человека: А не знаешь ли ты, сколько лет другому человеку?

Ответ: мы это также можем сделать. Для этого мы должны произвести операцию разыменования указателя p_a.

Для разыменования у нас существует вот такой оператор


Нет, не прикалывается. Опять эта звёздочка!

Но здесь она уже служит унарным оператором разыменования указателя, который используется вот таким образом


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

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

Поэтому, чтобы получить значение нашей переменной a, используя её указатель, мы можем сделать, например вот так

unsigned int b = *p_a;

Теперь переменная b получит значение переменной a, которое мы взяли у указателя на эту переменную, применив операцию разыменования.

С помощью разыменования мы можем также и изменить значение переменной a, используя указатель p_a, который хранит её адрес

*p_a = 0xE2222222;

Ну конечно же можем!

Указатель на указатель мы можем объявить с помощью двух звёздочек, вот таким вот образом

unsigned int **p_p_a;

Наш новый указатель теперь тоже получит своё место в памяти


Только этот указатель пока ни на что не показывает, то есть он пока не хранит никаких адресов.

И теперь мы вот таким образом присваиваем адрес указателя p_a, который указывает на переменную a, нашему новому указателю

p_p_a = &p_a;

Получится теперь у нас вот такая картина в памяти


То есть теперь p_p_a хранит адрес p_a, который в свою очередь хранит адрес a.

Вообщем какой-то человек знает адрес другого человека, который в свою очередь знает адрес третьего человека.

Ну и тогда резонный вопрос: а можем ли мы у этого человека узнать возраст третьего человека, ведь он знает адрес другого человека, который знает адрес третьего человека и который может спросить у третьего человека его возраст и сказать первому человеку?

Ну конечно можем. И причём в нашем случае программирования на языке C всё гораздо проще.

Мы также используем две звёздочки в операции разыменования

unsigned int b = **p_p_a;

У нас получилось как бы двойное разыменование. Мы разыменовали адрес указателя p_a, получив его значение, являющееся адресом a и второй звёздочкой разыменовали и этот адрес, получив значение уже самой переменной a.

Вот так. Теперь, надеюсь, к вам немного пришло понимание физики адресов. У адресов существует также ещё и арифметика, но об этом в другом уроке.

Теперь давайте немного поговорим о массивах.

Например существует у нас массив данных типа unsigned char, мы его объявили и сразу проинициализировали

Данный массив также занял в памяти какое-то место и у каждого элемента массива теперь есть свои адреса в памяти


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

А как же получить указатель на массив, ну, то есть на то место с которого он начинается, на первый его элемент, вернее, если быть точным, на нулевой?

А, в принципе, его получать-то особо не всегда надо, так как имя массива и есть указатель на массив. Вот такая интересная получается история.

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

unsigned char *p_uch = &uch[0];

Мы в данном случае получаем адрес его нулевого элемента и присваиваем его переменной-указателю такого же типа, как и наш массив.

Теперь переменная p_uch хранит в себе адрес массива, то есть является указателем на массив uch


Здесь конечно же возникнуть может ещё один резонный вопрос. Почему указатель на наш массив занял в памяти 4 ячейки, а, следовательно, и 4 байта?

А потому, что указатель хоть и является указателем на тип unsigned char, но сама переменная-указатель всегда будет 32-битной, так как по-другому и не получится. Каким образом мы в 8 битах можем хранить число 0x0061FF11? Да никаким, вот поэтому и 4 ячейки.

Причём, наш указатель на массив, который мы только что создали, мы также можем использовать как имя нашего же массива. Если мы, например обратимся с помощью выражения p_uch[5], то мы получим имя 5 элемента массива uch. Это конечно уже тема не данного урока, а больше арифметика указателей, но тем не менее мы можем это делать.

В следующей части нашего урока мы отработаем все изученные в данной части урока приёмы с указателями и адресами на практике.

В этом посте я постараюсь окончательно разобрать такие тонкие понятия в C и C++, как указатели, ссылки и массивы. В частности, я отвечу на вопрос, так являются массивы C указателями или нет.

  • Я буду предполагать, что читатель понимает, что, например, в C++ есть ссылки, а в C — нет, поэтому я не буду постоянно напоминать, о каком именно языке (C/C++ или именно C++) я сейчас говорю, читатель поймёт это из контекста;
  • Также, я предполагаю, что читатель уже знает C и C++ на базовом уровне и знает, к примеру, синтаксис объявления ссылки. В этом посте я буду заниматься именно дотошным разбором мелочей;
  • Буду обозначать типы так, как выглядело бы объявление переменной TYPE соответствующего типа. Например, тип «массив длины 2 int'ов» я буду обозначать как int TYPE[2] ;
  • Я буду предполагать, что мы в основном имеем дело с обычными типами данных, такими как int TYPE , int *TYPE и т. д., для которых операции =, &, * и другие не переопределены и обозначают обычные вещи;
  • «Объект» всегда будет означать «всё, что не ссылка», а не «экземпляр класса»;
  • Везде, за исключением специально оговоренных случаев, подразумеваются C89 и C++98.

Указатели. Что такое указатели, я рассказывать не буду. :) Будем считать, что вы это знаете. Напомню лишь следующие вещи (все примеры кода предполагаются находящимися внутри какой-нибудь функции, например, main):



Также напомню следующее: char — это всегда ровно один байт и во всех стандартах C и C++ sizeof (char) == 1 (но при этом стандарты не гарантируют, что в байте содержится именно 8 бит :)). Далее, если прибавить к указателю на какой-нибудь тип T число, то реальное численное значение этого указателя увеличится на это число, умноженное на sizeof (T) . Т. е. если p имеет тип T *TYPE , то p + 3 эквивалентно (T *)((char *)p + 3 * sizeof (T)) . Аналогичные соображения относятся и к вычитанию.

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


Если слева от знака присваивания стоит ссылка, то нет никакого способа понять, хотим мы присвоить самой ссылке или объекту, на который она ссылается. Поэтому такое присваивание всегда присваивает объекту, а не ссылке. Но это не относится к инициализации ссылки: инициализируется, разумеется, сама ссылка. Поэтому после инициализации ссылки нет никакого способа изменить её саму, т. е. ссылка всегда постоянна (но не её объект).

Lvalue. Те выражения, которым можно присваивать, называются lvalue в C, C++ и многих других языках (это сокращение от «left value», т. е. слева от знака равенства). Остальные выражения называются rvalue. Имена переменных очевидным образом являются lvalue, но не только они. Выражения a[i + 2] , some_struct.some_field , *ptr , *(ptr + 3) — тоже lvalue.

Удивительный факт состоит в том, что ссылки и lvalue — это в каком-то смысле одно и то же. Давайте порассуждаем. Что такое lvalue? Это нечто, чему можно присвоить. Т. е. это некое фиксированное место в памяти, куда можно что-то положить. Т. е. адрес. Т. е. указатель или ссылка (как мы уже знаем, указатели и ссылки — это два синтаксически разных способа в C++ выразить понятие адреса). Причём скорее ссылка, чем указатель, т. к. ссылку можно поместить слева от знака равенства и это будет означать присваивание объекту, на который указывает ссылка. Значит, lvalue — это ссылка.

А что такое ссылка? Это один из синтаксисов для адреса, т. е., опять-таки, чего-то, куда можно класть. И ссылку можно ставить слева от знака равенства. Значит, ссылка — это lvalue.

Окей, но ведь (почти любая) переменная тоже может быть слева от знака равенства. Значит, (такая) переменная — ссылка? Почти. Выражение, представляющее собой переменную — ссылка.

Иными словами, допустим, мы объявили int x . Теперь x — это переменная типа int TYPE и никакого другого. Это int и всё тут. Но если я теперь пишу x + 2 или x = 3 , то в этих выражениях подвыражение x имеет тип int &TYPE . Потому что иначе этот x ничем не отличался бы от, скажем, 10, и ему (как и десятке) нельзя было бы ничего присвоить.

Этот принцип («выражение, являющееся переменной — ссылка») — моя выдумка. Т. е. ни в каком учебнике, стандарте и т. д. я этот принцип не видел. Тем не менее, он многое упрощает и его удобно считать верным. Если бы я реализовывал компилятор, я бы просто считал там переменные в выражениях ссылками, и, вполне возможно, именно так и предполагается в реальных компиляторах.

Более того, удобно считать, что особый тип данных для lvalue (т. е. ссылка) существует даже и в C. Именно так мы и будет дальше предполагать. Просто понятие ссылки нельзя выразить синтаксически в C, ссылку нельзя объявить.

Принцип «любое lvalue — ссылка» — тоже моя выдумка. А вот принцип «любая ссылка — lvalue» — вполне законный, общепризнанный принцип (разумеется, ссылка должна быть ссылкой на изменяемый объект, и этот объект должен допускать присваивание).

Теперь, с учётом наших соглашений, сформулируем строго правила работы со ссылками: если объявлено, скажем, int x , то теперь выражение x имеет тип int &TYPE . Если теперь это выражение (или любое другое выражение типа ссылка) стоит слева от знака равенства, то оно используется именно как ссылка, практически во всех остальных случаях (например, в ситуации x + 2 ) x автоматически конвертируется в тип int TYPE (ещё одной операцией, рядом с которой ссылка не конвертируется в свой объект, является &, как мы увидим далее). Слева от знака равенства может стоять только ссылка. Инициализировать (неконстантную) ссылку может только ссылка.

Операции * и &. Наши соглашения позволяют по-новому взглянуть на операции * и &. Теперь становится понятно следующее: операция * может применяться только к указателю (конкретно это было всегда известно) и она возвращает ссылку на тот же тип. & применяется всегда к ссылке и возвращает указатель того же типа. Таким образом, * и & превращают указатели и ссылки друг в друга. Т. е. по сути они вообще ничего не делают и лишь заменяют сущности одного синтаксиса на сущности другого! Таким образом, & вообще-то не совсем правильно называть операцией взятия адреса: она может быть применена лишь к уже существующему адресу, просто она меняет синтаксическое воплощение этого адреса.

Замечу, что указатели и ссылки объявляются как int *x и int &x . Таким образом, принцип «объявление подсказывает использование» лишний раз подтверждается: объявление указателя напоминает, как превратить его в ссылку, а объявление ссылки — наоборот.

Также замечу, что &*EXPR (здесь EXPR — это произвольное выражение, не обязательно один идентификатор) эквивалентно EXPR всегда, когда имеет смысл (т. е. всегда, когда EXPR — указатель), а *&EXPR тоже эквивалентно EXPR всегда, когда имеет смысл (т. е. когда EXPR — ссылка).

Итак, есть такой тип данных — массив. Определяются массивы, например, так:

Выражение в квадратных скобках должно быть непременно константой времени компиляции в C89 и C++98. При этом в квадратных скобках должно стоять число, пустые квадратные скобки не допускаются.

Подобно тому, как все локальные переменные (напомню, мы предполагаем, что все примеры кода находятся внутри функций) находятся на стеке, массивы тоже находятся на стеке. Т. е. приведённый код привёл к выделению прямо на стеке огромного блока памяти размером 5 * sizeof (int) , в котором целиком размещается наш массив. Не нужно думать, что этот код объявил некий указатель, который указывает на память, размещённую где-то там далеко, в куче. Нет, мы объявили массив, самый настоящий. Здесь, на стеке.

Чему будет равно sizeof (x) ? Разумеется, оно будет равно размеру нашего массива, т. е. 5 * sizeof (int) . Если мы пишем

то, опять-таки, место для массива будет целиком выделяться прямо внутри структуры, и sizeof от этой структуры будет это подтверждать.

От массива можно взять адрес ( &x ), и это будет самый настоящий указатель на то место, где этот массив расположен. Тип у выражения &x , как легко понять, будет int (*TYPE)[5] . В начале массива размещён его нулевой элемент, поэтому адрес самого массива и адрес его нулевого элемента численно совпадают. Т. е. &x и &(x[0]) численно равны (тут я лихо написал выражение &(x[0]) , на самом деле в нём не всё так просто, к этому мы ещё вернёмся). Но эти выражения имеют разный тип — int (*TYPE)[5] и int *TYPE , поэтому сравнить их при помощи == не получится. Но можно применить трюк с void * : следующее выражение будет истинным: (void *)&x == (void *)&(x[0]) .

Хорошо, будем считать, я вас убедил, что массив — это именно массив, а не что-нибудь ещё. Откуда тогда берётся вся эта путаница между указателями и массивами? Дело в том, что имя массива почти при любых операциях преобразуется в указатель на его нулевой элемент.

Итак, мы объявили int x[5] . Если мы теперь пишем x + 0 , то это преобразует наш x (который имел тип int TYPE[5] , или, более точно, int (&TYPE)[5] ) в &(x[0]) , т. е. в указатель на нулевой элемент массива x. Теперь наш x имеет тип int *TYPE .

Конвертирование имени массива в void * или применение к нему == тоже приводит к предварительному преобразованию этого имени в указатель на первый элемент, поэтому:

  • x[2] эквивалентно *(x + 2)
  • x + 2 относится к тем операциям, при которых имя массива преобразуется в указатель на его первый элемент, поэтому это происходит
  • Далее, в соответствии с моими объяснениями выше, x + 2 эквивалентно (int *)((char *)x + 2 * sizeof (int)) , т. е. x + 2 означает «сдвинуть указатель x на два int'а»
  • Наконец, от результата берётся операция разыменования и мы извлекаем тот объект, который размещён по этому сдвинутому указателю

Типы у участвовавших выражений следующие:


Также замечу, что слева от квадратных скобок необязательно должен стоять именно массив, там может быть любой указатель. Например, можно написать (x + 2)[3] , и это будет эквивалентно x[5] . Ещё замечу, что *a и a[0] всегда эквивалентны, как в случае, когда a — массив, так и когда a — указатель.

Теперь, как я и обещал, я возвращаюсь к &(x[0]) . Теперь ясно, что в этом выражении сперва x преобразуется в указатель, затем к этому указателю в соответствии с вышеприведённым алгоритмом применяется [0] и в результате получается значение типа int &TYPE , и наконец, при помощи & оно преобразуется к типу int *TYPE . Поэтому, объяснять при помощи этого сложного выражения (внутри которого уже выполняется преобразование массива к указателю) немного более простое понятие преобразования массива к указателю — это был немного мухлёж.

А теперь вопрос на засыпку: что такое &x + 1 ? Что ж, &x — это указатель на весь массив целиком, + 1 приводит к шагу на весь этот массив. Т. е. &x + 1 — это (int (*)[5])((char *)&x + sizeof (int [5])) , т. е. (int (*)[5])((char *)&x + 5 * sizeof (int)) (здесь int (*)[5] — это int (*TYPE)[5] ). Итак, &x + 1 численно равно x + 5 , а не x + 1 , как можно было бы подумать. Да, в результате мы указываем на память, которая находится за пределами массива (сразу после последнего элемента), но кого это волнует? Ведь в C всё равно не проверяется выход за границы массива. Также, заметим, что выражение *(&x + 1) == x + 5 истинно. Ещё его можно записать вот так: (&x)[1] == x + 5 . Также будет истинным *((&x)[1]) == x[5] , или, что тоже самое, (&x)[1][0] == x[5] (если мы, конечно, не схватим segmentation fault за попытку обращения за пределы нашей памяти :)).

Массив нельзя передать как аргумент в функцию. Если вы напишите int x[2] или int x[] в заголовке функции, то это будет эквивалентно int *x и в функцию всегда будет передаваться указатель (sizeof от переданной переменной будет таким, как у указателя). При этом размер массива, указанный в заголовке будет игнорироваться. Вы запросто можете указать в заголовке int x[2] и передать туда массив длины 3.

Однако, в C++ существует способ передать в функцию ссылку на массив:

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

И что самое интересное, эту передачу можно использовать так:

Похожим образом реализована функция std::end в C++11 для массивов.

«Указатель на массив». Строго говоря, «указатель на массив» — это именно указатель на массив и ничто другое. Иными словами:

Однако, иногда под фразой «указатель на массив» неформально понимают указатель на область памяти, в которой размещён массив, даже если тип у этого указателя неподходящий. В соответствии с таким неформальным пониманием c и d (и b + 0 ) — это указатели на массивы.

Многомерные массивы. Если объявлено int x[5][7] , то x — это не массив длины 5 неких указателей, указывающих куда-то далеко. Нет, x теперь — это единый монолитный блок размером 5 x 7, размещённый на стеке. sizeof (x) равен 5 * 7 * sizeof (int) . Элементы располагаются в памяти так: x[0][0] , x[0][1] , x[0][2] , x[0][3] , x[0][4] , x[0][5] , x[0][6] , x[1][0] и так далее. Когда мы пишем x[0][0] , события развиваются так:

То же самое относится к **x . Замечу, что в выражениях, скажем, x[0][0] + 3 и **x + 3 в реальности извлечение из памяти происходит только один раз (несмотря на наличие двух звёздочек), в момент преобразования окончательной ссылки типа int &TYPE просто в int TYPE . Т. е. если бы мы взглянули на ассемблерный код, который генерируется из выражения **x + 3 , мы бы в нём увидели, что операция извлечения данных из памяти выполняется там только один раз. **x + 3 можно ещё по-другому записать как *(int *)x + 3 .

А теперь посмотрим на такую ситуацию:


Что теперь есть y? y — это указатель на массив (в неформальном смысле!) указателей на массивы (опять-таки, в неформальном смысле). Нигде здесь не появляется единый блок размера 5 x 7, есть 5 блоков размера 7 * sizeof (int) , которые могут находиться далеко друг от друга. Что есть y[0][0] ?

Теперь, когда мы пишем y[0][0] + 3 , извлечение из памяти происходит два раза: извлечение из массива y и последующее извлечение из массива y[0] , который может находиться далеко от массива y. Причина этого в том, что здесь не происходит преобразования имени массива в указатель на его первый элемент, в отличие от примера с многомерным массивом x. Поэтому **y + 3 здесь не эквивалентен *(int *)y + 3 .

Объясню ещё разок. x[2][3] эквивалентно *(*(x + 2) + 3) . И y[2][3] эквивалентно *(*(y + 2) + 3) . Но в первом случае наша задача найти «третий элемент во втором ряду» в едином блоке размера 5 x 7 (разумеется, элементы нумеруются с нуля, поэтому этот третий элемент будет в некотором смысле четвёртым :)). Компилятор вычисляет, что на самом деле нужный элемент находится на 2 * 7 + 3 -м месте в этом блоке и извлекает его. Т. е. x[2][3] здесь эквивалентно ((int *)x)[2 * 7 + 3] , или, что то же самое, *((int *)x + 2 * 7 + 3) . Во втором случае сперва извлекает 2-й элемент в массиве y, а затем 3-й элемент в полученном массиве.

В первом случае, когда мы делаем x + 2 , мы сдвигаемся сразу на 2 * sizeof (int [7]) , т. е. на 2 * 7 * sizeof (int) . Во втором случае, y + 2 — это сдвиг на 2 * sizeof (int *) .

В первом случае (void *)x и (void *)*x (и (void *)&x !) — это один и тот же указатель, во втором — это не так.

Оператор адреса &

При выполнении инициализации переменной, ей автоматически присваивается свободный адрес памяти, и, любое значение, которое мы присваиваем переменной, сохраняется по этому адресу в памяти. Например:

При выполнении этого стейтмента процессором, выделяется часть оперативной памяти. В качестве примера предположим, что переменной b присваивается ячейка памяти под номером 150. Всякий раз, когда программа встречает переменную b в выражении или в стейтменте, она понимает, что для того, чтобы получить значение — ей нужно заглянуть в ячейку памяти под номером 150.

Хорошая новость — нам не нужно беспокоиться о том, какие конкретно адреса памяти выделены для определенных переменных. Мы просто ссылаемся на переменную через присвоенный ей идентификатор, а компилятор конвертирует это имя в соответствующий адрес памяти. Однако этот подход имеет некоторые ограничения, которые мы обсудим на этом и следующих уроках.

Оператор адреса & позволяет узнать, какой адрес памяти присвоен определенной переменной. Всё довольно просто:

std :: cout << & a << '\n' ; // выводим адрес памяти переменной a

Результат на моем компьютере:

Примечание: Хотя оператор адреса выглядит так же, как оператор побитового И, отличить их можно по тому, что оператор адреса является унарным оператором, а оператор побитового И — бинарным оператором.

Оператор разыменования *

Оператор разыменования * позволяет получить значение по указанному адресу:

std :: cout << * & a << '\n' ; /// выводим значение ячейки памяти переменной a

Результат на моем компьютере:

Указатели

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

int * iPtr3 ; // корректный синтаксис (допустимый, но не желательный) int * iPtr5 , * iPtr6 ; // объявляем два указателя для переменных типа int

Синтаксически язык C++ принимает объявление указателя, когда звёздочка находится рядом с типом данных, с идентификатором или даже посередине. Обратите внимание, эта звёздочка не является оператором разыменования. Это всего лишь часть синтаксиса объявления указателя.

Однако, при объявлении нескольких указателей, звёздочка должна находиться возле каждого идентификатора. Это легко забыть, если вы привыкли указывать звёздочку возле типа данных, а не возле имени переменной. Например:

int * iPtr3 , iPtr4 ; // iPtr3 - это указатель на значение типа int, а iPtr4 - это обычная переменная типа int!

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

Присваивание значений указателю

int * ptr = & value ; // инициализируем ptr адресом значения переменной

Приведенное выше можно проиллюстрировать следующим образом:


Вот почему указатели имеют такое имя: ptr содержит адрес значения переменной value , и, можно сказать, ptr указывает на это значение.

Еще очень часто можно увидеть следующее:

int * ptr = & value ; // инициализируем ptr адресом значения переменной std :: cout << & value << '\n' ; // выводим адрес значения переменной value std :: cout << ptr << '\n' ; // выводим адрес, который хранит ptr

Результат на моем компьютере:

Тип указателя должен соответствовать типу переменной, на которую он указывает:

iPtr = & dValue ; // неправильно: указатель типа int не может указывать на адрес переменной типа double dPtr = & iValue ; // неправильно: указатель типа double не может указывать на адрес переменной типа int

Следующее не является допустимым:

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

Язык C++ также не позволит вам напрямую присваивать адреса памяти указателю:

double * dPtr = 0x0012FF7C ; // не ок: рассматривается как присваивание целочисленного литерала

Оператор адреса возвращает указатель

Стоит отметить, что оператор адреса & не возвращает адрес своего операнда в качестве литерала. Вместо этого он возвращает указатель, содержащий адрес операнда, тип которого получен из аргумента (например, адрес переменной типа int передается как адрес указателя на значение типа int):

Результат выполнения программы:

Разыменование указателей

std :: cout << & value << std :: endl ; // выводим адрес value std :: cout << value << std :: endl ; // выводим содержимое value std :: cout << ptr << std :: endl ; // выводим адрес, который хранится в ptr, т.е. &value std :: cout << * ptr << std :: endl ; // разыменовываем ptr (получаем значение на которое указывает ptr)

0034FD90
5
0034FD90
5

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

Одному указателю можно присваивать разные значения:

Когда адрес значения переменной присвоен указателю, то выполняется следующее:

*ptr обрабатывается так же, как и value .

Поскольку *ptr обрабатывается так же, как и value , то мы можем присваивать ему значения так, как если бы это была обычная переменная. Например:

* ptr = 7 ; // *ptr - это то же самое, что и value, которому мы присвоили значение 7

Разыменование некорректных указателей

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

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

int * p ; // создаем неинициализированный указатель (содержимым которого является мусор) foo ( p ) ; // вводим компилятор в заблуждение, будто бы собираемся присвоить указателю корректное значение std :: cout << * p ; // разыменовываем указатель с мусором

Размер указателей

Размер указателя зависит от архитектуры, на которой скомпилирован исполняемый файл: 32-битный исполняемый файл использует 32-битные адреса памяти. Следовательно, указатель на 32-битном устройстве занимает 32 бита (4 байта). С 64-битным исполняемым файлом указатель будет занимать 64 бита (8 байт). И это вне зависимости от того, на что указывает указатель:

В чём польза указателей?

Сейчас вы можете подумать, что указатели являются непрактичными и вообще ненужными. Зачем использовать указатель, если мы можем использовать исходную переменную?

Однако, оказывается, указатели полезны в следующих случаях:

Случай №1: Массивы реализованы с помощью указателей. Указатели могут использоваться для итерации по массиву.

Случай №2: Они являются единственным способом динамического выделения памяти в C++. Это, безусловно, самый распространенный вариант использования указателей.

Случай №3: Они могут использоваться для передачи большого количества данных в функцию без копирования этих данных.

Случай №4: Они могут использоваться для передачи одной функции в качестве параметра другой функции.

Случай №5: Они используются для достижения полиморфизма при работе с наследованием.

Случай №6: Они могут использоваться для представления одной структуры/класса в другой структуре/классе, формируя, таким образом, целые цепочки.

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

Заключение

Совет: При объявлении указателя указывайте звёздочку возле имени переменной.

Задание №1

Какие значения мы получим в результате выполнения следующей программы (предположим, что это 32-битное устройство, и тип short занимает 2 байта):

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

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

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

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

В результате выполнения данного программного кода на экране появляется примерно следующее (шестнадцатеричное число у вас будет другим):

Знак амперсанда (&) перед переменной позволяет получить ее адрес в памяти. Для вывода адреса переменной на экран используется специальный формат %p . Адреса обычных переменных (не указателей) в процессе выполнения программы никогда не меняются. В этом можно убедиться:

Как мы видим, несмотря на то, что значения переменных поменялись, ячейки памяти остались прежними.

Зная адрес, можно получить значение, которое находится по этому адресу, поставив знак * перед адресом:

На экране будет выведено число 8.

Однако запись типа &a не всегда возможна или удобна. Поэтому существует специальный тип данных — указатели, которым и присваивается адрес на область памяти.

Указатели в языке C, как и другие переменные, являются типизированными, т.е. при объявлении указателя надо указывать его тип. Как мы узнаем позже, с указателями можно выполнять некоторые арифметические операции, и именно точное определение их типа позволяет протекать им корректно. Чтобы объявить указатель, надо перед его именем поставить знак *. Например:

Обратите внимание на то, что в данном случае * говорит о том, что объявляется переменная-указатель. Когда * используется перед указателем не при его объявлении, а в выражениях, то обозначает совсем иное — "получить значение (данные) по адресу, который присвоен указателю". Посмотрите на код ниже:

С помощью комментариев указаны текущие значения ячеек памяти. Подробно опишем, что происходит:

  1. Выделяется память под пять переменных: три типа int и два указателя на int . В ячейки x и z записываются числа 1 и 3 соответственно.
  2. Указателю p присваивается адрес ячейки x. Извлекая оттуда значение ( *p ), получаем 1.
  3. В область памяти, которая названа именем у, помещают значение равное содержимому ячейки, на которую ссылается указатель p. В результате имеем две области памяти (x и y), в которые записаны единицы.
  4. В качестве значения по адресу p записываем 0. Поскольку p указывает на x, то значение xменяется. Переменная p не указывает на y, поэтому там остается прежнее значение.
  5. Указателю q присваивается адрес переменной z. Извлекая оттуда значение ( *q ), получаем 3.
  6. Переменной p присваивается значение, хранимое в q. Это значит, что p начинает ссылаться на тот же участок памяти, что и q. Поскольку q ссылается на z, то и p начинает ссылаться туда же.
  7. В качестве значения по адресу p записываем 4. Т.к. p является указателем на z, следовательно, меняется значение z.
  8. Проверяем, p и q являются указателями на одну и туже ячейку памяти.

Под сам указатель (там, где хранится адрес) также должна быть выделена память. Объем этой памяти можно узнать с помощью функции sizeof() :

Под указатели всех типов выделяется одинаковый объем памяти, т.к. размер адреса не зависит от типа, а зависит от вычислительной системы. В таком случае, зачем при объявлении указателя следует указывать тип данных, на который он может ссылаться? Дело в том, что по типу данных определяется, сколько ячеек памяти занимает значение, на которое ссылается указатель, и через сколько ячеек начнется следующее значение.

Если указатель объявлен, но не определен, то он ссылается на произвольный участок памяти с неизвестно каким значением:

Результат (в Ubuntu):

Использование неопределенных указателей в программе при вычислениях чревато возникновением серьезных ошибок. Чтобы избежать этого, указателю можно присвоить значение, говорящее, что указатель никуда не ссылается (NULL). Использовать такой указатель в выражениях не получится, пока ему не будет присвоено конкретное значение:

Результат (в Ubuntu):

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

На этом уроке вы должны понять, что такое адрес переменной и как его получить ( &var ), что такое переменная-указатель ( type *p_var; p_var = &var ) и как получить значение, хранимое в памяти, зная адрес ячейки ( *p_var ). Однако у вас может остаться неприятный осадок из-за непонимания, зачем все это надо? Это нормально. Понимание практической значимости указателей придет позже по мере знакомства с новым материалом.

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

Читайте также: