Как работают указатели в C на практике

Пробуем работать с памятью напрямую

Как работают указатели в C на практике

В прошлый раз мы разобрали основные принципы указателей в языке 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;
}

Теперь при запуске программы мы получаем предупреждение:

Как работают указатели в C на практике

❗️ Вот она, опасность языка С: даже если вы сделаете что-то не так с точки зрения здравого смысла, то язык вас всего лишь предупредит, но продолжит выполнять код. Единственное, когда он выдаст сообщение об ошибке — при неправильном синтаксисе, переполнении стека или при ошибке вычислений.

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

Как работают указатели в C на практике

В 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;
}

Что в следующий раз

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

Обложка:

Алексей Сухов

Корректор:

Ирина Михеева

Вёрстка:

Кирилл Климентьев

Соцсети:

Юлия Зубарева

Получите ИТ-профессию
В «Яндекс Практикуме» можно стать разработчиком, тестировщиком, аналитиком и менеджером цифровых продуктов. Первая часть обучения всегда бесплатная, чтобы попробовать и найти то, что вам по душе. Дальше — программы трудоустройства.
Получите ИТ-профессию Получите ИТ-профессию Получите ИТ-профессию Получите ИТ-профессию
А вы читали это?
hard