Svartsinn1
Руководство по языку Си

Самиздат: [Регистрация] [Найти] [Рейтинги] [Обсуждения] [Новинки] [Обзоры] [Помощь|Техвопросы]
Links
Кожевенное мастерство: сумки, ремни своими руками Юристы. Круглосуточно
 Ваша оценка:

Современный курс по языку C: от первого файла до понимания машины

Ядро Linux, ядро Windows, ядро macOS. Базы данных Oracle, PostgreSQL, MySQL, SQLite. Интерпретатор Python. Веб-сервер Nginx. Виртуальные машины, компиляторы, драйверы, микроконтроллеры, системы голосового управления в автомобилях. Всё это написано на одном языке, которому больше пятидесяти лет. Язык C не умер и не умирает. Он перестал быть модным, но стал фундаментом, на котором стоит почти всё, что вы запускаете на компьютере.

Если вы знаете Python и хотите понять, что происходит под капотом вашей программы, этот курс для вас. Python прячет от вас память, типы, указатели, адреса и машинные инструкции. C раскрывает их. Прелесть C в том, что он не абстрагирует компьютер. Он показывает его устройство. Каждая переменная в C имеет адрес в памяти. Каждая функция имеет адрес в кодовом сегменте. Каждый массив занимает непрерывный блок байтов. Поняв C, вы начинаете понимать, как процессор работает с памятью, как операционная система управляет процессами и почему высокоуровневые языки жертвуют скоростью ради удобства.

Python даёт вам инструменты. C даёт понимание, как эти инструменты устроены. Список в Python за O(1) добавляет элемент в конец? Под капотом C-массив перевыделяет память через realloc. Строка в Python неизменяема? В C это просто указатель на первый байт, за которым идёт последовательность символов и нулевой байт в конце. Хеш-таблица в Python работает быстро? В C вы напишете её сами и увидите, почему она работает.

Этот курс не повторяет университетский план. Он рассчитан на человека, который уже программировал. Мы не объясняем, что такое цикл или переменная. Мы объясняем, как цикл и переменная устроены в C, чем они отличаются от Python и почему это отличие принципиально. Курс разбит на 25 тем, каждая по одному принципу: краткая теория, практическое задание, домашняя работа. Пройдя их, вы не просто выучите синтаксис C. Вы поймёте, как компьютер работает на уровне, ближайшем к железу.

Зачем учить C, если есть Python

Python запускается поверх интерпретатора, который написан на C. Стандартная библиотека Python, виртуальная машина CPython, модули ctypes и cffi, расширения на Cython, bindings к C-библиотекам через SWIG. Всё это разные уровни взаимодействия с одним и тем же языком. Знание C превращает вас из пользователя Python в человека, который понимает, почему list.append работает за O(1) в среднем и что происходит при переполнении буфера под капотом.

C остаётся языком выбора для задач, где важны три вещи: скорость, контроль над памятью и прямой доступ к операционной системе. В мире встраиваемых систем, операционных ядер, драйверов и сетевых стеков альтернатив C практически нет. Rust активно наступает, но в наследовании кода, существующем тридцать-сорок лет, C остаётся стандартом. Даже Rust привязан к C через FFI: все системные вызовы операционной системы экспортируются в виде C-функций.

Есть и образовательный аргумент. Язык C выстроен так, что он почти однозначно транслируется в машинный код. Каждая строка C соответствует нескольким инструкциям процессора. Это делает C идеальным инструментом для изучения архитектуры компьютера. Вы видите, где данные лежат, как они выровнены, почему целочисленное переполнение молча проходит и почему указатели на разные типы нельзя смешивать без явного приведения.

Какие уровни работы с компьютером открывает C

Программирование работает на нескольких уровнях абстракции. На вершине сидит Python: вы пишете result = sum(numbers), не думая ни о памяти, ни о типах, ни о процессоре. Python сам решает, где выделить память, как освободить, как преобразовать типы. Это удобно, но создаёт иллюзию, что компьютер работает с данными как абстрактными объектами.

Следующий уровень вниз - это C. Здесь вы управляете выделением памяти, указываете компилятору точный размер каждого значения, передаёте указатели и работаете с адресами. Вы определяете, когда создать буфер, когда его расширить и когда вернуть память операционной системе. Вы можете написать программу, которая читает конкретный байт по конкретному адресу, меняет его значение и записывает обратно. В Python это невозможно без специальных модулей.

Ещё ниже - ассемблер и машинный код. C не опускается до ассемблера, но даёт инструменты для прямого взаимодействия с железом: inline-ассемблер, указатели на аппаратные регистры, управление выравниванием данных. Большинство программистов никогда не опускаются ниже C, и это нормально. C даёт достаточно контроля, чтобы понимать, как железо взаимодействует с программой, не теряя при этом читаемость.

Развёртывание окружения на macOS

macOS поставляется с компилятором Clang - частью LLVM. Clang - это современный компилятор с открытым исходным кодом, который поддерживает все актуальные стандарты C, включая C23 (ISO/IEC 9899:2024, опубликован в октябре 2024 года). Вам не нужно устанавливать GCC. Clang на macOS - это нативный и рекомендуемый инструмент.

Для начала установите средства командной строки Xcode. Откройте терминал и выполните команду:

xcode-select --install

Появится диалоговое окно с предложением установить инструменты разработчика. Согласитесь и дождитесь завершения. После установки проверьте, что Clang доступен:

clang --version

Вы увидите версию LLVM/Clang, обычно 15.x или 16.x на современных macOS. Если Clang не найден, значит установка не завершилась или инструменты недоступны в вашей версии macOS. Для macOS Sonoma и Sequoia установка занимает несколько минут и требует около 2 ГБ свободного места.

Для управления несколькими версиями компиляторов или установки свежего LLVM (актуально для C23) можно использовать Homebrew:

brew install llvm

Это установит последнюю версию LLVM, которая включает Clang с полной поддержкой C23. По умолчанию Homebrew ставит её в /opt/homebrew/opt/llvm/ (Apple Silicon) или /usr/local/opt/llvm/ (Intel). Добавьте путь в PATH, если хотите использовать именно эту версию вместо системного Clang.

Visual Studio Code: настройка для C

Visual Studio Code - бесплатный редактор с поддержкой C через расширение C/C++ от Microsoft. Установите VS Code с официального сайта, затем откройте панель расширений (Cmd+Shift+X) и найдите "C/C++". Установите расширение от Microsoft. Для удобства также поставьте расширение "C/C++ Extension Pack", которое добавляет шаблоны проектов и интеграцию с CMake.

Создайте папку для проекта, откройте её в VS Code и создайте файл hello.c:

#include <stdio.h>
int main(void) { printf("Hello, C!\n"); return 0; }

Для компиляции и отладки в VS Code нужно создать конфигурацию. Нажмите Cmd+Shift+P, введите "Tasks: Configure Default Build Task" и выберите "C/C++: clang build active file". VS Code создаст файл .vscode/tasks.json с конфигурацией сборки. Для отладки создайте launch.json через меню Run → Add Configuration → "C++ (GDB/LLDB)". VS Code автоматически использует LLDB на macOS.

Вот пример tasks.json для компиляции с поддержкой C23 и отладочной информацией:

{
  "version": "2.0.0",
  "tasks": [{
    "type": "cppbuild",
    "label": "C: clang build (C23)",
    "command": "/usr/bin/clang",
    "args": [
      "-std=c2x", "-Wall", "-Wextra", "-g",
      "${file}", "-o", "${fileDirname}/${fileBasenameNoExtension}"
    ],
    "group": { "kind": "build", "isDefault": true }
  }]
}

Флаг -std=c2x включает экспериментальную поддержку C23 (до выхода финального стандарта использовался флаг -std=c2x, теперь поддерживается -std=c23 в свежих версиях Clang). -Wall -Wextra включает расширенные предупреждения, -g - отладочную информацию для LLDB. После сборки нажмите F5 для запуска под отладчиком.

Современные средства отладки

На macOS стандартный отладчик - это LLDB, часть LLVM. Он заменяет GDB, который традиционно используется на Linux. LLDB интегрирован в VS Code через расширение C/C++, но его можно использовать и из терминала. Основные команды LLDB для C:

lldb ./hello # запустить отладчик
breakpoint set --name main # поставить точку останова
run # запустить программу
next # выполнить строку (без входа в функции)
step # войти в функцию
print variable # вывести значение переменной
frame variable # показать все локальные переменные
continue # продолжить выполнение

Для поиска утечек памяти и ошибок работы с памятью на macOS используется AddressSanitizer (ASan). Это инструмент анализа памяти, встроенный прямо в Clang. Он перехватывает обращения к памяти и сообщает о выходе за границы, использовании освобождённой памяти и утечках. Включается одним флагом:

clang -fsanitize=address -g -o program program.c
./program

Если программа обращается к памяти за пределами выделенного буфера, ASan выведет детальный отчёт с указанием строки кода и типа ошибки. На Linux вместо ASan часто используют Valgrind, но на macOS Valgrind не работает - ASan является основной альтернативой.

Ещё один полезный инструмент - UndefinedBehaviorSanitizer (UBSan). Он ловит неопределённое поведение: переполнение знаковых чисел, деление на ноль, null-указатели. Включается аналогично: -fsanitize=undefined. Можно комбинировать с ASan: -fsanitize=address,undefined.

Стандарты C: C89, C99, C11, C17, C23

Язык C развивается медленно, но верно. Стандарт C89 (он же ANSI C) - это базовый C, который вы встретите в университетских учебниках. C99 добавил возможность объявлять переменные в любом месте функции (раньше - только в начале блока), комментарии //, типы long long, заголовок <stdint.h> с типами фиксированной ширины и массивы переменной длины. C11 ввёл атомарные операции, потоки, поддержку Unicode и анонимные структуры. C17 - это исправления к C11 без новых возможностей.

C23, опубликованный как ISO/IEC 9899:2024, - это самый значительный пересмотр за десятилетие. Он добавляет ключевые слова nullptr, true, false, constexpr, typeof, двоичные литералы 0b1010, разделители цифр 1'000'000, атрибуты C++-стиля [[nodiscard]], [[deprecated]], пустые инициализаторы int arr[] = {} и убирает K&R-объявления функций. По сути, C23 приближает C к современному виду, сохраняя его компактность и эффективность. Clang 16+ и GCC 13+ поддерживают большинство возможностей C23.

В этом курсе мы ориентируемся на C11 и C23. Где возможности C23 существенны, мы их отмечаем и показываем разницу с предыдущим стандартом. Используйте флаг -std=c2x или -std=c23 при компиляции, чтобы включить современные возможности.

Аналогия с Python: что общего и чем отличается

Python и C решают разные задачи. Python - это язык высокого уровня с динамической типизацией, автоматическим управлением памятью и богатой стандартной библиотекой. C - это язык низкого уровня со статической типизацией, ручным управлением памятью и минимальной стандартной библиотекой. Но между ними есть мосты: интерпретатор CPython написан на C, и расширения Python на C - обычная практика.

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

АспектPythonC
Типизациядинамическая (проверяется во время выполнения)статическая (проверяется при компиляции)
Памятьсборщик мусора, авто-управлениеручное: malloc/free, стек/куча
Строкиобъект str, неизменяемый, Unicodechar[] - массив байтов с терминатором \0
Спискиlist - динамический массив объектовнет встроенного списка, массивы фиксированного размера
Указателискрыты, доступны через ctypesоснова языка, явное управление
Скоростьв 10-100 раз медленнее Cблизка к скорости машинного кода
Сборкаинтерпретация на летукомпиляция в машинный код
Ошибкиисключения (try/except)коды возврата, errno, signal
Заголовкиимпорт модулей (import)#include заголовочных файлов

Каждая тема курса будет отмечать, где Python делает что-то автоматически, а C заставляет вас делать это вручную. Это не упрёк C, а его достоинство: ручное управление даёт контроль, который в Python недоступен.

Структура курса

Курс состоит из 25 тем. Каждая тема построена по схеме: краткая теория (синтаксис, механика, отличия от Python), практика (код, который нужно набрать и запустить) и домашнее задание (задача для самостоятельного решения). Темы идут от простых к сложным. Первые пять - это базовый синтаксис, который вы освоите быстро, если знаете Python. Дальше начинается специфика C: указатели, память, модели представления данных. Финальные темы посвящены структурам данных, безопасности памяти и современным возможностям C23.

Рекомендуется проходить темы последовательно. Каждая последующая тема опирается на предыдущую. Код всех примеров компилируется флагами -std=c2x -Wall -Wextra. Если что-то не компилируется - проверьте версию Clang: clang --version. Для полной поддержки C23 нужен Clang 16 или новее.

Тема 1. Структура программы и процесс компиляции

Теория

Программа на C состоит из функций. Одна из них обязательно называется main - это точка входа. Функция main возвращает целое число: 0 означает успешное завершение, ненулевое значение - ошибку. Заголовочные файлы подключаются через #include - это директива препроцессора, которая вставляет содержимое файла в ваш код до компиляции.

Процесс сборки C-программы состоит из четырёх этапов: препроцессор (обработка #include, #define, макросов), компиляция (превращение кода в ассемблер), ассемблирование (ассемблер в объектный файл) и линковка (объединение объектных файлов и библиотек в исполняемый файл). В Python этот процесс скрыт: интерпретатор читает файл и выполняет его напрямую. В C каждый этап можно увидеть и контролировать.

В Python вы пишете print("Hello") и запускаете python hello.py. В C вы сначала компилируете: clang hello.c -o hello, затем запускаете: ./hello. Два шага вместо одного. Это кажется лишним, но именно компиляция в машинный код даёт C его скорость.

Практика

#include <stdio.h>

int main(void) {
    printf("Hello, C!\n");
    printf("Compiled with clang.\n");
    return 0;
}

Скомпилируйте: clang -std=c2x -Wall -o hello hello.c. Запустите: ./hello. Вывод - две строки. Теперь посмотрите промежуточные этапы: clang -E hello.c покажет результат препроцессора, clang -S hello.c сгенерирует ассемблерный код hello.s, clang -c hello.c создаст объектный файл hello.o.

Домашнее задание

Напишите программу, которая выводит ваше имя, возраст и текущий язык программирования. Каждый факт на отдельной строке. Добавьте комментарий в начале файла с датой написания. Скомпилируйте с флагом -Wall и убедитесь, что нет предупреждений.

Тема 2. Типы данных и переменные

Теория

C - язык со статической типизацией. Каждая переменная объявляется с указанием типа, и тип не меняется до конца жизни переменной. В Python тип определяется во время выполнения: x = 5, затем x = "hello" - это нормальный Python, но ошибка в C.

Основные типы C: int (целое, обычно 4 байта), char (один байт, часто используется для символов), float (32-битное число с плавающей точкой), double (64-битное число с плавающей точкой), long (расширенное целое), short (короткое целое). Для точного контроля размера в C99 добавлен заголовок <stdint.h> с типами int32_t, uint64_t, int8_t и так далее. В C23 тип bool стал полноценным ключевым словом вместо _Bool, а true и false - встроенными константами.

В Python x = 5 создаёт объект int в куче и привязывает имя x к нему. В C int x = 5; выделяет 4 байта на стеке (если переменная локальная) и записывает туда значение 5. Нет объекта, нет ссылочного счётчика, нет сборщика мусора. Просто байты по адресу.

Практика

#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>  // C23: можно не подключать, bool встроен

int main(void) {
    int a = 42;
    double pi = 3.14159;
    char grade = 'A';
    int64_t big = 9223372036854775807LL;
    bool flag = true;  // C23: ключевое слово, не макрос

    printf("int:   %d, размер %zu байт\n", a, sizeof(a));
    printf("double: %f, размер %zu байт\n", pi, sizeof(pi));
    printf("char:  %c, размер %zu байт\n", grade, sizeof(grade));
    printf("int64: %lld, размер %zu байт\n", big, sizeof(big));
    printf("bool:  %d, размер %zu байт\n", flag, sizeof(flag));
    return 0;
}

Оператор sizeof возвращает размер типа в байтах. Обратите внимание: на разных платформах размеры int и long различаются. Типы из <stdint.h> гарантируют фиксированный размер.

Домашнее задание

Объявите переменные всех базовых типов. Выведите их значения и размеры через sizeof. Поменяйте значение int на максимальное и добавьте 1. Что произойдёт? Объясните результат.

Тема 3. Операторы и выражения

Теория

Арифметические операторы в C те же, что в Python: + - * / %. Но есть отличия. Деление целых в C даёт целое: 7 / 2 равно 3, а не 3.5. В Python 7 // 2 даёт 3, а 7 / 2 даёт 3.5. В C для дробного деления нужно хотя бы один операнд сделать дробным: 7.0 / 2 или (float)7 / 2.

Побитовые операторы в C: & (И), | (ИЛИ), ^ (исключающее ИЛИ), ~ (НЕ), << (сдвиг влево), >> (сдвиг вправо). В Python те же операторы, но Python также имеет << и >> без ограничений. В C сдвиг имеет ограничение: сдвиг на количество бит больше размера типа - неопределённое поведение.

Логические операторы в C: && (и), || (или), ! (не). В Python это and, or, not. В C нет булева типа в том смысле, в каком он есть в Python: ноль - ложь, любое ненулевое значение - истина.

Практика

#include <stdio.h>

int main(void) {
    int a = 10, b = 3;
    printf("a / b = %d (целочисленное деление)\n", a / b);
    printf("a %% b = %d (остаток)\n", a % b);
    printf("(float)a / b = %.2f\n", (float)a / b);

    int mask = 0xF0;
    int val  = 0x0A;
    printf("mask & val = 0x%02X\n", mask & val);
    printf("mask | val = 0x%02X\n", mask | val);
    printf("~mask = 0x%02X\n", (unsigned char)~mask);

    // C23: разделитель цифр
    int million = 1'000'000;
    int binary = 0b1010'1010;
    printf("million = %d, binary = %d\n", million, binary);

    return 0;
}

Домашнее задание

Напишите программу, которая обменивает значения двух целых переменных без использования третьей переменной. Используйте побитовый XOR. Выведите значения до и после обмена.

Тема 4. Управление потоком: if/else и switch

Теория

Конструкция if/else в C идентична Python по логике, но отличается по синтаксису: вместо отступов используются фигурные скобки {}. В Python отступ - часть синтаксиса, в C отступ - только для читаемости. Это значит, что пропущенные скобки в C - частый источник багов.

Оператор switch - это множественный выбор по значению целочисленной переменной. В Python аналога нет (ближайший - match/case в Python 3.10+). В C switch работает только с целыми типами и требует break в каждом случае, иначе выполнение провалится в следующий (fall-through). Это известная ловушка C.

Практика

#include <stdio.h>

int main(void) {
    int score = 85;

    if (score >= 90) {
        printf("Отлично\n");
    } else if (score >= 70) {
        printf("Хорошо\n");
    } else {
        printf("Удовлетворительно\n");
    }

    // switch с явными break
    int day = 3;
    switch (day) {
        case 1: printf("Понедельник\n"); break;
        case 2: printf("Вторник\n"); break;
        case 3: printf("Среда\n"); break;
        case 4: printf("Четверг\n"); break;
        case 5: printf("Пятница\n"); break;
        default: printf("Выходной\n");
    }

    return 0;
}

Домашнее задание

Напишите калькулятор, который читает два числа и оператор из ввода (используйте scanf). Реализуйте выбор операции через switch. Обработайте деление на ноль.

Тема 5. Циклы: for, while, do-while

Теория

C имеет три типа циклов: for, while и do-while. В Python цикл for итерирует по коллекциям: for x in [1, 2, 3]. В C цикл for - это计数ный цикл с инициализацией, условием и шагом: for (int i = 0; i < 10; i++). Цикл while работает одинаково в обоих языках. do-while - это цикл с постусловием: тело выполняется хотя бы один раз, затем проверяется условие. В Python такого цикла нет.

Ключевые слова break и continue работают так же, как в Python. В C23 появился атрибут [[fallthrough]] для явного указания намеренного провала в switch.

Практика

#include <stdio.h>

int main(void) {
    // for: классический счётный цикл
    int sum = 0;
    for (int i = 1; i <= 100; i++) {
        sum += i;
    }
    printf("Сумма 1..100 = %d\n", sum);

    // while: пока условие истинно
    int n = 1024;
    int bits = 0;
    while (n > 0) {
        n >>= 1;
        bits++;
    }
    printf("Потребовалось %d бит\n", bits);

    // do-while: хотя бы один раз
    int val;
    do {
        printf("Введите число > 0: ");
        scanf("%d", &val);
    } while (val <= 0);
    printf("Вы ввели: %d\n", val);

    return 0;
}

Домашнее задание

Напишите программу, которая выводит таблицу умножения 1-9 с использованием двух вложенных циклов for. Формат: 3 x 7 = 21.

Тема 6. Функции и прототипы

Теория

Функции в C объявляются с типом возвращаемого значения, именем и параметрами. Если функция используется до её определения в файле, нужно объявить прототип - сигнатуру функции без тела. В Python порядок функций в файле не важен, потому что интерпретатор читает весь файл перед выполнением. В C компилятор идёт сверху вниз, и если функция вызвана до определения, компилятор не знает её сигнатуры.

В C нет значений по умолчанию для параметров (как в Python). Все аргументы передаются по значению. Чтобы изменить значение аргумента внутри функции, нужно передать указатель на него. Это ключевое отличие от Python, где всё передаётся по ссылке на объект.

Практика

#include <stdio.h>

// Прототип - объявление без тела
int factorial(int n);

// Определение функции
int factorial(int n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1);
}

// Функция, меняющая значение через указатель
void swap(int *a, int *b) {
    int tmp = *a;
    *a = *b;
    *b = tmp;
}

int main(void) {
    printf("5! = %d\n", factorial(5));

    int x = 10, y = 20;
    swap(&x, &y);  // передаём адреса
    printf("x = %d, y = %d\n", x, y);  // x=20, y=10
    return 0;
}

Обратите внимание: swap принимает указатели int*, а вызов передаёт адреса через &. В Python аналог - swap(a, b) работает через упаковку/распаковку кортежа, но механизм совершенно иной.

Домашнее задание

Напишите функцию int gcd(int a, int b), которая вычисляет наибольший общий делитель по алгоритму Евклида. Добавьте прототип. Вызовите из main для пары чисел и выведите результат.

Тема 7. Массивы и строки

Теория

Массив в C - это непрерывный блок памяти фиксированного размера. В отличие от списка Python, C-массив не может расти и не хранит метаданные о своём размере. Вы должны сами отслеживать длину. Размер массива известен при компиляции, но не доступен во время выполнения при передаче массива в функцию (массив превращается в указатель).

Строка в C - это массив char, заканчивающийся нулевым байтом '\0'. Этот терминатор называется NUL (не путать с NULL - указателем). Строковый литерал "hello" - это массив из 6 байтов: h e l l o \0. В Python строка - это объект с известной длиной, кодировкой и методами. В C строка - просто байты, и вы сами отвечаете за терминатор.

Практика

#include <stdio.h>
#include <string.h>

int main(void) {
    // Массив фиксированного размера
    int nums[5] = {10, 20, 30, 40, 50};
    for (int i = 0; i < 5; i++) {
        printf("nums[%d] = %d\n", i, nums[i]);
    }

    // Строка - массив char
    char greeting[] = "Hello";
    printf("Длина строки: %zu\n", strlen(greeting));  // 5
    printf("Размер массива: %zu\n", sizeof(greeting)); // 6 (вкл. \0)

    // Копирование и объединение
    char buf[20];
    strcpy(buf, greeting);
    strcat(buf, ", World!");
    printf("%s\n", buf);

    return 0;
}

Функция strlen считает длину до NUL, sizeof возвращает реальный размер массива. При передаче массива в функцию sizeof вернёт размер указателя, а не массива - это частая ловушка.

Домашнее задание

Напишите функцию int my_strlen(const char *s), которая считает длину строки без использования string.h. Напишите функцию void reverse_string(char *s), которая переворачивает строку на месте.

Тема 8. Указатели: основы

Теория

Указатель - это переменная, хранящая адрес в памяти. Если int x = 42 хранит значение 42, то int *p = &x хранит адрес, по которому лежит 42. Оператор & берёт адрес переменной, оператор * разыменовывает указатель (читает значение по адресу). В Python прямого аналога указателей нет, но id(obj) возвращает адрес объекта в памяти CPython.

Указатели - то, ради чего многие учат C. Они дают прямой доступ к памяти, но требуют дисциплины. Неверный указатель - причина большинства багов в C. Указатель на неинициализированную память, освобождённый указатель (dangling pointer), null-указатель - все это может привести к падению или тихой ошибке.

В C23 появился nullptr - типобезопасный null-указатель, заменяющий макрос NULL. Он неявно преобразуется в указатель любого типа, но не в целое число, что устраняет неоднозначности.

Практика

#include <stdio.h>

int main(void) {
    int x = 42;
    int *p = &x;  // p хранит адрес x

    printf("Значение x:    %d\n", x);
    printf("Адрес x:       %p\n", (void*)&x);
    printf("Значение p:    %p\n", (void*)p);
    printf("Значение *p:   %d\n", *p);

    // Меняем через указатель
    *p = 100;
    printf("Теперь x = %d\n", x);  // 100

    // C23: nullptr вместо NULL
    int *null_ptr = nullptr;
    if (null_ptr == nullptr) {
        printf("Указатель пуст\n");
    }

    return 0;
}

Домашнее задание

Создайте переменную типа double, указатель на неё. Выведите значение, адрес и размер указателя. Измените значение через указатель. Выведите новое значение. Объясните, почему размер указателя не зависит от типа, на который он указывает.

Тема 9. Указатели и массивы: связь

Теория

Имя массива в C - это указатель на первый элемент. int arr[5] - arr эквивалентно &arr[0]. Арифметика указателей: arr + 1 указывает на второй элемент, arr + i - на i-й. Разыменование *(arr + i) равносильно arr[i]. В Python такого нет: индексация - это метод объекта, а не арифметика адресов.

Это значит, что массив и указатель в C взаимозаменяемы в большинстве контекстов. При передаче массива в функцию вы передаёте указатель, а не копию. Функция не знает размер массива - вы должны передать его отдельным аргументом.

Практика

#include <stdio.h>

// Массив как указатель - размер не передаётся
int sum_array(int *arr, int size) {
    int total = 0;
    for (int i = 0; i < size; i++) {
        total += *(arr + i);  // то же, что arr[i]
    }
    return total;
}

int main(void) {
    int data[5] = {1, 2, 3, 4, 5};
    printf("Сумма: %d\n", sum_array(data, 5));

    // Арифметика указателей
    int *p = data;
    printf("p[0]=%d, p[1]=%d, p[2]=%d\n", *p, *(p+1), *(p+2));

    // p++ сдвигает на sizeof(int) байт
    p++;
    printf("После p++: *p = %d\n", *p);  // 2

    return 0;
}

Домашнее задание

Напишите функцию int find_max(int *arr, int size), которая находит максимум в массиве, используя только арифметику указателей (без arr[i]). Объявите указатель, двигайте его по массиву и сравнивайте значения.

Тема 10. Динамическая память: malloc, calloc, free

Теория

Локальные переменные и массивы в C размещаются на стеке. Стек ограничен (обычно 1-8 МБ) и автоматически очищается при выходе из функции. Для больших данных или данных, чей размер известен только во время выполнения, используется куча - динамическая память.

Функция malloc(size) выделяет size байт и возвращает указатель на начало блока. calloc(n, size) выделяет n * size байт и заполняет их нулями. realloc(ptr, new_size) меняет размер блока. free(ptr) освобождает память. В Python сборщик мусора делает это автоматически. В C вы обязаны освободить каждый выделенный блок - иначе утечка памяти.

Правило: на каждый malloc должен приходиться один free. Освобождать можно только память, выделенную через malloc/calloc/realloc. Освобождение одного блока дважды - неопределённое поведение. Освобождение NULL безопасно.

Практика

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int n;
    printf("Сколько чисел? ");
    scanf("%d", &n);

    // Динамический массив
    int *arr = malloc(n * sizeof(int));
    if (arr == nullptr) {
        printf("Не удалось выделить память\n");
        return 1;
    }

    for (int i = 0; i < n; i++) {
        arr[i] = i * i;
    }

    for (int i = 0; i < n; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");

    free(arr);  // обязательно!
    arr = nullptr;  // хорошая практика

    return 0;
}

Домашнее задание

Напишите программу, которая читает N чисел из ввода в динамический массив, затем удваивает размер массива через realloc и заполняет вторую половину нулями. Выведите весь массив. Не забудьте free.

Тема 11. Модель памяти C: стек, куча, сегменты данных

Теория

Память процесса в C разделена на четыре логические области. Сегмент кода (text) содержит скомпилированные инструкции. Сегмент данных (data) хранит глобальные и статические переменные, инициализированные при запуске. Сегмент BSS - неинициализированные глобальные и статические переменные, заполненные нулями. Стек - локальные переменные, аргументы функций, адреса возврата. Куча - динамически выделенная память.

Стек растёт вниз (от больших адресов к меньшим), куча - вверх. Стек управляется автоматически: при вызове функции выделяется фрейм, при возврате - освобождается. Куча управляется вручную через malloc/free. В Python стек используется для вызовов функций, но все объекты лежат в куче - даже локальные переменные являются ссылками на объекты в куче.

Понимание модели памяти - фундамент для работы с указателями. Вы должны знать, где живёт каждая переменная: на стеке (локальная), в data (глобальная статическая), в BSS (глобальная нулевая) или в куче (динамическая). Возврат указателя на стековую переменную из функции - классическая ошибка, потому что стек очищается после возврата.

Практика

#include <stdio.h>
#include <stdlib.h>

int global_var = 42;       // data segment
int global_zero;           // BSS (ноль по умолчанию)

int *create_heap_int(int val) {
    int *p = malloc(sizeof(int));
    *p = val;
    return p;  // OK - память в куче, переживёт функцию
}

int *bad_stack_ptr(void) {
    int local = 99;
    return &local;  // ОШИБКА - local умрёт при возврате!
}

int main(void) {
    int stack_var = 7;  // стек

    printf("global_var   @ %p = %d\n", (void*)&global_var, global_var);
    printf("global_zero  @ %p = %d\n", (void*)&global_zero, global_zero);
    printf("stack_var    @ %p = %d\n", (void*)&stack_var, stack_var);

    int *heap = create_heap_int(123);
    printf("heap         @ %p = %d\n", (void*)heap, *heap);
    free(heap);

    return 0;
}

Скомпилируйте с ASan: clang -fsanitize=address -g -o model model.c. ASan поймает возврат указателя на стек. Запустите и посмотрите отчёт об ошибке.

Домашнее задание

Напишите программу, в которой объявлены глобальная, статическая, локальная и динамическая переменные. Выведите их адреса. Объясните, почему адреса делятся на группы. Попробуйте вернуть указатель на локальную переменную и посмотрите, что скажет ASan.

Тема 12. Строки: char*, string.h, манипуляции

Теория

Строки в C - массивы char с нулевым терминатором. Стандартная библиотека <string.h> предоставляет функции: strlen (длина), strcpy/strncpy (копирование), strcat/strncat (конкатенация), strcmp/strncmp (сравнение), strchr (поиск символа), strstr (поиск подстроки). В Python все это - методы строкового объекта. В C - функции, работающие с указателями и байтами.

Главное правило: строки неизменяемы по своей природе - строковые литералы хранятся в read-only памяти. Попытка изменить литерал "hello"[0] = 'H' - неопределённое поведение (обычно segfault). Чтобы изменить строку, поместите её в изменяемый массив char buf[] = "hello".

Практика

#include <stdio.h>
#include <string.h>

int main(void) {
    char s1[] = "Hello";
    char s2[] = "World";
    char buf[20];

    // Длина
    printf("len(s1) = %zu\n", strlen(s1));

    // Копирование
    strcpy(buf, s1);
    printf("buf = %s\n", buf);

    // Конкатенация
    strcat(buf, " ");
    strcat(buf, s2);
    printf("buf = %s\n", buf);

    // Сравнение
    int cmp = strcmp(s1, s2);
    printf("strcmp = %d (%s)\n", cmp, cmp < 0 ? "s1 < s2" : cmp > 0 ? "s1 > s2" : "равны");

    // Поиск
    char *p = strchr(buf, 'W');
    if (p) printf("Найден 'W' на позиции %ld\n", p - buf);

    // Безопасное копирование (C11)
    strncpy(buf, s1, sizeof(buf) - 1);
    buf[sizeof(buf) - 1] = '\0';  // гарантия терминатора

    return 0;
}

Домашнее задание

Напишите функцию char* my_strdup(const char *s), которая выделяет новую строку в куче и копирует туда содержимое s. Не забудьте free результат в main. Реализуйте int my_strcmp(const char *a, const char *b) без string.h.

Тема 13. Структуры (struct) и объединения (union)

Теория

Структура struct - это пользовательский тип, объединяющий несколько переменных под одним именем. Аналог в Python - класс с атрибутами без методов (dataclass). В C структура хранит данные плотно в памяти, с возможным выравниванием (padding) между полями для эффективности процессора.

Объединение union - это тип, в котором все поля разделяют одну область памяти. В любой момент времени активен только один член union. Размер union равен размеру его наибольшего члена. В Python аналога нет, но концепция похожа на типобезопасный enum с данными в других языках.

Практика

#include <stdio.h>

struct Point {
    int x;
    int y;
};

// C11: анонимная структура внутри union
union Value {
    int i;
    double d;
    char s[8];
};

int main(void) {
    struct Point p = {3, 4};
    printf("Point: (%d, %d), размер %zu\n", p.x, p.y, sizeof(p));

    // Доступ через . (точка)
    p.x = 10;
    p.y = 20;
    printf("New Point: (%d, %d)\n", p.x, p.y);

    // Union: все разделяют память
    union Value v;
    v.i = 42;
    printf("v.i = %d\n", v.i);
    v.d = 3.14;
    printf("v.d = %f (v.i теперь мусор: %d)\n", v.d, v.i);

    printf("sizeof union = %zu\n", sizeof(v));

    return 0;
}

Домашнее задание

Определите структуру Student с полями: имя (char[50]), возраст (int), средний балл (double). Создайте массив из 3 студентов. Выведите их данные. Посчитайте средний балл всех студентов.

Тема 14. Указатели на структуры и оператор ->

Теория

Указатель на структуру - обычный указатель, хранящий адрес структуры. При работе через указатель используется оператор -> вместо точки: p->x равносильно (*p).x. Это синтаксический сахар, но он используется повсеместно. В Python аналога нет - объекты и так передаются по ссылке.

Указатели на структуры - основа для связанных списков, деревьев и других структур данных. Структура может содержать указатель на собственный тип: struct Node { int val; struct Node *next; };. Так строятся все классические структуры данных в C.

Практика

#include <stdio.h>
#include <stdlib.h>

struct Node {
    int val;
    struct Node *next;
};

// Создание узла
struct Node *node_new(int val) {
    struct Node *n = malloc(sizeof(struct Node));
    n->val = val;
    n->next = nullptr;
    return n;
}

int main(void) {
    // Построение списка: 1 -> 2 -> 3
    struct Node *head = node_new(1);
    head->next = node_new(2);
    head->next->next = node_new(3);

    // Обход
    struct Node *cur = head;
    while (cur != nullptr) {
        printf("%d -> ", cur->val);
        cur = cur->next;
    }
    printf("NULL\n");

    // Освобождение
    cur = head;
    while (cur != nullptr) {
        struct Node *tmp = cur;
        cur = cur->next;
        free(tmp);
    }

    return 0;
}

Домашнее задание

Добавьте в связанный список функцию void list_push_front(struct Node **head, int val), которая добавляет элемент в начало. Напишите функцию void list_print(struct Node *head). Протестируйте: добавьте 5 элементов, выведите список.

Тема 15. Файловый ввод-вывод

Теория

Файловый ввод-вывод в C осуществляется через тип FILE* и функции fopen, fread/fwrite, fgets/fputs, fprintf/fscanf, fclose. В Python файлы - это объекты с методами .read(), .write(), .close(), управляемые через with. В C вы открываете файл, получаете дескриптор FILE* и работаете с ним через функции.

Режимы открытия: "r" - чтение, "w" - запись (стирает файл), "a" - дозапись, "rb"/"wb" - бинарные режимы. При ошибке открытия fopen возвращает NULL - нужно проверять. Не забыть закрыть файл через fclose.

Практика

#include <stdio.h>

int main(void) {
    // Запись
    FILE *out = fopen("data.txt", "w");
    if (out == nullptr) {
        perror("fopen");
        return 1;
    }
    fprintf(out, "Имя: %s\n", "Студент");
    fprintf(out, "Возраст: %d\n", 21);
    fprintf(out, "Балл: %.1f\n", 4.5);
    fclose(out);

    // Чтение
    FILE *in = fopen("data.txt", "r");
    if (in == nullptr) {
        perror("fopen");
        return 1;
    }

    char line[256];
    while (fgets(line, sizeof(line), in) != nullptr) {
        printf("%s", line);
    }
    fclose(in);

    return 0;
}

Домашнее задание

Напишите программу, которая копирует текстовый файл. Имена исходного и целевого файлов передаются через аргументы командной строки. Обрабатывайте ошибки открытия. Проверьте результат на файле размером более 1000 строк.

Тема 16. Указатели на функции

Теория

Указатель на функцию - это переменная, хранящая адрес функции. Фожет быть вызвана через указатель. В Python функции - объекты первого класса: можно передавать как аргументы, хранить в переменных. В C функции не являются объектами, но их адреса можно брать и использовать через указатели.

Синтаксис объявления указателя на функцию неочевиден: int (*cmp)(int, int) - указатель на функцию, принимающую два int и возвращающую int. Указатели на функции используются для callback-ов, виртуальных таблиц (vtable), реализации полиморфизма и в стандартной библиотеке (например, qsort принимает функцию сравнения).

Практика

#include <stdio.h>
#include <stdlib.h>

// Функция сравнения для qsort
int cmp_asc(const void *a, const void *b) {
    return *(const int*)a - *(const int*)b;
}

int cmp_desc(const void *a, const void *b) {
    return *(const int*)b - *(const int*)a;
}

// C23: typedef для читаемости
typedef int (*cmp_fn)(const void*, const void*);

int main(void) {
    int arr[] = {5, 2, 8, 1, 9, 3, 7, 4, 6, 0};
    int n = 10;

    // Сортировка по возрастанию
    qsort(arr, n, sizeof(int), cmp_asc);
    printf("Asc:  ");
    for (int i = 0; i < n; i++) printf("%d ", arr[i]);
    printf("\n");

    // Сортировка по убыванию через другой указатель
    qsort(arr, n, sizeof(int), cmp_desc);
    printf("Desc: ");
    for (int i = 0; i < n; i++) printf("%d ", arr[i]);
    printf("\n");

    return 0;
}

Домашнее задание

Напишите функцию void apply(int *arr, int size, int (*fn)(int)), которая применяет функцию fn к каждому элементу массива. Создайте три функции: квадрат, куб, инкремент. Вызовите apply с каждой из них и выведите результат.

Тема 17. Динамические массивы и перераспределение

Теория

Список Python - это динамический массив: он автоматически растёт при добавлении элементов. В C нет встроенного динамического массива, но его можно реализовать через malloc, realloc и отслеживание размера и вместимости. Это одна из ключевых задач, которая учит понимать, как работают высокоуровневые контейнеры.

Паттерн: структура содержит указатель на буфер, текущий размер (количество элементов) и вместимость (размер буфера). При добавлении, если размер равен вместимости, буфер перевыделяется через realloc с увеличенным размером (обычно в 1.5-2 раза). Это амортизированная O(1) операция, как в Python-списке.

Практика

#include <stdio.h>
#include <stdlib.h>

typedef struct {
    int *data;
    size_t size;
    size_t capacity;
} Vec;

Vec vec_new(void) {
    Vec v = { malloc(4 * sizeof(int)), 0, 4 };
    return v;
}

void vec_push(Vec *v, int val) {
    if (v->size >= v->capacity) {
        v->capacity *= 2;
        v->data = realloc(v->data, v->capacity * sizeof(int));
    }
    v->data[v->size++] = val;
}

void vec_free(Vec *v) {
    free(v->data);
    v->data = nullptr;
    v->size = v->capacity = 0;
}

int main(void) {
    Vec v = vec_new();
    for (int i = 1; i <= 10; i++) {
        vec_push(&v, i * 10);
    }
    for (size_t i = 0; i < v.size; i++) {
        printf("%d ", v.data[i]);
    }
    printf("\nsize=%zu, cap=%zu\n", v.size, v.capacity);
    vec_free(&v);
    return 0;
}

Домашнее задание

Добавьте в Vec функцию vec_pop (удаление последнего элемента) и vec_at (доступ по индексу с проверкой границ). Напишите функцию vec_shrink_to_fit, которая уменьшает вместимость до размера. Протестируйте все функции.

Тема 18. Аргументы командной строки: argc и argv

Теория

Функция main может принимать аргументы командной строки: int main(int argc, char *argv[]). argc - количество аргументов (включая имя программы), argv - массив строк. В Python это sys.argv - список строк, где argv[0] - имя скрипта. В C те же правила, но argv - массив C-строк, заканчивающийся NULL.

Аргументы передаются как строки. Для преобразования в числа используйте atoi, atol, strtol, strtod. strtol предпочтительнее atoi, так как она обрабатывает ошибки и разные системы счисления.

Практика

#include <stdio.h>
#include <stdlib.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]);
    }

    if (argc < 3) {
        fprintf(stderr, "Использование: %s <a> <b>\n", argv[0]);
        return 1;
    }

    int a = atoi(argv[1]);
    int b = atoi(argv[2]);
    printf("%d + %d = %d\n", a, b, a + b);

    return 0;
}

Запуск: ./prog 5 7. Выведет 5 + 7 = 12. Попробуйте без аргументов - получите сообщение об использовании.

Домашнее задание

Напишите программу-калькулятор, которая принимает операцию и два числа через аргументы: ./calc add 3 5, ./calc mul 4 6. Поддержите операции: add, sub, mul, div. Обработайте деление на ноль и неверное количество аргументов.

Тема 19. Препроцессор: макросы и условная компиляция

Теория

Препроцессор C - это текстовый процессор, работающий до компиляции. Он обрабатывает директивы #include (вставка файла), #define (макросы), #ifdef/#ifndef/#endif (условная компиляция), #pragma (управление компилятором). В Python нет препроцессора, но концепция условного импорта есть через try/except ImportError.

Макросы #define - это текстовая замена. #define PI 3.14159 заменит все вхождения PI на 3.14159. Макросы с параметрами: #define SQUARE(x) ((x) * (x)). В C23 рекомендуется использовать constexpr и static_assert вместо многих макросов. Также в C23 появились #elifdef и #elifndef для удобства.

Практика

#include <stdio.h>

#define VERSION "1.0"
#define MAX(a, b) ((a) > (b) ? (a) : (b))
#define MIN(a, b) ((a) < (b) ? (a) : (b))

// C23: constexpr вместо макроса
constexpr double E = 2.71828;

#ifdef DEBUG
  #define LOG(msg) printf("[DEBUG] %s\n", msg)
#else
  #define LOG(msg) ((void)0)
#endif

int main(void) {
    printf("Version: %s\n", VERSION);
    printf("MAX(3, 7) = %d\n", MAX(3, 7));
    printf("MIN(3, 7) = %d\n", MIN(3, 7));
    printf("E = %f\n", E);
    LOG("Это сообщение видно только в debug-сборке");
    return 0;
}

Скомпилируйте с -DDEBUG и без. Сравните вывод. Флаг -D определяет макрос из командной строки.

Домашнее задание

Напишите макрос ARRAY_LEN(arr), который вычисляет длину статического массива через sizeof(arr) / sizeof(arr[0]). Объясните, почему этот макрос не работает для динамических массивов (указателей).

Тема 20. Многофайловые проекты и заголовочные файлы

Теория

В C проект разбивается на файлы: .c - исходный код (определения функций), .h - заголовочные файлы (объявления, прототипы, типы). Заголовочный файл включается через #include в каждый .c, который использует его содержимое. В Python модули импортируются через import, и система сама находит файлы. В C вы управляете связями вручную.

Защита от повторного включения (include guard) - стандартная практика. В C23 появился #pragma once как стандартизированная альтернатива include guards. Ключевое слово extern объявляет переменную или функцию, определённую в другом файле.

Практика

Файл math_utils.h:

#pragma once  // C23: стандартизированный include guard

int gcd(int a, int b);
int lcm(int a, int b);

Файл math_utils.c:

#include "math_utils.h"

int gcd(int a, int b) {
    while (b != 0) {
        int t = b;
        b = a % b;
        a = t;
    }
    return a;
}

int lcm(int a, int b) {
    return (a / gcd(a, b)) * b;
}

Файл main.c:

#include <stdio.h>
#include "math_utils.h"

int main(void) {
    printf("gcd(12, 18) = %d\n", gcd(12, 18));
    printf("lcm(12, 18) = %d\n", lcm(12, 18));
    return 0;
}

Компиляция: clang -std=c2x -Wall main.c math_utils.c -o app. Компилятор собирает оба файла и линкует их вместе.

Домашнее задание

Создайте заголовочный файл string_utils.h и реализацию string_utils.c с функциями: my_strlen, my_strcpy, my_strcmp. Напишите main.c, который использует все три. Скомпилируйте многофайловый проект.

Тема 21. Связанные списки и простые структуры данных

Теория

Связанный список - это структура данных, где каждый узел содержит значение и указатель на следующий узел. В Python список - это встроенный динамический массив, а не связанный список. В C вы строите связанный список вручную через структуры и указатели. Это учит работать с динамической памятью и указателями в связке.

Кроме односвязного списка, бывают двусвязные (указатели на следующий и предыдущий), циклические (последний указывает на первый), стеки (LIFO через голову списка), очереди (FIFO через голову и хвост). Все они строятся из узлов, malloc и указателей.

Практика

#include <stdio.h>
#include <stdlib.h>

typedef struct Node {
    int val;
    struct Node *next;
} Node;

typedef struct {
    Node *head;
    int size;
} List;

List *list_create(void) {
    List *l = malloc(sizeof(List));
    l->head = nullptr;
    l->size = 0;
    return l;
}

void list_push(List *l, int val) {
    Node *n = malloc(sizeof(Node));
    n->val = val;
    n->next = l->head;
    l->head = n;
    l->size++;
}

void list_print(List *l) {
    Node *cur = l->head;
    while (cur) {
        printf("%d ", cur->val);
        cur = cur->next;
    }
    printf("\n");
}

void list_free(List *l) {
    Node *cur = l->head;
    while (cur) {
        Node *tmp = cur;
        cur = cur->next;
        free(tmp);
    }
    free(l);
}

int main(void) {
    List *l = list_create();
    for (int i = 1; i <= 5; i++) list_push(l, i * 10);
    list_print(l);
    printf("size = %d\n", l->size);
    list_free(l);
    return 0;
}

Домашнее задание

Добавьте функцию list_remove(List *l, int val), которая удаляет первое вхождение значения. Напишите функцию list_reverse(List *l), которая разворачивает список на месте. Протестируйте обе функции.

Тема 22. Безопасность памяти и AddressSanitizer

Теория

C не проверяет границы массивов, не инициализирует память автоматически и не отслеживает освобождённые указатели. Это делает его быстрым, но опасным. Переполнение буфера - самая распространённая уязвимость в C-коде. Использование освобождённой памяти (use-after-free), двойное освобождение, неинициализированные переменные - все это потенциальные источники сбоев и уязвимостей.

AddressSanitizer (ASan) - инструмент от Google, встроенный в Clang и GCC. Он инструментирует код при компиляции и проверяет каждый доступ к памяти во время выполнения. ASan ловит: переполнение буфера (heap и stack), use-after-free, double-free, использование неинициализированной памяти (через MemorySanitizer), утечки памяти (через LeakSanitizer). Замедление программы - 2-3x, но для отладки это приемлемо.

Практика

#include <stdio.h>
#include <stdlib.h>

// Баг 1: переполнение буфера
void buffer_overflow(void) {
    int *arr = malloc(5 * sizeof(int));
    for (int i = 0; i <= 5; i++) {  // i <= 5 - выход за границу!
        arr[i] = i;
    }
    free(arr);
}

// Баг 2: use-after-free
void use_after_free(void) {
    int *p = malloc(sizeof(int));
    *p = 42;
    free(p);
    printf("Значение: %d\n", *p);  // BUG!
}

// Баг 3: утечка памяти
void memory_leak(void) {
    int *p = malloc(100 * sizeof(int));
    p[0] = 1;
    // нет free - утечка
}

int main(void) {
    // Раскомментируйте по одной функции и запустите с ASan
    // buffer_overflow();
    // use_after_free();
    // memory_leak();
    printf("Все функции с багами закомментированы\n");
    return 0;
}

Скомпилируйте: clang -fsanitize=address,undefined -g -o bugs bugs.c. Раскомментируйте по одной функции, запустите, изучите отчёт ASan. Каждая ошибка будет показана с указанием строки и типа.

Домашнее задание

Напишите программу с тремя функциями, в каждой из которых есть один из багов: переполнение стека (большой локальный массив), неинициализированная переменная, двойное освобождение. Скомпилируйте с ASan и UBSan. Зафиксируйте, какие ошибки и на каких строках они находят. Исправьте все баги.

Тема 23. typedef, enum и пользовательские типы

Теория

typedef создаёт псевдоним для типа. Это улучшает читаемость: вместо unsigned long long - u64. В Python аналога нет (но есть type в Python 3.12 через PEP 695). В C typedef не создаёт новый тип, а лишь даёт имя существующему.

enum - перечисление, набор именованных констант. В Python аналогичен enum.Enum из модуля enum. В C23 enum может иметь указанный базовый тип: enum Color : int { RED, GREEN, BLUE };. Также в C23 добавлена возможность хранить значения, не помещающиеся в int.

Практика

#include <stdio.h>
#include <stdint.h>

// typedef для читаемости
typedef uint32_t u32;
typedef uint64_t u64;
typedef int (*cmp_fn)(int, int);

// enum для состояний
typedef enum {
    STATE_IDLE = 0,
    STATE_RUNNING = 1,
    STATE_STOPPED = 2,
    STATE_ERROR = -1
} State;

// C23: enum с указанным типом
enum Status : uint8_t {
    OK = 0,
    WARNING = 1,
    FAIL = 2
};

const char *state_name(State s) {
    switch (s) {
        case STATE_IDLE:    return "IDLE";
        case STATE_RUNNING: return "RUNNING";
        case STATE_STOPPED:  return "STOPPED";
        case STATE_ERROR:    return "ERROR";
        default:             return "UNKNOWN";
    }
}

int main(void) {
    u32 counter = 0;
    State current = STATE_RUNNING;
    enum Status st = OK;

    printf("Counter: %u, State: %s, Status: %d\n",
           counter, state_name(current), st);
    return 0;
}

Домашнее задание

Создайте typedef enum Direction { NORTH, SOUTH, EAST, WEST }. Напишите функцию, которая принимает Direction и возвращает название направления как строку. Создайте typedef для struct Point { int x, y; } под именем Point. Используйте оба типа в программе.

Тема 24. Variadic функции и stdarg

Теория

Variadic функция - функция с переменным числом аргументов. В Python *args собирает лишние аргументы в кортеж. В C variadic-функция использует заголовок <stdarg.h> и макросы va_list, va_start, va_arg, va_end. Функция printf - variadic: первый аргумент определяет, сколько и каких типов аргументов последует.

В C23 упростили variadic-функции: va_start больше не требует второго аргумента (имени последнего параметра перед ...), а в прототипе можно писать ... без обязательного именованного параметра перед ним.

Практика

#include <stdio.h>
#include <stdarg.h>

// Функция, суммирующая N чисел
int sum_n(int count, ...) {
    va_list args;
    va_start(args, count);  // C23: можно без второго аргумента

    int total = 0;
    for (int i = 0; i < count; i++) {
        total += va_arg(args, int);
    }

    va_end(args);
    return total;
}

// Аналог printf с префиксом
void log_msg(const char *prefix, const char *fmt, ...) {
    printf("[%s] ", prefix);
    va_list args;
    va_start(args, fmt);
    vprintf(fmt, args);
    va_end(args);
}

int main(void) {
    printf("sum = %d\n", sum_n(4, 10, 20, 30, 40));
    log_msg("INFO", "x=%d, y=%d\n", 5, 7);
    log_msg("ERROR", "код %d\n", 42);
    return 0;
}

Домашнее задание

Напишите функцию int max_n(int count, ...), которая находит максимум среди переданных чисел. Напишите функцию void print_all(const char *first, ...), которая печатает все строки до NULL. Протестируйте обе функции.

Тема 25. Современный C: C23 и новые возможности

Теория

C23 (ISO/IEC 9899:2024) - самый значительный пересмотр стандарта за десятилетие. Он добавляет возможности, которые приближают C к современным языкам, не разрушая его компактности. Ключевые нововведения: nullptr вместо NULL, true/false как ключевые слова, constexpr для констант времени компиляции, typeof для вывода типа, двоичные литералы 0b1010, разделители цифр 1'000'000, атрибуты C++-стиля [[nodiscard]], [[deprecated]], [[fallthrough]], пустые инициализаторы int arr[] = {}, #embed для включения бинарных файлов.

C23 удаляет устаревшие возможности: K&R-объявления функций (функции без прототипа), неявный int, макрос NULL остаётся, но nullptr предпочтительнее. Заголовок <stdbool.h> больше не нужен - bool, true, false стали ключевыми словами. Эти изменения делают C безопаснее и читаемее, не жертвуя производительностью.

Практика

#include <stdio.h>
// C23: bool, true, false - встроены, stdbool.h не нужен

// constexpr вместо #define
constexpr int BUFFER_SIZE = 256;

// [[nodiscard]] - компилятор предупредит, если результат проигнорирован
[[nodiscard]] int compute_important(void) {
    return 42;
}

// [[maybe_unused]] - подавить предупреждение о неиспользуемом параметре
void debug_print([[maybe_unused]] int verbose, int value) {
    printf("value = %d\n", value);
}

// typeof - вывод типа
#define SWAP(a, b) do { \
    typeof(a) tmp = (a); \
    (a) = (b); \
    (b) = tmp; \
} while (0)

int main(void) {
    // Двоичный литерал и разделитель цифр
    int mask = 0b1010'1010;
    int million = 1'000'000;
    printf("mask = %d, million = %d\n", mask, million);

    // bool как ключевое слово
    bool active = true;
    if (active) printf("Активен\n");

    // Пустой инициализатор (все нули)
    int zeros[5] = {};
    for (int i = 0; i < 5; i++) printf("%d ", zeros[i]);
    printf("\n");

    // [[nodiscard]]
    int result = compute_important();  // если убрать присваивание - предупреждение

    // typeof через макрос SWAP
    int x = 10, y = 20;
    SWAP(x, y);
    printf("x=%d, y=%d\n", x, y);

    // nullptr - типобезопасный null
    int *p = nullptr;
    if (p == nullptr) printf("Указатель пуст\n");

    return 0;
}

Скомпилируйте: clang -std=c2x -Wall -Wextra -o modern modern.c. Если Clang старше 16, некоторые возможности могут не работать. Проверьте версию: clang --version.

Домашнее задание

Перепишите программу из темы 1, используя все возможности C23: constexpr для констант, bool для флагов, nullptr для указателей, [[nodiscard]] для функций, двоичные литералы и разделители цифр. Сравните с оригинальной версией: что стало чище и безопаснее.

Что дальше

25 тем этого курса - не предел, а фундамент. Дальше идёт системное программирование: работа с POSIX API, сокеты, процессы, потоки, мьютексы, условные переменные. Затем - углубление в архитектуру: выравнивание данных, кэш-линейки, векторизация, SIMD-инструкции. Потом - встраиваемые системы: прямая работа с регистрами, прерывания, DMA, реальное время.

C - не конечная точка, а отправная. После C легче понять Rust, который решает проблемы безопасности памяти, не жертвуя производительностью. Знание C делает вас лучшим Python-программистом: вы понимаете, что стоит за ctypes, cffi, Cython и как писать расширения на C для Python. Вы видите, почему list амортизированно O(1) и как сборщик мусора находит недостижимые объекты.

Главное, что даёт этот курс, - не синтаксис, а модель мышления. Вы начинаете видеть программу как поток байтов в памяти, управляемый указателями и инструкциями. Это уровень абстракции, который недоступен из Python, Java или JavaScript. И именно он отличает программиста, который понимает компьютер, от программиста, который умеет пользоваться инструментами.

Учите C. Не потому что он модный, а потому что он показывает, как устроена машина. А машина - это то, на чём вы работаете каждый день.


 Ваша оценка:

Связаться с программистом сайта.

Новые книги авторов СИ, вышедшие из печати:
О.Болдырева "Крадуш. Чужие души" М.Николаев "Вторжение на Землю"

Как попасть в этoт список

Кожевенное мастерство | Сайт "Художники" | Доска об'явлений "Книги"