Си — это язык программирования, который был разработан в начале 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, которая является точкой входа в программу. Вот пример простой программы на Си:
Hello, World!
Давайте разберем данный кусок кода по частям:
Переменные и типы данных
Си сам по себе достаточно минималистичный и маленький язык программирования, у него есть необходимый минимум возможностей, но не более.
В Си есть несколько основных типов данных:
| Категория | Описание | Примеры типов |
|---|---|---|
| Целые числа (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; В примере вверху мы просто объявили две переменные без каких-либо значений. Мы можем объявить переменные и сразу же инициализировать их, как это сделать показано внизу:
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 | Выводит указатель (адрес в памяти) |
%% | Выводит символ % |
Мы можем выводить значения с помощью спецификаторов формата:
10, 2.20000, c
Операторы
В Си есть все необходимые операторы для работы с числовыми данными и не только, внизу перечислен каждый из них:
| Оператор | Название | Пример | Описание |
|---|---|---|---|
+ | Сложение | a + b | Складывает два значения |
- | Вычитание | a - b | Вычитает второе значение из первого |
* | Умножение | a * b | Умножает два значения |
/ | Деление | a / b | Делит первое значение на второе |
% | Остаток от деления | a % b | Возвращает остаток от целочисленного деления |
Размерность типа данных
В Си есть специальный оператор sizeof, который позволяет узнать размер типа данных в байтах.
В будущем нам все время нужно будет работать с их размерностью, этот оператор будет нам очень полезен:
Тайп-кастинг (приведение типов)
Си не обладает возможностью автоматического приведения типов, мы можем рассмотреть это на следующем примере:
Для того чтобы преобразовать типы на лету, мы можем указать нужный нам тип в круглых скобочках:
У приведения типов, естественно, есть ограничения, среди которых:
- Приведение типов может привести к потере данных. Например, если вы преобразуете
doubleвint, дробная часть будет отброшена. - Приведение типов может вызвать переполнение или потерю значений. Например, преобразование большого
longчисла вshortможет привести к обрезанию данных. - Приведение типов может привести к неопределенному поведению. Например, если вы преобразуете указатель (о них мы поговорим позже) на один тип в указатель на другой тип, это может привести к ошибкам доступа к памяти.
- Приведение типов может быть неэффективным. Например, преобразование
floatвintможет потребовать дополнительных вычислений и замедлить выполнение программы. - Приведение типов может привести к ошибкам компиляции. Например, если вы пытаетесь преобразовать указатель на функцию в указатель на другой тип функции, это приведет к ошибке компиляции.
- Приведение типов может нарушить выравнивание данных. Например, преобразование указателя на структуру с одним выравниванием в указатель на структуру с другим выравниванием может вызвать ошибки при доступе к данным.
- Приведение типов может затруднить отладку. Ошибки, связанные с некорректным приведением типов, часто сложно обнаружить и исправить, особенно в больших проектах.
Массивы и строки
В Си строки тесно связаны с массивами. Массивы же, в свою очередь, тесно связаны с типами данных и размерностями
На этом этапе мы научимся объявлять и инициализировать одномерные и двумерные массивы, эффективно итерироваться по ним, а также работать со строками и базовыми функциями из стандартной библиотеки string.h.
Массивы
Массив — это базовая структура данных, предназначенная для хранения фиксированного количества элементов одного типа. Все элементы массива располагаются в памяти последовательно, что обеспечивает быстрый доступ по индексу. Массивы позволяют удобно представлять и обрабатывать наборы данных, таких как список чисел, координаты, таблицы и другие последовательности.
Вот пример инициализации и использования массивов:
Для того чтобы изменить значение в ячейке массива или достать определенное значение из массива, мы должны использовать индекс. Индексы в Си начинаются с нуля, мы можем обратиться к определенной ячейке массива указав индекс в квадратных скобках:
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. В Си же все немного иначе, в нем нет булевого типа данных, но мы можем использовать целые числа для представления истинности или ложности.
Булев тип данных и директивы препроцессора
Директива препроцессора #define используется для определения макросов — именованных констант или шаблонов кода, которые заменяются на этапе препроцессинга перед компиляцией.
Например, мы создадим макрос, который будет заменять все значения tokiory на “Даниил”, вот как это будет выглядеть:
Мы можем использовать #define для определения булевых значений:
Циклы
В Си реализованы несколько типов циклов, которые можно использовать с помощью ключевых слов while, do ... while и for.
Цикл с предусловием
Начнем с простого, while это цикл, который будет выполнять код до тех пор, пока условие внутри скобочек истинно:
Текущий индекс: 1
Текущий индекс: 2
Текущий индекс: 3
Текущий индекс: 4
Оператор ++ в сниппете выше, увеличивает значение i на 1. Это необходимо для того, чтобы мы не попали в вечный цикл.
Если же мы специально хотим попасть в вечный цикл, то это можно сделать просто пробросив в скобки условие, которое всегда будет истинно:
int i = 0;
while (1) {
printf("Текущий индекс: %d", i);
i++;
} Текущий индекс: 1
Текущий индекс: 2
Текущий индекс: 3
Текущий индекс: 4
Текущий индекс: 5
Текущий индекс: 6
Текущий индекс: 7
… (до бесконечности или пока i не переполнится)
Цикл с постусловием
Цикл do while занимается примерно тем же, что и while. Их различие состоит только в том, что do while всегда выполняется хотя бы один раз.
Использование данного цикла распространено в геймдеве, где определенному действию нужна хотя бы одна итерация:
Цикл с параметрами
Цикл for является самым подробным циклом, он позволяет объявить и инициализировать переменную, указать условие в рамках которого цикл будет работать и указать что и как будет изменяться после каждой итерации цикла. Рассмотрим пример:
Текущая итерация: 2
Текущая итерация: 3
Текущая итерация: 4
Текущая итерация: 5
int i = 0является инициализацией, в данном блоке мы можем объявить переменную с любым названием для использования в цикле. После того как цикл закончится, переменная перестанет существовать;i < 5является условием цикла, по которому будет работать цикл, пока данное условие истинно – цикл будет продолжаться;i++является выражением итерации, данное выражение будет выполняться после каждой итерации;
Операторы прерывания
В любом из вышеперечисленных циклов можно использовать операторы прерывания. Данные операторы нужны для того чтобы прерывать выполнение итераций и в зависимости от указанного оператора продолжать цикл со следующей итерации или выходить из цикла.
Всего в Си два ключевых слова для прерывания циклов:
break— ключевое слово, которое используется для того чтобы немедленно завершить цикл;continue— ключевое слово, которое используется для того чтобы пропустить текущую итерацию;
Текущая итерация: 2
Текущая итерация: 4
Текущая итерация: 6
Текущая итерация: 8
Оператор выбора
Конструкцией выбора в Си является блок кода, где используется ключевое слово switch. Данный оператор позволяет выбрать один из блоков кода в зависимости от выполнения условия:
switch— это ключевое слово, которое указывает на то, что мы сейчас будем выбирать один из вариантов выполнения кода.case— ключевое слово, которое говорит нам о том, чему должна быть равна переменная или выражение в скобках возлеswitchдля того чтобы выполнился блокcase;default— это блок кода, который выполнится, если ни одно из условий изcaseне было удовлетворено;
Примечательно, что тут используется оператор прерывания break, который мы рассматривали до этого.
Если мы не укажем его, то при удовлетворении условий одного из case, все последующие блоки case тоже будут выполнены:
В данном случае a равно трём
В данном случае a равно чему-то другому
Оператор перехода
Оператор перехода — это низкоуровневая управляющая конструкция, позволяющая передавать управление не по логике программы, а непосредственно в заданное место кода.
Использовать оператор перехода стоит только в очень крайних случаях, когда кровь из носа как нужно пропустить определенный блок кода.
Но почему мне не стоит его использовать?
Использование данного оператора сильно усложнит вам и вашим коллегам жизнь при отладке кода, да и в целом оно сильно усложняет логику.
Оператор перехода в Си реализован через ключевое слово goto и метки:
Закончили вычисление
Функции
Функции — это базовые строительные блоки программы, позволяющие структурировать код, повысить читаемость, переиспользуемость и модульность. Функции в Си могут принимать аргументы и отдавать определенные значения.
В Си функции не являются объектами первого класса, то есть вы не можете передавать функции в другие функции через аргументы, но об этом мы поговорим позже, когда доберемся до указателей.
Функции в Си объявляются с помощью следующего синтаксиса:
[возвращаемый тип данных] [название функции]([?аргументы]) {
[код]
} Давайте напишем простую функцию, заачей которой будет просто выводить слово "Привет!", данная функция не будет принимать никаких входных данных и ничего не будет отдавать:
Для того чтобы вызвать нашу функцию, мы должны указать её имя и поставить круглые скобочки.
В круглые скобки можно будет передавать аргументы согласно параметрам, об этом мы поговорим чуть-чуть позже. Пока что мы можем просто указать пустые скобки (то есть ничего в них не передавать), для того чтобы просто вызвать функцию без аргументов:
Привет!
Привет!
Привет!
Аргументы и возвращаемые значения
Функции в Си могут получать аргументы и отдавать возвращаемые значения. При объявлении функции мы указываем тип, который наша функция должна вернуть, данный тип называется типом возвращаемого значения. Мы можем вернуть значение из функции с помощью ключевого слова return:
double get_five() {
return 5.12;
}
int main() {
printf("%f", get_five()); // 5.12000
} Например, функция main, которую мы уже не один раз писали должна возвращать число. Мы все время возвращали его с помощью return 0, однако,
если мы явно не укажем число возвращаемое число с помощью ключевого слова return, то функция будет возвращать нулевое значение.
В случае с возвращаемым типом int будет возвращаться 0 (даже без использования ключевого слова return):
Параметры функции — это переменные, которые объявляются в определении функции и служат для получения данных, с которыми функция будет работать. Они указываются в круглых скобках после имени функции и позволяют сделать функцию универсальной, чтобы она могла выполнять действия с разными входными данными.
Давайте создадим простую функцию, которая будет складывать три числа:
В данном случае числа 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 файле.
Второй способ является предпочтительным, так как помогает отделить реализацию функции от её интерфейса:
// Прототип функции
void say_hello(); Привет!
Область видимости
Блочная область видимости — это область действия переменной, ограниченная телом блока, заключённого в фигурные скобки {}. Переменные с такой областью видимы и доступны только внутри этого блока и уничтожаются при выходе из него.
По умолчанию все переменные ограничены блочной областью видимости.
b=20
a=10
Функциональная область видимости — это область действия параметров функции, которые видимы и доступны только внутри тела этой функции.
#include <stdio.h>
void foo() {
int a = 0;
}
// Здесь a более не доступна Глобальная область видимости — это область действия переменных, объявленных вне всех функций и блоков. Такие переменные доступны во всём исходном файле, а при использовании ключевого слова extern — и в других файлах проекта.
// Доступна только в main.c
int a = 0;
// Доступна в рамках всей программы
extern int b = 2; Статические переменные
Если нам нужно хранить какое-либо состояние между вызовами функции, то мы можем сделать это двумя способами. Первый способ заключается в объявлении переменной в глобальной области видимости:
2
Второй способ заключается в использовании статической переменной, она объявляется с помощью ключевого слова static.
Данное ключевое слово позволяет нам назначить переменной статический адрес. При вызове функции адрес переменной не будет меняться, сама же переменная не будет реинициализирована, в случае если инициализация уже произошла:
2
Указатели
Указатели — это переменные, которые хранят адрес другой переменной, то есть ссылку на её расположение в памяти. Вместо того чтобы работать с данными напрямую, указатели позволяют работать с их адресами, что открывает огромные возможности для более эффективной работы с памятью и гибкости программирования.
Для начала давайте разберемся с адресами. У каждой переменной, которую вы объявили есть адрес, мы можем получить его с помощью оператора &:
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] — эквиваленты:
Когда мы прибалвяем число к указателю, мы сразу же прибавляем количество необходимых байтов для перехода на следующую ячейку. Это достигается за счет того, что у указателя тоже есть размерность и тип.
Под капотом Си берёт тип данных указателя и смотрит его размерность. При добавлении/вычитании от указателя, он берет переданное число после арифметической операции и умножает его на размерность типа данных:
Тип данных: double
Размерность: 8
Изначальная позиция: 0
Выражение: ptr + 5
Получаемое выражение: ptr(адрес) + (5 * 8) Строки
Мы можем объявить строки двумя способами:
- Через массив символов
char[]; - Через указатель на первый символ
char*;
char foo[] = "str"; // Данный тип превратится в foo[3]
char *bar = "Dynamic String"; // Данный тип превратится в char* (&"Dynamic String"[0]) Второй способ ищет и занимает память в разделе “только для чтения” и помещает туда массив из char.
При использовании такого способа инициализации строки мы не можем изменять саму строку, так как строка расположена в сегменте .rodata (Read Only Data).
При использовании фиксированного массива строки помещаются в стек как массив символов, на который ссылается указатель. Изменять такие строки можно:
Указатель на константу
В Си существуют указатели на константу, такие указатели объявляются через const, мы можем изменять данные указатели, но не можем изменять значения, на которые данные указатели ссылаются. Отличным примером таких указателей являются указатели на строки:
const char *fooPtr = "Hello"; Если мы попробуем изменить какой-либо символ в литерале строки, то мы получим ошибку:
char *foo = "Hello";
foo[0] = 'h';
printf("%s\n", foo); // Segmentation fault Именно для таких случаев и нужны константные указатели:
const char *foo = "Hello"; Указатель на функцию
В C каждая функция располагается в определённой области памяти. Имя функции на самом деле является адресом начала этой области — аналогично имени массива, которое указывает на его первый элемент. Это означает, что мы можем объявить переменную, которая будет хранить этот адрес, и вызывать функцию через неё.
Именно это и называется указателем на функцию — переменная, содержащая адрес функции с определённой сигнатурой (тип возвращаемого значения и список параметров).
Форма записи указателя на функцию примерно следующая:
возвращаемый_тип (*имя)(аргументы); Давайте создадим функцию add, которая будет складывать два числа и поместим её в указатель:
Использование указателей в функциях
По умолчанию при передаче аргументов в функцию – все аргументы копируются по значению. Это означает, что если функция изменяет значение аргумента, то это изменение не отразится на исходном значении переменной, переданной в функцию:
#include <stdio.h>
void sum_to_first(int a, int b) {
a = a + b;
}
int main() {
int a = 2;
int b = 2;
sum_to_first(a, b);
printf("%d\n", a); // 2, переменная не изменилась
printf("%d\n", b); // 2
return 0;
} Для того чтобы передавать переменную по указателю, а не по значению, нужно передать адрес переменной в функцию. Для этого нужно передать указатель на переменную в функцию:
#include <stdio.h>
void sum_to_first(int *a, int b) {
*a = *a + b;
}
int main() {
int a = 2;
int b = 2;
sum_to_first(&a, b);
printf("%d\n", a); // 4, переменная изменилась
printf("%d\n", b); // 2
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;
} Теперь давайте рассмотрим более подробно данный сниппет:
Реаллоцирование памяти
Иногда во время выполнения программы возникает необходимость изменить размер уже выделенного блока памяти.
Для этого предусмотрена функция 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:
Структуры
Структура — это агрегированный тип данных, который позволяет объединить несколько переменных разного типа под одним именем. Это основа для создания пользовательских типов, приближённых к объектам в ООП.
struct Point {
int x;
int y;
}; Здесь Point — структура, содержащая две переменные типа int.
Структуры полезны, когда необходимо логически сгруппировать связанные данные: координаты точки, сведения о студенте, параметры конфигурации и т.д.
Использование структур
Мы можем объявить анонимную структуру и переменную, которая будет инициализировать её:
struct {
int x, y;
} p1 = {10, 20}; Здесь ключевое слово struct говорит об объявлении структуры. Внутри фигурных скобок мы указываем необходимые поля структуры, а затем называем переменную, которая будет содержать данную структуру. Опционально с помощью оператора присваивания =, можно сразу же инициализировать структуру.
Также, можно использовать структуру, которую мы объявили заранее:
struct Point {
int x;
int y;
};
struct Point p1 = {10, 20}; Кроме того, мы можем объявить структуру без инициализации, а сами поля инициализировать позже:
struct Point {
int x;
int y;
};
struct Point p1;
p1.x = 10;
p1.y = 20; Использование внутри функций
Мы можем использовать структуры как параметры функций или возвращаемые значения:
struct Point {
int x;
int y;
};
struct Point create_point(int x, int y) {
struct Point p = {x, y};
return p;
}
struct Point add_points(struct Point p1, struct Point p2) {
struct Point result = {p1.x + p2.x, p1.y + p2.y};
return result;
} В данном сниппете мы объявили две функции:
create_point- функция, которая принимает два целых числа и возвращает структуруPoint, содержащую эти числа в качестве координат.add_points- функция, которая принимает две структурыPointи возвращает новую структуруPoint, содержащую сумму координат входных структур.
Стоит обратить внимание на то, что мы не просто указываем названия структуры, в случае её использования в качестве возвращаемого значения или параметра функции, мы используем префиксное ключевое слово struct перед названием структуры.
Использование с указателями
В случае если мы будем использовать структуры через указатели, мы должны использовать оператор -> для доступа к полям структуры.
Допустим, что у нас есть всё та же структура Point, но теперь мы будем использовать её внутри указателя, у нас есть два варианта как завести указатель со структурой. Первый вариант подразумевает инициализацию структуры и последующую передачу ссылки переменной в указатель:
int main() {
struct Point p = {1, 2};
struct Point *pp = &p;
return 0;
} Вторым вариантом является инициализация структуры непосредственно при объявлении указателя.
В данном случае нам нужно явно указать тип структуры при инициализации, мы делаем это с помощью круглых скобок (struct Point){}:
int main() {
struct Point *pp = (struct Point){1, 2};
} После того как мы инициализировали указатель, мы можем использовать его для доступа к полям структуры, для того чтобы “достучаться” до полей необходимо заранее разыменовывать указатель, раньше это делали следующим образом:
#include <stdio.h>
struct Point {
int x;
int y;
};
struct Point *pp = (struct Point){1, 2};
printf("x = %d, y = %d\n", (*pp).x, (*pp).y); Разыменовывать указатель каждый раз с помощью оператора * можно, но это не очень удобно и читабельно. Лучше использовать оператор ->, который автоматически разыменовывает указатель и позволяет достучаться к полям структуры без использования оператора разыменовывания:
struct Point {
int x;
int y;
};
int main() {
struct Point *pp = (struct Point){1, 2};
printf("x = %d, y = %d\n", pp->x, pp->y);
} По сути структура->поле является сокращением от (*структура).поле, что позволяет нам более удобно и читабельно работать с полями структур, особенно когда мы работаем с указателями на структуры.
Кастомные типы данных
Кастомные типы данных в языке C позволяют создавать свои собственные типы данных на основе примитивных типов данных, таких как int, float, char, struct и т.д. Это может быть полезно, когда нужно создать тип данных, который подходит под контекст задачи.
Для примера, давайте создадим специальный тип данных Id, который будем использовать для идентификаторов, мы можем сделать это с помощью ключевого слова typedef:
#include <stdio.h>
typedef unsigned int Id;
// Здесь мы используем наш тип Id для типизации возвращаемого значения
Id create_id(unsigned int value) {
return value;
}
// Заметьте, что в данной функции мы используем не Id, а тип, от которого он наследуется.
// Мы можем так делать благодаря унаследованной совместимости типов
void print_id(unsigned int id) {
printf("id = %u\n", id);
}
int main() {
// Здесь мы используем Id для того чтобы задать тип переменной
Id id = create_id(12345);
print_id(id);
return 0;
} В случае с Id мы используем typedef преимущественно для косметических целей, чтобы сделать код более читаемым и выразительным.
Однако typedef часто применяется для упрощения адаптации типизации переменных под разные архитектуры системы. Например, представим систему с архитектурой, значительно отличающейся от x86.
В таких случаях typedef позволяет быстро переключать числовые типы данных, обеспечивая гибкость и переносимость кода. Это особенно полезно, когда необходимо унифицировать работу с разными размерами данных (например, 32-битные или 64-битные целые числа) в зависимости от целевой платформы, минимизируя изменения в коде:
typedef int Id; // Базовый тип для x86
// В случае другой архитектуры нужно расскомментировать следующую строку
// typedef long long Id; // Для другой архитектуры Например, встроенный тип int в зависимости от архитектуры может быть 32-битным или 64-битным. Размерность данного типа определяется исходя из архитектуры системы, и очень упрощенно int может быть записан с помощью typedef следующим образом:
#include <stdint.h>
typedef int32_t int; // Для x86
// В случае другой архитектуры нужно расскомментировать следующую строку
// typedef int64_t int; // Для другой архитектуры Пакет stdint.h предоставляет набор типов данных с фиксированным размером, что позволяет создавать более гибкие и переносимые программы. Зачастую типы из него используются вместо базового int из опасения использовать тип данных, который может измениться в зависимости от архитектуры.
Объединения типов
union — это специальный тип данных, который позволяет хранить в одном месте данные разные типы данных:
#include <stdio.h>
union {
int i;
float f;
} u;
int main() {
u.i = 37;
printf("%d\n", u.i); // 37
// Стирает u.i и записывает u.f в ту же ячейку памяти, где хранился u.i
u.f = 37;
printf("%f\n", u.f); // 37.000000
// Стирает u.f и записывает u.i в ту же ячейку памяти, где хранился u.f
u.i = 37;
printf("%f\n", u.f); // 0.000000
return 0;
} Объединение работает следующим образом:
- Вычисление размерности всех вложенных типов
- Вычисление самого большого типа из всех вложенных типов
- Аллокация памяти
- Инициализация объединения
Если объединение содержит поля типа int и long, то само объединение будет иметь размер 8 байт (размер long).
В случае если мы назначаем одному из полей объединения значение, то старое значение будет стерто и записано новое значение в ту же ячейку памяти, где хранилось старое значение. Именно поэтому данный сниппет кода выводит 0.000000, ибо старое значение u.f было стерто и записано новое значение u.i в ту же ячейку памяти, где хранилось старое значение u.f:
// Стирает u.f и записывает u.i в ту же ячейку памяти, где хранился u.f
u.i = 37;
printf("%f\n", u.f); // 0.000000 Перечисления
Перечисления — это тип данных, который позволяет определить набор именованных констант. Перечисления могут быть использованы для представления различных состояний или значений, которые могут быть использованы в программе.
В C, перечисления определяются с помощью ключевого слова enum. Например, следующий код определяет перечисление Weekday, которое содержит значения дней недели:
enum Weekday {
MONDAY,
TUESDAY,
WEDNESDAY,
THURSDAY,
FRIDAY,
SATURDAY,
SUNDAY
}; Перечисления могут быть использованы для представления различных состояний или значений, которые могут быть использованы в программе.
Мы также можем задать определенные числовые значения для каждого элемента перечисления. Например, следующий код определяет перечисление Color, которое содержит значения цветов:
enum Color {
RED = 1,
GREEN = 2,
BLUE = 4
}; Для того чтобы создать переменную с типом перечисления, мы можем использовать ключевое слово enum с названием перечисления:
enum Color color = RED;