Указатели, функции, структуры и динамическая память в C
В этом посте разбираю указатели, функции, препроцессор, структуры и динамическую память в C: от базовых правил до практических шаблонов безопасного кода.
Материал ориентирован на C23 с сохранением совместимости со старшими стандартами C11/C17. Для проверки примеров используйте -std=c23 -Wall -Wextra -Wpedantic -Werror и проверяйте предупреждения так же строго, как и ошибки компиляции.
Указатели
Указатели позволяют хранить адрес объекта в памяти и работать с этим объектом косвенно. Это ключевой механизм, на котором построены массивы, строки, передача параметров по адресу и динамическая память.
Формальные правила
- Разыменовывать можно только валидный указатель на существующий объект подходящего типа.
- Арифметика указателей определена только в пределах одного массива и позиции one-past (на один элемент за концом массива).
- Сравнение указателей на порядок корректно только для элементов одного массива.
- После
freeуказатель становится висячим (dangling pointer), его чтение/разыменование недопустимо.
Что такое указатели
Указатель - это переменная, значением которой является адрес другого объекта. Тип указателя определяет, как интерпретируется память по этому адресу: int * читает целое, double * - вещественное,char * - байты символов.
Самая частая ошибка на старте - разыменование неинициализированного или нулевого указателя. Безопасный шаблон: инициализировать указатель сразу, проверять на NULL до доступа и не хранить адрес объекта дольше, чем живет сам объект.
#include <stdio.h>
int main(void) {
int value = 42;
int *ptr = &value;
printf("value = %d\n", value);
printf("*ptr = %d\n", *ptr);
*ptr = 100;
printf("value after pointer write = %d\n", value);
return 0;
}Ожидаемый вывод:
value = 42
*ptr = 42
value after pointer write = 100Операции с указателями
Базовые операции: взятие адреса &, разыменование *, присваивание адреса, сравнение с NULL и сравнение на равенство/неравенство. В практическом коде почти все операции с указателем сводятся к этим примитивам.
Важно не смешивать адрес и значение: если вы пишете int *p, то p хранит адрес, а*p - значение по адресу.
#include <stddef.h>
#include <stdio.h>
int main(void) {
int x = 10;
int y = 20;
int *p = &x;
printf("p points to x: %d\n", *p);
p = &y;
*p += 5;
printf("y after update: %d\n", y);
p = NULL;
printf("p == NULL -> %d\n", p == NULL);
return 0;
}Ожидаемый вывод:
p points to x: 10
y after update: 25
p == NULL -> 1Арифметика указателей
При добавлении к указателю единицы он смещается на размер одного элемента его типа. Для int * это обычно 4 байта, для double * обычно 8 байт. Разность двух указателей дает количество элементов между ними, а не разницу в байтах.
Выход за границы массива - undefined behavior. Допустима только позиция one-past для сравнения и вычислений, но разыменовывать one-past нельзя.
one-past - это указатель на позицию сразу за последним элементом того же массива. Для массива из 5 элементов arr это arr + 5 (эквивалентно &arr[5]). Такой указатель не указывает на реальный объект, поэтому операция *one_past запрещена. Но one-past можно безопасно использовать в сравнении (например, в условии цикла) и для вычисления расстояния между указателями внутри одного массива.
#include <stddef.h>
#include <stdio.h>
int main(void) {
int arr[] = {10, 20, 30, 40, 50};
int *a = &arr[1];
int *b = &arr[4];
int *one_past = arr + 5; // позиция сразу за arr[4]
printf("*a = %d\n", *a);
printf("*(a + 2) = %d\n", *(a + 2));
printf("b - a = %td\n", b - a);
printf("one_past == &arr[5] -> %d\n", one_past == &arr[5]);
printf("distance(one_past - a) = %td\n", one_past - a);
for (int *p = arr; p != one_past; p++) {
printf("%d ", *p);
}
printf("\n");
// printf("%d\n", *one_past); // UB: разыменовывать one-past нельзя
return 0;
}Ожидаемый вывод:
*a = 20
*(a + 2) = 40
b - a = 3
one_past == &arr[5] -> 1
distance(one_past - a) = 4
10 20 30 40 50 Константы и указатели
Важны три формы: const int *p (нельзя менять объект через указатель), int *const p(нельзя переназначить сам указатель) и const int *const p (оба ограничения одновременно).
Ошибки в placement слова const приводят к неверным API-контрактам. Если функция не должна менять входные данные, используйте const в сигнатуре - это помогает и читателю, и компилятору.
#include <stdio.h>
int main(void) {
int value = 7;
int other = 11;
const int *ptr_to_const = &value;
int *const const_ptr = &value;
const int *const const_both = &value;
ptr_to_const = &other;
*const_ptr = 8;
printf("*ptr_to_const = %d\n", *ptr_to_const);
printf("value via const_ptr = %d\n", value);
printf("*const_both = %d\n", *const_both);
return 0;
}Ожидаемый вывод:
*ptr_to_const = 11
value via const_ptr = 8
*const_both = 8Указатели и массивы
В большинстве выражений имя массива неявно преобразуется в указатель на первый элемент. Поэтому и записьarr[i], и *(arr + i) читают один и тот же элемент.
Критичный нюанс: внутри функции массив в параметре фактически уже указатель. Поэтому sizeof(arr)в параметре возвращает размер указателя, а не длину исходного массива. Длину нужно передавать отдельным аргументом.
#include <stddef.h>
#include <stdio.h>
int sum_array(const int *arr, size_t n) {
int sum = 0;
for (size_t i = 0; i < n; i++) {
sum += *(arr + i);
}
return sum;
}
int main(void) {
int numbers[] = {3, 6, 9, 12};
size_t n = sizeof(numbers) / sizeof(numbers[0]);
printf("sum = %d\n", sum_array(numbers, n));
return 0;
}Ожидаемый вывод:
sum = 30Указатели и строки
Строка в C - это массив символов, завершенный нулевым байтом '\0'. Строковый литерал обычно размещается в read-only памяти, поэтому храните его как const char *.
Попытка изменить символ в строковом литерале - undefined behavior. Если строку нужно менять, создавайте изменяемый массив char text[] = "...", а не указатель на литерал.
#include <stdio.h>
int main(void) {
char mutable_text[] = "hello";
const char *literal = "world";
char *p = mutable_text;
p[0] = 'H';
printf("mutable_text = %s\n", mutable_text);
printf("literal = %s\n", literal);
return 0;
}Ожидаемый вывод:
mutable_text = Hello
literal = worldМассивы указателей и многоуровневая адресация
Массив указателей хранит адреса нескольких объектов одинакового типа. Это базовый инструмент для наборов строк, таблиц адресов функций и интерфейсов вида char **argv.
Многоуровневая адресация (int **, char ***) полезна, когда нужно менять сам указатель в вызывающей функции. На каждом уровне косвенности важно явно понимать, адрес чего хранится в текущей переменной.
#include <stdio.h>
int main(void) {
const char *colors[] = {"red", "green", "blue"};
const char **pp = colors;
int value = 123;
int *p = &value;
int **pp_int = &p;
printf("colors[1] = %s\n", colors[1]);
printf("*(pp + 2) = %s\n", *(pp + 2));
printf("**pp_int = %d\n", **pp_int);
return 0;
}Ожидаемый вывод:
colors[1] = green
*(pp + 2) = blue
**pp_int = 123Функции
Функции в C формируют модульность: они изолируют ответственность, задают контракт параметров и возвращаемого значения, и определяют точки расширения через указатели на функции.
Формальные правила
- Функция должна быть объявлена до первого вызова через прототип или полное определение.
- Аргументы передаются по значению; для эффекта «по ссылке» передают указатель.
- Рекурсия требует корректного базового случая, иначе будет переполнение стека.
- Вызов через указатель на функцию корректен только при совместимых типах; mismatch сигнатур - undefined behavior.
Определение и описание функций
Описание (прототип) фиксирует сигнатуру: имя, тип результата и параметры. Определение содержит тело функции. Разделение удобно для интерфейсных заголовков и реализации в отдельных файлах.
Если сигнатуры прототипа и определения расходятся, вы получите ошибки компиляции или опасные преобразования на этапе вызова. Поэтому прототипы всегда держат синхронными с реализацией.
#include <stdio.h>
int square(int x);
int square(int x) {
return x * x;
}
int main(void) {
printf("square(9) = %d\n", square(9));
return 0;
}Ожидаемый вывод:
square(9) = 81Параметры функции
Параметры функции - это локальные переменные, инициализированные значениями аргументов. В C аргументы всегда передаются по значению, даже если это указатель.
Поэтому изменение обычного параметра внутри функции не меняет исходную переменную вызывающего кода. Для изменения внешнего объекта передавайте адрес этого объекта.
#include <stdio.h>
void inc_copy(int x) {
x++;
printf("inside inc_copy: x = %d\n", x);
}
int main(void) {
int n = 10;
inc_copy(n);
printf("outside after call: n = %d\n", n);
return 0;
}Ожидаемый вывод:
inside inc_copy: x = 11
outside after call: n = 10Результат функции
Тип результата определяет, какое значение возвращает функция через return. Это может быть скаляр, указатель или структура, если стоимость копирования приемлема.
Нельзя «вернуть» адрес локальной переменной автоматического хранения: после выхода из функции она уничтожается, и указатель становится dangling. Для сложных результатов используйте структуру или внешний буфер.
#include <stdio.h>
typedef struct {
int sum;
int diff;
} PairResult;
PairResult calc(int a, int b) {
PairResult r;
r.sum = a + b;
r.diff = a - b;
return r;
}
int main(void) {
PairResult r = calc(11, 4);
printf("sum = %d, diff = %d\n", r.sum, r.diff);
return 0;
}Ожидаемый вывод:
sum = 15, diff = 7Рекурсивные функции
Рекурсия - это вызов функцией самой себя для решения подзадачи. Подход удобен для деревьев, обходов графов и задач, которые естественно раскладываются на однотипные шаги.
Главный риск - отсутствие корректной остановки и чрезмерная глубина вызовов. В этом случае программа может закончиться stack overflow, поэтому всегда формулируйте базовый случай явно.
#include <stdio.h>
unsigned long long factorial(unsigned int n) {
if (n <= 1) {
return 1ULL;
}
return n * factorial(n - 1);
}
int main(void) {
printf("factorial(5) = %llu\n", factorial(5));
return 0;
}Ожидаемый вывод:
factorial(5) = 120Область видимости переменных
Область видимости определяет, где имя переменной доступно. Локальные переменные функции видны только в теле функции, а блочные переменные - только внутри соответствующего блока { ... }.
Ошибка затенения (shadowing), когда внутреннее имя перекрывает внешнее, часто усложняет чтение кода и маскирует дефекты. В критичных участках лучше использовать уникальные имена и короткие блоки.
#include <stdio.h>
int g = 100;
int main(void) {
int x = 10;
{
int x = 20;
printf("inner x = %d\n", x);
}
printf("outer x = %d\n", x);
printf("global g = %d\n", g);
return 0;
}Ожидаемый вывод:
inner x = 20
outer x = 10
global g = 100Внешние объекты
Внешние объекты - это глобальные переменные с storage duration на время всей программы. Их можно объявлять в одном переводимом модуле через extern и определять в другом.
Глобальное изменяемое состояние затрудняет сопровождение и тестирование. Практичный подход - минимизировать объем внешних объектов и документировать, кто имеет право их менять.
#include <stdio.h>
extern int g_counter;
void tick(void) {
g_counter++;
}
int g_counter = 0;
int main(void) {
tick();
tick();
printf("g_counter = %d\n", g_counter);
return 0;
}Ожидаемый вывод:
g_counter = 2Классы хранения (Storage class)
Классы хранения в C задают три вещи: время жизни объекта (storage duration), область видимости (scope) и, для объектов на уровне файла, тип связывания (linkage).
auto: автоматическое хранение для локальных переменных блока. Время жизни - от входа в блок до выхода из него. Это значение по умолчанию для обычных локальных переменных.register: тоже автоматическое хранение, но с подсказкой компилятору держать переменную в регистре. Современные компиляторы сами решают оптимизацию, поэтому ключевое слово чаще используется как часть старого кода. У классическогоregisterнельзя брать адрес через&.extern: объявление внешнего объекта/функции, определенной в другом месте (или позже в этом же файле). Объект имеет статическое время жизни и внешнее связывание.static: для локальной переменной - статическое время жизни с сохранением значения между вызовами; для объекта на уровне файла - внутреннее связывание (доступ только в текущем переводимом модуле).
Ниже пример, который иллюстрирует все классы хранения в одном месте: auto, register,extern и два варианта static (локальный и файловый).
#include <stdio.h>
extern int shared_counter; // extern: объявление внешнего объекта
static int file_local_total = 100; // static (file scope): внутреннее связывание
int shared_counter = 10; // определение внешнего объекта
void use_static_local(void) {
static int calls = 0;
calls++;
printf("static local calls = %d\n", calls);
}
void bump_extern(void) {
shared_counter++;
printf("extern shared_counter = %d\n", shared_counter);
}
int main(void) {
auto int local_auto = 5; // auto: переменная блока
register int i; // register: переменная цикла (адрес брать нельзя)
register int sum = 0;
printf("auto local_auto = %d\n", local_auto);
printf("static file_local_total = %d\n", file_local_total);
for (i = 1; i <= 3; i++) {
sum += i;
}
printf("register sum = %d\n", sum);
use_static_local();
use_static_local();
bump_extern();
return 0;
}Ожидаемый вывод:
auto local_auto = 5
static file_local_total = 100
register sum = 6
static local calls = 1
static local calls = 2
extern shared_counter = 11Указатели в параметрах функции
Если нужно изменить данные в вызывающем коде, функция должна получить адрес объекта. Такой прием используют для swap-операций, записи результата в out-параметры и работы с буферами.
Перед разыменованием параметра-указателя проверяйте его на NULL. Для API это простая защита от аварий, особенно когда функции вызываются из разных модулей и слоев приложения.
#include <stdio.h>
void swap_int(int *a, int *b) {
if (a == NULL || b == NULL) {
return;
}
int tmp = *a;
*a = *b;
*b = tmp;
}
int main(void) {
int x = 3;
int y = 9;
swap_int(&x, &y);
printf("x = %d, y = %d\n", x, y);
return 0;
}Ожидаемый вывод:
x = 9, y = 3Указатели на функции
Указатель на функцию хранит адрес исполняемого кода функции с конкретной сигнатурой. Это базовый инструмент для callback-механизмов, таблиц обработчиков и параметризуемых алгоритмов.
Базовая форма синтаксиса: return_type (*name)(param_types). Скобки вокруг *name обязательны: без них объявление читается как прототип функции, а не как переменная-указатель. Примеры: int (*fn)(int, int) - указатель на функцию int f(int, int); int (*table[2])(int, int) - массив из двух указателей на функции такой сигнатуры.
Присваивание возможно как через имя функции, так и через адрес: fn = add; и fn = &add; эквивалентны. Вызов также эквивалентен в двух формах: fn(2, 5) и (*fn)(2, 5). Нельзя вызывать функцию через указатель несовместимого типа: mismatch сигнатур приводит к undefined behavior.
Формальные правила
- Тип указателя на функцию должен быть совместим с типом вызываемой функции.
- Оператор вызова
()для указателя на функцию и для разыменованного указателя эквивалентен:p(args)и(*p)(args). - Арифметика указателей для function pointer не применяется (это не указатель на объект).
- Для сложных сигнатур используйте
typedef, чтобы сократить риск ошибок в объявлениях и вызовах.
Для практики полезно читать объявление «изнутри наружу»: сначала имя, затем уровень указателя, затем список параметров и тип результата. Этот прием помогает быстро разбирать даже сложные объявления.
#include <stdio.h>
int add(int a, int b) {
return a + b;
}
int mul(int a, int b) {
return a * b;
}
int apply(int (*op)(int, int), int x, int y) {
if (op == NULL) {
return 0;
}
return op(x, y);
}
int main(void) {
int (*fn)(int, int) = add;
int (*table[2])(int, int) = {add, mul};
printf("fn(add): %d\n", fn(2, 5));
fn = &mul;
printf("fn(mul): %d\n", (*fn)(2, 5));
printf("table[0]: %d\n", apply(table[0], 3, 4));
printf("table[1]: %d\n", apply(table[1], 3, 4));
return 0;
}Ожидаемый вывод:
fn(add): 7
fn(mul): 10
table[0]: 7
table[1]: 12Тип функции
В C тип функции и тип указателя на функцию - разные сущности. Для читаемости сложных сигнатур обычно вводятtypedef, чтобы сигнатура callback-а имела короткое имя.
Тип функции удобно использовать как контракт API: так проще контролировать совместимость при рефакторинге. Если сигнатура меняется, компилятор покажет все места, где контракт нарушен.
#include <stdio.h>
typedef int operation_fn(int, int);
int sub(int a, int b) {
return a - b;
}
int run(operation_fn *op, int x, int y) {
return op(x, y);
}
int main(void) {
printf("sub: %d\n", run(sub, 10, 3));
return 0;
}Ожидаемый вывод:
sub: 7Функции как параметры других функций
Передача функции как параметра позволяет отделить алгоритм обхода данных от конкретной операции над элементом. Это делает код переиспользуемым и снижает дублирование.
Синтаксис callback-параметра обычно выглядит так: return_type (*name)(param_types). Например, сигнатура void map_int(int *arr, size_t n, int (*fn)(int)) означает, что третий параметр - это указатель на функцию, которая принимает int и возвращает int. Скобки вокруг *fn обязательны: без них объявление читается как функция, возвращающая указатель.
Для сложных API запись через typedef часто проще: сначала объявляют тип callback-а, затем используют его в параметрах (например, typedef int (*map_fn)(int); и далее void map_int(int *arr, size_t n, map_fn fn)). При таком подходе важно четко описать контракт callback-а: допустимые значения, side effects и требования к потокобезопасности.
Формальные правила
- Тип указателя callback-а должен быть совместим с фактически передаваемой функцией, включая типы аргументов и тип результата.
- Выражения
fn(x)и(*fn)(x)для указателя на функцию эквивалентны. - Если callback может отсутствовать, API должен явно обрабатывать
NULLдо вызова. - Function pointer не поддерживает арифметику указателей; допустимы только присваивание, сравнение и вызов (при валидном значении).
Практичный прием чтения сигнатуры: сначала смотрите на имя параметра, затем на уровень указателя, потом на список аргументов и тип результата.
#include <stddef.h>
#include <stdio.h>
void map_int(int *arr, size_t n, int (*fn)(int)) {
for (size_t i = 0; i < n; i++) {
arr[i] = fn(arr[i]);
}
}
int square_value(int x) {
return x * x;
}
int main(void) {
int a[] = {1, 2, 3, 4};
map_int(a, 4, square_value);
printf("%d %d %d %d\n", a[0], a[1], a[2], a[3]);
return 0;
}Ожидаемый вывод:
1 4 9 16Функция как результат другой функции
Функция не может вернуть функцию напрямую, но может вернуть указатель на функцию. Так обычно выбирают стратегию вычисления на основе параметров конфигурации.
Вариант с typedef обычно самый читаемый: сначала объявляют тип указателя на функцию, затем используют его как тип результата. Например: typedef int (*binary_op)(int, int); и далее binary_op select_op(char mode).
Без typedef синтаксис выглядит так: int (*select_op_raw(char mode))(int, int). Читать удобно «изнутри наружу»: select_op_raw - функция, которая принимает char и возвращает указатель на функцию с сигнатурой int (int, int). Скобки вокруг *select_op_raw(...) обязательны.
Формальные правила
- Возвращаемый function pointer должен быть совместим с фактической сигнатурой функции, на которую он указывает.
- Сигнал «нет подходящей функции» обычно передается через
NULL, и вызывающий код обязан это проверять до вызова. - Вызовы
op(args)и(*op)(args)эквивалентны для валидного указателя на функцию. - Вызов через несовместимый тип function pointer - undefined behavior.
#include <stdio.h>
typedef int (*binary_op)(int, int);
int add_op(int a, int b) {
return a + b;
}
int max_op(int a, int b) {
return (a > b) ? a : b;
}
binary_op select_op(char mode) {
if (mode == '+') {
return add_op;
}
if (mode == 'm') {
return max_op;
}
return NULL;
}
int (*select_op_raw(char mode))(int, int) {
return select_op(mode);
}
int main(void) {
binary_op op1 = select_op('m');
int (*op2)(int, int) = select_op_raw('+');
if (op1 != NULL) {
printf("select_op('m') -> %d\n", op1(8, 5));
}
if (op2 != NULL) {
printf("select_op_raw('+') -> %d\n", op2(8, 5));
}
return 0;
}Ожидаемый вывод:
select_op('m') -> 8
select_op_raw('+') -> 13Функции с переменным количеством параметров
Variadic-функции работают через stdarg.h: va_list, va_start, va_arg, va_end. Такой подход нужен для API уровня printf и логгеров.
Как используются ключевые элементы varargs по шагам:
va_list: служебный тип, который хранит текущее состояние обхода переменных аргументов. Обычно объявляется как локальная переменная, напримерva_list ap;.va_start(ap, last_named_param): инициализирует обход. Второй аргумент обязан быть последним именованным параметром функции (в примере этоnвsum_ints(size_t n, ...)).va_arg(ap, T): читает следующий аргумент как типTи сдвигает внутренний указатель. Вызывать нужно столько раз, сколько аргументов реально передано по вашему протоколу.va_end(ap): завершает работу сva_list. Вызывать обязательно перед выходом из функции, даже если реализация на платформе выглядит как «пустая» операция.
Дополнительно: если нужно пройтись по аргументам повторно, используют va_copy(dst, src), а затем завершают оба списка через va_end. У varargs нет полной статической проверки типов, поэтому любое несоответствие протоколу аргументов приводит к чтению мусора и потенциальному UB.
Типичный корректный порядок всегда один: объявить va_list → вызвать va_start → читать аргументы через va_arg → завершить va_end.
#include <stdarg.h>
#include <stdio.h>
int sum_ints(size_t n, ...) {
va_list ap;
va_start(ap, n);
int sum = 0;
for (size_t i = 0; i < n; i++) {
sum += va_arg(ap, int);
}
va_end(ap);
return sum;
}
int main(void) {
printf("sum = %d\n", sum_ints(5, 10, 20, 30, 40, 50));
return 0;
}Ожидаемый вывод:
sum = 150Параметры командной строки
В сигнатуре main(int argc, char *argv[]) параметр argc хранит число аргументов, а argv - массив строк. Элемент argv[0] обычно содержит имя запускаемой программы.
При обработке аргументов всегда проверяйте границы и ожидаемые форматы. Нельзя читать argv[i], если i >= argc, иначе доступ выйдет за пределы массива указателей.
#include <stdio.h>
int main(int argc, char *argv[]) {
printf("argc = %d\n", argc);
for (int i = 0; i < argc; i++) {
printf("argv[%d] = %s\n", i, argv[i]);
}
return 0;
}Ожидаемый вывод:
# пример запуска: ./app Alice 42
argc = 3
argv[0] = ./app
argv[1] = Alice
argv[2] = 42Препроцессор
Препроцессор работает до компиляции: он подставляет заголовки, раскрывает макросы и управляет условной компиляцией. Это мощный, но «текстовый» этап, поэтому правила безопасности здесь особенно важны.
Директива #include. Включение файлов
Директива #include вставляет содержимое заголовочного файла в точку включения. Угловые скобки обычно используют для системных заголовков, кавычки - для локальных файлов проекта.
В больших проектах критичны include guards или #pragma once, иначе возможны повторные определения. Также важно избегать циклических включений и тяжелых лишних зависимостей в публичных заголовках.
#include <stdio.h>
#include <string.h>
int main(void) {
const char *text = "include";
printf("length = %zu\n", strlen(text));
return 0;
}Ожидаемый вывод:
length = 7Директива #define
#define задает текстовую подстановку, которую препроцессор выполняет до синтаксического анализа C. Константные макросы часто используют для размеров буферов, флагов сборки и compile-time конфигурации.
Макросы не знают типов, поэтому для типобезопасных констант часто лучше использовать const или enum. Для публичных макро-имен важно соблюдать префиксы проекта, чтобы избежать конфликтов.
#include <stdio.h>
#define APP_NAME "c-demo"
#define BUFFER_SIZE 64
int main(void) {
printf("app = %s\n", APP_NAME);
printf("buffer = %d\n", BUFFER_SIZE);
return 0;
}Ожидаемый вывод:
app = c-demo
buffer = 64Макросы
Функциональные макросы применяют для коротких шаблонов, которые должны работать на этапе препроцессора. Безопасный стиль: оборачивать аргументы и все выражение скобками, а для многострочных макросов использовать шаблон do { ... } while (0).
Макросы с побочными эффектами опасны: например, SQUARE(i++) может расшириться до выражения с двойной модификацией переменной без упорядочивания, что ведет к undefined behavior. Такие случаи лучше решать обычной функцией.
Препроцессор делает чистую текстовую подстановку и не учитывает приоритет операторов. Если написать #define BAD_SQUARE(x) x * x, то вызов BAD_SQUARE(1 + 2) превратится в 1 + 2 * 1 + 2 (получится 5, а не 9). Поэтому безопасный шаблон всегда такой: скобки вокруг каждого аргумента и вокруг всего результата, как в #define SQUARE(x) ((x) * (x)). Даже если сегодня вы передаете просто переменную, завтра в этот же макрос могут передать выражение.
#include <stdio.h>
#define SQUARE(x) ((x) * (x))
#define SWAP_INT(a, b) \
do { \
int tmp = (a); \
(a) = (b); \
(b) = tmp; \
} while (0)
int main(void) {
int x = 2;
int y = 9;
int v = SQUARE(x + 1);
SWAP_INT(x, y);
printf("v = %d\n", v);
printf("x = %d, y = %d\n", x, y);
return 0;
}Ожидаемый вывод:
v = 9
x = 9, y = 2Условная компиляция
Условная компиляция через #if, #ifdef, #ifndef позволяет включать платформенные или отладочные блоки кода без runtime-ветвлений.
Важно, чтобы обе ветки условной компиляции регулярно проверялись в CI. Иначе неиспользуемая ветка быстро деградирует и ломается при первом переключении флага.
#include <stdio.h>
#define DEBUG 1
int main(void) {
int value = 42;
#if DEBUG
printf("DEBUG: value = %d\n", value);
#else
printf("release mode\n");
#endif
printf("result = %d\n", value * 2);
return 0;
}Ожидаемый вывод:
DEBUG: value = 42
result = 84Встроенные макросы
Стандартные встроенные макросы вроде __FILE__, __LINE__, __DATE__, __TIME__ полезны для диагностики и простого логирования.
Эти значения зависят от окружения сборки и не должны участвовать в критичной бизнес-логике. Обычно их оставляют только в отладочных сообщениях и аварийных логах.
#include <stdio.h>
int main(void) {
printf("file: %s\n", __FILE__);
printf("line: %d\n", __LINE__);
printf("date: %s\n", __DATE__);
printf("time: %s\n", __TIME__);
return 0;
}Ожидаемый вывод:
file: demo.c
line: 6
date: Feb 18 2026
time: 12:34:56
# line/date/time зависят от файла и момента сборкиСтруктуры
Структуры объединяют связанные данные в один тип и задают явную модель предметной области. Это базовый инструмент для проектирования интерфейсов между модулями на языке C.
Формальные правила
- Поля структуры располагаются в порядке объявления, но между ними возможны паддинги.
- Оператор
.работает с объектом структуры, оператор->- с указателем. - Поля union разделяют одну область памяти и перекрывают друг друга.
- Размер и размещение битовых полей имеют implementation-defined аспекты.
Определение структур
Структура определяется через struct и список полей. Для удобства часто сразу вводят алиас через typedef, чтобы использовать тип без префикса struct.
Ошибки в именах полей и их типах быстро распространяются по всему проекту, поэтому структуру лучше проектировать как стабильный контракт. Особенно важно это для форматов сериализации и межмодульных API.
#include <stdio.h>
typedef struct {
double x;
double y;
} Point;
int main(void) {
Point p = {3.0, 4.0};
printf("point = (%.1f, %.1f)\n", p.x, p.y);
return 0;
}Ожидаемый вывод:
point = (3.0, 4.0)Структуры как элементы структур
Поле структуры может быть другой структурой. Такой подход полезен для иерархий: например, пользователь содержит адрес, адрес содержит индекс и строковые поля.
При вложенных структурах стоит контролировать инициализацию всех уровней. Частичная инициализация допустима, но без явного намерения она часто приводит к неочевидным значениям по умолчанию.
#include <stdio.h>
typedef struct {
const char *city;
int zip;
} Address;
typedef struct {
const char *name;
Address address;
} User;
int main(void) {
User u = {"Alex", {"Moscow", 101000}};
printf("%s -> %s, %d\n", u.name, u.address.city, u.address.zip);
return 0;
}Ожидаемый вывод:
Alex -> Moscow, 101000Указатели на структуры
Указатели на структуры нужны, когда объект большой, меняется в функции или живет в динамической памяти. Доступ к полям через указатель обычно записывают оператором ->. Это сокращение для формы (*ptr).field: запись ptr->field полностью эквивалентна (*ptr).field.
При передаче указателя важно гарантировать валидность времени жизни объекта. Доступ через dangling pointer к структуре приводит к тем же проблемам, что и для любых других типов: UB и повреждение состояния программы. Важно не забывать скобки в форме (*ptr).field: вариант *ptr.field некорректен из-за приоритета операторов.
#include <stdio.h>
typedef struct {
int id;
double score;
} Record;
void update_score(Record *r, double score) {
if (r == NULL) {
return;
}
r->score = score;
}
int main(void) {
Record rec = {1, 0.0};
Record *p = &rec;
update_score(p, 98.5);
printf("via -> : id=%d score=%.1f\n", p->id, p->score);
printf("via (*p). : id=%d score=%.1f\n", (*p).id, (*p).score);
return 0;
}Ожидаемый вывод:
via -> : id=1 score=98.5
via (*p). : id=1 score=98.5Массивы структур
Массив структур удобен для хранения однотипных записей: пользователей, точек, транзакций, задач очереди. Индексация работает так же, как у обычных массивов примитивов.
При линейном поиске по массиву структур сложность остается O(n). Если объем данных растет, имеет смысл проектировать индексные структуры или сортировку с бинарным поиском.
#include <stdio.h>
typedef struct {
const char *name;
int age;
} Person;
int main(void) {
Person team[] = {{"Ann", 23}, {"Bob", 29}, {"Kate", 31}};
size_t n = sizeof(team) / sizeof(team[0]);
for (size_t i = 0; i < n; i++) {
printf("%s: %d\n", team[i].name, team[i].age);
}
return 0;
}Ожидаемый вывод:
Ann: 23
Bob: 29
Kate: 31Структуры и функции
Структуру можно передавать в функцию по значению или по указателю. Передача по значению копирует весь объект, а передача по указателю работает с исходными данными без копии.
Для небольших immutable-структур копия обычно приемлема. Для крупных структур или частых вызовов выгоднее передавать const T * для чтения и T * для изменения.
#include <stdio.h>
typedef struct {
int w;
int h;
} Size;
int area_by_value(Size s) {
return s.w * s.h;
}
void scale(Size *s, int k) {
if (s == NULL) {
return;
}
s->w *= k;
s->h *= k;
}
int main(void) {
Size s = {3, 4};
printf("area = %d\n", area_by_value(s));
scale(&s, 2);
printf("scaled = %d x %d\n", s.w, s.h);
return 0;
}Ожидаемый вывод:
area = 12
scaled = 6 x 8Размещение структур и их полей в памяти
Размер структуры может быть больше суммы размеров полей из-за выравнивания и паддинга. Это влияет на объем памяти, бинарные протоколы и совместимость layout между компиляторами.
Ключевая идея выравнивания: каждое поле обычно должно начинаться с адреса, кратного его alignment. Если текущее смещение не подходит, компилятор вставляет пустые байты (padding) до следующей допустимой границы.
- Внутренний паддинг (internal padding): вставка между полями, чтобы выровнять следующее поле.
- Хвостовой паддинг (tail padding): вставка в конце структуры, чтобы
sizeof(struct)был кратен максимальному alignment ее полей. - Порядок полей влияет на итоговый размер структуры: группировка полей от «более широких» к «более узким» часто уменьшает паддинг.
Нельзя сериализовать структуру «сырыми байтами» без явного контроля layout. Для внешних форматов лучше использовать явную упаковку полей, фиксированные типы и проверять смещения через offsetof.
Оператор alignof(T) в C23 возвращает требование выравнивания для типа T в байтах. По нему можно заранее понять, почему компилятор вставляет паддинг: если поле требует выравнивание 4, его смещение обычно будет кратно 4.
#include <stddef.h>
#include <stdio.h>
typedef struct {
char tag;
int value;
short code;
} ItemA;
typedef struct {
int value;
short code;
char tag;
} ItemB;
int main(void) {
printf("alignof(char) = %zu\n", alignof(char));
printf("alignof(short) = %zu\n", alignof(short));
printf("alignof(int) = %zu\n", alignof(int));
printf("sizeof(ItemA) = %zu\n", sizeof(ItemA));
printf("A offsets: tag=%zu value=%zu code=%zu\n",
offsetof(ItemA, tag), offsetof(ItemA, value), offsetof(ItemA, code));
printf("sizeof(ItemB) = %zu\n", sizeof(ItemB));
printf("B offsets: value=%zu code=%zu tag=%zu\n",
offsetof(ItemB, value), offsetof(ItemB, code), offsetof(ItemB, tag));
return 0;
}Ожидаемый вывод:
alignof(char) = 1
alignof(short) = 2
alignof(int) = 4
sizeof(ItemA) = 12
A offsets: tag=0 value=4 code=8
sizeof(ItemB) = 8
B offsets: value=0 code=4 tag=6
# конкретные значения зависят от ABI и компилятораСоставные литералы
Составной литерал позволяет создать временный объект заданного типа прямо в выражении: например, (Point){1.0, 2.0}. Это сокращает шаблонный код и делает вызовы функций компактнее.
Время жизни составного литерала зависит от контекста. У литерала с блочной областью действия хранение автоматическое, поэтому нельзя сохранять его адрес и использовать после выхода из блока.
#include <stdio.h>
typedef struct {
double x;
double y;
} Point;
void print_point(Point p) {
printf("(%.1f, %.1f)\n", p.x, p.y);
}
int main(void) {
print_point((Point){1.5, 2.5});
int *arr = (int[]){10, 20, 30};
printf("arr[2] = %d\n", arr[2]);
return 0;
}Ожидаемый вывод:
(1.5, 2.5)
arr[2] = 30Перечисления
Перечисления (enum) задают именованный набор целочисленных констант. Они повышают читаемость кода и уменьшают число «магических чисел» в условиях и состояниях.
Базовый синтаксис: enum Tag { A, B, C };. Можно сразу объявить переменную: enum Tag value;, либо создать псевдоним через typedef: typedef enum Tag { ... } TagType;. Значения можно задавать явно (B = 10) или оставить неявными: тогда следующее значение будет на единицу больше предыдущего.
Хотя перечислители имеют целочисленную природу, стоит валидировать входные значения при преобразовании из внешних данных. Иначе программа может попасть в неучтенное состояние.
Формальные правила
- Имена перечислителей в одном перечислении должны быть уникальны.
- Если значение перечислителя не указано, оно вычисляется как значение предыдущего плюс 1 (первый по умолчанию 0).
- Явно заданные значения перечислителей должны быть целочисленными константными выражениями.
- Перечислители можно использовать в
switch/caseи других контекстах, где ожидаются целочисленные константы.
Практичный стиль: задавайте явные значения для стабильных внешних протоколов и храните проверку «неизвестного значения» в default-ветке.
#include <stdio.h>
typedef enum StatusTag {
STATUS_NEW = 0,
STATUS_READY = 10,
STATUS_DONE // 11
} Status;
int main(void) {
Status st = STATUS_READY;
printf("STATUS_NEW = %d\n", STATUS_NEW);
printf("STATUS_READY = %d\n", STATUS_READY);
printf("STATUS_DONE = %d\n", STATUS_DONE);
if (st == STATUS_READY) {
printf("ready\n");
}
printf("numeric = %d\n", st);
return 0;
}Ожидаемый вывод:
STATUS_NEW = 0
STATUS_READY = 10
STATUS_DONE = 11
ready
numeric = 10Объединения
В union все поля используют одну и ту же область памяти. Это полезно для экономии памяти и для представления вариативных данных вместе с явным тегом текущего типа.
Чтение поля union, которое не было последним записанным, имеет ограничения и может быть implementation-defined/undefined в зависимости от случая. Безопасный путь - хранить явный discriminant и читать только активное поле.
Формальные правила
- Все поля
unionразделяют одну область памяти и начинаются с одного адреса. - Размер
unionне меньше размера самого крупного поля и обычно учитывает требования выравнивания этого поля. - Гарантированно корректно читать то поле, в которое было последнее присваивание (активное поле).
- Чтение другого поля возможно только в специальных случаях; в общем коде это считается implementation-defined/undefined и требует аккуратного документирования.
#include <stdint.h>
#include <stdio.h>
typedef union {
uint32_t u32;
unsigned char bytes[4];
} NumberView;
int main(void) {
NumberView v;
v.u32 = 0x12345678u;
printf("u32 = 0x%08x\n", v.u32);
printf("bytes: %u %u %u %u\n", v.bytes[0], v.bytes[1], v.bytes[2], v.bytes[3]);
return 0;
}Ожидаемый вывод:
u32 = 0x12345678
bytes: 120 86 52 18
# порядок байт зависит от endianness платформыБитовые поля
Битовые поля позволяют упаковать набор флагов в один структурный объект и описать ширину каждого поля в битах. Это удобно для регистров устройств и компактных флаговых состояний.
Порядок размещения битов и некоторые аспекты выравнивания зависят от реализации компилятора. Для сетевых и файловых форматов не стоит полагаться на битовые поля без дополнительной нормализации.
Синтаксис битового поля: тип имя : ширина;. Например, unsigned read : 1; выделяет 1 бит под флаг read. Несколько таких полей можно объявлять подряд в struct, а сумма их ширин обычно группируется в единицы хранения, выбранные компилятором.
Формальные правила
- Ширина битового поля задается целочисленным константным выражением и не может быть отрицательной.
- Чаще всего для битовых полей используют
unsigned int/signed int/int; поведение других базовых типов зависит от реализации. - Порядок упаковки битов, выравнивание и размещение между единицами хранения являются implementation-defined.
- Битовые поля удобны для внутренних флагов, но для межплатформенных бинарных протоколов лучше использовать явные маски и сдвиги.
#include <stdio.h>
typedef struct {
unsigned read : 1;
unsigned write : 1;
unsigned exec : 1;
unsigned reserved : 5;
} Flags;
int main(void) {
Flags f = {1, 0, 1, 0};
printf("read=%u write=%u exec=%u\n", f.read, f.write, f.exec);
printf("sizeof(Flags) = %zu\n", sizeof(Flags));
return 0;
}Ожидаемый вывод:
read=1 write=0 exec=1
sizeof(Flags) = 4
# размер может отличаться на другой платформеДинамическая память
Динамическая память в C управляется вручную: программист выделяет блоки через allocator API и сам отвечает за своевременное освобождение. Это дает контроль и производительность, но требует дисциплины.
Allocator API - это набор функций стандартной библиотеки для управления памятью в куче: malloc, calloc, realloc и free. По сути это контракт между вашей программой и аллокатором: вы запрашиваете блок нужного размера, используете его по назначению и возвращаете обратно через free.
Память в куче (heap) - это область памяти процесса для динамических выделений во время выполнения программы. В отличие от стека, где объекты обычно живут до выхода из блока/функции, блок в куче живет, пока вы явно не освободите его через free. Это удобно для данных переменного размера и объектов, которые должны переживать границы функций, но требует строгого контроля времени жизни и владения памятью.
Формальные правила
malloc/calloc/reallocвозвращают указатель на блок илиNULLпри ошибке.free(NULL)допустим и безопасен.- Повторный
freeодного и того же блока (double free) - undefined behavior. - После
reallocстарый указатель недействителен при успешном переносе.
Выделение и освобождение памяти
malloc выделяет неинициализированный блок, calloc выделяет и обнуляет, аrealloc изменяет размер существующего блока. Освобождение всегда делается через free.
Синтаксис allocator API из <stdlib.h>:
void *malloc(size_t size);- выделяетsizeбайт и возвращает указатель на начало блока илиNULLпри ошибке. Содержимое блока не инициализируется.void *calloc(size_t nmemb, size_t size);- выделяет память подnmembэлементов поsizeбайт и заполняет блок нулями.void *realloc(void *ptr, size_t new_size);- изменяет размер ранее выделенного блока. Может вернуть тот же адрес или новый; при переносе данные копируются в новый блок (в пределах старого/нового размера).void free(void *ptr);- освобождает блок, ранее полученный через allocator API. После этого указатель становится висячим и его нужно считать недействительным.
Безопасный шаблон для realloc: использовать временный указатель. Если сразу присвоить результат в исходный указатель и получить NULL, можно потерять ссылку на уже выделенную память и получить утечку.
Формальные правила
- Выделенная память принадлежит вызывающему коду и должна быть освобождена ровно один раз через
free. free(NULL)допустим и безопасен.- Повторный
freeодного и того же адреса (double free) и доступ к памяти послеfree(use-after-free) - undefined behavior. - При неуспешном
reallocвозвращаетсяNULL, а исходный блок остается валидным. - Корректный контракт: передавать в
realloc/freeтолько указатели, полученные из allocator API, либоNULL.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
size_t n = 3;
int *arr = malloc(n * sizeof(*arr));
if (arr == NULL) {
return 1;
}
for (size_t i = 0; i < n; i++) {
arr[i] = (int)(i + 1) * 10;
}
int *tmp = realloc(arr, 5 * sizeof(*arr));
if (tmp == NULL) {
free(arr);
return 1;
}
arr = tmp;
arr[3] = 40;
arr[4] = 50;
int *zeroed = calloc(4, sizeof(*zeroed));
if (zeroed == NULL) {
free(arr);
return 1;
}
printf("arr: %d %d %d %d %d\n", arr[0], arr[1], arr[2], arr[3], arr[4]);
printf("zeroed: %d %d %d %d\n", zeroed[0], zeroed[1], zeroed[2], zeroed[3]);
free(zeroed);
zeroed = NULL;
free(arr);
arr = NULL;
return 0;
}Ожидаемый вывод:
arr: 10 20 30 40 50
zeroed: 0 0 0 0Выделение памяти для двухмерного массива произвольной длины
Для матрицы произвольных размеров обычно выделяют массив указателей на строки, а затем отдельно каждую строку. Такой подход дает гибкость по числу строк и столбцов во время выполнения.
Если выделение очередной строки не удалось, нужно корректно освободить уже выделенные строки. Иначе получится частичная утечка памяти и нестабильное состояние приложения.
#include <stdio.h>
#include <stdlib.h>
int **alloc_matrix(size_t rows, size_t cols) {
int **m = malloc(rows * sizeof(*m));
if (m == NULL) {
return NULL;
}
for (size_t i = 0; i < rows; i++) {
m[i] = malloc(cols * sizeof(*m[i]));
if (m[i] == NULL) {
for (size_t k = 0; k < i; k++) {
free(m[k]);
}
free(m);
return NULL;
}
}
return m;
}
void free_matrix(int **m, size_t rows) {
if (m == NULL) {
return;
}
for (size_t i = 0; i < rows; i++) {
free(m[i]);
}
free(m);
}
int main(void) {
size_t rows = 3;
size_t cols = 4;
int **m = alloc_matrix(rows, cols);
if (m == NULL) {
return 1;
}
for (size_t i = 0; i < rows; i++) {
for (size_t j = 0; j < cols; j++) {
m[i][j] = (int)(i * 10 + j);
}
}
for (size_t i = 0; i < rows; i++) {
for (size_t j = 0; j < cols; j++) {
printf("%d ", m[i][j]);
}
printf("\n");
}
free_matrix(m, rows);
return 0;
}Ожидаемый вывод:
0 1 2 3
10 11 12 13
20 21 22 23Управление динамической памятью
Управление памятью - это не только вызовы allocator API, но и дисциплина владения: кто создает блок, кто и когда обязан его освободить, допустимо ли разделение владения между модулями.
Практичные правила: фиксировать ownership в API, освобождать ресурсы в одной точке выхода, обнулять указатель после free и исключать double free через утилиты вроде safe_free.
#include <stdio.h>
#include <stdlib.h>
void safe_free(void **pp) {
if (pp != NULL && *pp != NULL) {
free(*pp);
*pp = NULL;
}
}
int main(void) {
int *data = malloc(3 * sizeof(*data));
if (data == NULL) {
return 1;
}
data[0] = 7;
data[1] = 8;
data[2] = 9;
printf("data[1] = %d\n", data[1]);
safe_free((void **)&data);
printf("data == NULL -> %d\n", data == NULL);
return 0;
}Ожидаемый вывод:
data[1] = 8
data == NULL -> 1Указатель как результат функции
Функция может возвращать указатель на динамически выделенную память, если вызывающий код берет на себя освобождение этого блока. Такой интерфейс часто используют для фабрик строк и буферов.
Нельзя возвращать адрес локального массива автоматического хранения. Корректно возвращать либо динамический блок, либо указатель на static-объект при хорошо документированном контракте.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char *build_message(const char *name) {
const char *prefix = "Hello, ";
size_t n = strlen(prefix) + strlen(name) + 1;
char *msg = malloc(n);
if (msg == NULL) {
return NULL;
}
snprintf(msg, n, "%s%s", prefix, name);
return msg;
}
int main(void) {
char *msg = build_message("C programmer");
if (msg == NULL) {
return 1;
}
printf("%s\n", msg);
free(msg);
return 0;
}Ожидаемый вывод:
Hello, C programmerОрганизация памяти программы и структура сегментов
Адресное пространство процесса обычно делят на четыре основные области: сегмент кода (text), сегмент глобальных и статических данных (data/bss), кучу (heap) и стек (stack). Такой разбор помогает сразу понимать, почему одни объекты живут всю программу, а другие - только до выхода из функции.
Сегмент code/text хранит машинные инструкции функций. Сегмент data/bss содержит глобальные и static-объекты, а также литералы. В heap лежат блоки, полученные через malloc/calloc/realloc. В stack размещаются кадры вызовов: параметры, автоматические локальные переменные и служебные данные вызова.
Куча и стек обычно растут навстречу друг другу: heap - в сторону больших адресов, stack - в сторону меньших. Из-за этого нельзя возвращать адрес локальной автоматической переменной: после выхода из функции соответствующий стековый кадр уничтожается.
В программе ниже это видно по адресам: &local_auto попадает в область стека, heap_value указывает на блок в куче, а &global_value, &global_static и &local_static относятся к сегменту данных.
Адреса в примерах зависят от ОС, рантайма и защиты памяти (ASLR), поэтому важны не конкретные числа, а взаимная роль областей памяти. Для диагностики утечек и выходов за границы полезно подключать ASan/Valgrind.
#include <stdio.h>
#include <stdlib.h>
int global_value = 1;
static int global_static = 2;
int main(void) {
static int local_static = 3;
int local_auto_1 = 1;
int local_auto_2 = 2;
int *heap_value_1 = malloc(sizeof(*heap_value_1));
if (heap_value_1 == NULL) {
return 1;
}
int *heap_value_2 = malloc(sizeof(*heap_value_2));
if (heap_value_2 == NULL) {
return 1;
}
*heap_value_1 = 1;
*heap_value_2 = 2;
printf("&global_value = %p\n", (void *)&global_value);
printf("&global_static = %p\n", (void *)&global_static);
printf("&local_static = %p\n", (void *)&local_static);
printf("&local_auto_1 = %p\n", (void *)&local_auto_1);
printf("&local_auto_2 = %p\n", (void *)&local_auto_2);
printf("heap_value_1 = %p\n", (void *)heap_value_1);
printf("heap_value_2 = %p\n", (void *)heap_value_2);
free(heap_value_1);
free(heap_value_2);
return 0;
}
Ожидаемый вывод (точные адреса зависят от ОС, ASLR и запуска):
&global_value = 0x104ca4000
&global_static = 0x104ca4008
&local_static = 0x104ca4004
&local_auto_1 = 0x16b16288c
&local_auto_2 = 0x16b162888
heap_value_1 = 0x1052b5ec0
heap_value_2 = 0x1052b5ed0