Указатели в языке C

Курс объясняет, что такое указатели в C и как безопасно и эффективно применять их в программах. Разбираются работа с памятью, массивы и строки, указатели на функции и типичные ошибки.

1. Основы указателей: адреса, разыменование, типы

Основы указателей: адреса, разыменование, типы

Зачем в C нужны указатели

Указатели — это механизм языка C, который позволяет:

  • Работать с памятью напрямую через адреса.
  • Передавать данные в функции без копирования (в том числе менять значения «снаружи»).
  • Эффективно обрабатывать массивы, строки и структуры данных.
  • Создавать динамические структуры (списки, деревья) и управлять динамической памятью.
  • > «C provides a number of operators to specify the address of an object and to access the value of an object through its address.» — cppreference: Pointer

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

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

    Выполняющаяся программа использует память. Каждая переменная в памяти размещается по некоторому адресу.

  • Адрес — это число, по которому в памяти находится объект (например, переменная int x).
  • Указатель — это переменная, которая хранит адрес другого объекта.
  • Важно:

  • Обычная переменная хранит значение.
  • Указатель хранит адрес, по которому лежит значение.
  • !Диаграмма показывает, что указатель хранит адрес переменной и позволяет обратиться к её значению.

    Объявление указателей и типы

    Как объявляется указатель

    Указатель объявляется с помощью * рядом с именем (или типом):

    Это читается как: p — указатель на int.

    Эквивалентные по смыслу варианты записи:

    Рекомендация по стилю (чтобы меньше ошибаться): ставить * рядом с именем:

    Потому что запись ниже часто вводит в заблуждение:

    Тип указателя — это часть контракта

    Тип указателя говорит компилятору две ключевые вещи:

  • Какой тип данных лежит по адресу.
  • Сколько байт читать/писать при разыменовании.
  • Примеры типов указателей:

    | Запись | Смысл | Что лежит по адресу | |---|---|---| | int *p | указатель на int | целое типа int | | double *pd | указатель на double | число типа double | | char *s | указатель на char | символ (часто первый символ строки) | | void *vp | указатель на «неизвестно что» | тип не определён, разыменовывать нельзя без приведения |

    Оператор * в объявлении и в выражении — это разные роли

    Символ * встречается в двух местах, и его легко перепутать:

  • В объявлении int *p; он означает, что p — указатель.
  • В выражении p он означает разыменование* (доступ к значению по адресу).
  • Контекст решает всё.

    Получение адреса: оператор &

    Чтобы получить адрес переменной, используется оператор & (address-of):

    Здесь:

  • x — обычная переменная типа int.
  • &x — адрес переменной x, тип выражения &x будет int *.
  • p хранит адрес x.
  • Частые замечания:

  • Можно брать адрес только у объекта, который существует в памяти (например, у переменной). У временных значений адрес обычно брать нельзя.
  • & в C также бывает побитовым И (a & b), но это другой оператор и другой контекст.
  • Разыменование: оператор *

    Если указатель хранит корректный адрес, то можно получить или изменить значение по этому адресу через *.

    Прочитать значение по адресу

    *p читается как: «значение, на которое указывает p».

    Изменить значение по адресу

    Здесь меняется не p, а содержимое памяти по адресу, который хранится в p.

    Нулевой указатель: NULL

    Иногда нужно значение «ни на что не указывает». Для этого используют нулевой указатель.

    Обычно применяют NULL из стандартных заголовков:

    Правило безопасности:

  • Разыменовывать NULL нельзя: *p при p == NULL приводит к неопределённому поведению.
  • Проверка перед использованием:

    void *: универсальный указатель и его ограничения

    void * может хранить адрес объекта любого типа, но компилятор не знает, что лежит по этому адресу.

    Пример:

    Важно:

  • Нельзя разыменовать void * напрямую, потому что неизвестно, сколько байт и как интерпретировать.
  • Нужно привести к конкретному типу:
  • Константность и указатели: минимальная база

    Ключевое различие:

  • Константные данные — нельзя менять значение по адресу.
  • Константный указатель — нельзя менять сам адрес, который хранится в указателе.
  • | Запись | Что запрещено | |---|---| | const int p | нельзя менять p, но можно менять p | | int const p = &x; | нельзя менять p, но можно менять p |

    Пример для const int *p:

    Эта тема будет регулярно встречаться дальше, особенно при работе со строками и API.

    Мини-словарь терминов

  • Адрес — местоположение объекта в памяти.
  • Указатель — переменная, содержащая адрес.
  • Разыменование — получение доступа к объекту по адресу (*p).
  • Нулевой указатель — значение, означающее «нет адреса» (NULL).
  • Типичные ошибки новичков

  • Путать p и *p: первый — адрес, второй — значение по адресу.
  • Использовать неинициализированный указатель (в нём мусорный адрес).
  • Разыменовывать NULL.
  • Неправильно понимать int* p, q;.
  • Пытаться разыменовать void * без приведения.
  • Что дальше

    В следующих статьях курса мы используем базу из этой темы, чтобы уверенно:

  • Передавать указатели в функции и менять переменные «снаружи».
  • Работать с массивами и строками через указатели.
  • Понимать арифметику указателей и адресацию элементов.
  • 2. Указатели и память: стек, куча, malloc/free

    Указатели и память: стек, куча, malloc/free

    Как эта тема связана с базой про указатели

    В прошлой статье мы разобрали, что указатель хранит адрес, а разыменование p даёт доступ к объекту по этому адресу. Теперь ключевой вопрос: какой именно объект лежит по адресу и сколько он будет жить*.

    В C это напрямую связано с тем, где и как выделена память:

  • Стек — обычно для локальных переменных.
  • Куча — для динамически выделенных блоков через malloc-семейство.
  • Понимание времени жизни объектов — основа безопасной работы с указателями.

    Стек и куча: что это и почему важно

    !Диаграмма показывает типичное расположение стека и кучи и то, что они растут навстречу друг другу.

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

    Стек

    Стек (stack) чаще всего используется для:

  • локальных переменных функций;
  • параметров функций;
  • служебной информации вызовов (адрес возврата и прочее).
  • Свойства стека:

  • выделение и освобождение происходят автоматически при входе в функцию и выходе из неё;
  • работа очень быстрая;
  • размер обычно ограничен;
  • нельзя возвращать указатель на локальную переменную как на “живой” объект.
  • Куча

    Куча (heap) используется для динамической памяти, которую программа запрашивает сама:

  • malloc, calloc, realloc выделяют блоки памяти;
  • free освобождает блок.
  • Свойства кучи:

  • память живёт, пока вы явно не вызовете free (или пока процесс не завершится);
  • можно выделять большие блоки, но операции обычно медленнее стека;
  • появляется ответственность: не забыть free и не использовать освобождённую память.
  • Время жизни объекта: почему “адрес” сам по себе ничего не гарантирует

    Один и тот же указатель может быть:

  • корректным (указывает на существующий объект);
  • некорректным (указывает на уже несуществующий объект);
  • нулевым (NULL);
  • неинициализированным (содержит “мусорный” адрес).
  • Критически важно различать:

  • область видимости имени (где переменная доступна по имени);
  • время жизни объекта в памяти (когда адрес ещё соответствует существующему объекту).
  • Подробно термины “storage duration” и связанные правила описаны на cppreference: Storage duration.

    Указатель на стековую переменную: типичная ошибка

    Рассмотрим пример функции, которая “возвращает строку”:

    Проблема: массив buf размещён на стеке. После выхода из bad_make_message его время жизни заканчивается, а указатель становится висячим (dangling pointer). Использование такого указателя приводит к неопределённому поведению.

    Правильные варианты зависят от задачи:

  • вернуть результат через буфер, который передал вызывающий код;
  • выделить память в куче и вернуть её;
  • использовать статический буфер (редко и осторожно, особенно в многопоточности).
  • Динамическая память: malloc и free

    malloc

    malloc выделяет блок памяти заданного размера (в байтах) и возвращает void *.

  • При успехе возвращается адрес начала блока.
  • При ошибке возвращается NULL.
  • Источник: cppreference: malloc.

    Пример выделения массива из n целых:

    Почему важно использовать sizeof(int): размер int зависит от платформы.

    free

    free освобождает память, ранее выделенную malloc-семейством.

    Источник: cppreference: free.

    Правила безопасности:

  • освобождать можно только то, что получено из malloc/calloc/realloc;
  • один и тот же блок нельзя освобождать дважды;
  • после free(p) нельзя разыменовывать p.
  • Частый паттерн:

    free(NULL) безопасен и ничего не делает.

    calloc: выделить и обнулить

    calloc(count, size) выделяет память под count элементов по size байт и обнуляет её.

    Источник: cppreference: calloc.

    Пример:

    Важно: “обнуление” означает, что все байты равны нулю. Для целых типов это даёт значение 0, для указателей — нулевой указатель. Для float и double на практике тоже обычно получается 0.0, но опираться на это как на универсальное правило не стоит.

    realloc: изменить размер блока

    realloc может:

  • увеличить блок;
  • уменьшить блок;
  • переместить блок в другое место.
  • Источник: cppreference: realloc.

    Опасный шаблон:

    Безопаснее:

    Что именно лежит в куче после malloc

    malloc даёт “сырой” блок байтов. Компилятор не “знает”, что вы там храните, пока вы не интерпретируете адрес как указатель нужного типа.

    Пример с массивом структур:

    Здесь items — указатель на Item, а арифметика индекса items[i] опирается на размер Item.

    Типичные ошибки при работе с кучей

    Утечка памяти

    Утечка — это ситуация, когда на выделенный блок больше нет указателей, и вы не можете его освободить.

    Пример:

    Use-after-free

    Использование памяти после освобождения:

    Double free

    Повторное освобождение одного и того же блока:

    Несоответствие “выделил” и “освободил”

    Нельзя делать так:

  • free(&x), если x — локальная переменная;
  • free для указателя, который указывает в середину блока, а не на его начало.
  • Корректно:

  • free должен получать ровно тот адрес, который вернули malloc/calloc/realloc.
  • Практические рекомендации

  • Всегда проверяйте результат malloc/calloc/realloc на NULL.
  • Сразу определяйте “владельца” памяти: кто обязан вызвать free.
  • После free часто полезно присвоить указателю NULL.
  • Для массивов используйте выражения вида n sizeof(p), чтобы тип и размер не расходились:
  • Для поиска утечек и ошибок памяти используйте инструменты анализа, например Valgrind.
  • Итог

  • Указатель — это адрес, но безопасность зависит от времени жизни объекта по этому адресу.
  • Локальные переменные на стеке живут до выхода из функции.
  • Динамическая память в куче живёт до free.
  • malloc выделяет неинициализированную память, calloc выделяет и обнуляет, realloc меняет размер блока.
  • Классические ошибки: утечки, dangling pointer, use-after-free, double free.
  • 3. Массивы и строки: связь с указателями, арифметика

    Массивы и строки: связь с указателями, арифметика

    Как эта тема продолжает курс

    В прошлых статьях мы закрепили две опоры:

  • Указатель хранит адрес, а *p даёт доступ к объекту по адресу.
  • Безопасность указателей зависит от времени жизни памяти: стек и куча, malloc/free.
  • Теперь мы разберём, почему в C массивы и строки почти всегда «выглядят» как указатели, что означает выражение a[i], что такое арифметика указателей, и где чаще всего появляются ошибки.

    Массив и указатель: похожи, но не одно и то же

    Массив

    Массив — это объект, содержащий подряд идущие элементы одного типа.

  • a — это массив из 5 элементов int.
  • Каждый элемент имеет тип int.
  • Память под весь массив выделена целиком (обычно на стеке, если это локальная переменная).
  • Справка: Arrays (cppreference)

    Указатель

    Указатель — отдельная переменная, которая хранит адрес.

    p можно переназначать на другие адреса, массив a переназначить нельзя.

    «Преобразование массива в указатель» (array-to-pointer decay)

    Во многих выражениях имя массива неявно преобразуется в указатель на его первый элемент.

    Здесь:

  • a в этом контексте превращается в int *, указывающий на a[0].
  • p хранит адрес первого элемента массива.
  • Это правило объясняет, почему массивы удобно передавать в функции.

    Справка: Array-to-pointer conversion (cppreference)

    Когда массив не превращается в указатель

    Есть важные исключения, где массив остаётся массивом:

  • sizeof(a) даёт размер всего массива в байтах.
  • &a даёт указатель на массив целиком (тип будет «указатель на массив»).
  • В некоторых контекстах инициализации (например, строковые литералы в char s[] = "...").
  • Пример с sizeof:

  • sizeof(a) обычно будет 10 * sizeof(int).
  • sizeof(p) — размер самого указателя (например, 8 байт на 64-битной системе).
  • Индексация — это арифметика указателей

    В C выражение a[i] определено через указатели:

  • a[i] эквивалентно *(a + i).
  • Это ключевая формула понимания массивов.

    Причина: после «преобразования массива в указатель» a становится адресом &a[0], а a + i означает «сдвинуться на i элементов вперёд».

    Справка: Pointer arithmetic (cppreference)

    !Иллюстрация того, как указатель «шагает» по элементам массива

    Что значит p + 1

    Если p имеет тип int *, то:

  • p + 1 указывает на следующий int.
  • Фактически адрес увеличится на sizeof(int) байт.
  • То же для других типов:

  • double *pd: pd + 1 смещается на sizeof(double).
  • char *pc: pc + 1 смещается на 1 байт.
  • Разница p + i и «прибавить байты вручную»

    Правильная арифметика указателей учитывает размер типа автоматически. «Ручное» прибавление байтов через приведение к целому типу делает код менее переносимым и чаще приводит к ошибкам.

    Массивы в параметрах функций: на самом деле это указатели

    Если написать функцию так:

    то в параметрах это будет интерпретировано как:

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

    Пример: суммирование массива.

    Обратите внимание на const int *a:

  • Мы обещаем не менять элементы массива внутри функции.
  • Это делает интерфейс безопаснее и понятнее.
  • &a и «указатель на массив»

    Важный, но часто путаемый случай:

    Здесь:

  • &a имеет тип int ()[5]указатель на массив из 5 int*.
  • pa + 1 перескочит сразу через весь массив (на 5 * sizeof(int) байт).
  • Это используется реже, но полезно для строгих интерфейсов и работы с многомерными массивами.

    Многомерные массивы и указатели

    Что такое int m[3][4]

    Это массив из 3 элементов, каждый из которых — массив из 4 int.

  • m в большинстве выражений преобразуется к типу int (*)[4] (указатель на строку из 4 int).
  • m[i] — это массив из 4 int, который в выражениях преобразуется к int *.
  • Как правильно передавать в функцию

    Нужно указать размер всех измерений, кроме первого.

    Эквивалентная запись параметра:

    Тип int * здесь не подходит*, потому что int[3][4] не является «массивом указателей».

    Строки в C: это массив char с нулём в конце

    Что такое C-строка

    В C строка обычно представлена как массив char, заканчивающийся нулевым байтом \0.

    Пример:

    В памяти это будет: {'h', 'i', '\0'}.

    Справка по работе со строками: String handling (cppreference)

    Почему char * часто означает «строку»

    Если у нас есть C-строка, то указатель на её первый символ (char *) достаточно, чтобы начать работу:

    Справка: strlen (cppreference)

    Два похожих случая: char s[] и char *p

    Они похожи, но отличаются по смыслу и по времени жизни.

    Различия:

  • s1 — массив, содержащий копию символов. Его можно менять: s1[0] = 'H';.
  • s2 — указатель на строковый литерал.
  • - Строковые литералы могут располагаться в памяти, которую менять нельзя. - Попытка модифицировать s2[0] приводит к неопределённому поведению.

    Практическое правило:

  • Для литералов используйте const char *.
  • Строки и арифметика указателей

    Раз строка — это char[], то и «шагать» по ней можно указателем:

    Здесь:

  • p двигается по одному байту (char).
  • Условие *p != '\0' защищает от выхода за конец строки.
  • Границы массива и неопределённое поведение

    Самая частая причина ошибок с указателями и массивами — выход за границы.

    Правила, которые стоит запомнить:

  • Можно брать адрес &a[n], то есть «указатель на элемент за последним» (one-past-the-end). Такой указатель можно хранить и сравнивать, но нельзя разыменовывать.
  • Разыменование за границами массива — неопределённое поведение.
  • Пример корректного обхода через два указателя:

    Справка: Pointer comparisons and one-past-the-end (cppreference)

    Частые ошибки и как их избежать

  • Путать размер массива и размер указателя
  • - Внутри функции sizeof(a) для параметра int a[] даст размер указателя. - Передавайте длину явно.
  • Пытаться изменить строковый литерал
  • - Пишите const char *s = "text";.
  • Передавать int * вместо int ()[N] для двумерных массивов
  • - int m[R][C] «превращается» в int (*)[C].
  • Выход за границы
  • - Всегда контролируйте индексы или используйте пару указателей begin/end.
  • Использовать неинициализированные указатели при обходе
  • - Указатель должен начинаться с корректного адреса, например p = a.

    Итог

  • Имя массива в большинстве выражений преобразуется в указатель на первый элемент.
  • a[i] эквивалентно *(a + i), а p + 1 сдвигается на размер типа.
  • В параметрах функций массивы становятся указателями, поэтому размер нужно передавать отдельно.
  • C-строка — это char[], заканчивающаяся \0, и с ней работают как с указателем на char.
  • Строковые литералы следует хранить в const char *.
  • Выход за границы массива и неправильные типы (особенно для 2D массивов) приводят к неопределённому поведению.
  • 4. Указатели на структуры и двойные указатели

    Указатели на структуры и двойные указатели

    Как эта тема связана с предыдущими

    Ранее мы разобрали:

  • что указатель хранит адрес и как работает разыменование *p;
  • чем отличаются стек и куча, и как управлять временем жизни объектов через malloc/free;
  • почему массивы и строки тесно связаны с указателями и как работает арифметика указателей.
  • Теперь добавим два важных инструмента, которые постоянно встречаются в реальных программах на C:

  • указатели на структуры (для работы со сложными данными без копирования и для динамических структур данных);
  • двойные указатели (T *) (чтобы функция могла изменить сам указатель* у вызывающего кода, а также для работы с наборами указателей).
  • Указатель на структуру

    Что такое структура

    Структура объединяет несколько полей в один объект.

    Справка: Structures (cppreference)

    Получение указателя на структуру

  • st хранит сами данные.
  • p хранит адрес st.
  • !Указатель на структуру хранит адрес объекта структуры

    Доступ к полям: (*p).field и p->field

    Если у вас есть Student *p, то доступ к полю возможен двумя способами.

  • Через разыменование и точку:
  • Через оператор стрелки ->:
  • Эти записи эквивалентны:

  • p->id эквивалентно (*p).id.
  • Справка: Member access operators (cppreference)

    Почему нужны скобки в (*p).id

    Оператор . имеет более высокий приоритет, чем *. Поэтому выражение:

    интерпретируется как:

  • *(p.id)
  • а это почти всегда ошибка, потому что p — указатель, и у него нет поля id.

    Правильно:

  • (*p).id или p->id.
  • Указатели на структуры и динамическая память

    Часто структуру нужно создать в куче и вернуть/передать дальше как указатель.

    Выделение структуры через malloc

    Что важно:

  • malloc(sizeof(*p)) безопаснее, чем malloc(sizeof(Student)), потому что размер автоматически соответствует типу p.
  • результат malloc нужно проверять на NULL.
  • Справка: malloc (cppreference)

    Освобождение памяти

    Если объект выделен через malloc, его нужно освободить через free.

    Справка: free (cppreference)

    Важное правило владения

    При проектировании кода всегда фиксируйте, кто владеет памятью (кто обязан вызвать free). Иначе легко получить утечки или двойное освобождение.

    Передача структуры в функцию: по значению и по указателю

    По значению

    Плюсы:

  • простота.
  • Минусы:

  • копирование структуры (иногда дорого);
  • нельзя изменить исходный объект у вызывающего кода.
  • По указателю

    Это продолжение идеи из предыдущих тем: передавая адрес, мы можем менять данные снаружи.

    const с указателями на структуры

    Если функция не должна менять объект:

    Здесь const Student *st означает:

  • нельзя менять *st (то есть поля структуры через этот указатель);
  • но можно менять сам указатель st как локальную переменную функции.
  • Массив структур и указатель на структуру

    Структуры часто хранятся в массивах.

    Здесь полезно помнить формулу из прошлой статьи:

  • p[i] эквивалентно *(p + i).
  • Двойной указатель: что такое T **

    Определение

    T * хранит адрес объекта типа T.

    T * хранит адрес переменной типа T .

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

    !Двойной указатель указывает на указатель

    Зачем нужен T **: изменить указатель в функции

    Если функция получает T p, то она получает копию* указателя. Изменить p внутри функции можно, но это не изменит указатель у вызывающего кода.

    Неправильная попытка “выделить память внутри функции”

    Проблема: x не меняется, потому что p — локальная копия.

    Правильно: передать адрес указателя (int **)

    Разбор по уровням:

  • pp указывает на переменную x (то есть хранит адрес x);
  • pp — это сама переменная x (типа int ), её и меняет функция;
  • **pp — это int в выделенной памяти.
  • Двойные указатели как “указатель на голову структуры данных”

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

    Здесь Node **head нужен, потому что push_front должна обновить указатель на первый узел у вызывающего кода.

    char **argv — реальный пример двойного указателя

    В main часто встречается:

    argv — это набор указателей на char (то есть на строки). Удобно думать о нём как о “массиве строк”, но по типам это именно char **.

    Важно:

  • argv[i] имеет тип char * (указатель на первый символ строки аргумента);
  • argv[i][j] — конкретный символ в строке.
  • T ** и двумерные массивы: частая ловушка

    Из прошлой статьи:

  • int m[R][C] в выражениях превращается в int ()[C], а не в int *.
  • Это означает:

  • int * и int ()[C] — разные типы для разных представлений данных.
  • Когда уместен int **

    int ** обычно означает “набор указателей на строки”, где каждая строка может быть выделена отдельно и даже иметь разную длину.

    Пример схемы выделения:

  • rows — массив указателей int *.
  • Каждый rows[i] указывает на отдельно выделенный массив int.
  • Эта модель гибкая, но требует аккуратного free на каждом уровне.

    Когда int * не подходит*

    Если у вас есть настоящий непрерывный блок int m[R][C], то передавать его как int ** нельзя. Это нарушает ожидания по расположению памяти и приводит к неопределённому поведению.

    Частые ошибки и как их избежать

  • Путаница . и ->.
  • Пропущенные скобки: писать (*p).field, а проще p->field.
  • Неверный sizeof при malloc.
  • - Хороший стиль: malloc(n sizeof(p)).
  • Попытка изменить указатель в функции без T **.
  • Смешивание “настоящего 2D массива” int[R][C] и “набора строк” int **.
  • Неполное освобождение памяти при T **.
  • - Если выделяли в два уровня, освобождать нужно тоже в два уровня.

    Итог

  • struct *p позволяет работать со структурой по адресу, без копирования.
  • p->field эквивалентно (*p).field и обычно читается проще.
  • Динамические структуры в куче почти всегда живут как указатели (malloc/free).
  • T * нужен, когда функция должна изменить сам указатель* у вызывающего кода.
  • int ** не является заменой int[R][C]: это разные модели памяти и разные типы.
  • 5. Указатели на функции и типичные ошибки

    Указатели на функции и типичные ошибки

    Как эта тема продолжает курс

    Ранее мы разобрали указатели на данные: как хранится адрес, как работает разыменование, где живёт память (стек и куча), как массивы и строки связаны с указателями, и зачем нужны двойные указатели.

    Указатели на функции продолжают эту линию: адрес может указывать не только на данные, но и на код функции. Это позволяет передавать поведение как параметр: делать колбэки, таблицы диспетчеризации, универсальные алгоритмы сортировки и обработки.

    Что такое указатель на функцию

    Указатель на функцию — это переменная, которая хранит адрес функции с конкретной сигнатурой.

    Ключевое правило: тип указателя на функцию включает тип возвращаемого значения и типы всех параметров.

    Если сигнатуры не совпадают, вызов через такой указатель приводит к неопределённому поведению.

    Справка по функциям в C: cppreference: Function declaration

    Синтаксис: как объявлять и как читать

    Главная сложность — скобки. Они нужны из-за приоритетов операторов.

    | Запись | Как читать | Что это такое | |---|---|---| | int (*fp)(int) | fp — указатель на функцию, принимающую int и возвращающую int | указатель на функцию | | int fp(int) | fp — функция, принимающая int и возвращающая int | функция, а не указатель | | void (*handler)(int) | handler — указатель на функцию void (int) | типичный обработчик | | int (*ops[3])(int, int) | ops — массив из 3 указателей на функции int (int, int) | таблица операций |

    Практическое правило чтения:

  • Смотрите на имя.
  • Если рядом с именем стоит (*имя), то это указатель.
  • Часть справа в скобках ( ... ) — параметры функции.
  • Получение адреса функции

    Адрес функции можно получить двумя эквивалентными способами:

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

    Вызов функции через указатель

    Вызвать можно двумя эквивалентными формами:

    Обычно используют fp(10) как более читаемое.

    !Схема показывает идею колбэка: алгоритм вызывает переданную функцию

    typedef для упрощения сигнатур

    Сложные типы удобнее прятать за псевдоним.

    Плюсы:

  • меньше скобок;
  • проще читать API;
  • проще менять сигнатуру в одном месте.
  • Колбэки на практике: пример с qsort

    Стандартная библиотека C предоставляет сортировку qsort, которая принимает указатель на функцию сравнения.

    Справка: cppreference: qsort

    Сигнатура компаратора:

  • принимает два const void * (адреса элементов);
  • возвращает отрицательное, ноль или положительное число.
  • Пример сортировки int по возрастанию:

    Что важно в этом примере:

  • qsort работает с любыми типами, поэтому элементы передаются как void *.
  • Внутри компаратора вы обязаны привести void к правильному типу (const int ) и разыменовать.
  • Ключевая безопасность здесь такая же, как в теме про void : нельзя разыменовывать void без приведения к конкретному типу.
  • Указатели на функции в структурах и таблицы диспетчеризации

    Частый приём — хранить “операции” внутри структуры.

    Это похоже на “таблицу виртуальных функций” и часто используется для плагинной архитектуры, драйверов, обработчиков событий.

    Ещё вариант — массив указателей на функции (простая диспетчеризация по коду операции):

    Константность с указателями на функции

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

    При этом const не относится к “коду функции”, а относится только к переменной fp.

    Типичные ошибки и как их избежать

    Путаница со скобками в объявлении

    Частая ошибка — написать тип “похожий”, но означающий другое.

    Решение:

  • если это указатель, пишите (*name);
  • используйте typedef для читаемости.
  • Несовпадение сигнатуры

    Опасно (не делайте так):

    Почему это плохо:

  • соглашение о вызовах и передача аргументов зависят от типа функции;
  • приведение типа может “заткнуть” предупреждение, но не делает вызов корректным.
  • Правило:

  • тип указателя на функцию должен точно совпадать с функцией.
  • Вызов через NULL или неинициализированный указатель

    Как и с указателями на данные:

  • вызов через NULL — неопределённое поведение;
  • вызов через неинициализированный указатель — неопределённое поведение.
  • Безопасный паттерн:

    Путаница указателей на данные и указателей на функции

    В стандарте C указатели на данные и указатели на функции — разные “семейства”. В частности, не гарантируется, что можно безопасно преобразовать:

  • void * в “указатель на функцию” и обратно;
  • “указатель на функцию” в целое и обратно без специальных оговорок.
  • Практическое правило для учебных и прикладных задач:

  • не храните указатели на функции в void *;
  • используйте корректный тип указателя на функцию или typedef.
  • Отсутствие прототипа функции (особенно в старом коде)

    Если вы вызываете функцию без видимого объявления, компилятор может:

  • выдать ошибку (в современных стандартах это типично);
  • или, в старом/нестандартном режиме, сделать неверные предположения о типах.
  • Решение:

  • всегда объявляйте функции до использования (через заголовок или прототип);
  • включайте предупреждения компилятора.
  • Ошибки в колбэках qsort: неверные приведения и разыменование

    Типичные проблемы:

  • привести const void * к неправильному типу;
  • разыменовать как int *, хотя элементы имеют другой размер;
  • забыть const и попытаться модифицировать элементы в компараторе.
  • Решение:

  • компаратор пишите рядом с местом, где понятен тип элементов;
  • используйте шаблон вида const T pa = a; const T pb = b;.
  • Попытка “вернуть функцию” вместо “вернуть указатель на функцию”

    Функция не может возвращать функцию как значение, но может возвращать указатель на функцию.

    Правильная форма:

    Итог

  • Указатель на функцию хранит адрес функции и имеет строгий тип, включающий возвращаемое значение и параметры.
  • Скобки в объявлении критичны: int (fp)(int) и int fp(int) — разные вещи.
  • Колбэки позволяют передавать поведение: типичный пример — qsort с компаратором.
  • Основные источники ошибок: неверная сигнатура, неправильные скобки, вызов через NULL, хранение “не тем типом”, ошибки приведения в qsort.