/
d
e
v
/
t
o
k
i
o
r
y
Блог <tokiory>
В
в
е
д
е
н
и
е
в
С
и
Си — прекрасный язык программирования, который прошел сквозь огонь и воду. В данной статье мы разберем его основы и научимся с ним работать.

Си — это язык программирования, который был разработан в начале 1970-х годов. Он стал основой для многих других языков, таких как C++, Java и Python. Си известен своей эффективностью и низким уровнем абстракции, что позволяет программистам работать ближе к аппаратному обеспечению.

Где сейчас используется Си?

Си по-прежнему широко используется в различных областях, включая:

  • Разработка операционных систем (например, Linux)
  • Встраиваемые системы (например, прошивки для микроконтроллеров)
  • Игровая индустрия (например, Unreal Engine)
  • Научные вычисления (например, библиотеки для численных методов)
  • Системное программирование (например, драйверы устройств)
  • Разработка компиляторов и интерпретаторов
  • Сетевое программирование (например, реализация протоколов)
  • Разработка баз данных (например, SQLite)
  • Разработка графических интерфейсов (например, GTK+)

Почему вообще нужно изучать эту древность?

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

  • Си является основой для многих современных языков программирования, таких как C++, Java и Python. Изучение Си поможет мне лучше понимать как работают другие языки программирования;
  • Си это низкоуровневый язык, который позволяет мне работать ближе к аппаратному обеспечению. Это полезно для понимания работы компьютера и операционных систем;

Среда разработки

Для того чтобы писать на Си вам нужен любой редактор (практически все редакторы сходу поддерживают Си) и компилятор. Во многих системах компилятор для Си уже установлен. Проверить установлен ли компилятор можно с помощью следующей команды:

gcc -v

В случае если компилятор не установлен, мы можем установить его:

# MacOS
brew install gcc

# Ubuntu/Debian
sudo apt install build-essential

# Fedora/RHEL
sudo dnf groupinstall "Development Tools"
sudo dnf group install development-tools # Fedora 41+

# Arch
sudo pacman -S base-devel

# Windows
choco install mingw -y

Структура программы

Программа на Си состоит из функций. Каждая программа должна содержать функцию main, которая является точкой входа в программу. Вот пример простой программы на Си:

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

Hello, World!

Давайте разберем данный кусок кода по частям:

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

С помощью #include <stdio.h> мы подключаем стандартную библиотеку ввода-вывода, которая позволяет нам использовать функции для работы с вводом и выводом данных.

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

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

В данном случае мы определяем функцию main, которая является точкой входа в программу. Каждая программа на Си должна содержать эту функцию.

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

Функция main возвращает целое число, потому что мы определили её как int main.

Само число указывает на статус завершения программы. Обычно мы возвращаем 0, что означает успешное завершение программы.

1 / 0
Свайпайте для просмотра слайдов

Переменные и типы данных

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

В Си есть несколько основных типов данных:

Категория Описание Примеры типов
Целые числа (Integers) Целые числа, могут быть положительными или отрицательными. char, int, short, long, long long
Беззнаковые целые (Unsigned integers) Целые числа, могут быть только положительными (или ноль). unsigned char, unsigned int, unsigned short, unsigned long, unsigned long long
Числа с плавающей точкой (Floating point numbers) Вещественные числа (с дробной частью). Используются для точных вычислений. float, double
Структуры (Structures) Пользовательские типы, объединяющие разные переменные под одним именем. Будут рассмотрены далее. struct MyStruct (будет объяснено позже)
Пустой тип данных Данный тип указывает на отсутствие каких-либо данных void
А где?

В Си нет типа данных для строк, но строки можно представлять как массивы символов. Например, строка “Hello” может быть представлена как массив char.

У каждого из этих типов данных есть своя размерность и диапазон значений, которые они могут хранить. Размеры типов данных могут варьироваться в зависимости от платформы и компилятора, но обычно они следующие:
Тип Размер (байт) Диапазон значений
char 1 -128 до 127
int 4 -2,147,483,648 до 2,147,483,647
float 4 ±1.2E-38 до ±3.4E+38
double 8 ±2.3E-308 до ±1.7E+308

Сам синтаксис объявления переменных в Си выглядит следующим образом:

тип_данных имя_переменной [?= значение];

Вот пример объявления переменных:

int a;
int b;

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

int a = 5;
int b = 10;

printf("%d", a + b);

15

printf

На протяжении данного туториала мы будем использовать функцию printf, данная функция выводит данные в стандартный поток вывода.

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

#include <stdio.h>

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

#include <stdio.h>

int main() {
  printf(213); // Вызовет ошибку компиляции
  printf("");  // Сработает нормально
}

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

Спецификатор Значение
%d Выводит целое число в десятичном формате (знак учитывается)
%i То же, что и %d, целое число в десятичном формате
%u Выводит беззнаковое целое число в десятичном формате
%f Выводит число с плавающей запятой (десятичное дробное число)
%c Выводит одиночный символ
%s Выводит строку символов (массив char, заканчивающийся \0)
%x Выводит целое число в шестнадцатеричном формате (строчные буквы)
%X Выводит целое число в шестнадцатеричном формате (прописные буквы)
%o Выводит целое число в восьмеричном формате
%p Выводит указатель (адрес в памяти)
%% Выводит символ %
#include <stdio.h>

int main() {
  int foo = 10;
  double bar = 2.2;
  char ch = 'c';

  printf("Мы можем выводить значения с помощью спецификаторов формата:\n")
  printf("%d, %f, %c", foo, bar, ch);
}

Мы можем выводить значения с помощью спецификаторов формата:

10, 2.20000, c

Операторы

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

Оператор Название Пример Описание
+ Сложение a + b Складывает два значения
- Вычитание a - b Вычитает второе значение из первого
* Умножение a * b Умножает два значения
/ Деление a / b Делит первое значение на второе
% Остаток от деления a % b Возвращает остаток от целочисленного деления

Размерность типа данных

В Си есть специальный оператор sizeof, который позволяет узнать размер типа данных в байтах.

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

int a = 5;
printf("%d", sizeof(a));
4

Тайп-кастинг (приведение типов)

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

int a = 5;
int b = 2;
printf("%d", a / b);
2

Для того чтобы преобразовать типы на лету, мы можем указать нужный нам тип в круглых скобочках:

int a = 5;
int b = 2;
printf("%f", (float)a / b);
2.50000

У приведения типов, естественно, есть ограничения, среди которых:

  • Приведение типов может привести к потере данных. Например, если вы преобразуете double в int, дробная часть будет отброшена.
  • Приведение типов может вызвать переполнение или потерю значений. Например, преобразование большого long числа в short может привести к обрезанию данных.
  • Приведение типов может привести к неопределенному поведению. Например, если вы преобразуете указатель (о них мы поговорим позже) на один тип в указатель на другой тип, это может привести к ошибкам доступа к памяти.
  • Приведение типов может быть неэффективным. Например, преобразование float в int может потребовать дополнительных вычислений и замедлить выполнение программы.
  • Приведение типов может привести к ошибкам компиляции. Например, если вы пытаетесь преобразовать указатель на функцию в указатель на другой тип функции, это приведет к ошибке компиляции.
  • Приведение типов может нарушить выравнивание данных. Например, преобразование указателя на структуру с одним выравниванием в указатель на структуру с другим выравниванием может вызвать ошибки при доступе к данным.
  • Приведение типов может затруднить отладку. Ошибки, связанные с некорректным приведением типов, часто сложно обнаружить и исправить, особенно в больших проектах.

Массивы и строки

В Си строки тесно связаны с массивами. Массивы же, в свою очередь, тесно связаны с типами данных и размерностями

На этом этапе мы научимся объявлять и инициализировать одномерные и двумерные массивы, эффективно итерироваться по ним, а также работать со строками и базовыми функциями из стандартной библиотеки string.h.

Массивы

Массив — это базовая структура данных, предназначенная для хранения фиксированного количества элементов одного типа. Все элементы массива располагаются в памяти последовательно, что обеспечивает быстрый доступ по индексу. Массивы позволяют удобно представлять и обрабатывать наборы данных, таких как список чисел, координаты, таблицы и другие последовательности.

Вот пример инициализации и использования массивов:

#include <stdio.h>

int main() {
  // Объявление массива без явной инициализации элементов
  int foo[5];

  // Объявление массива с инициализацией
  double bar[3] = {1, 2, 3};

  // Объявление массива с автовычислением размера
  short far[] = {1, 2, 3, 4};
}

Для того чтобы изменить значение в ячейке массива или достать определенное значение из массива, мы должны использовать индекс. Индексы в Си начинаются с нуля, мы можем обратиться к определенной ячейке массива указав индекс в квадратных скобках:

int foo[5] = { 1, 2, 3, 4, 5 };

// Достаем элемент из ячейки с индексом 2
printf("%d\n", foo[2]); // 3

// Изменяем значение в ячейке массива с индексом 4
foo[4] = 20;

// Достаем элемент из ячейки с индексом 4
printf("%d\n", foo[4]); // 20

3

20

Строки

Строки в Си не являются отдельным типом данных — они реализованы как массивы символов, завершаемые специальным нулевым символом '\0'. Этот символ служит маркером конца строки и позволяет стандартным функциям определять её длину, копировать, сравнивать и объединять строки.

char str[] = "Hello"; // то же самое, что {'H', 'e', 'l', 'l', 'o', '\0'}

Для того чтобы работать со строками в Си, нам предоставлена библиотека string.h:

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

int main() {
    char name[20] = "John";
    char greeting[50];

    // Копирование значения в строчный массив
    strcpy(greeting, "Hello, ");

    // Конкатенация строк
    strcat(greeting, name);

    printf("%s\n", greeting); // Выведет "Hello, John"
}

В string.h реализованы следующие функции:

  • strlen(char[]) — данная функция возвращает длину строки не учитывая \\0;
  • strcpy(dest char[], s char[]) — данная функция копирует строку s в строку dest;
  • strcat(dest char[], s char[]) — данная функция конкатенирует строки dest и s;
  • strcmp(s1 char[], s2 char[]) — сравнивает строки и возвращает 0, если они идентичны;
Сравнение строк

Сравнивать строки напрямую с помощью оператора == нельзя — это сравнение адресов в памяти, а не содержимого. Для корректного сравнения используется функция strcmp.

Управляющие конструкции

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

К основным управляющим конструкциям в C относятся:

  • Условные операторы — применяются для выбора направления выполнения программы в зависимости от заданного условия;
  • Циклы — используются для повторного выполнения блока кода до тех пор, пока соблюдаются определённые условия;
  • Оператор выбора — предоставляет способ обработки различных значений одного выражения и исполнения соответствующего кода для каждого случая, часто применяясь в структурах меню или при множественном ветвлении;
  • Операторы прерывания — позволяют досрочно завершить выполнение цикла или пропустить текущую итерацию;
  • Оператор перехода — позволяет осуществлять переход к произвольной метке в пределах функции;

Условия

Условия в C реализованы с помощью операторов if, else if и else. Данные операторы позволяют выполнять определенные блоки кода в зависимости от истинности или ложности условия.

Истинными условиями в Си являются все значения, кроме нуля.

Обычно в других языках программирования существуют булев тип данных, который может принимать только два значения: true и false. В Си же все немного иначе, в нем нет булевого типа данных, но мы можем использовать целые числа для представления истинности или ложности.

int a = 5;
if (a >= 5) {
  printf("Данный блок кода выполнится\n");
} else if (a < 5) {
  printf("Данный блок кода не выполнится\n");
} else {
  printf("Данный блок кода не выполнится\n");
}

Булев тип данных и директивы препроцессора

Директива препроцессора #define используется для определения макросов — именованных констант или шаблонов кода, которые заменяются на этапе препроцессинга перед компиляцией.

Например, мы создадим макрос, который будет заменять все значения tokiory на “Даниил”, вот как это будет выглядеть:

#include <stdio.h>
#define tokiory "Даниил"

int main() {
  printf("Привет, меня зовут %s", tokiory);
  return 0;
}
Привет, меня зовут Даниил

Мы можем использовать #define для определения булевых значений:

#include <stdio.h>
#define TRUE 1
#define FALSE 0

int main() {
  if (TRUE) {
    printf("Данный блок кода выполнится\n");
  } else {
    printf("Данный блок кода не выполнится\n");
  }

  return 0;
}
Данный блок кода выполнится

Циклы

В Си реализованы несколько типов циклов, которые можно использовать с помощью ключевых слов while, do ... while и for.

Цикл с предусловием

Начнем с простого, while это цикл, который будет выполнять код до тех пор, пока условие внутри скобочек истинно:

int i = 0;
while (i < 5) {
  printf("Текущий индекс: %d", i);
  i++;
}
Текущий индекс: 0

Текущий индекс: 1

Текущий индекс: 2

Текущий индекс: 3

Текущий индекс: 4

Оператор ++ в сниппете выше, увеличивает значение i на 1. Это необходимо для того, чтобы мы не попали в вечный цикл.

Если же мы специально хотим попасть в вечный цикл, то это можно сделать просто пробросив в скобки условие, которое всегда будет истинно:

int i = 0;
while (1) {
  printf("Текущий индекс: %d", i);
  i++;
}
Текущий индекс: 0

Текущий индекс: 1

Текущий индекс: 2

Текущий индекс: 3

Текущий индекс: 4

Текущий индекс: 5

Текущий индекс: 6

Текущий индекс: 7

… (до бесконечности или пока i не переполнится)

Цикл с постусловием

Цикл do while занимается примерно тем же, что и while. Их различие состоит только в том, что do while всегда выполняется хотя бы один раз. Использование данного цикла распространено в геймдеве, где определенному действию нужна хотя бы одна итерация:

int i = 0;

// Условие уже не истинно со старта, но блок кода все равно выполнится один раз
do {
  printf("Текущий i: %d", i);
} while (i < 0);
Текущий i: 0

Цикл с параметрами

Цикл for является самым подробным циклом, он позволяет объявить и инициализировать переменную, указать условие в рамках которого цикл будет работать и указать что и как будет изменяться после каждой итерации цикла. Рассмотрим пример:

for (int i = 0; i < 5; i++) {
  printf("Текущая итерация: %d\n", i + 1);
}
Текущая итерация: 1

Текущая итерация: 2

Текущая итерация: 3

Текущая итерация: 4

Текущая итерация: 5

  • int i = 0 является инициализацией, в данном блоке мы можем объявить переменную с любым названием для использования в цикле. После того как цикл закончится, переменная перестанет существовать;
  • i < 5 является условием цикла, по которому будет работать цикл, пока данное условие истинно – цикл будет продолжаться;
  • i++ является выражением итерации, данное выражение будет выполняться после каждой итерации;

Операторы прерывания

В любом из вышеперечисленных циклов можно использовать операторы прерывания. Данные операторы нужны для того чтобы прерывать выполнение итераций и в зависимости от указанного оператора продолжать цикл со следующей итерации или выходить из цикла.

Всего в Си два ключевых слова для прерывания циклов:

  • break — ключевое слово, которое используется для того чтобы немедленно завершить цикл;
  • continue — ключевое слово, которое используется для того чтобы пропустить текущую итерацию;
for (int i = 0; i < 20; i++) {

  // Пропускаем итерации, когда i — нечетное число
  if (i % 2 != 0) {
    continue;
  }

  // Выходим из цикла, когда i больше, либо равно 10
  if (i >= 10) {
    break;
  }

  printf("Текущая итерация: %d\n", i);
}
Текущая итерация: 0

Текущая итерация: 2

Текущая итерация: 4

Текущая итерация: 6

Текущая итерация: 8

Оператор выбора

Конструкцией выбора в Си является блок кода, где используется ключевое слово switch. Данный оператор позволяет выбрать один из блоков кода в зависимости от выполнения условия:

int a = 2;

switch (a) {
  case 1:
    printf("В данном случае a равно единице");
    break;
  case 2:
    printf("В данном случае a равно двум");
    break;
  case 3:
    printf("В данном случае a равно трём");
    break;
  default:
    printf("В данном случае a равно чему-то другому");
}
В данном случае a равно двум
  • switch — это ключевое слово, которое указывает на то, что мы сейчас будем выбирать один из вариантов выполнения кода.
  • case — ключевое слово, которое говорит нам о том, чему должна быть равна переменная или выражение в скобках возле switch для того чтобы выполнился блок case;
  • default — это блок кода, который выполнится, если ни одно из условий из case не было удовлетворено;

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

Если мы не укажем его, то при удовлетворении условий одного из case, все последующие блоки case тоже будут выполнены:

int a = 2;

switch (a) {
  case 1:
    printf("В данном случае a равно единице\n");
  case 2:
    printf("В данном случае a равно двум\n");
  case 3:
    printf("В данном случае a равно трём\n");
  default:
    printf("В данном случае a равно чему-то другому\n");
}
В данном случае a равно двум

В данном случае a равно трём

В данном случае a равно чему-то другому

Оператор перехода

Оператор перехода — это низкоуровневая управляющая конструкция, позволяющая передавать управление не по логике программы, а непосредственно в заданное место кода.

Плохая практика

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

Но почему мне не стоит его использовать?
Использование данного оператора сильно усложнит вам и вашим коллегам жизнь при отладке кода, да и в целом оно сильно усложняет логику.

Оператор перехода в Си реализован через ключевое слово goto и метки:

int a = 0;

printf("Начали вычисление\n");
goto skip;

if (a < 0) {
  printf("a меньше нуля\n");
} else if (a == 0) {
  printf("a равно нулю\n");
} else {
  printf("a больше нуля\n");
}

skip:
printf("Закончили вычисление\n");
Начали вычисление

Закончили вычисление

Функции

Функции — это базовые строительные блоки программы, позволяющие структурировать код, повысить читаемость, переиспользуемость и модульность. Функции в Си могут принимать аргументы и отдавать определенные значения.

Объекты первого класса

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

Функции в Си объявляются с помощью следующего синтаксиса:

[возвращаемый тип данных] [название функции]([?аргументы]) {
  [код]
}

Давайте напишем простую функцию, заачей которой будет просто выводить слово "Привет!", данная функция не будет принимать никаких входных данных и ничего не будет отдавать:

#include <stdio.h>

void say_hello() {
  printf("Привет!\n");
}

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

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

#include <stdio.h>

void say_hello() {
  printf("Привет!\n");
}

int main() {
  say_hello();
  say_hello();
  say_hello();
}

Привет!

Привет!

Привет!

Аргументы и возвращаемые значения

Функции в Си могут получать аргументы и отдавать возвращаемые значения. При объявлении функции мы указываем тип, который наша функция должна вернуть, данный тип называется типом возвращаемого значения. Мы можем вернуть значение из функции с помощью ключевого слова return:

double get_five() {
  return 5.12;
}

int main() {
  printf("%f", get_five()); // 5.12000
}

Например, функция main, которую мы уже не один раз писали должна возвращать число. Мы все время возвращали его с помощью return 0, однако, если мы явно не укажем число возвращаемое число с помощью ключевого слова return, то функция будет возвращать нулевое значение.

В случае с возвращаемым типом int будет возвращаться 0 (даже без использования ключевого слова return):

#include <stdio.h>
int get_number() {}

int main() {
  printf("%d", get_number());
}
0

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

Давайте создадим простую функцию, которая будет складывать три числа:

#include <stdio.h>

int sum3(int a, int b, int c) {
  return a + b + c;
}

int main() {
  printf("%d\n", sum3(12, 24, 36));
}
72

В данном случае числа 12, 24, 36 — являются аргументами функции. Тип аргументов, которые мы передаем в функцию всегда должен соответствовать типам указанных параметров при объявлении функции.

Прототипы функций

Прототип функции — это объявление функции, которое указывает её имя, тип возвращаемого значения и типы параметров, позволяя компилятору заранее проверить корректность вызовов до фактической реализации функции.

void say_hello();

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

int main() {
  say_hello();
}

void say_hello() {
  printf("Привет!\n");
}

error: implicit declaration of function ‘say_hello’

[-Wimplicit-function-declaration]

4 | say_hello();

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

Второй способ является предпочтительным, так как помогает отделить реализацию функции от её интерфейса:

main.c
c
#include <stdio.h>
#include <say_hello.h>

int main() {
  say_hello();
}

// Реализация функции
void say_hello() {
  printf("Привет!\n");
}
say_hello.h
c
// Прототип функции
void say_hello();

Привет!

Область видимости

Блочная область видимости — это область действия переменной, ограниченная телом блока, заключённого в фигурные скобки {}. Переменные с такой областью видимы и доступны только внутри этого блока и уничтожаются при выходе из него.

По умолчанию все переменные ограничены блочной областью видимости.

#include <stdio.h>

int main() {
  int a = 10;

  {
    int b = 20;
    printf("b=%d\n", b);
  }
  // Здесь b более недоступна

  printf("a=%d\n", a);
}
// Здесь a более недоступна

b=20

a=10

Функциональная область видимости — это область действия параметров функции, которые видимы и доступны только внутри тела этой функции.

#include <stdio.h>

void foo() {
  int a = 0;
}
// Здесь a более не доступна

Глобальная область видимости — это область действия переменных, объявленных вне всех функций и блоков. Такие переменные доступны во всём исходном файле, а при использовании ключевого слова extern — и в других файлах проекта.

main.c
c
// Доступна только в main.c
int a = 0;

// Доступна в рамках всей программы
extern int b = 2;

Статические переменные

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

#include <stdio.h>
int state = 0;

void inc() {
  state++;
}

int main() {
  inc();
  inc();

  printf("%d\n", state);
}

2

Второй способ заключается в использовании статической переменной, она объявляется с помощью ключевого слова static.

Данное ключевое слово позволяет нам назначить переменной статический адрес. При вызове функции адрес переменной не будет меняться, сама же переменная не будет реинициализирована, в случае если инициализация уже произошла:

#include <stdio.h>
int inc() {
  static int state = 0;
  state++;
  return state;
}

int main() {
  inc();
  int s = inc();

  printf("%d\n", s);
}

2

Указатели

Указатели — это переменные, которые хранят адрес другой переменной, то есть ссылку на её расположение в памяти. Вместо того чтобы работать с данными напрямую, указатели позволяют работать с их адресами, что открывает огромные возможности для более эффективной работы с памятью и гибкости программирования.

Для начала давайте разберемся с адресами. У каждой переменной, которую вы объявили есть адрес, мы можем получить его с помощью оператора &:

int foo = 12;
printf("Value is: %d\nAddress is: %x\n", foo, &foo);

Value is: 12

Address is: a0ce480c

Для того чтобы объявить указатель, нужно указать тип данных, на который он будет ссылаться, а затем использовать символ *:

int *ptr;     // Указатель на целое число
double *dptr; // Указатель на число с плавающей запятой
char *cptr;   // Указатель на символ

Мы можем инициализировать указатели с помощью специального значения NULL (будет означать что указатель никуда не ссылается) или с помощью оператора ссылки:

int a = 5;
int *a_ptr;
a_ptr = &a;   // Теперь a_ptr ссылается на a
a_ptr = NULL; // Теперь a_ptr никуда не ссылается

После того, как в нашем указателе есть какая-то ссылка, мы можем изменить значение переменной, адрес которой хранится в нашем указателе:

int a = 5;
int *a_ptr;
a_ptr = &a;
*a_ptr = 15;

Обратите внимание на использование оператора *. В случае с изменением ссылки, на которую ссылается указатель – мы не используем оператор разыменовывания (*), а в случае когда мы устанавливаем значение переменной, ссылка которой находится в нашем указателе – мы используем *.

Если же мы попытаемся установить значение переменной без использования разыменовывания, то мы изменим адрес, на который ссылается наш указатель, а не значение:

*a_ptr = 10; // Меняем значение по адресу
a_ptr = 10;  // Меняем сам адрес

Массивы

Массив в Си — это структура, представляющая собой последовательность однотипных элементов, хранящихся в непрерывной области памяти. При этом имя массива само по себе выражается как указатель на его первый элемент. Это поведение лежит в основе всей семантики взаимодействия массивов и указателей.

Если мы разыменуем переменную массива, то получим первый элемент, по сути выражения *arr и arr[0] — эквиваленты:

#include <stdio.h>

int main() {
  int arr[] = {1, 2, 3};

  printf("%d\n", *arr == arr[0]);       // 1
  printf("%d\n", *(arr + 1) == arr[1]); // 1
  printf("%d\n", *(arr + 2) == arr[2]); // 1
}

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

Под капотом Си берёт тип данных указателя и смотрит его размерность. При добавлении/вычитании от указателя, он берет переданное число после арифметической операции и умножает его на размерность типа данных:

Тип данных: double
Размерность: 8
Изначальная позиция: 0
Выражение: ptr + 5
Получаемое выражение: ptr(адрес) + (5 * 8)

Строки

Мы можем объявить строки двумя способами:

  1. Через массив символов char[];
  2. Через указатель на первый символ char*;
char foo[] = "str"; // Данный тип превратится в foo[3]
char *bar = "Dynamic String"; // Данный тип превратится в char* (&"Dynamic String"[0])

Второй способ ищет и занимает память в разделе “только для чтения” и помещает туда массив из char. При использовании такого способа инициализации строки мы не можем изменять саму строку, так как строка расположена в сегменте .rodata (Read Only Data).

При использовании фиксированного массива строки изменять можно:

char foo[] = "Hello";
foo[0] = 'h';
printf("%s\n", foo); // hello

Указатель на константу

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

const char *fooPtr = "Hello";

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

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

Именно это и называется указателем на функцию — переменная, содержащая адрес функции с определённой сигнатурой (тип возвращаемого значения и список параметров).

Форма записи указателя на функцию примерно следующая:

возвращаемый_тип (*имя)(аргументы);

Давайте создадим функцию add, которая будет складывать два числа и поместим её в указатель:

#include <stdio.h>

int add(int first, int second) {
  return first + second;
}

int main() {
  int (*operation)(int, int);

  operation = &add;

  printf("%d\n", operation(2, 2)); // 4
  return 0;
}

Динамическая память

Ранее мы работали только с данными, у которых фиксированная длина. Для того чтобы работать с динамической память в Си есть специальные функции, которые позволяют нам выделять память в куче.

Динамическая память — это область памяти (так называемая куча, или heap), которая выделяется программой во время выполнения. Управление этой памятью осуществляется вручную: программист сам отвечает за выделение и освобождение ресурсов.

Всё управление динамической памятью в C осуществляется с помощью набора функций из заголовочного файла <stdlib.h>:

Функция Назначение
malloc Выделяет заданное количество байт
calloc Выделяет память и инициализирует её нулями
realloc Изменяет размер ранее выделенного блока
free Освобождает ранее выделенную память

Давайте рассмотрим легкий пример с выделением памяти. Мы реализуем динамический массив состоящий из int:

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

int main() {
    int n;
    printf("Введите размер массива: ");
    scanf("%d", &n);

    // Выделяем память для n целых чисел
    int *arr = (int *)malloc(n * sizeof(int));

    // Проверяем успешность выделения
    if (arr == NULL) {
        printf("Ошибка: не удалось выделить память.\n");
        return 1;
    }

    // Инициализируем массив
    for (int i = 0; i < n; i++) {
        arr[i] = i * 10;
    }

    // Выводим массив
    printf("Массив:\n");
    for (int i = 0; i < n; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");

    // Освобождаем память
    free(arr);
    return 0;
}

Теперь давайте рассмотрим более подробно данный сниппет:

int n;
printf("Введите размер массива: ");
scanf("%d", &n);

В данном сниппете мы используем функцию scanf для того чтобы прочитать ввод пользователя.

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

int *arr = (int *)malloc(n * sizeof(int));

Здесь мы выделяем память с помощью функции malloc. Мы умножаем количество элементов n на размер одного элемента sizeof(int).

(int*) кастует возвращаемый указатель типа void* в указатель типа int*, так как по умолчанию malloc возвращает указатель с неизвестным типом данных.

if (arr == NULL) {
    printf("Ошибка: не удалось выделить память.\n");
    return 1;
}

После выделения памяти важно проверить, успешно ли оно прошло - если malloc вернул NULL, значит память выделить не удалось.

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

В этом цикле мы заполняем выделенную память значениями.

Так как arr — это указатель на первый элемент нашего массива, мы можем использовать нотацию через квадратные скобочки.

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

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

free(arr);

Последний и критически важный шаг - освобождение выделенной памяти с помощью функции free.

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

1 / 0
Свайпайте для просмотра слайдов

Реаллоцирование памяти

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

Для этого предусмотрена функция realloc, которая позволяет перераспределить ранее выделенный участок памяти и сохранить уже записанные в него данные.

void* realloc(void* ptr, size_t new_size);
  • ptr — указатель на ранее выделенный блок памяти с помощью malloc, calloc или realloc.
  • new_size — новый требуемый размер в байтах.

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

Если новый размер больше предыдущего, старые данные сохраняются, а оставшаяся часть остаётся неинициализированной (при использовании malloc и realloc). Если меньше — данные обрезаются:

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

int main() {
  // Аллоцируем массив
  int SIZE = 10;
  int *ptr = (int*)malloc(SIZE * sizeof(int));

  if (ptr == NULL) {
    printf("Не удалось выделить память\n");
  }

  // Реаллоцируем массив
  ptr = (int*)realloc(ptr, (SIZE + 2) * sizeof(int));

  if (ptr == NULL) {
    printf("Не удалось перевыделить память\n");
  }

  free(ptr);
  return 0;
}

Выделение памяти с инициализацией

Иногда требуется не только выделить память, но и сразу инициализировать её нулями. Для этого в Cи предусмотрена функция calloc, которая объединяет в себе действия malloc и инициализации.

void* calloc(size_t num, size_t size);
  • num — количество элементов;
  • size — размер одного элемента в байтах.

В отличие от malloc, которая просто выделяет память (с произвольным содержимым), calloc инициализирует все выделенные байты нулями. Это полезно, когда важно, чтобы переменные изначально имели значение 0:

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

int main() {
  int size = 12;
  int *arrPtr = (int*)calloc(size, size * sizeof(int));

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

Структуры

Структура — это агрегированный тип данных, который позволяет объединить несколько переменных разного типа под одним именем. Это основа для создания пользовательских типов, приближённых к объектам в ООП.

struct Point {
  int x;
  int y;
};

Здесь Point — структура, содержащая две переменные типа int.

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

Использование структур

Мы можем использовать нашу структуру как тип переменной с помощью ключевого слова struct и названия структуры:

struct Point p1;
p1.x = 10;
p1.y = 20;

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

typedef struct {
    int x, y;
} Point;

Point p2 = {1, 2};

Перечисления

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

enum Color { RED, GREEN, BLUE };
enum Status { OK = 200, NOT_FOUND = 404, SERVER_ERROR = 500 };

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

enum Color { RED, GREEN, BLUE };

int main() {
  enum Color c = GREEN;

  if (c == RED) {
    // ...
  } else if (c == GREEN) {
    // ...
  }

  return 0;
}