Си (от лат. буквы C, англ. языка ) — компилируемый статически типизированный язык программирования общего назначения, разработанный в 1969—1973 годах сотрудником Bell Labs Деннисом Ритчи как развитие языка Би. Первоначально был разработан для реализации операционной системы UNIX, но впоследствии был перенесён на множество других платформ. Согласно дизайну языка, его конструкции близко сопоставляются типичным машинным инструкциям, благодаря чему он нашёл применение в проектах, для которых был свойственен язык ассемблера, в том числе как в операционных системах, так и в различном прикладном программном обеспечении для множества устройств — от суперкомпьютеров до встраиваемых систем. Язык программирования Си оказал существенное влияние на развитие индустрии программного обеспечения, а его синтаксис стал основой для таких языков программирования, как C++, C#, Java и Objective-C.
C | |
---|---|
![]() | |
Класс языка | процедурный |
Тип исполнения | компилируемый |
Появился в | 1972 |
Автор | Деннис Ритчи |
Разработчик | Bell Labs, Деннис Ритчи[1], Национальный институт стандартов США, ИСО и Кен Томпсон |
Расширение файлов |
.c — для файлов кода, .h — для заголовочных файлов |
Выпуск | ISO/IEC 9899:2018 (5 июля 2018 года) |
Система типов | статическая слабая |
Основные реализации | GCC, Clang, TCC, Turbo C, Watcom, Oracle Solaris Studio C, Pelles C |
Диалекты |
«K&R» C (1978) ANSI C (1989) C99 (1999) C11 (2011) |
Испытал влияние | BCPL, B |
Повлиял на | C++, Objective-C, C#, Java, Nim |
ОС | Microsoft Windows и Unix-подобная операционная система |
![]() |
ISO/IEC 9899 | |
Information technology — Programming languages — C | |
Издатель | Международная организация по стандартизации (ISO) |
Сайт | www.iso.org |
Комитет (разработчик) | ISO/IEC JTC 1/SC 22 |
Сайт комитета | Programming languages, their environments and system software interfaces |
МКС (ICS) | 35.060 |
Текущая редакция | ISO/IEC 9899:2018 |
Предыдущие редакции | ISO/IEC 9899:1990/COR2:1996 ISO/IEC 9899:1999/COR3:2007 ISO/IEC 9899:2011/COR1:2012 |
Язык программирования Си разрабатывался в период с 1969 по 1973 годы в лабораториях Bell Labs, и к 1973 году на этот язык была переписана большая часть ядра UNIX, первоначально написанного на ассемблере PDP-11/20. Название языка стало логическим продолжением старого языка «Би»[a], многие особенности которого были положены в основу.
По мере развития язык сначала стандартизировали как ANSI C, а затем этот стандарт был принят комитетом по международной стандартизации ISO как ISO C, ставший также известным под названием C90. В стандарте С99 язык получил новые возможности, такие как массивы переменной длины и встраиваемые функции. А в стандарте C11 в язык добавили реализацию потоков и поддержку атомарных типов. Однако с тех пор язык развивается медленно, и в стандарт C18 попали лишь исправления ошибок стандарта C11.
Язык Си разрабатывался как язык системного программирования, для которого можно создать однопроходный компилятор. Стандартная библиотека также невелика. Как следствие данных факторов — компиляторы разрабатываются сравнительно легко[2]. Поэтому данный язык доступен на самых различных платформах. К тому же, несмотря на свою низкоуровневую природу, язык ориентирован на переносимость. Программы, соответствующие стандарту языка, могут компилироваться под различные архитектуры компьютеров.
Целью языка было облегчение написания больших программ с минимизацией ошибок по сравнению с ассемблером, следуя принципам процедурного программирования, но избегая всего, что может привести к дополнительным накладным расходам, специфичным для языков высокого уровня.
Основные особенности Си:
В то же время в Си отсутствуют:
Часть отсутствующих возможностей может имитироваться встроенными средствами (например, сопрограммы можно имитировать с помощью функций setjmp
и longjmp
), часть добавляется с помощью сторонних библиотек (например, для поддержки многозадачности и для сетевых функций можно использовать библиотеки pthreads, sockets и тому подобные; существуют библиотеки для поддержки автоматической сборки мусора[3]), часть реализуется в некоторых компиляторах в виде расширений языка (например, вложенные функции в GCC).
Существует несколько громоздкая, но вполне работоспособная методика, позволяющая реализовывать на Си механизмы ООП[4], базирующаяся на фактической полиморфности указателей в Си и поддержке в этом языке указателей на функции. Механизмы ООП, основанные на данной модели, реализованы в библиотеке GLib и активно используются во фреймворке GTK+. GLib предоставляет базовый класс GObject
, возможности наследования от одного класса[5] и реализации множества интерфейсов[6].
После появления язык был хорошо принят, потому что он позволял быстро создавать компиляторы для новых платформ, а также позволял программистам довольно точно представлять, как выполняются их программы. Благодаря близости к языкам низкого уровня программы на Си работали эффективнее написанных на многих других языках высокого уровня, и лишь оптимизированный вручную код на ассемблере мог работать ещё быстрее, потому что давал полный контроль над машиной. На сегодняшний день развитие компиляторов и усложнение процессоров привело к тому, что вручную написанный ассемблерный код (кроме разве что очень коротких программ) практически не выигрывает по сравнению с кодом, генерируемым компиляторами, при этом Си продолжает оставаться одним из наиболее эффективных языков высокого уровня.
Синтаксис языка Си достаточно сложный, а семантика неоднозначная[7]. Основными двумя особенностями языка на момент его появления были унифицирование работы с массивами и указателями, а также схожесть того, как что-либо объявляется, с тем, как это в дальнейшем используется в выражениях[8]. Однако в последующем эти две особенности языка были в числе наиболее критикуемых[8], и обе являются сложными для понимания среди начинающих программистов[9]. Стандарт языка, определяя его семантику, не стал слишком сильно ограничивать реализации языка компиляторами, но этим самым сделал семантику недостаточно определённой. В частности, в стандарте есть 3 типа недостаточно определённой семантики: определяемое реализацией поведение, не заданное стандартом поведение и неопределённое поведение[10].
Размер целочисленных типов данных варьируется от не менее 8 до не менее 32 бит. Стандарт C99 увеличивает максимальный размер целого числа — не менее 64 бит. Целочисленные типы данных используются для хранения целых чисел (тип char
также используется для хранения ASCII-символов). Все размеры диапазонов представленных ниже типов данных минимальны и на отдельно взятой платформе могут быть больше[11].
Как следствие минимальных размеров типов стандарт требует, чтобы для размеров целочисленных типов выполнялось условие:
1
= sizeof(char)
≤ sizeof(short)
≤ sizeof(int)
≤ sizeof(long)
≤ sizeof(long long)
.
Таким образом, размеры некоторых типов по количеству байт могут совпадать, если будет удовлетворяться условие по минимальному количеству бит. Даже char
и long
могут иметь одинаковый размер, если один байт будет занимать 32 бита или более, но такие платформы будут очень редки или не будут существовать. Стандарт гарантирует, что тип char
всегда равен 1 байту. Размер байта в битах определяется константой CHAR_BIT
из заголовочного файла limits.h
, у POSIX-совместимых систем равен 8 битам[12].
Минимальный диапазон значений целых типов по стандарту определяется с -(2N-1-1)
по 2N-1-1
для знаковых типов и с 0
по 2N-1
— для беззнаковых, где N — разрядность типа. Реализация компиляторов может расширять этот диапазон по своему усмотрению. На практике для знаковых типов чаще используется диапазон с -2N-1
по 2N-1-1
. Минимальное и максимальное значения каждого типа указывается в файле limits.h
в виде макроопределений.
Отдельное внимание стоит уделить типу char
. Формально это отдельный тип, но фактически char
эквивалентен либо signed char
, либо unsigned char
, в зависимости от компилятора[13].
Для того, чтобы избежать путаницы между размерами типов стандарт C99 ввел новые типы данных, описанные в файле stdint.h
. Среди них такие типы как: intN_t
, int_leastN_t
, int_fastN_t
, где N
= 8, 16, 32 или 64. Приставка least-
обозначает минимальный тип, способный вместить N
бит, приставка fast-
обозначает тип размером не менее 16 бит, работа с которым наиболее быстрая на данной платформе. Типы без приставок обозначают типы с фиксированном размером, равным N
бит.
Типы с приставками least-
и fast-
можно считать заменой типам int
, short
, long
, с той лишь разницей, что первые дают программисту выбрать между скоростью и размером.
Тип данных | Размер | Минимальный диапазон значений | Стандарт |
---|---|---|---|
signed char
|
минимум 8 бит | от −127[14] (= -(27−1)) до 127 | C90[b] |
int_least8_t
|
C99 | ||
int_fast8_t
| |||
unsigned char
|
минимум 8 бит | от 0 до 255 (=28−1) | C90[b] |
uint_least8_t
|
C99 | ||
uint_fast8_t
| |||
char
|
минимум 8 бит | от −127 до 127 или от 0 до 255 в зависимости от компилятора | C90[b] |
short int
|
минимум 16 бит | от −32,767 (= -(215−1)) до 32,767 | C90[b] |
int
| |||
int_least16_t
|
C99 | ||
int_fast16_t
| |||
unsigned short int
|
минимум 16 бит | от 0 до 65,535 (= 216−1) | C90[b] |
unsigned int
| |||
uint_least16_t
|
C99 | ||
uint_fast16_t
| |||
long int
|
минимум 32 бита | от −2,147,483,647 до 2,147,483,647 | C90[b] |
int_least32_t
|
C99 | ||
int_fast32_t
| |||
unsigned long int
|
минимум 32 бита | от 0 до 4,294,967,295 (= 232−1) | C90[b] |
uint_least32_t
|
C99 | ||
uint_fast32_t
| |||
long long int
|
минимум 64 бита | от −9,223,372,036,854,775,807 до 9,223,372,036,854,775,807 | C99 |
int_least64_t
| |||
int_fast64_t
| |||
unsigned long long int
|
минимум 64 бита | от 0 до 18,446,744,073,709,551,615 (= 264−1) | |
uint_least64_t
| |||
uint_fast64_t
| |||
int8_t
|
8 бит | от −127 до 127 | |
uint8_t
|
8 бит | от 0 до 255 (=28−1) | |
int16_t
|
16 бит | от −32,767 до 32,767 | |
uint16_t
|
16 бит | от 0 до 65,535 (= 216−1) | |
int32_t
|
32 бита | от −2,147,483,647 до 2,147,483,647 | |
uint32_t
|
32 бита | от 0 до 4,294,967,295 (= 232−1) | |
int64_t
|
64 бита | от −9,223,372,036,854,775,807 до 9,223,372,036,854,775,807 | |
uint64_t
|
64 бита | от 0 до 18,446,744,073,709,551,615 (= 264−1) | |
В таблице приведён минимальный диапазон значений согласно стандарту языка. Компиляторы языка Си могут расширять диапазон значений. |
Также со стандарта C99 добавлены типы intmax_t
и uintmax_t
, соответствующие самым большим знаковому и беззнаковому типам соответственно. Данные типы удобны при использовании в макросах для хранения промежуточных или временных значений при операциях над целочисленными аргументами, так как позволяют уместить значения любого типа. Например, эти типы используются в макросах сравнения целочисленных значений библиотеки модульного тестирования Check для языка Си[15].
В Си существует несколько дополнительных целочисленных типов для безопасной работы с типом данных указателей: intptr_t
, uintptr_t
и ptrdiff_t
. Типы intptr_t
и uintptr_t
из стандарта C99 предназначены для хранения соответственно знакового и беззнакового значений, которые по размеру могут уместить в себе указатель. Эти типы часто применяются для хранения произвольного целого числа в указателе, например, как способ избавиться от лишнего выделения памяти при регистрации функций обратной связи[16] либо при использовании сторонних связных списков, ассоциативных массивов и прочих структур, в которых данные хранятся по указателю. Тип ptrdiff_t
из заголовочного файла stddef.h
предназначен для безопасного хранения разности двух указателей.
Для хранения размера предусмотрен беззнаковый тип size_t
из заголовочного файла stddef.h
. Данный тип способен уместить максимально возможное количество байт, доступное по указателю, и обычно используется для хранения размера в байтах. Значение именно этого типа возвращает оператор sizeof
[17].
Преобразования целочисленных типов могут происходить как явно, с помощью оператора приведения типов, так и неявно. Значения типов, меньших по размеру, чем int
, при участии в каких-либо операциях или при передаче в вызов функции автоматически приводятся к типу int
, а в случае невозможности преобразования — к типу unsigned int
. Зачастую подобные неявные приведения необходимы, чтобы результат вычисления оказался правильным, но иногда приводят к интуитивно-непонятным ошибкам в вычислениях. Например, если в операции участвуют числа типа int
и unsigned int
, а знаковое значение отрицательно, то преобразование отрицательного числа к беззнаковому типу приведёт к переполнению и возникновению очень большого положительного значения, что может привести к неверному результату операций сравнения[18].
Знаковый и беззнаковый типы меньше, чем int
|
Знаковый меньше беззнакового, а беззнаковый не менее int
|
---|---|
#include <stdio.h>
signed char x = -1;
unsigned char y = 0;
if (x > y) { // условие ложно
printf("Сообщение не будет показано.\n");
}
if (x == UCHAR_MAX) {
// условие ложно
printf("Сообщение не будет показано.\n");
}
|
#include <stdio.h>
signed char x = -1;
unsigned int y = 0;
if (x > y) { // условие истинно
printf("Переполнение в переменной x.\n");
}
if ((x == UINT_MAX) && (x == ULONG_MAX)) {
// условие всегда будет истинным
printf("Переполнение в переменной x.\n");
}
|
В данном примере оба типа, знаковый и беззнаковый, будут приведены к знаковому типу int , поскольку он позволяет уместить диапазоны обоих типов. Поэтому сравнение в условном операторе будет корректным.
|
Знаковый тип будет приведён к беззнаковому, поскольку беззнаковый больше или равен по размеру типу int , но произойдёт переполнение, поскольку в беззнаковом типе невозможно представить отрицательное значение.
|
Также автоматическое приведение типов сработает, если в выражении используется два или более разных целочисленных типа. Стандарт определяет ряд правил, согласно которым выбирается такое преобразование типов, которое может дать правильный результат вычислений. Разным типам назначены разные ранги в рамках преобразования, а сами ранги основаны на размере типа. При участии в выражении разных типов обычно выбирается приведение этих значений к типу большего ранга[18].
Числа с плавающей запятой в языке Си представлены тремя основными типами: float
, double
и long double
.
Вещественные числа имеют представление, сильно отличающее их от целых. Константы вещественных чисел разных типов, записанные в десятичном представлении, могут быть не равны друг другу. Например, условие 0.1 == 0.1f
будет ложным из-за потери точности у типа float
, в то время как условие 0.5 == 0.5f
будет истинным, поскольку эти числа конечны в двоичном представлении. Однако условие с приведением (float) 0.1 == 0.1f
также будет истинным, поскольку при приведении типа к менее точному теряются разряды, из-за которых в этих двух константах есть различия.
Арифметические операции с вещественными числами также являются неточными и зачастую имеют некоторую плавающую погрешность[19]. Наибольшая погрешность будет возникать при операциях над значениями, близкими к минимально возможному для конкретного типа. Также погрешность может оказаться большой при вычислениях над одновременно очень маленькими (≪ 1) и очень большими по модулю числами (≫ 1). В ряде случаев погрешность может быть снижена изменением алгоритмов и методик вычислений. Например, при замене многократного сложения умножением погрешность может снизиться во столько раз, сколько изначально было операций сложения.
Также в заголовочном файле math.h
присутствуют два дополнительных типа float_t
и double_t
, которые соответствуют как минимум типам float
и double
соответственно, но могут быть отличными от них. Типы float_t
и double_t
добавлены в стандарте C99, а их соответствие основным типам определяется значением макроса FLT_EVAL_METHOD
.
Тип данных | Размер | Стандарт |
---|---|---|
float
|
32 бита | IEC 60559 (IEEE 754), расширение F стандарта Си[20][c], число одинарной точности |
double
|
64 бита | IEC 60559 (IEEE 754), расширение F стандарта Си[20][c], число двойной точности |
long double
|
минимум 64 бита | зависит от реализации |
float_t (C99)
|
минимум 32 бита | зависит от базового типа |
double_t (C99)
|
минимум 64 бита | зависит от базового типа |
FLT_EVAL_METHOD
|
float_t
|
double_t
|
---|---|---|
1 | float
|
double
|
2 | double
|
double
|
3 | long double
|
long double
|
Хотя как такового специального типа для строк в Си не предусмотрено, в языке активно используются нуль-терминированные строки. ASCII-строки объявляются как массив типа char
, последним элементом которого должен быть символ с кодом 0
('\0'
). В этом же формате принято хранить и строки в формате UTF-8. Однако все функции, работающие с ASCII-строками, рассматривают каждый символ как байт, что ограничивает применение стандартных функций при использовании данной кодировки.
Несмотря на широкое распространение идеи нуль-терминированных строк и удобство их использования в некоторых алгоритмах, у них есть несколько серьёзных недостатков.
В современных условиях, когда производительность кода приоритетнее расхода памяти, может оказаться эффективнее и проще использовать структуры, содержащие в себе как саму строку, так и её размер[22] , например:
struct string_t {
char *str; // указатель на строку
size_t str_size; // размер строки
};
typedef struct string_t string_t; // альтернативное имя для упрощения кода
Альтернативным вариантом хранения размера строки с низким потреблением памяти может оказаться подход добавления в начало строки её размера в формате размера переменной длины . Подобный подход применяется в протокольных буферах, однако только на этапе передачи данных, но не их хранения.
Строковые литералы в Си по своей сути являются константами[23]. При объявлении заключаются в двойные кавычки, а терминирующий 0
добавляются компилятором автоматически. Допускается два способа присваивания строкового литерала: по указателю и по значению. При присваивании по указателю в переменную типа char *
заносится указатель на неизменяемую строку, то есть формируется константная строка. Если же заносить строковый литерал в массив, то происходит копирование строки в область стека.
#include <stdio.h>
#include <string.h>
int main(void)
{
const char *s1 = "Константная строка";
char s2[] = "Строка, которую можно менять";
memcpy(s2, "с", strlen("с")); // замена первой буквы на маленькую
puts(s2); // выведется текст строки
memcpy((char *) s1, "к", strlen("к")); // ошибка сегментирования
puts(s1); // строка не будет исполнена
}
Поскольку строки являются обычными массивами символов, вместо литералов можно использовать инициализаторы, если каждый символ умещается в 1 байт:
char s[] = {'I', 'n', 'i', 't', 'i', 'a', 'l', 'i', 'z', 'e', 'r', '\0'};
Однако на практике такой подход имеет смысл только в крайне редких случаях, когда к ASCII-строке требуется не добавлять терминирующий ноль.
Платформа | Кодировка |
---|---|
GNU/Linux | USC-4[24] |
macOS | |
Windows | USC-2[25] |
AIX | |
FreeBSD | Зависит от локали,
не документировано[25] |
Solaris |
Альтернативой обычным строкам могут служить широкие строки, в которых каждый символ хранится в специальном типе wchar_t
. Данный тип по стандарту должен быть способен уместить в себе все символы самой большой из существующих локалей. Функции для работы с широкими строками описаны в заголовочном файле wchar.h
, а функции для работы с широкими символами описаны в заголовочном файле wctype.h
.
При объявлении строковых литералов для широких строк используется модификатор L
:
const wchar_t *wide_str = L"Широкая строка";
В форматированном выводе используется спецификатор %ls
, однако спецификатор размера, если задан, указывается в байтах, а не в символах[26].
Тип wchar_t
задумывался для того, чтобы в него мог поместиться любой символ, а широкие строки — для хранения строк любой локали, но в результате API оказался неудобным, а реализации — платформозависимыми. Так, на платформе Windows в качестве размера типа wchar_t
было выбрано 16 бит, а позже появился стандарт UTF-32, таким образом тип wchar_t
на платформе Windows уже не способен уместить в себе все символы из кодировки UTF-32, в результате чего теряется смысл данного типа[25]. В то же время на платформах Linux[24] и macOS данный тип занимает 32 бита, поэтому для реализации кроссплатформенных задач тип wchar_t
не подходит.
Существует много разных кодировок, в которых отдельный символ может быть запрограммирован разным количеством байт. Такие кодировки называются многобайтовыми. К ним относится также и UTF-8. В Си существует набор функций для преобразования строк из многобайтовых в рамках текущей локали в широкие и наоборот. Функции для работы с многобайтовыми символами имеют префикс либо суффикс mb
и описаны в заголовочном файле stdlib.h
. Для поддержки многобайтовых строк в программах на языке Си, такие строки должны поддерживаться на уровне текущей локали. Для явного задания кодировки можно менять текущую локаль с помощью функции setlocale()
из заголовочного файла locale.h
. Однако задание кодировки для локали должно поддерживаться используемой стандартной библиотекой. Так, например, стандартная библиотека Glibc полностью поддерживает кодировку UTF-8 и способна преобразовывать текст во множество других кодировок[27].
Начиная со стандарта C11 язык поддерживает также 16-битные и 32-битные широкие многобайтовые строки с соответствующими типами символа char16_t
и char32_t
из заголовочного файла uchar.h
, а также объявление строковых литералов в формате UTF-8 с помощью модификатора u8
. 16-битные и 32-битные строки могут использоваться для хранения кодировок UTF-16 и UTF-32, если в заголовочном файле uchar.h
заданы макроопределения __STDC_UTF_16__
и __STDC_UTF_32__
, соответственно. Для задания строковых литералов в данных форматах используются модификаторы: u
для 16-битных строк и U
для 32-битных строк. Примеры объявления строковых литералов для многобайтовых строк:
const char *s8 = u8"Многобайтовая строка в кодировке UTF-8";
const char16_t *s16 = u"16-битная многобайтовая строка";
const char32_t *s32 = U"32-битная многобайтовая строка";
Следует иметь в виду, что функция c16rtomb()
для преобразования из 16-битной строки в многобайтовую работает не так, как задумывалось, и в стандарте C11 оказалась неспособной переводить из UTF-16 в UTF-8[28]. Исправление работы данной функции может зависеть от конкретной реализации компилятора.
Перечисления представляют собой набор именованных целочисленных констант и обозначаются с помощью ключевого слова enum
. Если константе не сопоставлено число, то ей автоматически задаётся либо 0
для первой константы в списке, либо число на единицу бо́льшее, чем задано в предыдущей константе. При этом сам тип данных перечисления по факту может соответствовать любому знаковому или беззнаковому примитивному типу, в диапазон которого умещаются все значения перечислений; решение о выборе того или иного типа принимает компилятор. Однако явно заданные значения для констант должны быть выражениями типа int
[29].
Тип перечисления может быть также анонимным, если не указано название перечисления. Константы, указанные в двух разных перечислениях, относятся к двум разным типам данных, независимо от того, являются ли перечисления именованными или анонимными.
На практике перечисления часто используются для обозначения состояний конечных автоматов, для задания вариантов режимов работы или значений параметров[30], для создания целочисленных констант, а также для перечисления каких-либо уникальных объектов или свойств[31].
Структуры представляют собой объединение переменных разных типов данных в рамках одной области памяти; обозначаются ключевым словом struct
. Переменные внутри структуры называются полями структуры. С точки зрения адресного пространства поля всегда идут друг за другом в том же порядке, в котором указаны, но компиляторы могут выравнивать адреса полей для оптимизации под ту или иную архитектуру. Таким образом, фактически поле может занимать бо́льший размер, чем указано в программе.
Каждое поле имеет определённое смещение относительно адреса структуры и размер. Смещение можно получить с помощью макроса offsetof()
из заголовочного файла stddef.h
. При этом смещение будет зависеть от выравнивания и размера предыдущих полей. Размер поля обычно определяется выравниванием структуры: если размер выравнивания типа данных поля меньше значения выравнивания структуры, то размер поля определяется выравниванием структуры. Выравнивание типов данных можно получить с помощью макроса alignof()
[d] из заголовочного файла stdalign.h
. Размер самой структуры является совокупным размером всех её полей с учётом выравнивания. При этом некоторые компиляторы предоставляют специальные атрибуты, позволяющие упаковывать структуры, убирая из них выравнивания[32].
Полям структур можно явно задавать размер в битах через двоеточие после определения поля и указание количества бит, что ограничивает диапазон их возможных значений, несмотря на тип поля. Подобный подход может использоваться как альтернатива флагам и битовым маскам для обращения к ним. Однако указание количества бит не отменяет возможного выравнивания полей структур в памяти. Работа с битовыми полями имеет ряд ограничений: к ним невозможно применить оператор sizeof
или макрос alignof()
, на них невозможно получить указатель.
Объединения необходимы в тех случаях, когда требуется обращаться к одной и той же переменной как к разным типам данных; обозначаются ключевым словом union
. Внутри объединения может быть объявлено произвольное количество пересекающихся полей, которые по факту предоставляют доступ к одной и той же области памяти как к разным типам данных. Размер объединения выбирается компилятором исходя из размера самого большого поля в объединении. Следует иметь в виду, что изменение одного поля объединения приводит к изменению и всех других полей, но гарантированно правильным будет только значение того поля, которое менялось.
Объединения могут служить более удобной альтернативной приведению указателя к произвольному типу. К примеру, с помощью объединения, помещённого в структуру, можно создавать объекты с динамически меняющимся типом данных:
#include <stddef.h>
enum value_type_t {
VALUE_TYPE_LONG, // целое число
VALUE_TYPE_DOUBLE, // вещественное число
VALUE_TYPE_STRING, // строка
VALUE_TYPE_BINARY, // произвольные данные
};
struct binary_t {
void *data; // указатель на данные
size_t data_size; // размер данных
};
struct string_t {
char *str; // указатель на строку
size_t str_size; // размер строки
};
union value_contents_t {
long as_long; // значение как целое число
double as_double; // значение как вещественное число
struct string_t as_string; // значение как строка
struct binary_t as_binary; // значение как произвольные данные
};
struct value_t {
enum value_type_t type; // тип значения
union value_contents_t contents; // содержимое значения
};
Массивы в языке Си примитивны и являются лишь синтаксическим абстрагированием над арифметикой указателей. Сам по себе массив является указателем на область памяти, поэтому вся информация о размерности массива и его границах может быть доступна только на этапе компиляции согласно описанию типа. Массивы могут быть как одномерными, так и многомерными, но обращение к элементу массива сводится к простому вычислению смещения относительно адреса начала массива. Поскольку массивы основаны на адресной арифметике, с ними возможно работать без использования индексов[33]. Так, например, следующие два примера считывания с потока ввода 10 чисел идентичны друг другу:
Код примера работы через индексы | Код примера работы через адресную арифметику |
---|---|
#include <stdio.h>
int a[10] = {0}; // Инициализация нулями
unsigned int count = sizeof(a) / sizeof(a[0]);
for (int i = 0; i < count; ++i) {
int *ptr = &a[i]; // Указатель на текущий элемента массива
int n = scanf("%8d", ptr);
if (n != 1) {
perror("Не удалось считать значение");
// Обработка ошибки
break;
}
}
|
#include <stdio.h>
int a[10] = {0}; // Инициализация нулями
unsigned int count = sizeof(a) / sizeof(a[0]);
int *a_end = a + count; // Указатель на элемент, следующий за последним
for (int *ptr = a; ptr != a_end; ++ptr) {
int n = scanf("%8d", ptr);
if (n != 1) {
perror("Не удалось считать значение");
// Обработка ошибки
break;
}
}
|
Длина массивов с заранее известным размером вычисляется на этапе компиляции. В стандарте C99 появилась возможность объявлять массивы переменной длины, у которых длина может задаваться на этапе выполнения. Под такие массивы выделяется память из области стека, поэтому их необходимо использовать с осторожностью, если их размер может задаваться извне программы. В отличие от выделения динамической памяти, превышение допустимого размера в области стека может повлечь непредсказуемые последствия, а отрицательная длина массива — неопределённое поведение. Начиная с C11 массивы переменной длины являются опциональными для компиляторов, а отсутствие поддержки определяется наличием макроса __STDC_NO_VLA__
[34].
Массивы фиксированного размера, объявляемые как локальные или глобальные переменные, можно инициализировать, задавая им начальное значение с помощью фигурных скобок и перечисления элементов массива через запятую. В инициализаторах глобальных массивов допускается использовать только такие выражения, которые вычисляются на этапе компиляции[35]. Переменные, используемые в таких выражениях должны объявляться как константы, с модификатором const
. У локальных массивов инициализаторы могут содержать выражения с вызовами функций и использованием других переменных, в том числе можно заносить указатель на сам объявляемый массив.
Со стандарта C99 последним элементом структур допускается объявлять массив произвольной длины, что широко используется на практике и поддерживается различными компиляторами. Размер такого массива зависит от объёма памяти, выделяемого под структуру. При этом нельзя объявлять массив таких структур и нельзя их помещать в другие структуры. В операциях над такой структурой массив произвольной длины обычно игнорируется, в том числе и при вычислении размера структуры, а выход за пределы массива влечёт за собой неопределённое поведение[36].
Язык Си не предусматривает какого-либо контроля выхода за пределы массива, поэтому программист сам должен следить за работой с массивами. Ошибки при обработке массивов не всегда явно влияют на ход исполнения программы, но могут приводить к ошибкам сегментирования и уязвимостям .
Язык Си допускает создание собственных названий типов с помощью оператора typedef
. Альтернативные названия можно задавать как системным типам, так и пользовательским. Такие названия объявляются в глобальном пространстве имён и не конфликтуют с названиями типов структур, перечислений и объединений.
Альтернативные названия могут применяться как для упрощения кода, так и для создания уровней абстракции. Например, некоторые системные типы можно сократить для повышения читабельности кода или унифицирования его написания в пользовательском коде:
#include <stdint.h>
typedef int32_t i32_t;
typedef int_fast32_t i32fast_t;
typedef int_least32_t i32least_t;
typedef uint32_t u32_t;
typedef uint_fast32_t u32fast_t;
typedef uint_least32_t u32least_t;
Примером абстрагирования могут служить названия типов в заголовочных файлах операционных системах. Так, стандарт POSIX определяет тип pid_t
, предназначенный для хранения числового идентификатора процесса. На самом деле данный тип является альтернативным названием для какого-либо примитивного типа, например:
typedef int __kernel_pid_t;
typedef __kernel_pid_t __pid_t
typedef __pid_t pid_t;
Поскольку типы с альтернативными названиями являются лишь синонимами оригинальным типам, то между ними сохраняется полная совместимость и взаимозаменяемость.
Препроцессор работает до компиляции и преобразует текст файла программы согласно встреченным в нём или переданным в препроцессор директивам. Технически препроцессор может быть реализован по-разному, но логически его удобно представлять именно как отдельный модуль, целиком обрабатывающий каждый предназначенный для компиляции файл и формирующий текст, попадающий затем на вход компилятора. Препроцессор ищет в тексте строки, начинающиеся с символа #
, вслед за которым должны следовать директивы препроцессора. Всё, что не относится к директивам препроцессора и не исключено из компиляции согласно директивам, передаётся на вход компилятора в неизменном виде.
В число возможностей препроцессора входит:
#define
, включая возможность создания параметризованных шаблонов текста (вызываются аналогично функциям), а также отменять подобные подмены, что даёт возможность осуществлять подмену на ограниченных участках текста программы;#ifdef
, #ifndef
, #if
, #else
и #endif
;#include
.
Важно понимать, что препроцессор обеспечивает только подстановку текста, не учитывая синтаксис и семантику языка. Так, например, макроопределения #define
могут встречаться внутри функций или определений типов, а директивы условной компиляции могут приводить к исключению из компилируемого текста программы любой части кода, без оглядки на грамматику языка. Вызов параметрического макроса также отличается от вызова функции, поскольку не происходит анализа семантики аргументов, разделяемых запятой. Так, например, в аргументы параметрического макроса невозможно передать инициализацию массива, поскольку его элементы тоже разделяются запятой:
#define array_of(type, array) (((type) []) (array))
int *a;
a = array_of(int, {1, 2, 3}); // ошибка компиляции:
// в макрос "array_of" передано 4 аргумента, но он принимает лишь 2
Макроопределения часто используются для обеспечения совместимости с разными версиями библиотек, у которых изменился API, включая те или иные участки кода в зависимости от версии библиотеки. Для этих целей библиотеки часто предоставляют макроопределения с описанием своей версии[37], а иногда и макросы с параметрами для сравнения текущей версии с заданной в рамках препроцессора[38]. Также макроопределения применяются для условной компиляции отдельных кусков программы, например для включения поддержки какого-либо дополнительного функционала.
Макроопределения с параметрами широко используются в Си-программах для создания аналогов обобщённых функций. Ранее они также применялись для реализации встраиваемых функций, но начиная со стандарта С99 эта необходимость исчезла благодаря добавлению inline
-функций. Однако в связи с тем, что макроопределения с параметрами функциями не являются, но вызываются аналогичным образом, по ошибке программиста могут возникать неожиданные проблемы, включая отработку только части кода из макроопределения[39] и неправильные приоритеты выполнения операций[40]. В качестве примера ошибочного кода можно привести макрос возведения числа в квадрат:
#include <stdio.h>
int main(void)
{
#define SQR(x) x * x
printf("%d", SQR(5)); // всё верно, 5*5=25
printf("%d", SQR(5 + 0)); // предполагалось 25, но будет выведено 5 (5+0*5+0)
printf("%d", SQR(4 / 3)); // всё верно, 1 (т. к. 4/3=1, 1*4=4, 4/3=1)
printf("%d", SQR(5 / 2)); // предполагалось 4 (2*2), но будет выведено 5 (5/2*5/2)
return 0;
}
В приведённом выше примере ошибкой является то, что содержимое аргумента макроса подставляется в текст как есть, без учёта приоритетов операций. В таких случаях необходимо использовать inline
-функции либо явно расставлять приоритеты операций в выражениях, использующих параметры макроса, с помощью круглых скобок:
#include <stdio.h>
int main(void)
{
#define SQR(x) ((x) * (x))
printf("%d", SQR(4 + 1)); // верно, 25
return 0;
}
Программа представляет собой набор файлов с кодом на языке Си, которые могут компилироваться в объектные файлы. Объектные файлы затем проходят этап компоновки друг с другом, а также со внешними библиотеками, в результате чего получается итоговый исполняемый файл или библиотека. Связь файлов друг с другом, равно как и с библиотеками, требует описания прототипов используемых функций, внешних переменных и необходимых типов данных в каждом файле. Такие данные принято выносить в отдельные заголовочные файлы, которые подключаются с помощью директивы #include
в тех файлах, где требуется та или иная функциональность, и позволяют организовывать систему, похожую на систему модулей. Модулем в таком случае может выступать:
Поскольку директива #include
лишь подставляет текст другого файла на этапе препроцессора, многократное подключение одного и того же файла может приводить к ошибкам этапа компиляции. Поэтому в таких файлах используется защита от повторного включения с помощью макрокоманд #define
и #ifndef
[41].
Текст файла исходного кода на языке Си состоит из набора глобальных определений данных, типов и функций. Глобальные переменные и функции, объявленные со спецификаторами static
и inline
, доступны только в пределах того файла, в котором они объявлены, либо при включении одного файла в другой через директиву #include
. При этом функции и переменные, объявленные в заголовочном файле со словом static
, будут создаваться заново при каждом подключении заголовочного файла к очередному файлу с исходным кодом. Глобальные переменные и прототипы функции, объявленные со спецификатором extern, считаются подключаемыми из других файлов. То есть их допускается использовать в соответствии с описанием; предполагается, что после сборки программы они будут связаны компоновщиком с оригинальными объектами и функциями, описанными в своих файлах.
Глобальные переменные и функции, кроме static
и inline
, могут быть доступны из других файлов при условии их надлежащего объявления там со спецификатором extern
. Переменные и функции, объявленные с модификатором static
, также могут быть доступны в других файлах, но лишь при передаче их адреса по указателю. Объявления типов typedef
, struct
и union
не могут импортироваться в других файлах. При необходимости использования в других файлах они должны быть там продублированы либо вынесены в отдельный заголовочный файл. То же самое относится и к inline
-функциям.
Для исполняемой программы стандартной точкой входа является функция с именем main
, которая не может быть статической и должна быть единственной в программе. Исполнение программы начинается с первого оператора функции main()
и продолжается до выхода из неё, после чего программа завершается и возвращает операционной системе абстрактный целочисленный код результата своей работы.
Без аргументов | С аргументами командной строки |
---|---|
int main(void);
|
int main(int argc, char** argv);
|
В переменную argc
при вызове передаётся количество аргументов, переданных программе, включая и путь к самой программе, поэтому обычно переменная argc содержит значение не меньшее, чем 1. В переменную argv
передаётся сама строка запуска программы в виде массива текстовых строк, последним элементом которого является NULL
. Компилятор гарантирует, что на момент запуска функции main()
все глобальные переменные в программе будут инициализированы[43].
В качестве результата функция main()
может вернуть любое целое число в диапазоне значений типа int
, которое будет передано операционной системе или другому окружению в качестве кода возврата программы[42]. Стандарт языка не определяет смысла кодов возврата[44]. Обычно операционная система, где работают программы, имеет те или иные средства, позволяющие получить значение кода возврата и проанализировать его. Иногда существуют определённые соглашения о значениях этих кодов. Общим является соглашение о том, что нулевое значение кода возврата сигнализирует об успешном завершении программы, а ненулевое представляет собой код возникшей ошибки. Заголовочный файл stdlib.h
определяет два общих макроопределения EXIT_SUCCESS
и EXIT_FAILURE
, которые соответствуют успешному и неуспешному завершению работы программы[44]. Коды возврата также могут использоваться в рамках приложений, включающих в себя множество процессов, для обеспечения взаимодействия между этими процессами, в случае чего приложение само определяет смысловое значение для каждого кода возврата.
В Си предусмотрено 4 способа выделения памяти, которые определяют время жизни переменной и момент её инициализации[43].
Способ выделения | Целевые объекты | Время выделения | Время освобождения | Накладные расходы |
---|---|---|---|---|
Статическое выделение памяти | Глобальные переменные и переменные, помеченные ключевым словом static (но без _Thread_local )
|
При старте программы | По завершении работы программы | Отсутствуют |
Выделение памяти на уровне потока | Переменные, помеченные ключевым словом _Thread_local
|
При старте потока | По завершении потока | При создании потока |
Автоматическое выделение памяти | Аргументы функций и возвращаемые ими значения, локальные переменные функций, в том числе регистры и массивы переменной длины | При вызове функций на уровне стека. | Автоматически по завершении функций | Незначительны, поскольку изменяется лишь указатель на вершину стека |
Динамическое выделение памяти | Память, выделяемая через функции malloc() , calloc() и realloc()
|
Вручную из кучи в момент вызова используемой функции. | Вручную с помощью функции free()
|
Большие как на выделение, так и на освобождение |
Все эти способы хранения данных пригодны в различных ситуациях и имеют свои преимущества и недостатки. Глобальные переменные не позволяют писать реентерабельные алгоритмы, а автоматическое выделение памяти не позволяет возвращать произвольную область памяти из вызова функции. Автоматическое выделение также не подходит для выделения больших объёмов памяти, поскольку может привести к порче стека или кучи[45]. Динамическая память лишена этих недостатков, но имеет большие накладные расходы при её использовании и более сложна в использовании.
Там, где это возможно, предпочтительным является автоматическое или статическое выделение памяти: такой способ хранения объектов управляется компилятором, что освобождает программиста от трудностей ручного выделения и освобождения памяти, как правило, служащего источником трудно отыскиваемых ошибок утечек памяти, ошибок сегментирования и повторного освобождения в программе. К сожалению, многие структуры данных имеют переменный размер во время выполнения программы, поэтому из-за того, что автоматически и статически выделенные области должны иметь известный фиксированный размер во время компиляции, очень часто требуется использовать динамическое выделение.
Для автоматически выделяемых переменных с помощью модификатора register
можно давать подсказку компилятору о необходимости быстрого доступа к ним. Такие переменные могут помещаться в регистры процессора. Из-за ограниченного количества регистров и возможных оптимизаций компилятора переменные могут оказаться в обычной памяти, но тем не менее из программы на них невозможно будет получить указатель[46]. Модификатор register
является единственным, который можно указывать в аргументах функций[47].
Язык Си унаследовал линейную адресацию памяти при работе со структурами, массивами и выделенными областями памяти. Стандарт языка также допускает выполнение операций сравнения над нулевым указателем и над адресами в рамках массивов, структур и выделенных областей памяти. Также допускается работа с адресом элемента массива, следующим за последним, что сделано для облегчения написания алгоритмов. Однако сравнение указателей адресов, полученных для разных переменных (или областей памяти) не должно осуществляться, так как результат будет зависеть от реализации конкретного компилятора[48].
Представление памяти программы зависит от аппаратной архитектуры, от операционной системы и от компилятора. Так, например, на большинстве архитектур стек растёт вниз, но существуют архитектуры, где стек растёт вверх[49]. Граница между стеком и кучей может быть частично защищена от переполнения стека специальной областью памяти[50]. А расположение данных и кода библиотек может зависеть от параметров компиляции[51]. Стандарт Си абстрагируется над реализацией и позволяет писать переносимый код, однако понимание устройства памяти процесса помогает в отладке и написании безопасных и отказоустойчивых приложений.
При запуске программы из исполняемого файла в оперативную память импортируются инструкции процессора (машинный код) и инициализированные данные. В то же время в старшие адреса импортируются аргументы командной строки (доступные в функции main()
со следующей сигнатурой во втором аргументе int argc, char ** argv
) и переменные окружения.
Область неинициализированных данных содержит глобальные переменные (в том числе, объявленные как static
), которые не были проинициализированы в программном коде. Такие переменные по умолчанию инициализируются нулями после старта программы. Область инициализированных данных — сегмент данных — тоже содержит глобальные переменные, но в эту область попадают те переменные, которым было задано начальное значение. Неизменяемые данные, включающие в себя переменные, объявленные с модификатором const
, строковые литералы и другие составные литералы, помещаются в сегмент текста программы. Сегмент текста программы содержит также исполняемый код и доступен только на чтение, поэтому попытка изменения данных из этого сегмента приведёт к неопределённому поведению в виде ошибки сегментации.
Область стека предназначена для размещения данных, связанных с вызовом функций, и локальных переменных. Перед каждым запуском функции стек увеличивается для размещения в нём аргументов, передаваемых в функцию. В ходе своей работы функция может размещать в стеке локальные переменные и выделять в нём память под массивы переменной длины, а некоторые компиляторы предоставляют также средства выделения памяти в рамках стека посредством вызова alloca()
, который не входит в стандарт языка. После завершения работы функции стек уменьшается до того значения, которое было перед вызовом, однако этого может не происходить при некорректной работе со стеком. Память, выделенная динамически, предоставляется из кучи .
Немаловажной деталью является наличие случайного отступа между стеком и верхней областью[53], а также между областью инициализированных данных и кучей. Делается это в целях безопасности, например, для предотвращения встраивания в стек других функций.
Динамически подключаемые библиотеки и отображения файлов с файловой системы находятся между стеком и кучей[54].
В Си отсутствуют какие-либо встроенные механизмы контроля ошибок, но существует несколько общепринятых способов их обработки средствами языка. В общем виде практика обработки ошибок языка Си в отказоустойчивом коде вынуждает писать громоздкие, часто повторяющиеся конструкции, в которых алгоритм совмещён с обработкой ошибок
.В языке Си активно используется специальная переменная errno
из заголовочного файла errno.h
, в которую функции заносят код ошибки, возвращая при этом значение, являющееся маркером ошибки. Для проверки результата на ошибки результат сравнивают с маркером ошибки, и, если они совпадают, то можно проанализировать код ошибки, сохранённый в errno
, для корректировки работы программы или вывода отладочного сообщения. В стандартной библиотеке стандарт зачастую лишь определяет возвращаемые маркеры ошибок, а выставление errno
зависит от конкретной реализации[55].
В качестве маркеров ошибок обычно выступают следующие значения:
-1
для типа int
в случаях, когда отрицательный диапазон результата не используется[56];-1
для типа ssize_t
(POSIX)[57];(size_t) -1
для типа size_t
[56];(time_t) -1
при использовании некоторых функций для работы со временем[56];NULL
для указателей[56];EOF
при потоковой работе с файлами[56];Практика возвращения маркера ошибки, вместо кода ошибки, хоть и экономит количество передаваемых в функции аргументов, но в ряде случаев приводит к ошибкам в результате человеческого фактора. Например, программистами часто игнорируется проверка результата типа ssize_t
, а сам результат используется дальше в вычислениях, что приводит к трудно уловимым ошибкам, если возвращается -1
[58].
Ещё сильнее способствует появлению ошибок возврат в качестве маркера ошибки корректного значения[58], что также вынуждает программиста делать больше проверок, а соответственно и писать больше однотипного повторяющегося кода. Такой подход практикуется в потоковых функциях, работающих с объектами типа FILE *
: маркером ошибки является значение EOF
, одновременно являясь и маркером конца файла. Поэтому по EOF
иногда приходится проверять поток символов как на конец файла с помощью функции feof()
, так и наличие ошибки с помощью ferror()
[59]. При этом некоторые функции, которые могут вернуть EOF
по стандарту не обязаны выставлять errno
[55] .
Отсутствие единой практики обработки ошибок в стандартной библиотеке приводит к появлению собственных способов обработки ошибок и комбинированию часто используемых способов в сторонних проектах. Например, в проекте systemd совместили идеи возвращения кода ошибки и числа -1
в качестве маркера — возвращается отрицательный код ошибки[60]. А в библиотеке GLib ввели в практику возвращение в качестве маркера ошибки значение булева типа, в то время как подробная информация об ошибке помещается в специальную структуру, указатель на которую возвращается через последний аргумент функции[61]. Схожее решение использует проект Enlightenment, в котором в качестве маркера тоже используется булев тип, но информация об ошибке возвращается по аналогии со стандартной библиотекой — через отдельную функцию[62], которую необходимо проверять, если был возвращён маркер.
Альтернативой маркерам ошибок является возвращение кода ошибки напрямую, а результата работы функции — через аргументы по указателю. По такому пути пошли разработчики стандарта POSIX, в функциях которого принято возвращать код ошибки в виде числа типа int
. Однако возвращение значения типа int
явно не даёт понять, что возвращается именно код ошибки, а не маркер, что может вести к ошибкам, если результат таких функций будет проверяться на значение -1
. В расширении K стандарта C11 представлен специальный тип errno_t
для хранения кода ошибки. Существуют рекомендации использовать именно этот тип в пользовательском коде для возвращения ошибок, а если он не предоставлен стандартной библиотекой, то объявлять его самостоятельно[63]:
#ifndef __STDC_LIB_EXT1__
typedef int errno_t;
#endif
Такой подход, помимо повышения качества кода, избавляет от необходимости использования errno
, что позволяет делать библиотеки с реентерабельными функциями без необходимости подключения дополнительных библиотек, таких как POSIX Threads для правильного определения errno
.
Более сложной является обработка ошибок в математических функциях из заголовочного файла math.h
, в которых могут возникать 3 типа ошибок[64]:
Предотвращение двух из трёх типов ошибок сводится к проверкам входных данных на область допустимых значений. Однако предсказать выход результата за пределы типа крайне сложно. Поэтому стандартом языка предусмотрена возможность анализа математических функций на ошибки. Начиная со стандарта C99 такой анализ возможен двумя способами, в зависимости от значения, хранимого в макросе math_errhandling
.
MATH_ERRNO
, то переменную errno
необходимо предварительно сбросить в 0
, а после вызова математической функции — проверить на ошибки EDOM
и ERANGE
.MATH_ERREXCEPT
, то возможные математические ошибки предварительно сбрасываются функцией feclearexcept()
из заголовочного файла fenv.h
, а после вызова математической функции —тестируются с помощью функции fetestexcept()
.При этом способ обработки ошибок определяется конкретной реализацией стандартной библиотеки и может отсутствовать совсем. Поэтому в платформонезависимом коде может потребоваться проверка результата сразу двумя способами, в зависимости от значения math_errhandling
[64].
Как правило возникновение ошибки требует завершения работы функции с возвращением индикатора ошибки. Если в функции ошибка может возникнуть в разных её частях, требуется освобождать ресурсы, выделенные в ходе её работы, чтобы предотвратить утечки. Хорошей практикой освобождения ресурсов считается их чистка в обратном порядке перед возвратом из функции, а в случае ошибок — освобождение в обратном порядке после основного return
. В отдельные части такого освобождения можно сделать переход с помощью оператора goto
[65]. Подобный подход позволяет вынести не связанные с реализуемым алгоритмом участки кода за пределы самого алгоритма, повышая читабельность кода, и схож с работой оператора defer
из языка программирования Go. Пример освобождения ресурсов приведён ниже, в разделе примеров .
Для освобождения ресурсов в рамках программы предусмотрен механизм обработчиков выхода из программы. Обработчики назначаются с помощью функции atexit()
и исполняются как по завершении функции main()
через оператор return
, так и по исполнению функции exit()
. При этом обработчики не исполняются по функциям abort()
и _Exit()
[66].
В качестве примера освобождения ресурсов по завершении программы можно привести освобождение памяти, выделенной под глобальные переменные. Несмотря на то, что память так или иначе освобождается по завершении работы программы операционной системой, и допускается не освобождать ту память, которая требуется на протяжении всей работы программы[67], явное освобождение предпочтительнее, так как облегчает поиск утечек памяти сторонними средствами и уменьшает шанс на возникновение утечек памяти в результате ошибки:
#include <stdio.h>
#include <stdlib.h>
int numbers_count;
int *numbers;
void free_numbers(void)
{
free(numbers);
}
int main(int argc, char **argv)
{
if (argc < 2) {
exit(EXIT_FAILURE);
}
numbers_count = atoi(argv[1]);
if (numbers_count <= 0) {
exit(EXIT_FAILURE);
}
numbers = calloc(numbers_count, sizeof(*numbers));
if (!numbers) {
perror("Ошибка выделения памяти под массив");
exit(EXIT_FAILURE);
}
atexit(free_numbers);
// ... работа с массивом numbers
// Здесь будет автоматически вызван обработчик free_numbers()
return EXIT_SUCCESS;
}
Недостатком данного подхода является то, что формат назначаемых обработчиков не предусматривает передачу произвольных данных в функцию, что позволяет создавать обработчики только для глобальных переменных.
Минимальная программа на Си, не требующая обработки аргументов, имеет следующий вид:
int main(void){}
Допускается не писать оператор return
у функции main()
. В таком случае, согласно стандарту, функция main()
возвращает 0, исполняя все обработчики, назначенные на функцию exit()
. При этом подразумевается, что программа успешно завершилась[14].
Программа Hello, world! приведена ещё в первом издании книги «Язык программирования Си» Кернигана и Ритчи:
#include <stdio.h>
int main(void) // Не принимает аргументы
{
printf("Hello, world!\n"); // '\n' - новая строка
return 0; // Удачное завершение программы
}
Эта программа печатает сообщение «Hello, world!» на стандартном устройстве вывода.
Многие функции языка Си могут вернуть ошибку, не выполнив требуемых от них действий. Ошибки требуется проверять и правильно на них реагировать, в том числе часто требуется пробрасывать ошибку из функции на уровень выше для анализа. При этом функцию, в которой произошла ошибка, можно делать реентерабельной, в таком случае по ошибке функция не должна изменять входные или выходные данные, что позволяет безопасно перезапускать её после исправления ошибочной ситуации.
В примере реализована функция чтения файла на языке Си, однако она требует соответствия функций fopen()
и fread()
стандарту POSIX, иначе они могут не выставлять переменную errno
, что сильно усложняет как отладку, так и написание универсального и безопасного кода. На платформах, не соответствующих POSIX, поведение данной программы будет неопределённым в случае ошибки . Освобождение ресурсов по ошибкам находится за основным алгоритмом для повышения читабельности, а переход осуществляется с помощью goto
[65].
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
// Определяем тип для хранения кода ошибки, если он не определён
#ifndef __STDC_LIB_EXT1__
typedef int errno_t;
#endif
enum {
EOK = 0, // значение errno_t при успешном завершении
};
// Функция чтения содержимого файла
errno_t get_file_contents(const char *filename,
void **contents_ptr,
size_t *contents_size_ptr)
{
FILE *f;
f = fopen(filename, "rb");
if (!f) {
// В POSIX fopen() выставляет errno по ошибке
return errno;
}
// Получаем размер файла
fseek(f, 0, SEEK_END);
long contents_size = ftell(f);
if (contents_size == 0) {
*contents_ptr = NULL;
*contents_size_ptr = 0;
goto cleaning_fopen;
}
rewind(f);
// Переменная для хранения возвращаемого кода ошибки
errno_t saved_errno;
void *contents;
contents = malloc(contents_size);
if (!contents) {
saved_errno = errno;
goto aborting_fopen;
}
// Читаем содержимое файла целиком по указателю contents
size_t n;
n = fread(contents, contents_size, 1, f);
if (n == 0) {
// Не проверяем на feof(), т. к. буферизировано после fseek()
// В POSIX fread() выставляет errno по ошибке
saved_errno = errno;
goto aborting_contents;
}
// Возвращаем выделенную память и её размер
*contents_ptr = contents;
*contents_size_ptr = contents_size;
// Секция освобождения ресурсов по успешному завершению
cleaning_fopen:
fclose(f);
return EOK;
// Отдельная секция для освобождения ресурсов по ошибке
aborting_contents:
free(contents);
aborting_fopen:
fclose(f);
return saved_errno;
}
int main(int argc, char **argv)
{
if (argc < 2) {
return EXIT_FAILURE;
}
const char *filename = argv[1];
errno_t errnum;
void *contents;
size_t contents_size;
errnum = get_file_contents(filename, &contents, &contents_size);
if (errnum) {
char buf[1024];
const char *error_text = strerror_r(errnum, buf, sizeof(buf));
fprintf(stderr, "%s\n", error_text);
exit(EXIT_FAILURE);
}
printf("%.*s", (int) contents_size, contents);
free(contents);
return EXIT_SUCCESS;
}
Некоторые компиляторы идут в комплекте с компиляторами других языков программирования (включая C++) или являются составной частью среды разработки программного обеспечения.
|