Язык C — быстрый и эффективный язык программирования. Программист получает практически полную свободу над машиной, но вместе с этим ему нужно быть очень внимательным. В отличие от универсальных удобных языков вроде Python или Ruby, C обязывает разработчиков следить за кодом и самим брать ответственность за всё происходящее в программе, включая переменные и работу с памятью.
Сегодня разберём одну из самых основных вещей в языке C — указатели. Если поймёте её, то всё остальное вообще нестрашно.
Что за язык C
C — компилируемый статически типизированный язык. Он появился в 1972 году и послужил основой для многих других языков — C++, C#, Java. Это фундаментальная технология, которая даёт широкий контроль над компьютером.
Вот несколько основных вещей про C:
- Простой. В этом языке нет сложных длинных конструкций.
- Требует ручного управления памятью. Разработчик должен хорошо понимать, каких ресурсов требует его программа и когда их нужно освободить, чтобы не переполнить память ← мы здесь.
- Со строгой структурой. Код должен быть правильно организован. Например, функции объявляют заранее, а переменным явно присваивают типы данных.
- Встроенных функций почти нет. В других языках могут быть большие наборы удобных встроенных технологий, но в C нужно создавать большую часть программы самому с нуля или использовать библиотеки.
Все эти черты могут быть как минусами, так и плюсами.
Код на C может быть очень мощным и быстрым, но при этом на нём легче допустить критическую ошибку, которая может остановить компьютер. Особенно это касается работы с памятью.
Что такое указатели
Чтобы программа работала, её элементам нужно где-то существовать. Это место — оперативная память компьютера, которая делится на пронумерованные ячейки. Каждая ячейка может хранить 1 байт данных (ноль или единицу):
Когда мы создаём какой-то объект в программе (то есть переменную), он начинает занимать память. Сколько именно, зависит от типа данных объекта.
Вот несколько примеров типов и сколько байтов в памяти им нужно:
char
— 1 байт. Символ или маленькое число. Используется для хранения одного символа ('a
', 'B
', '1
') или небольших чисел: от −128 до 127 или от 0 до 255.unsigned char
— 1 байт. Это беззнаковый символ или маленькое положительное число от 0 до 255.short
— 2 байта. Хранит короткое целое число от −32 768 до 32 767. Обычно используется для экономии памяти, если не нужны большие числа.unsigned short
— 2 байта. Короткое целое число от 0 до 65 535.int
— 4 байта. Вмещает целое число. Наиболее часто используемый тип для целых чисел от −2 147 483 648 до 2 147 483 647.unsigned long long
— 8 байт. Очень длинное неотрицательное целое число от 0 до 18 446 744 073 709 551 615.
Указатель хранит адрес в памяти, по которому можно найти созданные нами объекты. Он хранит адрес не всех ячеек, а только первой, с которой начинается последовательность байтов:
Хотя сам объект может занимать разное количество байтов, указатель всегда имеет фиксированный размер. Его задача — наверняка сохранить адрес ячейки памяти, который в разных системах весит по-разному:
- В 32-битных системах адрес занимает 4 байта.
- В 64-битных системах адрес занимает 8 байт.
Что дают указатели
Указатели — это прямой доступ к памяти. С ними разработчик получает возможность напрямую изменять объекты, не используя переменные.
Что можно делать при помощи указателей:
- Работать с динамической памятью. Если заранее неизвестно, сколько памяти потребуется для хранения объекта, с указателем это количество можно рассчитать во время работы программы.
- Быстро передавать данных. Вместо копирования больших объектов можно передавать только их адрес, который всегда занимает фиксированный объём — 4 или 8 байт.
- Изменять данные в функциях. В C при передаче аргументов в функции создаётся копия данных. Если нужно изменить оригинальные данные, нужно использовать указатели, которые позволят работать с данными напрямую в памяти.
- Реализовать сложные проекты, которые требуют точного управления памятью.
❗️ Указатель — это не переменная! Это лишь адрес начала нужной переменной.
Синтаксис указателей
Указатель указывает на ячейку памяти, в которой уже что-то хранится. Поэтому самый простой способ создать указатель — передать в него адрес уже созданного фрагмента программы.
Вот основные правила для объявления указателей.
- Указатель должен быть того же типа, что и объект, на который он будет указывать. Если нам нужен указатель на переменную типа
int
, он тоже должен быть типаint
. - Чтобы сказать программе, что это именно указатель, после объявления типа нужно поставить астериск, например:
int*
. - После объявления типа указателю нужно придумать имя. Хорошим тоном считается давать имена, начинающиеся со строчной буквы
p
, от pointer. После буквыp
пишут с прописной буквы имя переменной, на которую он указывает. Например, если указатель указывает на переменнуюnumber
, его можно назватьpNumber
. - Чтобы передать в указатель адрес переменной, нужно поставить после объявления указателя знак равенства, знак амперсанда & и набрать имя переменной. Например,
&number
.
❗️ Астериск — это символ звёздочки → *
Так будет выглядеть пример простой программы с объявлением переменной и указателя на эту переменную:
// подключаем стандартную библиотеку для ввода/вывода
#include <stdio.h>
int main() {
/* объявляем переменную number
и присваиваем ей значение 42 */
int number = 42;
/* объявляем указатель типа int.
пока он ни на что не указывает */
int* pNumber;
/* присваиваем указателю адрес переменной number.
теперь pNumber указывает на number */
pNumber = &number;
/* выводим адрес, который хранится в указателе pNumber
это тот же адрес, что и у переменной number */
printf("Адрес, хранящийся в pNumber: %p\n", pNumber);
// завершаем программу с кодом 0 (успешное выполнение)
return 0;
}
Запускаем код и видим адрес ячейки памяти, где хранится значения переменной number
. Этот адрес хранится в нашем указателе pNumber
:
Адрес, хранящийся в pNumber: 0x7ff7b6b6a2b8
Как выглядит использование указателей
Чтобы начать использовать указатели, нужно запомнить два правила.
Без астериска пишем имя указателя, когда присваиваем в него адрес после объявления:
pNumber = &number;
С астериском * пишем, чтобы считать значение из ячейки памяти через указатель или записать в него новое значение. Оператор *
позволяет получить значение по адресу указателя. Эта операция называется «разыменование».
Например, мы хотим присвоить переменной y
такое же значение, как в переменной x
. При этом для переменной x
у нас есть указатель pX
. Тогда присваивание уже существующего значения будет выглядеть так:
y = *pX;
А если мы хотим напрямую изменить значение в ячейке памяти, то нужно передать это значение в указатель.
В этом коде мы изменим значение переменной, напрямую обращаясь к памяти:
// подключаем стандартную библиотеку для ввода/вывода
#include <stdio.h>
int main() {
/* объявляем переменную number
и присваиваем ей значение 42 */
int number = 42;
/* объявляем указатель типа int.
пока он ни на что не указывает */
int* pNumber;
/* присваиваем указателю адрес переменной number.
теперь pNumber указывает на number */
pNumber = &number;
/* выводим адрес, который хранится в указателе pNumber
это тот же адрес, что и у переменной number */
printf("Адрес, хранящийся в pNumber: %p\n", pNumber);
/* выводим значение по адресу, хранящемуся в pNumber
оператор * (разыменование) позволяет получить значение по адресу */
printf("Значение, на которое указывает pNumber: %d\n", *pNumber);
/* выводим адрес, который хранится в указателе pNumber
это тот же адрес, что и у переменной number */
printf("Адрес, хранящийся в pNumber: %p\n", pNumber);
/* меняем значение переменной number через указатель
теперь number = 100 */
*pNumber = 100;
// выводим обновлённое значение number
printf("Новое значение переменной number: %d\n", number);
// завершаем программу с кодом 0 (успешное выполнение)
return 0;
}
Этот код выведет:
Адрес, хранящийся в pNumber: 0x7ff7bf0cf2b8
Значение, на которое указывает pNumber: 42
Адрес, хранящийся в pNumber: 0x7ff7bf0cf2b8
Новое значение переменной number: 100
Адрес ячейки не изменился, а значение изменилось.
Самые частые проблемы
Указатели позволяют работать с динамической памятью и сильно экономить ресурсы компьютера, но при этом прямой доступ к памяти может создать серьёзные проблемы, особенно для начинающих разработчиков.
Причины большинства этих проблем — в двух ошибках.
Указатель указывает на ещё не выделенную или уже недействительную область памяти.
Почему так может быть:
- Мы инициализировали указатель, но перед этим не создали никакого объекта, чтобы указатель мог указывать на его адрес и изменять значение объекта по этому адреса. Если в этот указатель сразу присвоить какое-то значение, программа попытается записать это значение в случайную ячейку памяти.
- Мы создали объект в динамической памяти, применили его и вручную освободили ячейки. Но указатель остался и продолжает указывать на старый адрес. Это называется «висячий указатель».
В обоих случаях по адресу в указателях могут находиться данные другой части программы. Если использовать доступ к этим ячейкам и что-то записать в них, уже существующие данные будут повреждены. Даже если программа запишет значение в свободную ячейку, скорее всего, это вызовет ошибки и непредсказуемое поведение.
При работе с указателями память динамически выделяется, но не освобождается.
В C необязательно выделять память заранее. Можно добавить в код алгоритмы, которые будут вычислять необходимое количество ресурсов, а потом запрашивать у компьютера это вычисленное количество памяти через специальную функцию malloc
.
Память динамически выделяется, но освобождать её нужно вручную: явно указать, что мы очищаем ячейки от информации через функцию free
. Если этого не сделать, будет происходить утечка: какой-то фрагмент кода будет постепенно продолжать заполнять память, пока в какой-то момент она не заполнится полностью и не вызовет ошибку в системе.
При этом функция освобождения памяти тоже должна быть использована правильно. Например, нельзя просто вызвать её для подстраховки, потому что это может вызвать неопределённое поведение: ошибки, повреждённые данные и потенциальные уязвимости для атак.
Что дальше
В следующий раз разберём на практике, что можно делать с указателями, в чём суть основных ошибок и что можно сделать, чтобы этих ошибок не было.