В прошлый раз мы разобрали основные принципы указателей в языке C и как они устроены. Сегодня разберём их на практических примерах и посмотрим, как именно программисты используют эту технологию и почему с ней может быть сложно.
Что мы уже разобрали
Мы будем говорить про программирование на C. Это статически типизированный компилируемый язык программирования, один из самых старых и основных. Он до сих пор популярен из-за своей гибкости и простых конструкций, но писать код на нём сложнее, чем на других языках.
Универсальные современные языки могут направлять программиста и защищать от распространённых ошибок, а C даёт возможность писать всё что захочется, но под свою полную ответственность.
Один из инструментов, с которыми нужно обращаться особенно осторожно, — указатели. Это специальные переменные, в которых хранится адрес каких-то созданных объектов в программе: чисел, строк, массивов. Указатели указывают на адрес этих объектов и позволяют изменять ячейки с ними напрямую.
С использованием этой технологии можно написать более быструю и эффективную программу, но за работой указателей нужно следить: можно легко ошибиться, и тогда программа не будет работать вообще или будет не так, как было задумано. Или сломает всё остальное, что в этот момент работает на компьютере, такое тоже бывает.
Общий принцип работы указателя
Сразу перейдём к делу и напишем пример работы указателя. В этом коде мы:
- Создаём объект — переменную
number
, которая хранит число. - Объявляем указатель
p_number
— присваиваем ему тип и название. - Инициализируем указатель — присваиваем ему адрес переменной. Для этого используем оператор
&
, который возвращает номер ячейки в памяти, если его поставить перед именем. У нас это будет выглядеть как&number
. - Выводим на экран значение числовой переменной.
- Изменяем это значение напрямую в памяти, используя указатель.
- Снова выводим на экран значение переменной и убеждаемся, что теперь она хранит новое значение.
Теперь, зная это, посмотрите, как это реализовано в коде:
// подключаем стандартную библиотеку для ввода/вывода
#include <stdio.h>
int main() {
/* объявляем переменную number
и присваиваем ей значение 42 */
int number = 42;
/* объявляем указатель типа int.
пока он ни на что не указывает */
int* p_number;
/* присваиваем указателю адрес переменной number.
теперь p_number указывает на number */
p_number = &number;
/* выводим значение по адресу, хранящемуся в p_number
оператор * (разыменование) позволяет получить значение по адресу */
printf("Старое значение переменной number: %d\n", *p_number);
/* меняем значение переменной number через указатель
теперь number = 100 */
*p_number = 100;
// выводим обновлённое значение number
printf("Новое значение переменной number: %d\n", number);
// завершаем программу с кодом 0 (успешное выполнение)
return 0;
}
Код при запуске выводит два значения: одно после присваивания переменной значения и второе — после изменения этого значения через прямой доступ к памяти через указатель:
Старое значение переменной number: 42
Новое значение переменной number: 100
Как объявить два указателя на одну ячейку памяти
Иногда нужно передать один и тот же объект в разные функции, чтобы разделить доступ между разными частями программы. Для этого можно объявить сразу несколько указателей одного типа в одной строке.
Чтобы это получилось, сначала нужно объявить сам объект. При объявлении указателей на этот объект ставим тип данных и перечисляем их имена. Перед каждым новым указателем ставим символ астериска (проще говоря — звёздочку) *
:
// подключаем стандартную библиотеку для ввода/вывода
#include <stdio.h>
/* создаём основную функцию, которая
запускается при запуске кода */
int main() {
/* объявляем переменную number
и присваиваем ей значение 42 */
int number = 221;
/* объявляем два указателя типа int.
пока они ни на что не указывают */
int *p_number1, *p_number2;
Если *
поставить только перед первым указателем, то остальные будут объявлены просто как переменные указанного типа.
Теперь помещаем в первый указатель адрес объекта, а во второй присваиваем значение из первого:
// присваиваем указателям адрес переменной number
p_number1 = &number;
p_number2 = p_number1;
Проверим, что в обоих указателях хранится одно и то же:
// выводим значения, на которые указывают указатели
printf("p_number1 = %d\n", *p_number1);
printf("p_number2 = %d\n", *p_number2);
// выводим адреса, на которые указывают указатели
printf("Адрес, на который указывает p_number1: %p\n", (void*)p_number1);
printf("Адрес, на который указывает p_number2: %p\n", (void*)p_number2);
// завершаем программу с кодом 0 (успешное выполнение)
return 0;
}
Компилируем и запускаем программу и смотрим, что выдаёт код:
p_number1 = 221
p_number2 = 221
Адрес, на который указывает p_number1: 0x7ff7be6f32b8
Адрес, на который указывает p_number2: 0x7ff7be6f32b8
Приведение типов указателей
Это процесс изменения типа указателя с одного типа данных на другой. Приведение типов быть полезно, например, если библиотеки и функции требуют работать с одним типом данных, а вы используете другой.
Когда в предыдущем примере мы объявили два указателя на один объект, то присвоили им одинаковый тип данных:
int *p_number1, *p_number2;
Можно присвоить разным указателям разные типы и в то же время — один адрес ячейки. Это нужно прямо указать в коде:
// подключаем стандартную библиотеку для ввода/вывода
#include <stdio.h>
/* создаём основную функцию, которая
запускается при запуске кода */
int main() {
/* объявляем переменную number
и присваиваем ей значение 42 */
int number = 1961;
// объявляем два указателя с разными типами данных
int *p_number1;
char *p_number2;
// присваиваем обоим указателям адрес переменной number
p_number1 = &number;
p_number2 = p_number1;
// завершаем программу с кодом 0 (успешное выполнение)
return 0;
}
Теперь при запуске программы мы получаем предупреждение:

❗️ Вот она, опасность языка С: даже если вы сделаете что-то не так с точки зрения здравого смысла, то язык вас всего лишь предупредит, но продолжит выполнять код. Единственное, когда он выдаст сообщение об ошибке — при неправильном синтаксисе, переполнении стека или при ошибке вычислений.
Откуда это предупреждение: из-за разных типов данных работа с памятью будет различаться. Число типа int
занимает 4 байтовых ячейки в памяти, объект типа short
— 2 ячейки, а char
— только одну:

В int
можно хранить число от −2 147 483 648 до 2 147 483 647. Это число займёт от 1 до 4 ячеек, в зависимости от размера. Но указатель типа char
всегда будет работать только с первой ячейкой из этих четырёх.
Если мы попробуем через прямой доступ к памяти перезаписать число int
через указатель типа char
, то заменим только первую ячейку, а все остальные будут прежними. В итоге новое число будет содержать первую ячейку из char
, а остальные три ячейки — из прежнего int
.
Например, мы решили заменить число 1 961 типа int
на число 9 типа char
. Предполагается, что число 9 полностью заменит первоначальное:
// подключаем стандартную библиотеку для ввода/вывода
#include <stdio.h>
/* создаём основную функцию, которая
запускается при запуске кода */
int main() {
/* объявляем переменную number
и присваиваем ей значение 42 */
int number = 1961;
// объявляем два указателя с разными типами данных
int *p_number1;
char *p_number2;
// присваиваем указателям адрес переменной number
p_number1 = &number;
p_number2 = p_number1;
/* меняем значение исходного числа
int через указатель типа char */
*p_number2 = 9;
// проверяем, что хранится в int
printf("Число int после изменения через char*: %d\n", number);
// завершаем программу с кодом 0 (успешное выполнение)
return 0;
}
Что произойдёт на самом деле, можно увидеть, запустив код:
Число int после изменения через char*: 1801
Как это работает: число int
раскладывается на 4 слагаемых.
Каждое слагаемое — это произведение части числа на 256 в степени от 0 до 3. Степень от 0 до 3 обозначает порядковый номер байтовой ячейки, которые хранят число int
целиком.
- Часть числа умножается на 256 в 0-й степени.
- Часть числа умножается на 256 в 1-й степени.
- Часть числа умножается на 256 во 2-й степени.
- Часть числа умножается на 256 в 3-й степени.
- Все эти части суммируются, чтобы получилось исходное число.
Пример с числом 1 000 000:
- Первое слагаемое: 64 * 1.
- Второе слагаемое: 66 * 256.
- Третье слагаемое: 15 * 65 536.
- Четвёртое слагаемое: 0 * 16 777 216.
- В сумме: 64 * 1 + 66 * 256 + 15 * 65 536 + 0 * 16 777 216.
Обратите внимание: мы присвоили двум указателям разные типы данных, и наша программа работает не так, как могло быть задумано. Но при этом C не запрещает запуск — только выдаёт предупреждение о разных типах данных. В этом мощь C: он позволяет заставить компьютер выполнять практически любую задачу.
Теперь, зная это, попробуйте сами посчитать и понять, почему у нас получилось не 9 и не 9 961, а 1 801.
Приведение типов даёт использовать указатели разного вида и одновременно подсказывает программе, что мы делаем это намеренно. Для этого перед присваиванием адреса нужно явно указать тип указателя в скобках:
p_number2 = (char *)p_number1;
Есть ещё один способ запускать программу без того, чтобы компилятор выдавал предупреждение. Для этого нужно сразу объявить указатель через обобщённый тип void:
void *p_number2;
Тогда этому указателю можно будет присвоить адрес любого другого указателя, и наоборот:
p_number2 = p_number1
p_number1 = p_number2
Указатели и массивы
В C и C++ массивы — это структуры данных, которые позволяют хранить набор элементов одного типа в ячейках памяти с последовательными номерами. Массивы используются для удобного доступа и работы с набором значений через одну переменную, своё имя.
Элементы массива доступны по индексу — номеру, который начинается с 0.
Имя массива — это указатель на первый элемент. Например, мы создаём массив:
int arr[5] = {1, 2, 3, 4, 5};
Переменная arr будет хранить адрес первого элемента, то есть &arr[0]
.
Теперь, если мы хотим создать отдельный указатель на массив, нам достаточно присвоить ему саму переменную массива, которая уже хранит адрес:
int *p_arr = arr;
Для примера напишем программу, которая будет с помощью указателя проходить по всем элементам массива и выводить их на экран:
// подключаем стандартную библиотеку для ввода/вывода
#include <stdio.h>
int main() {
// создаём массив
int arr[5] = {1, 2, 3, 4, 5};
/* создаём указатель p_arr, который
указывает на первый элемент массива */
int *p_arr = arr;
// создаём цикл для итерации по массиву
for (int i = 0; i < 5; i++) {
// выводим значение, на которое указывает p_arr
printf("%d ", *p_arr);
// перемещаем указатель на следующий элемент
p_arr++;
}
// добавляем перевод строки в конце
printf("\n");
return 0;
}
На экране получаем:
1 2 3 4 5
Указатели позволяют работать с массивами эффективнее — например, передавать не все элементы в функцию для работы, а только адрес ячейки первого элемента.
При этом при работе нужно быть внимательным, чтобы не выйти за границы массива и не сказать программе работать с ячейкой памяти, которая может хранить данные уже другой части программы.
Указатели на функции
Указатель на функцию — это переменная, которая хранит адрес функции. Да, у функций тоже есть адреса и именно по ним компьютер их вызывает.
Для создания сначала нужно написать сами функции. Мы для примера создадим функции сложения и умножения. Они будут принимать два аргумента и складывать их или умножать:
// подключаем стандартную библиотеку для ввода/вывода
#include <stdio.h>
// создаём функцию сложения
int add(int a, int b) {
return a + b;
}
// создаём функцию умножения
int multiply(int a, int b) {
return a * b;
}
После этого можно создать основную функцию и объявить указатель того же типа, что и функции:
// создаём основную функцию
int main() {
// создаём указатель на функцию
int (*p_func)(int, int);
Теперь передадим в указатель сами функции — просто присвоим их как переменные. После этого попробуем передать в указатель два числа и посмотреть, что выдаст программа:
// присваиваем указателю адрес функции add
p_func = add;
printf("Сумма: %d\n", p_func(2, 3));
// присваиваем указателю адрес функции multiply
p_func = multiply;
printf("Произведение: %d\n", p_func(2, 3));
// завершаем работу функции
return 0;
}
Компилируем и запускаем код:
Сумма: 5
Произведение: 6
Передача в указатели функций особенно полезна для реализации callback-функций, которые вызываются в ответ на какое-то событие. Ещё можно возвращать одну функцию из другой и создавать массивы функций.
Самые частые проблемы
Вот несколько самых распространённых проблем при работе с указателями.
Разыменование нулевого указателя. Такой указатель не указывает на существующий объект или область памяти. Если попытаться прочесть значение по адресу из такого указателя, это вызовет ошибку или сбой:
#include <stdio.h>
// создаём основную функцию
int main() {
// создаём нулевой указатель
int *ptr = NULL;
// пытаемся прочесть значение из нулевого указателя
printf("%d", *ptr);
// завершаем работу функции
return 0;
}
При запуске получаем ошибку на этапе выполнения программы:
zsh: segmentation fault
Нулевые указатели появляются, когда мы инициализируем пустые указатели или обнуляем их значения после использования.
Ошибки разыменования таких указателей не всегда говорят, что код плохой. В некоторых случаях указатели хранят случайный или уже освободившийся адрес в памяти, и работа с ними может привести к непредвиденным трудноотслеживаемым ошибкам и результатам. Записывать в такие указатели нулевое значение — хорошая практика, потому что так мы получаем явную ошибку и можем её исправить.
Утечка памяти. При динамическом выделении ресурсов память выделяется автоматически, но освобождать её нужно явной командой free
. Если забыть это сделать, в конце концов компьютер исчерпает память, и программа начнёт выдавать сбои.
#include <stdio.h>
// создаём основную функцию
int main() {
/* выполняем динамическое выделение памяти
для массива из 10 чисел типа int */
int *ptr = (int*)malloc(sizeof(int) * 10);
// не вызываем после этого функцию free
// ❌ Так делать не надо, память надо освобождать
// завершаем работу функции
return 0;
}
Выход за границы массива. При работе с массивами можно присвоить указателю номер элемента, который больше или меньше граничных значений. Тогда программа обратится к значению ячейки памяти с какой-то другой частью программы и может повредить её или просто вызвать непредсказуемое поведение:
#include <stdio.h>
// создаём основную функцию
int main() {
// объявляем массив из 5 элементов
int arr[5] = {1, 2, 3, 4, 5};
// объявляем указатель на массив
int *ptr = arr;
// выходим за границы массива: указываем на 10-й элемент
ptr += 10;
// работа с этим элементом вызовет неопределённое поведение
printf("%d", *ptr);
// завершаем работу функции
return 0;
}
Эти ошибки могут появиться в ходе сложного взаимодействия работы программы, и гарантированного способа избежать их нет. Но вот несколько основных советов, которые помогут сократить количество проблем:
- Всегда проверяйте, что указатель не равен
NULL
, перед его использованием. - Используйте
malloc
иfree
в паре, чтобы избежать утечек памяти. - Следите за пограничными значениями массива.
В коде эти рекомендации будут выглядеть так:
// подключаем стандартную библиотеку для ввода/вывода
#include <stdio.h>
// подключаем стандартную библиотеку для работы с динамической памятью
#include <stdlib.h>
// создаём основную функцию
int main() {
// выделяем память для массива из 10 элементов типа int
int *ptr = (int*)malloc(sizeof(int) * 10);
// проверяем, удалось ли выделить память
if (ptr == NULL) {
// если память не выделилась, выводим сообщение об ошибке
printf("Ошибка выделения памяти\n");
// завершаем программу с кодом 1 (ошибка)
return 1;
}
/* заполняем массив значениями от 0 до 9.
в С нет автоматической защиты от выхода за границы массива */
for (int i = 0; i < 10; i++) {
ptr[i] = i; // присваиваем i-му элементу массива значение i
}
// освобождаем выделенную память
free(ptr);
// завершаем программу
return 0;
}
Что в следующий раз
Мы разобрали несколько основных концепций в С, в следующий раз попробуем применить их на практике и переписать один из наших проектов на этом языке. Или придумаем новый, чтобы было интереснее.