Указатели (часть 1)
LINEУказатели - крайне важная тема для любого языка программирования. Понимание работы указателей необходимо для написания эффективного кода. С помощью указателей мы можем реализовать структуры и объекты, полиморфизм функций, лямбда выражения и функции высших порядков, аллокаторы и сборщики мусора, динамические структуры данных (списки, деревья) и много чего еще. В 2-х статьях мы постараемся разобрать многое из того, что нужно знать при работе с указателями - от того, как их объявить и использовать явно в языке си, до того, как работает полиморфизм функций и как (не) получить утечки памяти в языках с автоматической сборкой мусора (например Java).
Память
Память компьютера устроена достаточно сложно - иерархия памяти, виртуальная память, свопинг, страничная организация, кеши нескольких уровней, MMU, TLB и т.д. Но в целом мы можем представить себе память как один последовательный адресованный массив байтов и это будет недалеко от истины. Именно так и видели первые компьютеры оперативную память, т.е. запись вида (mov ax, [100]) интерпретировалась буквально как переместить значение в регистр по абсолютному адресу 100. Современные аппаратное обеспечение и операционная система различными способами защищает адресное пространства процессов друг от друга и добавляет различные оптимизации и уровни абстракции для доступа к памяти. В дальнейшем мы будем считать что оперативная память - это огромный массив байт от 0 до некоторого максимального N, но помнить что все устроено сложнее.

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

Каждый язык имеет свою систему типов, но для большинства языков они совпадают. Во многих учебниках/статьях сказано о том, как важно давать хорошие имена переменным. Это действительно важно, но не для самого процессора. Ему в целом все равно на название переменной (названия в скомпилируемом и исполняемом файле скорее всего даже не сохранятся), ему важно загрузить эту переменную и произвести с ней некоторые преобразования, т.е. процессору нужно только знать адрес переменной в памяти (мы достаточно давно ушли от абсолютных адресов памяти, поэтому точнее будет говорить что переменная для процессора - это адрес смещения относительно какого-то адреса в памяти, т.к. программа может быть загружена ОС в разные участки памяти). И здесь мы переходим к самому важному - адреса.
Адреса
Теперь мы можем посмотреть на все вместе. Объявим переменную i типа int и посмотрим на адрес в памяти. В си есть возможность взять адрес переменной с помощью операнда "&". Если i - это переменная, то & - адрес в памяти.

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

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

- Автоматический - Давайте посмотрим на то, как выполняются функции. Напишем простую функцию sum, которая складывает 2 локальные переменные (c и d) и принимает 2 параметра (a и b), а затем просто суммирует их все.

Теперь с помощью gdb посмотрим на функции main и sum.

В функции main компилятор сначала разместил 2 переменные a и b (он поместил их в регистры esi и edi, т.к. это быстрее чем обращаться в будущем к памяти) и вызвал команду call sum - вызов функции. В этот момент в стек был помещен адрес команды, следующей за call - именно сюда вернется управление после выполнения функции sum.
В функции sum компилятор разместил локальные переменные (красный цвет), а затем с некоторыми манипуляциями с регистрами и сложил 3 числа (желтый цвет), результат сложения оказался в регистре eax.

Все вместе эти значения формируют стековый фрейм (есть еще некоторые детали, такие как конвенции вызова, пролог и эпилог, и др., которые мы пропустим). Если вызвать другую функцию из sum (возможно рекурсивно саму же sum), у нее будет свой стековый фрейм. Фрейм - это тоже участок памяти, в котором значения располагаются в строгом порядке.

Особенностью такого типа памяти является то, что после выхода из стекового фрейма (т.е. после слова return) получить доступ к переменным внутри фрейма уже нельзя. При последующем вызове другая функция перезапишем значения этого стекового фрейма. Новый фрейм создается для каждого вызова функции, а при выходе он удаляется (на самом деле он физически конечно никуда не девается, просто меняется значение указателя вершины стека). Область памяти для автоматических переменных называется стеком (stack).
- Статический. Модификатор static указывает компилятору сохранить переменную не в стеке, а в другом сегменте выполняемой программы. Сюда же относятся глобальные переменные, т.е. переменные вне функции.
Чтобы увидеть разницу между глобальными/статическими/автоматическими переменными, напишем 3 функции, в каждой из которых увеличиваем переменную на 1. Автоматическая переменная (функция local) всегда будет возвращать один результат, в отличие от статических/глобальных (можно также заметить что вычисления выражений начинается справа налево).

- Динамический - этот тип памяти позволяет нам как программисту выделить определенное количество памяти (под структуру, переменную) и получить указатель на этот участок памяти. Участок памяти для динамического выделения называется хипом (heap). В начальный момент мы можем не знать точно сколько памяти нам нужно, поэтому мы можем выдялять(и освобождать) эту память динамически по необходимости. Выглядит это примерно как "выдели мне 20 байт и верни указатель на первый байт этой памяти", а затем "вот тебе указатель на первый байт памяти, мне больше эта память не нужна, отдай ее кому-нибудь другому". Об этой памяти и аллокаторах мы поговорим чуть позже.
Собрав всю эту картину вместе, мы увидим что в большинстве исполняемых файлов память поделена на секции: есть исходный код (text/code) , секция данных (data) (с инициализированными и неинициализированными данными, для неинициализированных данных достаточно запомнить общий размер, т.к. значение нас не интересует. В этом сегменте находятся статические и глобальные переменные), секция с динамической памятью (heap), которую можно выделять по необходимости и секция стека (stack), которая используется для вызова функций. Секции stack и heap обычно "растут" навстречу друг другу.

Указатели
Теперь мы можем перейти непосредственно к указателям. Во-первых, указатель - это тоже переменная, а значит он сам имеет адрес и значение. Но эта переменная хранит адрес в памяти (вообще говоря адрес - это просто число). Заведем переменную ptr (часто используется как сокращение pointer - указатель), символ "*" после типа означает то, что эта переменная - указатель. Значением указателя будет адрес в памяти переменной i. Затем выведем значения и адреса переменных i и ptr и убедимся, что указатель ptr хранит в себе адрес переменной i, но при этом он сам имеет адрес в памяти.

Красным цветом выделено, что значение указателя совпадает с адресом переменной i, т.е.

Для языка си не имеет значения где конкретно располагается астерикс (звездочка), все 3 объявления эквивалентны, но дальше будет использоваться формат int* var, т.к. (как мне кажется) логично что астерикс относится именно к типу переменной, а не к ее названию (хотя в книге Керниган-Ритчи и многих других книгах по языку си используется формат int *i).

Мы можем изменить переменную i 2-мя способами. Первый, традиционный, когда мы просто пишем переменную и новое значение. Второй - через указатель. Запись вида ptr = ... означает записать вместо переменной ptr новое значение, а запись вида *ptr = ... (с астериксом) - взять тот адрес, который находится в переменной ptr, найти в памяти этот адрес и именно туда записать новое значение. Такая операция называется разыменованием указателя.

Визуально это выглядит так

Типы указателей
Указатель может указывать на различные типы, такие как char, int, long и другие. Это важно т.к. указатель всегда указывает на первый байт переменной, но без типа нельзя сказать точно сколько занимает эта переменная. Ниже мы определили 3 переменные типа int(4 байта), char (1 байт) и long (8 байт), а также 3 указателя. Все переменные занимают разное количество памяти, однако все указатели имеют один размер - 8 байт (зависит от платформы, но обычно это 8 или 4 байта).

Указатель на указатель
Указатель - это такая же переменная (имеет адрес в памяти, тип и значение), а поэтому нет ничего удивительного в том, что мы можем создать указатель на указатель, и даже указатель на указатель на указатель... Если int* ptr - указатель, то int** ptr - указатель на указатель и т.д. Чтобы разыменовать указатель на указатель, мы должны 2 раза перейти по адресу, поэтому используем 2 астерикса (**ptr).

Визуально это выглядит так

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

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

Как получить доступ к, например, элементу с индексом 2 (и значением 3)? Мы должны прибавить к элементу с индексом 0 (начало массива) индекс нужного элемента (т.е. arr[0] + 2). В итоге, чтобы получить указатель на элемент с индексом 2 мы должны выполнить следующую операцию: arr + 2, так мы получим указатель и чтобы получить элемент по этому указателю выполнить разыменование *(arr + 2). Но скорее всего вам более знаком такой вариант обращения по индексу 2: arr[2]. Это тоже самое, но компилятор сам развернет эту запись в вариант с указателем.
Мы также можем вывести адреса и значения всех элементов, но для этого нужно знать размер массива. Видно что каждый элемент находится на смещении 4 байта от предыдущего (т.к. у нас массив int, а int занимает 4 байта).

Узнать размер массива можно с помощью функции sizeof(arr), которая вернет количество байт, занимаемые массивом. Разделив это число на размер в байтах 1 элемента, мы получим количество элементов.
Строка - это массив символов. Здесь все также, можно создать массив и заполнить его символами, можно сразу указать строку в двойных кавычках, а можно - указатель. В си есть требование, что последний символ должен заканчиваться '\0'. Таким образом можно понять, что на этом месте строка заканчивается. Если вы используете строку в двойных кавычках, компилятор сам добавит этот символ. Опять же заметим, что с массивом длинна строки - 6 байт (5 символов + '\0'), в случае указателя - 8 байт.

И здесь есть особенность в виде различия указателя и массива. Мы можем изменить значение по индексу массива, но вот по указателю нет.

Добавим локальную переменную типа int, и 2 разных способа объявить строки, а затем посмотрим где они располагаются в памяти.

По адресам видно, что локальная переменная a и строка как массив располагаются в сегменте стека (и близко друг к другу), а вот адрес строки с указателем достаточно далеко, в другом сегменте (который доступен только для чтения, поэтому при попытке изменения происходит ошибка). Зачем это нужно? Для того чтобы мы могли не создавать одну и ту же строку несколько раз. Ниже в примере мы создаем 2 переменные, которые имеют одинаковое содержимое ("hello"), но компилятор разместил только 1 строку в памяти и присвоил обоим указателям адрес первого байта строки. Т.к. они находятся в области памяти только для чтения (значит их нельзя изменить), можно не бояться что кто-то изменит строку. Это достаточно популярный прием и в большинстве языков строки являются неизменяемыми объектами.

Наиболее частый вариант указателя на указатель - массив строк. Сигнатура метода main выглядит так

Первый парамерт - количество параметров при запуске программы, а второй параметр - указатель на массив строк, или указатель на указатель. Он указывает на массив, который является массивом указателей на строки. Вот как мы можем получить параметры при запуске программы (выполнив ./a.out a1 b2 c3 (где a.out - название исполняемого файла), первым параметром argv всегда будет название исполняемого файла).

Передача указателей/массивов в функцию
Попробуем решить простую задачу - написать функцию, которая принимает массив и возвращает сумму всех элементов. Задача кажется тривиальной, но здесь есть подводные камни.
Для начала выведем адрес и размер массива в функции main и sum

Адреса обоих массивов совпадают. Но вот размер - нет. Это происходит потому что массив не копируется как параметр функции из main в sum, копируется только указатель на массив (размер массива - 3 * 4 = 12, а размер указателя - 8 байт). Это важный для понимания момент. Поэтому мы должны передать 2 параметра в функцию sum - указатель на первый элемент массива, и количество элементов в массиве. Затем, увеличивая указатель на первый элемент массива мы сможем получить все остальные элементы, а размер нужен нам чтобы не выйти за пределы массива.

Все это наводит на мысль о том, что мы можем получить результат работы функции 2-мя способами. Первый - классический, с помощью оператора return. Второй - мы можем передать адрес в памяти и сказать, что результат нужно записать именно туда. Вот пример работы функции double, которая просто умножает на 2 переданное число. Передать указатель можно также, как обычный параметр функции (но функция должна принимать указатель, а не просто переменную, не забываем про звездочку).

Арифметика указателей
Как мы уже заметили при работе с массивами, с указателями можно выполнять арифметические действия - складывать, вычитать (умножать и делить, хотя в этом смысла не так много), а также сравнивать 2 указателя.
Самое важное, что здесь стоит запомнить - когда вы прибавляете 1 к указателю, вы прибавляете не 1 байт, а размер того типа, на который указывает указатель. Вот пример. Объявим переменную типа int, а затем 3 указателя типа char*, int* и long* (мы должны явно указать приведение типа для char и long). Затем увеличим каждый указатель на 1, и заметим что адрес char изменился на 1 байт, int на 4 байта, а long - на 8. Указатель++ не равно указатель+1 байт.

Во второй части мы поговорим о более серьезных вещах, связанных с указателями, такие как структуры и указатели на них, указатели на функции, полиморфизм функций, утечки памяти, аллокаторы и динамические структуры данных.
- - - - - - - - - - - - - - - -
Для тех, кто дочитал до конца
На написание подобных статей уходит много времени и сил, но я стараюсь достать информацию из разных источников и выложить в одной статье все самое ценное и полезное. Поэтому они получаются большими и поэтому выходят не так часто. Если вам нравится подобная подача, вы можете поддержать канал:
- поделиться ссылкой на канал [ https://t.me/line_of_code ]
- дополнить/уточнить статью в комментариях (я не знаю всего и я могу ошибаться) или предложить интересную тему
- поддержать материально - https://yoomoney.ru/to/4100117706200369