Си (англ. C) — компилируемый статически типизированный язык программирования общего назначения, разработанный в 1969—1973 годах сотрудником Bell Labs Деннисом Ритчи как развитие языка Би. Первоначально был разработан для реализации операционной системы UNIX, но впоследствии был перенесён на множество других платформ. Согласно дизайну языка, его конструкции близко сопоставляются типичным машинным инструкциям, благодаря чему он нашёл применение в проектах, для которых был свойственен язык ассемблера, в том числе как в операционных системах, так и в различном прикладном программном обеспечении для множества устройств — от суперкомпьютеров до встраиваемых систем. Язык программирования Си оказал существенное влияние на развитие индустрии программного обеспечения, а его синтаксис стал основой для таких языков программирования, как C++, C#, Java и Objective-C.
C | |
---|---|
![]() | |
Класс языка | процедурный |
Тип исполнения | компилируемый |
Появился в | 1972 |
Автор | Деннис Ритчи, Кен Томпсон |
Разработчик | Bell Labs, Деннис Ритчи[1], Национальный институт стандартов США, ИСО и Кен Томпсон |
Расширение файлов |
.c - для файлов кода, .h - для заголовочных файлов |
Система типов | статическая слабая |
Основные реализации | GCC, TCC, Turbo C, Watcom, Oracle Solaris Studio C, Pelles C |
Диалекты |
«K&R» C (1978) ANSI C (1989) C90 (1990) C99 (1999) C11 (2011) |
Испытал влияние | BCPL, B |
Повлиял на | C++, Objective-C, C#, Cyclone, Java, BitC, Nim |
Сайт |
iso.org/standard/74528.h… open-std.org/jtc1/sc22/w… |
ОС | 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 |
История
Год | Стандарт Си |
---|---|
1972 | Рождение языка |
1978 | K&R C |
1989 | ANSI C (C89) |
1990 | ISO C (совпадает с C89) |
1999 | C99 |
2011 | C11 |
2017 | C17 (исправленный C11) |
2018 | C18 (совпадает с C17) |
Язык был разработан в лабораториях Bell Labs в период с 1969 по 1973 годы. Согласно Ритчи, самый активный период творчества пришёлся на 1972 год. Язык назвали «Си» (C — третья буква английского алфавита), потому что многие его особенности берут начало от старого языка «Би» (B — вторая буква английского алфавита). Существует несколько различных версий происхождения названия языка Би. Кен Томпсон указывает на язык программирования BCPL, однако существует ещё и язык Bon, также созданный им, и названный так в честь его жены Бонни.
Существует несколько легенд, касающихся причин разработки Си и его отношения к операционной системе UNIX, включая следующие:
- Разработка Си стала результатом того, что его будущие авторы любили компьютерную игру, подобную популярной игре Asteroids (Астероиды). Они уже давно играли в неё на главном сервере компании, который был недостаточно мощным и должен был обслуживать около ста пользователей. Томпсон и Ритчи посчитали, что им не хватает контроля над космическим кораблём для того, чтобы избегать столкновений с некоторыми камнями. Поэтому они решили перенести игру на свободный PDP-7, стоящий в офисе. Однако этот компьютер не имел операционной системы, что заставило их её написать. В конце концов, они решили перенести эту операционную систему ещё и на офисный PDP-11, что было очень тяжело, потому что её код был целиком написан на ассемблере. Было вынесено предложение использовать какой-нибудь высокоуровневый портируемый язык, чтобы можно было легко переносить ОС с одного компьютера на другой. Язык Би, который они хотели сначала задействовать для этого, оказался лишён функциональности, способной использовать новые возможности PDP-11. Поэтому они и остановились на разработке языка Си.
- Самый первый компьютер, для которого была первоначально написана UNIX, предназначался для создания системы автоматического заполнения документов. Первая версия UNIX была написана на ассемблере. Позднее для того, чтобы переписать эту операционную систему, был разработан язык Си.
К 1973 году язык Си стал достаточно силён, и большая часть ядра UNIX, первоначально написанная на ассемблере PDP-11/20, была переписана на Си. Это было одно из самых первых ядер операционных систем, написанное на языке, отличном от ассемблера; более ранними были лишь системы Multics (написана на ПЛ/1) и TRIPOS (написана на BCPL).
K&R C
В 1978 году Брайан Керниган и Деннис Ритчи опубликовали первую редакцию книги «Язык программирования Си». Эта книга, известная среди программистов как «K&R», служила многие годы неформальной спецификацией языка. Версию языка Си, описанную в ней, часто называют «K&R C». Вторая редакция этой книги посвящена более позднему стандарту ANSI C, описанному ниже.
K&R ввёл следующие особенности языка:
- структуры (тип данных
struct
); - длинное целое (тип данных
long int
); - целое без знака (тип данных
unsigned int
); - оператор
+=
и подобные ему (старые операторы=+
вводили анализатор лексики компилятора Си в заблуждение, например, при сравнении выраженийi =+ 10
иi = +10
).
K&R C часто считают самой главной частью языка, которую должен поддерживать компилятор Си. Многие годы даже после выхода ANSI C он считался минимальным уровнем, которого следовало придерживаться программистам, желающим добиться от своих программ максимальной переносимости, потому что не все компиляторы тогда поддерживали ANSI C, а хороший код на K&R C был верен и для ANSI C.
После публикации K&R C в язык было добавлено несколько возможностей, поддерживаемых компиляторами AT&T и некоторых других производителей:
- функции, не возвращающие значение (с типом
void
), и указатели, не имеющие типа (с типомvoid*
); - функции, возвращающие объединения и структуры;
- имена полей данных структур в разных пространствах имён для каждой структуры;
- присваивания структур;
- спецификатор констант (
const
); - стандартная библиотека, реализующая большую часть функций, введённых различными производителями;
- перечислимый тип (
enum
); - дробное число одинарной точности (
float
).
ISO C
В конце 1970-х годов Си начал вытеснять Бейсик с позиции ведущего языка для программирования микрокомпьютеров. В 1980-х годах он был адаптирован для использования в IBM PC, что привело к резкому росту его популярности. В то же время Бьёрн Страуструп и другие в лабораториях Bell Labs начали работу по добавлению в Си возможностей объектно-ориентированного программирования. Язык, который они в итоге сделали, C++, оказал большое влияние на разработку ПО, но так и не смог сравняться по популярности[2] с Си, особенно в UNIX-подобных системах.
В 1983 году Американский национальный институт стандартов (ANSI) сформировал комитет для разработки стандартной спецификации Си. По окончании этого долгого и сложного процесса в 1989 году он был наконец утверждён как «Язык программирования Си» ANSI X3.159-1989. Эту версию языка принято называть ANSI C или C89. В 1990 году стандарт ANSI C был принят с небольшими изменениями Международной организацией по стандартизации (ISO) как ISO/IEC 9899:1990.
Одной из целей этого стандарта была разработка надмножества K&R C, включающего многие особенности языка, созданные позднее. Однако комитет по стандартизации также включил в него и несколько новых возможностей, таких, как прототипы функций (заимствованные из C++) и более сложный препроцессор.
ANSI C сейчас поддерживают почти все существующие компиляторы. Почти весь код Си, написанный в последнее время, соответствует ANSI C. Любая программа, написанная только на стандартном Си, гарантированно будет правильно выполняться на любой платформе, имеющей соответствующую реализацию Си. Однако большинство программ написаны так, что они будут компилироваться и исполняться только на определённой платформе, потому что:
- они используют нестандартные библиотеки, например, для графических дисплеев;
- они используют специфические платформо-зависимые средства;
- они рассчитаны на определённое значение размера некоторых типов данных или на определённый способ хранения этих данных в памяти для конкретной платформы.
C99
После стандартизации в ANSI спецификация языка Си оставалась относительно неизменной в течение долгого времени, в то время как C++ продолжал развиваться (в 1995 году в стандарт Си была внесена Первая нормативная поправка, но её почти никто не признавал). Однако в конце 1990-х годов стандарт подвергся пересмотру, что привело к публикации ISO 9899:1999 в 1999 году. Этот стандарт обычно называют «C99». В марте 2000 года он был принят и адаптирован ANSI.
Некоторые новые особенности C99:
- подставляемые функции (
inline
); - объявление локальных переменных в любом операторе программного текста (как в C++);
- новые типы данных, такие, как
long long int
(для облегчения перехода от 32- к 64-битным числам), явный булевый тип данных_Bool
и типcomplex
для представления комплексных чисел; - массивы переменной длины?!;
- поддержка ограниченных указателей (
restrict
); - именованная инициализация структур:
struct { int x, y, z; } point = { .y=10, .z=20, .x=30 };
- поддержка однострочных комментариев, начинающихся на
//
, заимствованных из C++ (многие компиляторы Си поддерживали их и ранее в качестве дополнения); - несколько новых библиотечных функций, таких, как
snprintf
; - несколько новых заголовочных файлов, таких, как
stdint.h
.
C11
8 декабря 2011 опубликован новый стандарт для языка Си (ISO/IEC 9899:2011)[3]. Основные изменения:
- поддержка многопоточности;
- улучшенная поддержка Юникода;
- обобщённые макросы (type-generic expressions, позволяют статичную перегрузку);
- анонимные структуры и объединения (упрощают обращение ко вложенным конструкциям);
- управление выравниванием объектов;
- статичные утверждения (static assertions);
- удаление опасной функции
gets
(в пользу безопаснойgets_s
); - функция
quick_exit
; - спецификатор функции
_Noreturn
; - новый режим эксклюзивного открытия файла.
C18 (С17)
Черновой вариант стандарта был представлен как C17 (ISO/IEC 9899:2017) в 2017 году[4]. В июне 2018 года стандарт был опубликован как C18 (ISO/IEC 9899:2018)[5][6]. Новый стандарт устраняет дефекты, замеченные в предыдущей версии, без добавления новых возможностей[6].
Принципы
Язык программирования Си отличается минимализмом. Авторы языка хотели, чтобы программы на нём легко компилировались с помощью однопроходного компилятора, чтобы каждой элементарной составляющей программы после компиляции соответствовало весьма небольшое число машинных команд, а использование базовых элементов языка не задействовало библиотеку времени выполнения. Однопроходный компилятор компилирует программу, не возвращаясь назад к уже обработанному тексту, поэтому использованию функций и переменных должно предшествовать их объявление. Код на Си можно легко писать на низком уровне абстракции, почти как на ассемблере. Иногда Си называют «универсальным ассемблером» или «ассемблером высокого уровня»[источник?], что отражает различие языков ассемблера для разных платформ и единство стандарта Си, код которого может быть скомпилирован без изменений практически на любой модели компьютера. Си часто называют языком среднего уровня или даже низкого уровня, учитывая то, как близко он работает к реальным устройствам. Однако в строгой классификации он является языком высокого уровня.
Компиляторы Си разрабатываются сравнительно легко благодаря простоте языка и малому размеру стандартной библиотеки. Поэтому данный язык доступен на самых различных платформах (возможно, круг этих платформ шире, чем у любого другого существующего языка). К тому же, несмотря на свою низкоуровневую природу, язык позволяет создавать переносимые программы и поддерживает в этом программиста. Программы, соответствующие стандарту языка, могут компилироваться на самых различных компьютерах.
Си (как и операционная система UNIX, с которой он долгое время был связан) создавался программистами и для программистов, круг которых был бы ненамного шире круга разработчиков языка. Несмотря на это, область использования языка значительно шире задач системного программирования.
Язык создавался с целью сделать более простым написание больших программ с минимумом ошибок (по сравнению с ассемблером), следуя принципам процедурного программирования, но избегая всего, что приведёт к дополнительным накладным расходам, обычным для языков высокого уровня.
Си имеет следующие основные особенности:
- простую языковую базу, из которой вынесены в библиотеки многие существенные возможности, вроде математических функций или функций управления файлами;
- ориентацию на процедурное программирование, обеспечивающую удобство применения структурного стиля программирования;
- систему типов, предохраняющую от бессмысленных операций;
- использование препроцессора для, например, определения макросов и включения файлов с исходным кодом;
- непосредственный доступ к памяти компьютера через использование указателей;
- минимальное число ключевых слов;
- передачу параметров в функцию по значению, а не по ссылке (при этом передача по ссылке эмулируется с помощью указателей);
- указатели на функции и статические переменные;
- области действия имён;
- структуры и объединения — определяемые пользователем собирательные типы данных, которыми можно манипулировать как одним целым.
В то же время в Си отсутствуют:
- вложенные функции;
- сопрограммы;
- средства автоматического управления памятью;
- встроенные средства объектно-ориентированного программирования;
- средства функционального программирования.
Часть отсутствующих возможностей может имитироваться встроенными средствами (например, сопрограммы можно имитировать с помощью функций setjmp
и longjmp
), часть добавляется с помощью сторонних библиотек (например, для поддержки многозадачности и для сетевых функций можно использовать библиотеки pthreads, sockets и тому подобными; существуют библиотеки для поддержки автоматической сборки мусора[7]), часть реализуется в некоторых компиляторах в виде расширений языка (например, вложенные функции в GCC).
Существует несколько громоздкая, но вполне работоспособная методика, позволяющая реализовывать на Си механизмы ООП[8], базирующаяся на фактической полиморфности указателей в Си и поддержке в этом языке указателей на функции. Механизмы ООП, основанные на данной модели реализованы в библиотеке GLib и активно используются в фреймворке GTK+. GLib предоставляет базовый класс GObject
, возможности наследования от одного класса и реализации множества интерфейсов.
После появления язык был хорошо принят, потому что он позволял быстро создавать компиляторы для новых платформ, а также позволял программистам довольно точно представлять, как выполняются их программы. Благодаря близости к языкам низкого уровня программы на Си работали эффективнее написанных на многих других языках высокого уровня, и лишь оптимизированный вручную код на ассемблере мог работать ещё быстрее, потому что давал полный контроль над машиной. На сегодняшний день развитие компиляторов и усложнение процессоров привело к тому, что вручную написанный ассемблерный код (кроме разве что очень коротких программ) практически не выигрывает по сравнению с кодом, генерируемым компиляторами, при этом Си продолжает оставаться одним из наиболее эффективных языков высокого уровня.
Одним из последствий высокой эффективности и переносимости языка стало то, что многие компиляторы, интерпретаторы и библиотеки других языков высокого уровня часто написаны на языке Си.
Синтаксис и основные возможности
Лексемы
В языке используются (являются допустимыми):
- все символы латинского алфавита
A
,B
,C
,D
,E
,F
,G
,H
,I
,J
,K
,L
,M
,N
,O
,P
,Q
,R
,S
,T
,U
,V
,W
,X
,Y
,Z
a
,b
,c
,d
,e
,f
,g
,h
,i
,j
,k
,l
,m
,n
,o
,p
,q
,r
,s
,t
,u
,v
,w
,x
,y
,z
- цифры
0
,1
,2
,3
,4
,5
,6
,7
,8
,9
- и специальные символы
,
(запятая),;
,.
(точка),+
,-
,*
,^
,&
(амперсанд),=
,~
(тильда),!
,/
,<
,>
,(
,)
,{
,}
,[
,]
,|
,%
,?
,'
(апостроф),"
(кавычки),:
(двоеточие),_
(знак подчёркивания)
Из допустимых символов формируются лексемы — предопределённые константы, идентификаторы и знаки операций. В свою очередь, лексемы являются частью выражений; а из выражений составляются инструкции и операторы.
При трансляции программы на Си из программного кода выделяются лексемы максимальной длины, содержащие допустимые символы. Если в программе имеется недопустимый символ, то лексический анализатор (или компилятор) выдаст ошибку, и трансляция программы окажется невозможной.
Также имеется символ #
, который не может быть частью никакой лексемы, и используется в препроцессоре .
Идентификаторы
Допустимый идентификатор — это слово (лексема), составленное из допустимых символов алфавита языка программирования, не являющееся знаком некоторой операции или разделителем.
Идентификаторы — это те имена, которые даются программным объектам — (именованным) константам, переменным, типам и функциям.
Некоторые лексемы являются зарезервированными (или, что то же самое, ключевыми) словами (см. ниже), и, поэтому, не могут быть использованы в программе в качестве идентификаторов программных объектов.
Предопределённые константы
Для введения в программе на Си именованных констант используется директива препроцессора #define
:
#define
имя константы [значение].
Введённая таким образом константа будет действовать всюду, начиная с момента задания константы и до конца программного кода или до тех пор, пока действие заданной константы не отменено другой директивой:
#undef
имя константы.
Как и для всякого макроса, для именованной константы происходит автоматическая подстановка значения константы в программном коде всюду, где употреблено имя константы.
Если для именованной константы указано некоторое значение, то для константы определяется так же и тип, соответствующий виду задаваемого значения. Различают следующие типы констант:
- числовые (целочисленные или вещественные);
- символьные (выделяются знаком апострофа);
- текстовые строки (выделяются знаком двойных кавычек).
Ключевые слова
Ключевые слова — это лексемы (слова), которые зарезервированы компилятором для обозначения типов переменных, класса хранения, элементов операторов.
Стандартом С89
предусмотрены следующие ключевые слова:sizeof
— для указания операции получения размера объекта;typedef
— для задания альтернативного имени типу данных;auto
,register
— для обозначения подсказки компилятору по месту хранения переменных;extern
— для обозначения того, что объект описывается в другом месте;static
— для обозначения того, что объект статический;char
,short
,int
,long
,signed
,unsigned
,float
,double
,void
— для обозначения типа значения;struct
— для обозначения типа данных в виде структуры с набором полей;enum
— для обозначения типа данных, хранящего одно из заданных целочисленных значений;union
— для обозначения типа данных, хранящего один из заданных типов значений;do
,for
,while
— для обозначения операторов цикла;if
,else
— для обозначения условного оператора;switch
,case
,default
— для обозначения оператора выбора;break
,continue
— для обозначения операторов прерывания исполнения кода;goto
— для обозначения оператора безусловного перехода;- для обозначения оператора возврата из функции:
return
.
В стандарте C99
добавлены следующие ключевые слова:inline
— для обозначения того, что функция является встраиваемой;_Bool
,_Complex
,_Imaginary
— для обозначения дополнительных типов переменной;restrict
— для обозначения того, что объявляемый указатель ссылается на блок памяти, на который не ссылается никакой другой указатель.
Комментарии
Текст программы на Си может содержать фрагменты, которые не являются частью программного кода, — комментарии. Комментарии специальным образом помечаются в тексте программы и пропускаются при компиляции.
Первоначально, в стандарте C89, были доступны встраиваемые комментарии, которые могли помещаться между последовательностями символов /*
и */
. При этом невозможно вложить один комментарий в другой, поскольку первая встреченная последовательность */
завершит комментарий, а текст, следующий непосредственно за обозначением */
, будет воспринят компилятором как исходный текст программы.
Следующий стандарт, C99, ввёл ещё один способ оформления комментариев: комментарием считается текст, начинающийся с последовательности символов //
и заканчивающийся концом строки.
Операции
Операция — это некоторая функция, которая выполняется над операндами и которая возвращает вычисленное значение — результат выполнения операции. Каждой операции в Си соответствует свой знак операции (см. выше).
Операнд — это константа, переменная, выражение или вызов какой-либо определённой в программе функции. Операции различают по количеству задействованных операндов, а именно — различают
- унарные операции — операции вида
- [знак операции] [операнд]
- бинарные операции
- [операнд] [знак операции] [операнд]
- и тернарные операции.
Унарные операции
Унарные операции — это операции, содержащие единственный операнд.
К унарным операциям в Си относятся следующие операции:
+
(унарный плюс),-
(унарный минус),~
(взятие обратного кода),!
(логическое отрицание),&
(взятие адреса),*
(операция разыменовывания указателя),sizeof
(операция определения занимаемого объектом объёма памяти).
Бинарные операции
Бинарные операции — это операции, содержащие два операнда, между которыми расположен знак операции.
К бинарным операциям в Си относятся следующие операции:
+
(сложение),-
(вычитание),*
(умножение),/
(деление),%
(взятие остатка от деления),&
(поразрядное И),|
(поразрядное ИЛИ),^
(поразрядное исключающее ИЛИ),<<
(логический сдвиг влево),>>
(логический сдвиг вправо),&&
(логическое И),||
(логическое ИЛИ).
Также к бинарным операциям в Си относятся операции, по сути представляющие собою присваивание:
+=
(добавление к левому операнду значения, представленного правым операндом);-=
(вычитание из левого операнда значения, представленного правым операндом);*=
(умножение левого операнда на значение, представленное правым операндом);/=
(деление левого операнда на значение, представленное правым операндом);&=
(поразрядное логическое И над левым и правым операндом);|=
(поразрядное логическое ИЛИ над левым и правым аргументом);^=
(поразрядное логическое исключающее ИЛИ над левым и правым аргументом);<<=
(поразрядный сдвиг влево левого аргумента на количество бит, заданное правым аргументом);>>=
(поразрядный сдвиг вправо левого аргумента на количество бит, заданное правым аргументом).
Данные операции предполагают, что левый операнд представляет собою лево-допустимое выражение.
Тернарные операции
В Си имеется единственная тернарная операция — условная операция, которая имеет следующий вид:
- [условие]
?
[выражение1]:
[выражение2];
и которая имеет три операнда:
- [условие] — логическое условие, которое проверяется на истинность,
- [выражение1] — выражение, значение которого возвращается в качестве результата выполнения операции, если условие истинно;
- [выражение2] — выражение, значение которого возвращается в качестве результата выполнения операции, если условие ложно.
Знаком операции здесь служит целое сочетание ? :
.
Выражения
Выражение — это упорядоченный набор операций над переменными. Выражения содержат операнды и знаки операций (см. выше). Порядок выполнения операций зависит от формы записи и от приоритета выполнения операций (см. ниже). У каждого выражения имеется значение — результат выполнения всех операций, входящих в выражение. С вычислением значения выражения может быть связан т. н. «побочный эффект», когда в ходе выполнения операций, меняется некоторая переменная. Другим возможным «побочным эффектом» является неявное преобразование типов.
Простейшее выражение содержит только имя переменной (её идентификатор). Значением такого выражения будет то значение (или тот объект), которое хранится в данной переменной.
Среди выражений выделяют класс лево-допустимых выражений — выражений, которые могут присутствовать слева от знака присваивания.
Приоритет выполнения операций
Операции в Си выполняются в соответствии следующей таблице приоритетов операций:
Лексемы | Операция | Класс | Приоритет | Ассоциативность |
---|---|---|---|---|
имена, литералы | простые лексемы | первичный | 16 | нет |
a[ индекс] |
индексы | постфиксный | 16 | слева направо |
f( аргументы) |
вызов функции | постфиксный | 16 | слева направо |
. |
прямой выбор | постфиксный | 16 | слева направо |
-> |
опосредованный выбор | постфиксный | 16 | слева направо |
++ -- |
положительное и отрицательное приращение | постфиксный | 16 | слева направо |
++ -- |
положительное и отрицательное приращение | префиксный | 15 | справа налево |
( имя типа) { инициализатор} |
составной литерал (C99) | постфиксный | 15 | справа налево |
sizeof |
размер | унарный | 15 | справа налево |
~ |
побитовое НЕ | унарный | 15 | справа налево |
! |
логическое НЕ | унарный | 15 | справа налево |
- + |
изменение знака, плюс | унарный | 15 | справа налево |
& |
адрес | унарный | 15 | справа налево |
* |
опосредование (разыменование) | унарный | 15 | справа налево |
( имя типа) |
приведение типа | унарный | 15 | справа налево |
* / % |
мультипликативные операции | бинарный | 13 | слева направо |
+ - |
аддитивные операции | бинарный | 12 | слева направо |
<< >> |
сдвиг влево и вправо | бинарный | 11 | слева направо |
< > <= >= |
отношения | бинарный | 10 | слева направо |
== != |
равенство/неравенство | бинарный | 9 | слева направо |
& |
побитовое И | бинарный | 8 | слева направо |
^ |
побитовое исключающее ИЛИ | бинарный | 7 | слева направо |
| |
побитовое ИЛИ | бинарный | 6 | слева направо |
&& |
логическое И | бинарный | 5 | слева направо |
|| |
логическое ИЛИ | бинарный | 4 | слева направо |
? : |
условие | тернарный | 3 | справа налево |
= += -= *= /= %= <<= >>= &= ^= |= |
присваивание | бинарный | 2 | справа налево |
, |
последовательное вычисление | бинарный | 1 | слева направо |
Операторы
Операторы предназначены для осуществления действий и для управления ходом выполнения программы.
Несколько идущих подряд операторов образуют последовательность операторов.
Пустой оператор
Самая простая языковая конструкция — это пустое выражение, называемое пустым оператором[9][10]:
;
Пустой оператор не совершает никаких действий и может находиться в любом месте программы.
Инструкции
Инструкция — это некое элементарное действие:
- (выражение)
;
Действие этого оператора заключается в выполнении указанного в теле оператора выражения.
Несколько идущих подряд инструкций образуют последовательность инструкций.
Блок вычислений
Инструкции могут быть сгруппированы в специальные вычислительные блоки следующего вида:
{
- (последовательность инструкций)
}
,
ограниченные при помощи двух разделителей:
- левая фигурная скобка (
{
) обозначает начало вычислительного блока, - правая фигурная скобка (
}
) обозначает конец вычислительного блока.
Вычислительный блок называют ещё составным оператором.
Каждая функция (см. ниже) содержит такой блок, который обозначает тело функции и является частью определения функции.
Условные операторы
В языке существует два условных оператора, реализующих ветвление программы:
- оператор
if
, содержащий проверку одного условия; - и оператор
switch
, содержащий проверку нескольких условий.
Самая простая форма оператора if
if(
(условие))
(оператор)- (следующий оператор)
Оператор if
работает следующим образом:
- если выполнено условие, указанное в скобках, то выполняется первый оператор, и затем выполняется оператор, указанный после оператора
if
. - если условие, указанное в скобках, не выполнено, то сразу выполняется оператор, указанный после оператора
if
.
В частности, следующий ниже код, в случае выполнения заданного условия, не будет выполнять никаких действий:
if(
(условие))
;
поскольку, фактически, выполняется пустой оператор.
Более сложная форма оператора if
содержит ключевое слово else
:
if(
(условие))
(оператор)else
(альтернативный оператор)- (следующий оператор)
Здесь, если условие, указанное в скобках, не выполнено, то выполняется оператор, указанный после ключевого слова else
.
Операторы выполнения цикла
Цикл — это фрагмент программного кода, содержащий
- условие выполнения цикла — условие, которое постоянно проверяется;
- и тело цикла — простой или составной оператор, выполнение которого зависит от условия цикла.
В соответствии с этим, различают два вида циклов:
- цикл с предусловием, где сначала проверяется условие выполнения цикла, и, если условие выполнено, то выполняется тело цикла;
- цикл с постусловием, где сначала выполняется тело цикла, а уже потом проверяется условие выполнения цикла, и, если условие выполнено, то…;
Тело цикла всегда выполняются до тех пор, пока выполняется указанное в цикле условие, таким образом цикл с постусловием гарантирует, что тело цикла обязательно выполнится хотя бы один раз.
В Си предусмотрен оператор цикла с предусловием:
while(
условие)
[тело цикла]
и оператор с постусловием do-while
:
do
[тело цикла]while(
условие)
Также имеется оператор
for(
блок инициализации;
условие;
оператор)
[тело цикла],
который эквивалентен следующему блоку операторов:
- [блок инициализации]
while(
условие)
{
- [тело цикла]
- [оператор]
}
В обычной ситуации блок инициализации содержит задание начального значения переменной, которая называется переменной цикла, а оператор, который выполняется сразу после тела цикла, меняет значения используемой переменной, а условие содержит сравнение значения используемой переменной цикла с некоторым заранее заданным значением, и, как только сравнение перестаёт выполняться, цикл прерывается, и начинает выполняться программный код, следующий сразу за оператором цикла.
Условие цикла — это логическое выражение. Однако неявное приведение типов позволяет использовать в качестве условия цикла арифметическое выражение. Это позволяет организовать т. н. «бесконечный цикл»:
while(1);
или то же самое, но уже с применением оператора for
:
for(;;);
В действительности, такие бесконечные циклы используются совместно с операторами прерывания работы цикла (см. ниже).
Операторы безусловного перехода
Операторы безусловного перехода позволяют прервать выполнение любого блока вычислений и перейти в другое место программы. Операторы безусловного перехода обычно используются совместно с условными операторами.
Оператор
goto
[метка],
где [метка] — это некоторый (числовой) идентификатор, передаёт управление тому оператору, который помечен в программе указанной меткой:
- [метка]
:
[оператор]
Если указанная метка отсутствует в программе, или, если существует несколько операторов с одной и той же меткой, компилятор сообщает об ошибке.
Передача управления возможна только в пределах той функции, где используется оператор перехода, следовательно, при помощи оператора goto
нельзя передать управление в другую функцию.
Другие операторы перехода связаны с циклами и позволяют прервать выполнения тела цикла:
- оператор
break
немедленно прерывает выполнение тела цикла, и происходит передача управления на оператор, следующий непосредственно сразу за оператором цикла; - оператор
continue
прерывает выполнение тела цикла и передаёт управление в начало цикла, что инициирует проверку условия цикла.
Оператор continue
может быть использован только внутри операторов do
, while
и for
; оператор break
также может использоваться внутри оператора switch
.
Существует два особых случая применения операторов break
и continue
:
- если оператор
continue
встретился в теле оператораfor
, то сначала происходит выполнение оператора, а уже затем происходит проверка условия цикла, таким образом операторcontinue
предназначен для немедленного перехода к следующей итерации выполнения цикла; - если имеется несколько вложенных циклов, то оператор
break
, в зависимости от реализации, либо полностью прерывает выполнение всех вложенных циклов, либо прерывает выполнение только того цикла, в котором расположен сам операторbreak
.
Оператор возврата из функции
В Си определён оператор return
, который прерывает выполнение функции, где использован данный оператор. Если функция не должна возвращать значение, то используется вызов
return;
Если функция должна возвращать какое-либо значение, то использует вызов
return
[значение];
Если после оператора возврата в теле функции имеются ещё какие-то операторы, то эти операторы никогда не будут выполняться, и в этом случае компилятор может выдать предупреждение.
Функции
Функция — это самостоятельный фрагмент программного кода, который может многократно использоваться в программе. Функции могут иметь аргументы и могут возвращать значения.
Для того, чтобы задать функцию в Си, необходимо её объявить:
- сообщить имя (идентификатор) функции,
- перечислить входные параметры (аргументы)
- и указать тип возвращаемого значения,
Также необходимо привести определение функции, которое содержит блок операторов, реализующих поведение функции.
Отсутствие определения ранее определённой функции является ошибкой, что, в зависимости от реализации, приводит к выдаче сообщений или предупреждений.
Когда компилятор встречает в программном коде идентификатор функции, то он оформляет операцию вызова функции, в рамках которой, в частности, адрес точки вызова помещается в стек, создаются и инициализируются переменные, отвечающие за параметры функции, и передаётся управление коду, реализующему вызываемую функцию. После выполнения функции происходит освобождение памяти, выделенной при вызове функции, возврат в точку вызова и, если вызов функции является частью некоторого выражения, передача в точку возврата вычисленного внутри функции значения.
Особый класс функций представляют встраиваемые (или подставляемые) функции — функции, объявленные с указанием ключевого слова inline
. Определения таких функций непосредственно подставляются в точку вызова, что, с одной стороны, увеличивает объём исполняемого кода, но, с другой стороны, позволяет экономить время его выполнения, поскольку не используется дорогая по времени операция вызова функции.
Объявление функции
Объявление функции имеет следующий формат:
- [описатель] [имя]
(
[список]);
,
где
- [описатель] — описатель типа возвращаемого функцией значения;
- [имя] — имя функции (уникальный идентификатор функции);
- [список] — список (формальных) параметров функции.
Признаком объявления функции является символ «;
», таким образом, объявление функции — это инструкция.
В самом простом случае [описатель] содержит указание на конкретный тип возвращаемого значения. Функция, которая не должна возвращать никакого значения, объявляется как имеющая тип
void
При необходимости в описателе могут присутствовать дополнительные элементы:
- модификатор
extern
указывает на то, что определение функции находится в другом модуле; - модификатор
static
задаёт статическую функцию; - модификаторы
pascal
илиcdecl
влияют на обработку формальных параметров и связаны с подключением внешних модулей.
Список параметров функции задаёт сигнатуру функции.
Си не допускает объявление нескольких функций, имеющих одно и то же имя, перегрузка функций не поддерживается.[11]
Определение функции
Определение функции имеет следующий формат:
- [описатель] [имя]
(
[список])
[тело]
Где [описатель], [имя] и [список] — те же, что и в объявлении, а [тело] — это составной оператор, который представляет собою конкретную реализацию функции. Компилятор различает определения одноимённых функций по их сигнатуре, и таким образом (по сигнатуре) устанавливается связь между определением и соответствующим ему объявлением.
Тело функции имеет следующий вид:
{
- [последовательность операторов]
return
([возвращаемое значение]);
}
Вызов функции
Вызов функции заключается в выполнении следующих действий:
- сохранение точки вызова в стеке;
- выделение памяти под переменные, соответствующие формальным параметрам функции;
- инициализация переменных значениями переменных (фактических параметров функции), переданных в функцию при её вызове, а также инициализация тех переменных, для которых в объявлении функции указаны значения по умолчанию, но для которых при вызове не были указаны соответствующие им фактические параметры;
- передача управления в тело функции.
В зависимости от реализации, компилятор либо строго следит за тем, чтобы тип фактического параметра совпадал с типом формального параметра, либо, если существует такая возможность, осуществляет неявное преобразование типа, что, очевидно, приводит к побочным эффектам.
Если в функцию передаётся переменная, то при вызове функции создаётся её копия (в стеке выделяется память и копируется значение). Например, передача структуры в функцию вызовет копирование всей структуры целиком. Если же передаётся указатель на структуру, то копируется только значение указателя. Передача в функцию массива также вызывает лишь копирование указателя на его первый элемент. При этом для явного обозначения того, что на вход функции принимается адрес начала массива, а не указатель на единичную переменную, вместо объявления указателя после названия переменной можно поставить квадратные скобки, например:
void example_func(int array[]); // array — указатель на первый элемент массива типа int
Си допускает вложенные вызовы. Глубина вложенности вызовов имеет очевидное ограничение, связанное с размером выделяемого программе стека. Поэтому в реализациях Си устанавливается некое предельное значение для глубины вложенности.
Частный случай вложенного вызова — это вызов функции внутри тела вызываемой функции. Такой вызов называется рекурсивным, и применяется для организации единообразных вычислений. Учитывая естественное ограничение на вложенные вызовы, рекурсивную реализацию заменяют на реализацию при помощи циклов.
Возврат из функции
При возврате из функции освобождается память, выделенная под параметры функции и под переменные, объявленные внутри функции, и управление возвращается в точку вызова.
Типы данных
Примитивные типы
Целые числа
Размер целочисленных типов данных варьируется от не менее 8 до не менее 32 бит. Стандарт C99 увеличивает максимальный размер целого числа — не менее 64 бит. Целочисленные типы данных используются для хранения целых чисел (тип char
также используется для хранения ASCII-символов). Все размеры диапазонов представленных ниже типов данных минимальны и на отдельно взятой платформе могут быть больше.[12]
Стандарт требует, чтобы для размеров целочисленых типов выполнялось условие[13]:
sizeof(char)
≤ sizeof(short)
≤ sizeof(int)
≤ sizeof(long)
≤ sizeof(long long)
.
Таким образом, размеры некоторых типов по количеству байт могут совпадать, если будет удовлетворяться условие по минимальному количеству бит. Даже char
и long
могут иметь одинаковый размер, если один байт будет занимать 32 бита или более, но такие платформы будут очень редки или не будут существовать. Стандарт гарантирует, что тип char
всегда равен 1 байту. Размер байта в битах определяется константой CHAR_BIT
из заголовочного файла limits.h
, у POSIX-совместимых систем равен 8 битам[14].
Минимальный диапазон значений целых типов по стандарту определяется с -(2N-1-1)
по 2N-1-1
для знаковых типов и с 0
по 2N
— для беззнаковых, где N — разрядность типа. Реализация компиляторов может расширять этот диапазон по своему усмотрению. На практике для знаковых типов чаще используется диапазон с -2N-1
по 2N-1-1
. Минимальное и максимальное значения каждого типа указывается в файле limits.h в виде макроопределений.
Отдельное внимание стоит уделить типу char
. Формально это отдельный тип, но фактически char
эквивалентен либо signed char
, либо unsigned char
, в зависимости от компилятора.
Для того, чтобы избежать путаницы между размерами типов стандарт 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[15] (= -(27−1)) до 127 | K&R C |
unsigned char
|
минимум 8 бит | от 0 до 255 (=28−1) | K&R C |
char
|
минимум 8 бит | от −127 до 127 или от 0 до 255 в зависимости от компилятора | K&R C |
short int
|
минимум 16 бит | от −32,767 (= -(215−1)) до 32,767 | K&R C |
unsigned short int
|
минимум 16 бит | от 0 до 65,535 (= 216−1) | K&R C |
int
|
минимум 16 бит | от −32,767 до 32,767 | K&R C |
unsigned int
|
минимум 16 бит | от 0 до 65,535 (= 216−1) | K&R C |
long int
|
минимум 32 бита | от −2,147,483,647 до 2,147,483,647 | K&R C |
unsigned long int
|
минимум 32 бита | от 0 до 4,294,967,295 (= 232−1) | K&R C |
long long int
|
минимум 64 бита | от −9,223,372,036,854,775,807 до 9,223,372,036,854,775,807 | C99 |
unsigned long long int
|
минимум 64 бита | от 0 до 18,446,744,073,709,551,615 (= 264−1) | C99 |
int8_t
|
8 бит | от −127 до 127 | C99 |
uint8_t
|
8 бит | от 0 до 255 (=28−1) | C99 |
int16_t
|
16 бит | от −32,767 до 32,767 | C99 |
uint16_t
|
16 бит | от 0 до 65,535 (= 216−1) | C99 |
int32_t
|
32 бита | от −2,147,483,647 до 2,147,483,647 | C99 |
uint32_t
|
32 бита | от 0 до 4,294,967,295 (= 232−1) | C99 |
int64_t
|
64 бита | от −9,223,372,036,854,775,807 до 9,223,372,036,854,775,807 | C99 |
uint64_t
|
64 бита | от 0 до 18,446,744,073,709,551,615 (= 264−1) | C99 |
Типы int_leastN_t , uint_leastN_t , int_fastN_t и uint_fastN_t (N = 8, 16, 32 или 64), введенные стандартом C99,
размером и диапазоном совпадают с соответствующими типами В таблице приведён минимальный диапазон значений согласно стандарту языка. Компиляторы языка Си могут расширять диапазон значений. |
Также со стандарта C99 добавлены типы intmax_t
и uintmax_t
, соответствующие самым большым знаковому и беззнаковому типам соответственно. Данные типы удобны при использовании в макросах для хранения промежуточных или временных значений при операциях над целочисленными аргументами, т. к. позволяют уместить значения любого типа. Например, эти типы используются в макросах сравнения целочисленных значений библиотеки модульного тестирования Check для языка Си.
В Си существует несколько дополнительных целочисленных типов для безопасной работы с типом данных указателей: intptr_t
, uintptr_t
и diffptr_t
. Типы intptr_t
и uintptr_t
из стандарта C99 предназначены для хранения соответственно знакового и беззнакового значений, которые по размеру могут уместить в себе указатель. Эти типы часто применяются для хранения произвольного целого числа в указателе, например, как способ избавиться от лишнего выделения памяти при регистрации функций обратной связи[16] либо при использовании сторонних связных список, ассоциативных массивов и прочих структур, в которых данные хранятся по указателю. Тип diffptr_t
предназначен для безопасного хранения разности двух указателей.
Для хранения размера предусмотрен беззнаковый тип size_t
из заголовочного файла stddef.h
. Данный тип способен уместить максимально возможное количество байт, доступных по указателю, и обычно используется для хранения размера в байтах. Значение именно этого типа возвращает оператор sizeof
.
Вещественные числа
Числа с плавающей запятой в языке Си представлены тремя основными типами: float
, double
и long double
. Также в заголовочном файле math.h
присутствуют два дополнительных типа float_t
и double_t
, которые соответствуют как минимум типам float
и double
соответственно, но могут быть отличными от них. Типы float_t
и double_t
добавлены в стандарте C99, а их соответствие основным типам определяется значением макроса FLT_EVAL_METHOD
.
Тип данных | Размер | Стандарт |
---|---|---|
float
|
32 бита | IEEE 754, число одинарной точности |
double
|
64 бита | IEEE 754, число двойной точности |
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-строками, рассматривают каждый символ как байт, что ограничивает применение стандартных функций при использовании данной кодировки.
Несмотря на широкое распространение идеи нуль-терминированных строк и удобство их использования в некоторых алгоритмах, у них есть несколько серьёзных недостатков.
- Необходимость добавления в конец строки терминального символа не даёт возможность получить подстроку без необходимости её копирования, а функций для работы с указателем на подстроку и её длиной в языке не предусмотрено.
- Если требуется заранее выделять память под результат алгоритма на основе входных данных, каждый раз требуется обходить всю строку для подсчёта её длины.
- При работе с большими объёмами текста подсчёт длины может оказаться узким местом.
- Работа со строкой, которая по ошибке не терминирована нулём, может приводить к неопределённому поведению программы, в том числе к ошибкам сегментирования, ошибкам переполнения буфера и к уязвимостям.
В современных условиях, когда производительность кода приоритетнее расхода памяти, может оказаться эффективнее и проще использовать структуры, содержащие в себе как саму строку, так и её размер
, например:struct string_t {
char *str; // указатель на строку
size_t str_size; // размер строки
};
typedef struct string_t string_t; // альтернативное имя для упрощения кода
Альтернативным вариантом хранения размера строки с низким потреблением памяти может оказаться подход добавления в начало строки её размера в формате размера переменной длины[англ.]. Подобный подход применяется в протокольных буферах, однако только на этапе передачи данных, но не их хранения.
Строковые литералы
Строковые литералы в Си по своей сути являются константами. При объявлении заключаются в двойные кавычки, а терминирующий 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-строке требуется не добавлять терминирующий ноль.
Широкие строки
Этот раздел не завершён. |
Альтернативой обычным строкам могут служить широкие строки, в которых каждый символ хранится в специальном типе wchar_t
. Данный тип по стандарту должен быть способен уместить в себе все символы самой большой из существующих локалей. Функции для работы с широкими строками описаны в заголовочном файле wchar.h
, а функции для работы с широкими символами описаны в заголовочном файле wctype.h
.
При объявлении строковых литералов для широких строк используется модификатор L
:
const wchar_t *wide_str = L"Широкая строка";
Тип wchar_t
задумывался для того, чтобы в него мог поместиться любой символ, а широкие строки — для хранения строк любой локали, но в результате API оказался неудобным, а реализации — платформозависимыми[17].
Многобайтовые строки
Этот раздел не завершён. |
Существует много разных кодировок, в которых отдельный символ может быть запрограммирован разным количеством байт. Такие кодировки называются многобайтовыми. К ним относится также и UTF-8. В Си существует набор функций для преобразования строк из многобайтовых в рамках текущей локали в широкие и наоборот. Функции для работы с многобайтовыми символами имеют префикс либо суффикс mb
и описаны в заголовочном файле stdlib.h
. Для поддержки многобайтовых строк в программах на языке Си, такие строки должны поддерживаться на уровне текущей локали.
Начиная со стандарта 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[18]. Исправление работы данной функции может зависеть от конкретной реализации компилятора.
Пользовательские типы
Перечисления (enum)
Перечисления представляют собой набор именованных целочисленных констант и обозначаются с помощью ключевого слова enum
. Если константе не сопоставлено число, то ей автоматически задаётся либо 0
для первой константы в списке, либо число на единицу бо́льшее, чем задано в предыдущей константе. При этом сам тип данных перечисления по факту может соответствовать любому знаковому или беззнаковому примитивному типу, в диапазон которого умещаются все значения перечислений; решение о выборе того или иного типа принимает компилятор. Однако явно заданные значения для констант должны быть выражениями типа int
[4].
Тип перечисления может быть также анонимным, если не указано название перечисления. Следует отметить, что константы, указанные в двух разных перечислениях, относятся к двум разным типам данных, независимо от того, являются ли перечисления именованными или анонимными.
На практике перечисления часто используются для обозначения состояний конечных автоматов, для задания вариантов режимов работы или значений параметров[19], для создания целочисленных констант, а также для перечисления каких-либо уникальных объектов или свойств[20].
Структуры (struct)
Структуры представляют из себя объединение переменных разных типов данных в рамках одной области памяти; обозначаются ключевым словом struct
. Переменные внутри структуры называются полями структуры. С точки зрения адресного пространства поля всегда идут друг за другом в том же порядке, в котором указаны, но компиляторы могут выравнивать адреса полей для оптимизации под ту или иную архитектуру. Таким образом, фактически поле может занимать бо́льший размер, чем указано в программе.
Каждое поле имеет определённое смещение относительно адреса структуры и размер. Смещение можно получить с помощью макроса offsetof()
из заголовочного файла stddef.h
. При этом смещение будет зависеть от выравнивания и размера предыдущих полей. Размер поля обычно определяется выравниванием структуры: если размер выравнивания типа данных поля меньше значения выравнивания структуры, то размер поля определяется выравниванием структуры. Выравнивание типов данных можно получить с помощью макроса alignof()
из заголовочного файла stdalign.h
. Размер самой структуры является совокупным размером всех её полей с учётом выравнивания. При этом некоторые компиляторы предоставляют специальные атрибуты, позволяющие упаковывать структуры, убирая из них выравнивания[21].
Полям структур можно явно задавать размер в битах через двоеточие после определения поля и указание количества бит, что ограничивает диапазон их возможных значений, несмотря на тип поля. Подобный подход может использоваться как альтернатива флагам и битовым маскам для обращения к ним. Однако указание количества бит не отменяет возможного выравнивания полей структур в памяти. Работа с битовыми полями имеет ряд ограничений: к ним невозможно применить оператор sizeof
или макрос alignof()
, на них невозможно получить указатель.
Объединения (union)
Объединения необходимы в тех случаях, когда требуется обращаться к одной и той же переменной как к разным типам данных; обозначаются ключевым словом union
. Внутри объединения может быть объявлено произвольное количество пересекающихся полей, которые по факту предоставляют доступ к одной и той же области памяти как к разным типам данных. Размер объединения выбирается компилятором исходя из размера самого большого поля в объединении. Следует иметь в виду, что изменение одного поля объединения приводит к изменению и всех других полей, но гарантированно правильным будет только значение того поля, которое менялось.
Объединения могут служить более удобной альтернативной приведению указателя к произвольному типу. К примеру, с помощью объединения, помещённого в структуру, можно создавать объекты с динамически меняющимся типом данных:
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; // содержимое значения
};
Массивы
Этот раздел не завершён. |
Массивы в языке Си примитивны и являются лишь синтаксическим абстрагированием над арифметикой указателей. Сам по себе массив является указателем на область памяти, поэтому вся информация о размерности массива и его границах может быть доступна только на этапе компиляции согласно описанию типа. Массивы могут быть как одномерными, так и многомерными, но обращение к элементу массива сводится к простому вычислению смещения относительно адреса начала массива. Поскольку массивы основаны на адресной арифметике, с ними возможно работать без использования индексов. Так, например, следующие два примера считывания с потока ввода 10 чисел идентичны друг другу:
Код примера работы через индексы | Код примера работы через адресную арифметику |
---|---|
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;
}
}
|
int a[10] = {0}; // Инициализация нулями
unsigned int count = sizeof(a) / sizeof(a[0]);
int *a_end = a + count; // Указатель на элемент, следующий за последним
for (int *ptr = a; a != a_end; ++a) {
int n = scanf("%8d", ptr);
if (n != 1) {
perror("Не удалось считать значение");
// Обработка ошибки
break;
}
}
|
Язык Си не предусматривает какого-либо контроля выхода за пределы массива, поэтому программист сам должен следить за работой с массивами. Ошибки при обработке массивов не всегда явно влияют на ход исполнения программы, но могут приводить к ошибкам сегментирования и уязвимостям .
Определение альтернативных названий
Язык Си допускает создание собственных названий типов с помощью оператора 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
могут встречаться внутри функций или определений типов, а директивы условной компиляции могут приводить к исключению из компилируемого текста программы любой части кода, без оглядки на грамматику языка.
Макроопределения часто используются для обеспечения совместимости с разными версиями библиотек, у которых изменился API, включая те или иные участки кода в зависимости от версии библиотеки. Для этих целей библиотеки часто предоставляют макроопределения с описанием своей версии[22], а иногда и макросы с параметрами для сравнения текущей версии с заданной в рамках препроцессора[23]. Также макроопределения применяются для условной компиляции отдельных кусков программы, например для включения поддержки какого-либо дополнительного функционала.
Макроопределения с параметрами широко используются в Си-программах для создания аналогов обобщённых функций. Ранее они также применялись для реализации встраиваемых функций, но начиная со стандарта С99 эта необходимость исчезла благодаря добавлению inline
-функций. Однако в связи с тем, что макроопределения с параметрами функциями не являются, но вызываются аналогичным образом, по ошибке программиста могут возникать неожиданные проблемы, включая отработку только части кода из макроопределения[24] и неправильные приоритеты выполнения операций[25]. В качестве примера ошибочного кода можно привести макрос возведения числа в квадрат:
#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
.
Файлы исходного кода
Текст файла исходного кода на языке Си состоит из набора глобальных определений данных, типов и функций. Глобальные переменные и функции, объявленные со спецификаторами static
и inline
, доступны только в пределах того файла, в котором они объявлены, либо при включении одного файла в другой через директиву #include
. При этом функции и переменные, объявленные в заголовочном файле со словом static
, будут создаваться заново при каждом подключении заголовочного файла к очередному файлу с исходным кодом. Глобальные переменные и прототипы функции, объявленные со спецификатором extern, считаются подключаемыми из других файлов. То есть их допускается использовать в соответствии с описанием; предполагается, что после сборки программы они будут связаны компоновщиком с оригинальными объектами и функциями, описанными в своих файлах.
Глобальные переменные и функции, кроме static
и inline
, могут быть доступны из других файлов, при условии их надлежащего объявления там со спецификатором extern. Переменные и функции, объявленные с модификатором static также могут быть доступны в других файлах, но лишь при передаче их адреса по указателю. Объявления типов typedef, struct и union не могут импортироваться в других файлах. При необходимости использования в других файлах они должны быть там продублированы, либо вынесены в отдельный заголовочный файл. То же самое относится и к inline-функциям.
Точка входа программы
Для исполняемой программы стандартной точкой входа является функция с именем main
, которая не может быть статической и должна быть единственной в программе. Исполнение программы начинается с первого оператора функции main()
и продолжается до выхода из неё, после чего программа завершается и и возвращает операционной системе абстрактный целочисленный код результата своей работы. Допустимые прототипы функции выглядят следующим образом[4]:
Без аргументов | С аргументами командной строки |
---|---|
int main(void);
|
int main(int argc, char** argv);
|
В переменную argc
при вызове передаётся количество аргументов, переданных программе, включая и путь к самой программе, поэтому обычно переменная argc содержит значение не меньшее, чем 1. В переменную argv
передаётся сама строка запуска программы в виде массива текстовых строк, последним элементом которого является NULL
. Компилятор гарантирует, что на момент запуска функции main()
все глобальные переменные в программе будут инициализированы.
В качестве результата функция main()
может вернуть любое целое число в диапазоне значений типа int
, которое будет передано операционной системе в качестве кода возврата программы. Стандарт языка не определяет смысла кодов возврата. Обычно операционная система, где работают программы, имеет те или иные средства, позволяющие получить значение кода возврата и проанализировать его. Иногда существуют определённые соглашения о значениях этих кодов. Общим является соглашение о том, что нулевое значение кода возврата сигнализирует об успешном завершении программы, а ненулевое представляет собой код возникшей ошибки. Заголовочный файл stdlib.h
определяет два общих макроопределения EXIT_SUCCESS
и EXIT_FAILURE
, которые соответствуют успешному и неуспешному завершению работы программы. Коды возврата также могут использоваться в рамках приложений, включающих в себя множество процессов, для обеспечения взаимодействия между этими процессами, в случае чего приложение само определяет смысловое значение для каждого кода возврата.
Работа с памятью
Хранение данных
Этот раздел требует существенной доработки. |
Одной из самых важных функций любого языка программирования является предоставление возможностей для управления памятью и объектами, хранящимися в ней.
В Си есть три разных способа выделения памяти (классы памяти) для объектов:
- Статическое выделение памяти: пространство для объектов создаётся в сегменте данных программы в момент компиляции; время жизни таких объектов совпадает со временем жизни этого кода. Изменение таких объектов ведёт к так называемому в стандарте «неопределённому поведению» (англ. undefined behaviour). На практике эта операция может приводить к ошибке во время выполнения.
- Автоматическое выделение памяти: объекты можно хранить в стеке; эта память затем автоматически освобождается и может быть использована снова, после того, как программа выходит из блока, использующего его.
- Динамическое выделение памяти: блоки памяти нужного размера могут запрашиваться во время выполнения программы с помощью библиотечных функций
malloc()
,realloc()
,calloc()
из области памяти, называемой кучей. Эти блоки освобождаются и могут быть использованы снова после вызова для них функцииfree()
.
Все три этих способа хранения данных пригодны в различных ситуациях и имеют свои преимущества и недостатки. Например, статическое выделение памяти не имеет накладных расходов по выделению, автоматическое выделение — лишь малые расходы при выделении, а вот динамическое выделение потенциально требует больших расходов и на выделение, и на освобождение памяти. С другой стороны, память стека гораздо больше ограничена, чем статическая или память в куче. Только динамическая память может использоваться в случаях, когда размер используемых объектов заранее неизвестен. Большинство программ на Си интенсивно используют все три этих способа.
Там, где это возможно, предпочтительным является автоматическое или статическое выделение памяти: такой способ хранения объектов управляется компилятором, что освобождает программиста от трудностей ручного выделения и освобождения памяти, как правило, служащего источником трудно отыскиваемых ошибок утечек памяти и повторного освобождения в программе. К сожалению, многие структуры данных имеют переменный размер во время выполнения программы, поэтому из-за того, что автоматически и статически выделенные области должны иметь известный фиксированный размер во время компиляции, очень часто требуется использовать динамическое выделение. Массивы переменного размера — самый распространённый пример такого использования памяти.
Адресация памяти
Язык Си унаследовал линейную адресацию памяти при работе со структурами, массивами и выделенными областями памяти. Стандарт языка также допускает выполнение операций сравнения над нулевым указателем и над адресами в рамках массивов, структур и выделенных областей памяти. Также допускается работа с адресом элемента массива, следующим за последним, что сделано для облегчения написания алгоритмов. Однако сравнение указателей адресов, полученных для разных переменных (или областей памяти) не должно осуществляться, т. к. результат будет зависеть от реализации конкретного компилятора[26].
Представление в памяти
В данном разделе рассмотрено представление в памяти программы в операционной системе GNU/Linux и архитектуре amd64. Порядок расположения кучи, стека и других областей может отличаться в других архитектурах и операционных системах.
При запуске программы из исполняемого файла в оперативную память импортируются инструкции процессора (машинный код) и инициализированные данные. В то же время в старшие адреса импортируются аргументы командной строки (доступные в функции main()
со следующей сигнатурой во втором аргументе int argc, char ** argv
) и переменные окружения.
В области инициализированных данных хранятся неизменяемые данные. Это могут быть, например, строковые литералы.
Глобальные переменные хранятся в области неинициализированных данных. В процессе запуска эта область изначально инициализируется нулевыми значениями.
Память, выделенная динамически, предоставляется из кучи (heap). Для выделения памяти во время работы программы используется функция malloc()
из stdlib.h
. Для освобождения функция free()
.
Область стека предназначена для размещения вызовов функций. Каждый раз, как вызывается какая-либо функция, стек увеличивается. После того как функция завершила свою работу стек уменьшается. Функции объявленные с квалификатором inline
могут не использовать стек, а подставляться в код (компилятор волен игнорировать inline
).
Также немаловажной деталью является наличие случайного отступа между стеком и верхней областью, а также между областью инициализированных данных и кучей. Делается это в целях безопасности, например, для предотвращения встраивания в стек других функций.
Динамически подключаемые библиотеки и отображения файлов с файловой системы находится между стеком и кучей.
Также стоит учитывать, что переменные с квалификатором register
скорее всего будут размещены прямо в памяти процессора (регистрах). Тем не менее количество регистров ограничено, поэтому компилятор может игнорировать такую рекомендацию. Для таких переменных отсутствует операция взятия адреса.
Обработка ошибок
В Си отсутствуют какие-либо встроенные механизмы контроля ошибок, но существует несколько общепринятых способов их обработки средствами языка. В общем виде практика обработки ошибок языка Си в отказоустойчивом коде вынуждает писать громоздкие, часто повторяющиеся конструкции, в которых алгоритм совмещён с обработкой ошибок
.Маркеры ошибок и errno
В стандартной библиотеке активно используется специальная переменная errno
из заголовочного файла errno.h
, в которую функции заносят код ошибки, возвращая при этом значение, являющееся маркером ошибки. Для проверки результата на ошибки результат сравнивают с маркером ошибки, и, если они совпадают, то можно проанализировать код ошибки, сохранённый в errno
, для корректировки работы программы или вывода отладочного сообщения. В качестве маркеров ошибок обычно выступают следующие значения:
-1
для типаint
в случаях, когда отрицательный диапазон результата не используется;-1
для типаssize_t
;(size_t) -1
для типаsize_t
;NULL
для указателей;- ненулевой код ошибки.
Практика возвращения маркера ошибки, вместо кода ошибки, хоть и экономит количество передаваемых в функции аргументов, но в ряде случаев приводит к ошибкам в результате человеческого фактора. Например, программистами часто игнорируется проверка результата типа ssize_t
, а сам результат используется дальше в вычислениях, что приводит к трудно уловимым ошибкам, если возвращается -1
.
Ещё сильнее способствует появлению ошибок возврат в качестве маркера ошибки корректного значения[27], что также вынуждает программиста делать больше проверок, а соответственно и писать больше однотипного повторяющегося кода. Такой подход практикуется в потоковых функциях, работающих с объектами типа FILE *
: маркером ошибки является значение EOF
, одновременно являясь и маркером конца файла. Поэтому по EOF
иногда приходится проверять поток символов как на конец файла с помощью функции feof()
, так и наличие ошибки с помощью ferror()
[28]. При этом некоторые функции, которые могут вернуть EOF
по стандарту не обязаны выставлять errno
.
Отсутствие единой практики обработки ошибок в стандартной библиотеке приводит к появлению собственных способов обработки ошибок и комбинированию часто используемых способов в сторонних проектах. Например, в проекте systemd совместили идеи возвращения кода ошибки и числа -1
в качестве маркера — возвращается отрицательный код ошибки[29]. А в библиотеке GLib ввели в практику возвращение в качестве маркера ошибки значение булева типа, в то время как подробная информация об ошибке помещается в специальную структуру, указатель на которую возвращается через последний аргумент функции[30]. Схожее решение использует проект Enlightenment, в котором в качестве маркера тоже используется булев тип, но информация об ошибке возвращается по аналогии со стандартной библиотекой — через отдельную функцию[31], которую необходимо проверять, если был возвращён маркер.
Возврат кода ошибки
Альтернативой маркерам ошибок является возвращение кода ошибки напрямую, а результата работы функции — через аргументы по указателю. По такому пути пошли разработчики стандарта POSIX, в функциях которого принято возвращать код ошибки в виде числа типа int
. Однако возвращение значения типа int
явно не даёт понять, что возвращается именно код ошибки, а не маркер, что может вести к ошибкам, если результат таких функций будет проверяться на значение -1
. В расширении K стандарта C11 представлен специальный тип errno_t
для хранения кода ошибки. Существуют рекомендации использовать именно этот тип в пользовательском коде для возвращения ошибок, а если он не предоставлен стандартной библиотекой, то объявлять его самостоятельно[32]:
#ifndef __STDC_LIB_EXT1__
typedef int errno_t;
#endif
Такой подход, помимо повышения качества кода, избавляет от необходимости использования errno
, что позволяет делать библиотеки с реентерабельными функциями без необходимости подключения дополнительных библиотек, таких как POSIX Threads для правильного определения errno
.
Ошибки в математических функциях
Более сложной является обработка ошибок в математических функциях из заголовочного файла math.h
, в которых могут возникать 3 типа ошибок[33]:
- выход за пределы диапазона входных значений;
- получение бесконечного результата для конечных входных данных;
- выход результата за пределы диапазона используемого типа данных.
Предотвращение двух из трёх типов ошибок сводится к проверкам входных данных на область допустимых значений. Однако предсказать выход результата за пределы типа крайне сложно. Поэтому стандартом языка предусмотрена возможность анализа математических функций на ошибки. Начиная со стандарта C99 такой анализ возможен двумя способами, в зависимости от значения, хранимого в макросе math_errhandling
.
- Если выставлен бит
MATH_ERRNO
, то переменнуюerrno
необходимо предварительно сбросить в0
, а после вызова математической функции — проверить на ошибкиEDOM
иERANGE
. - Если выставлен бит
MATH_ERREXCEPT
, то возможные математические ошибки предварительно сбрасываются функциейfeclearexcept()
из заголовочного файлаfenv.h
, а после вызова математической функции —тестируются с помощью функцииfetestexcept()
.
При этом способ обработки ошибок определяется конкретной реализацией стандартной библиотеки и может отсутствовать совсем. Поэтому в платформонезависимом коде может потребоваться проверка результата сразу двумя способами, в замисимости от значения math_errhandling
[33].
Освобождение ресурсов
Как правило возникновение ошибки требует завершения работы функции с возвращением индикатора ошибки. Если в функции ошибка может возникнуть в разных её частях, требуется освобождать ресурсы, выделенные в ходе её работы, чтобы предотвратить утечки. Хорошей практикой освобождения ресурсов считается их чистка в обратном порядке перед возвратом из функции, а в случае ошибок — освобождение в обратном порядке после основного return
. В отдельные части части такого освобождения можно сделать переход с помощью оператора goto
[34]. Подобный подход позволяет вынести не связанные с реализуемым алгоритмом участки кода за пределы самого алгоритма, повышая читабельность кода, и схож с работой оператора defer
из языка программирования Go. Пример освобождения ресурсов приведён ниже, в разделе примеров .
Для освобождения ресурсов в рамках программы предусмотрен механизм обработчиков выхода из программы. Обработчики назначаются с помощью функции atexit()
и исполняются как по завершению функции main()
через оператор return
, так и по исполнению функции exit()
. При этом обработчики не исполняются по функциям abort()
и _Exit()
[35].
В качестве примера освобождения ресурсов по завершению программы можно привести освобождение памяти, выделенной под глобальные переменные. Несмотря на то, что память так или иначе освобождается по завершению работы программы операционной системой, и допускается не освобождать ту память, которая требуется на протяжении всей работы программы[36], явное освобождение предпочтительнее, т. к. облегчает поиск утечек памяти сторонними средствами и уменьшает шанс на возникновение утечек памяти в результате ошибки:
#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()
. При этом подразумевается, что программа успешно завершилась[15].
Hello, world!
Программа Hello, world! приведена ещё в первом издании книги «Язык программирования Си» Кернигана и Ритчи:
#include <stdio.h>
int main(void) // Не принимает аргументы
{
printf("Hello, world!\n"); // '\n' - новая строка
return 0; // Удачное завершение программы
}
Эта программа печатает сообщение «Hello, world!» на стандартном устройстве вывода.
Обработка ошибок на примере чтения файла
Многие функции языка Си могут вернуть ошибку, не выполнив требуемых от них действий. Ошибки требуется проверять и правильно на них реагировать, в том числе часто требуется пробрасывать ошибку из функции на уровень выше для анализа. При этом функцию, в которой произошла ошибка, можно делать реентерабельной, в таком случае по ошибке функция не должна изменять входные или выходные данные, что позволяет безопасно перезапускать её после исправления ошибочной ситуации.
В примере реализована функция чтения файла на языке Си, однако она требует соответствия функций fopen()
и fread()
стандарту POSIX, иначе они могут не выставлять переменную errno
, что сильно усложняет как отладку, так и написание универсального и безопасного кода. На платформах, не соответствующих POSIX, поведение данной программы будет неопределённым в случае ошибки .
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
// Определяем тип для хранения кода ошибки, если он не определён
#ifndef __STDC_LIB_EXT1__
typedef int errno_t;
#endif
// Функция чтения содержимого файла
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 0; // 0 - успешное исполнение
// Отдельная секция для освобождения ресурсов по ошибке
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;
}
Функциональное программирование
Пример программы, которая использует так называемые «функции высшего порядка» и «чистые функции»:
#include <stdio.h>
#include <stdlib.h>
int add_one(const int) __attribute__((const));
int subtract_one(const int) __attribute__((const));
int modify(const int, int (*f)(const int));
int add_one(const int num) return num + 1;
int subtract_one(const int num) return num - 1;
int modify(const int num, int (*f)(const int)) return f(num);
int main(void)
{
int num_1 = modify(10, *(add_one));
printf("%d\n", num_1); // выведет: 11
num_1 = modify(10, *(subtract_one));
printf("%d\n", num_1); // выведет: 9
return EXIT_SUCCESS;
}
В приведенном выше примере атрибут const
указывает компилятору, что возвращаемое функцией значение зависит только от входных параметров и функция не использует глобальные переменные. Более «мягким» аналогом атрибутом const
является атрибут pure
, который позволяет функции использовать глобальные переменные.[37]
Средства разработки
Компиляторы Си
Некоторые компиляторы идут в комплекте с компиляторами других языков программирования (включая C++) или являются составной частью среды разработки программного обеспечения.
Реализации стандартной библиотеки
Несмотря на то, что стандартная библиотека входит в стандарт языка, её реализации идут отдельно от компиляторов. Поэтому стандарты языка, поддерживаемые компилятором и библиотекой, могут отличаться.
- Открытая библиотека glibc является основной во многих дистрибутивах GNU/Linux, поддерживает стандарты C11 и POSIX.1-2008[38], а также предоставляет набор исправлений и дополнительных возможностей от GNU.
- Открытая библиотека musl задумывалась в качестве более легковесной замены для glibc, используется как библиотека по умолчанию в дистрибутиве Alpine Linux[39].
- Библиотека CRT от Microsoft поддерживает стандарт C11, поставляется как компонент в составе Windows 10[40].
Компиляторы на динамические языки и платформы
Иногда, в целях переноса тех или иных библиотек, функций и инструментов, написанных на Си, в иную среду, требуется компиляция Си-кода на язык более высокого уровня или в код виртуальной машины, предназначенной для такого языка. Следующие проекты предназначены для этих целей:
- Emscripten[41] — компилятор из LLVM-байтокода (например, полученный из C++) в JavaScript.
- Flascc[англ.] (старое название — Alchemy)[42] — компилятор из Си/C++ в ActionScript Virtual Machine (AVM2). Позволяет использовать Си-библиотеки в Flash- и Adobe AIR-приложениях.
- Clue[43] — компилятор из ANSI Си в Lua, JavaScript, Perl, Java, Common Lisp.
- AMPC[44] — компилятор из Си в виртуальную машину Java.
Этот раздел не завершён. |
- Eclipse — свободная интегрированная среда разработки, поддерживающая язык Си стандарта С99. Имеет модульную архитектуру, что даёт возможность подключения поддержки разных языков программирования и дополнительных возможностей. Доступен модуль для интеграции с Git, однако отсутствует интеграция с CMake.
- KDevelop — свободная интегрированная среда разработки, поддерживающая некоторые особенности языка Си из стандарта C11. Позволяет управлять проектами, использующими разные языки программирования, включая C++ и Python, поддерживает систему сборки CMake. Из особенностей стоит отметить встроенную поддержку Git на уровне работы с файлами и настраиваемое форматирование исходного кода для разных языков.
Средства модульного тестирования
Этот раздел не завершён. |
Поскольку язык Си не предоставляет средств для безопасного написания кода, а многие элементы языка способствуют появлению ошибок, написание качественного и отказоустойчивого кода можно гарантировать только с помощью создания автоматизированных тестов. Для упрощения такого тестирования существуют различные реализации сторонних библиотек модульного тестирования.
- Библиотека Check предоставляет фреймворк для тестирования программного кода на языке Си в общепринятом стиле xUnit. Среди возможностей можно упомянуть запуск тестов в отдельных процессах через
fork()
, что позволяет распознавать в тестах ошибки сегментирования, а также даёт возможность задавать максимальное время исполнения отдельных тестов. - Библиотека Google Test также предоставляет тестирование по принципам xUnit, но предназначена для тестирования кода на языке C++, что позволяет её использовать для тестирования кода и на языке Си. Также поддерживает изолированное тестирование отдельных частей программы. Одним из достоинств библиотеки является разделение макросов тестирования на утверждения и ошибки, что может облегчить отладку кода.
Область применения
Язык широко применяется при разработке операционных системах, на уровне прикладного интерфейса операционных систем, во встраиваемых системах, а также для создания высокопроизводительного или критического в плане обработки ошибок кода. Одной из причин широкого распространения для программирования на низком уровне является возможность писать кроссплатформенный код, который может по-разному обрабатываться на разном оборудовании и на разных операционных системах.
Возможность писать высокопроизводительный код обеспечивается за счёт полной свободы действий программиста и отсутствия строгого контроля со стороны компилятора. Так, например, на языке Си написаны первые реализации языков Java, Python, Perl и PHP. При этом во многих программах наиболее требовательные к ресурсам части принято писать на языке Си. Такие программы, как Mathematica и MATLAB, частично или полностью написаны на Си.
Также Си иногда используется как промежуточный язык при компиляции более высокоуровневых языков. Например, по такому принципу работали первые реализации языков С++, Objective-C и Go, — код, написанный на этих языках, транслировался в промежуточное представление на языке Си. Из современных языков, работающих по такому же принципу, стоит отметить язык Vala.
Языки-потомки
Язык Си был и остаётся одним из самых распространённых языков программирования в течение более чем сорока лет. Естественно, что его влияние можно проследить в той или иной мере во многих более поздних языках. Тем не менее, среди языков, достигших определённого распространения, прямых потомков у Си немного.
Часть языков-потомков надстраивают Си дополнительными средствами и механизмами, добавляющими поддержку новых парадигм программирования (ООП, функциональное программирование, обобщённое программирование и пр.). К таким языкам относятся, прежде всего, C++ и Objective-C, а опосредованно — их потомки Swift и D. Также известны попытки улучшить Си, исправив его наиболее существенные недостатки, но сохранив его привлекательные черты. Среди них можно упомянуть исследовательский язык Cyclone (и его потомок Rust). Иногда оба направления развития объединяются в одном языке, примером может служить Go.
Отдельно необходимо упомянуть о целой группе языков, которые в большей или меньшей мере унаследовали базовый синтаксис Си (использование фигурных скобок в качестве ограничителей блоков кода, описание переменных, характерные формы операторов for
, while
, if
, switch
с параметрами в скобках, комбинированные операции ++
, --
, +=
, -=
и другие), из-за чего программы на этих языках имеют характерный внешний вид, ассоциирующийся именно с Си. Это такие языки как Java, JavaScript, PHP, Perl, AWK, C#. В действительности структура и семантика этих языков сильно отличается от Си, и обычно они предназначены для тех сфер применения, где оригинальный Си никогда не использовался.
C++
Язык программирования C++ был создан из Си и унаследовал его синтаксис, дополнив его новыми конструкциями в духе языков Simula-67, Smalltalk, Modula-2, Ada, Mesa и Clu[46]. Основными дополнениями стали поддержка ООП (описание классов, множественное наследование, полиморфизм, основанный на виртуальных функциях) и обобщённого программирования (механизм шаблонов). Но помимо этого в язык внесено множество самых различных дополнений. На данный момент C++ является одним из наиболее распространённых языков программирования в мире. Он позиционируется как универсальный язык, предназначенный, главным образом, для разработки крупных программных комплексов.
Изначально C++ сохранял совместимость с Си, которая была заявлена как одно из преимуществ нового языка. Первые реализации C++ просто переводили новые конструкции в чистый Си, после чего код обрабатывался обычным Си-компилятором. Для сохранения совместимости создатели C++ отказались от исключения из него некоторых часто критикуемых особенностей Си, вместо этого создав новые, «параллельные» механизмы, которые рекомендуется применять при разработке нового кода на C++ (шаблоны вместо макроопределений, явное приведение типов вместо автоматического, контейнеры стандартной библиотеки вместо ручного динамического выделения памяти и так далее). Однако в дальнейшем языки развивались независимо, и сейчас Си и C++ последних выпущенных стандартов являются лишь частично совместимыми: не гарантируется успешная компиляция программы на Си компилятором C++, а в случае успеха нет гарантии, что откомпилированная программа будет работать правильно. Особенно неприятны некоторые тонкие семантические различия, которые могут приводить к разному поведению одного и того же кода, синтаксически корректного для обоих языков. Например, символьные константы (символы, заключённые в одинарные кавычки) имеют тип int
в Си и тип char
в C++, так что объём памяти, занимаемый такими константами, в разных языках различается.[47] Если программа чувствительна к размеру символьной константы, она будет работать по-разному, будучи откомпилирована трансляторами Си и C++.
Подобные различия затрудняют написание программ и библиотек, которые могли бы нормально компилироваться и работать одинаково и в Си и в C++, что, конечно, запутывает тех, кто программирует на обоих языках. Среди разработчиков и пользователей как Си, так и C++, есть сторонники максимального сокращения различий между языками, что объективно принесло бы ощутимую пользу. Существует, однако, и противоположная точка зрения, согласно которой совместимость не особенно важна, хоть и полезна, и усилия по уменьшению несовместимости не должны препятствовать улучшению каждого языка в отдельности.
Objective-C
Ещё одним вариантом расширения Си объектными средствами является язык Objective-C, созданный в 1983-м году. Объектная подсистема была заимствована из Smalltalk, причём все элементы, связанные с этой подсистемой, реализованы в собственном синтаксисе, достаточно резко отличающемся от синтаксиса Си (вплоть до того, что в описании классов синтаксис объявления полей противоположен синтаксису описания переменных в Си: сначала пишется имя поля, затем его тип). В отличие от C++, Objective-C является надмножеством классического Си, то есть сохраняет совместимость с исходным языком; правильная программа на Си является правильной программой на Objective-C. Другим существенным отличием от идеологии C++ является то, что Objective-C реализует взаимодействие объектов путём обмена полноценными сообщениями, тогда как в C++ реализована концепция «отправка сообщения как вызов метода». Полноценная обработка сообщений является значительно более гибкой, к тому же она естественным образом сочетается с параллельными вычислениями. Objective-C, а также его прямой потомок Swift являются одними из самых популярных на платформах, поддерживаемых Apple.
Проблемы и критика
Язык Си уникален с той точки зрения, что именно он стал первым языком высокого уровня, всерьёз потеснившим ассемблер в разработке системного программного обеспечения. Он остаётся языком, реализованным на максимальном количестве аппаратных платформ, и одним из самых популярных языков программирования, особенно в мире свободного программного обеспечения[48]. Тем не менее, язык имеет множество недостатков, он с момента появления подвергается критике многих специалистов.
Общая критика
Не только критики, но и приверженцы Си признают, что этот язык весьма сложен и наполнен опасными элементами, которые очень легко использовать неправильно. Своей структурой и правилами он никак не поддерживает программирование, нацеленное на создание надёжного и удобного в сопровождении программного кода, напротив, рождённый в эпоху прямого программирования под различные процессоры, язык способствует написанию небезопасного и запутанного кода. Многие профессиональные программисты склонны считать, что язык Си — мощный инструмент для создания элегантных программ, но в том же время с его помощью можно создавать крайне некачественные решения[49][50].
Из-за различных допущений в языке программы могут компилироваться с множественными ошибками, что часто приводит к непредсказуемому поведению программы. Современные компиляторы предоставляют опции для статического анализа кода[51][52], но даже они не способны выявить все возможные ошибки. Результатом неграмотного программирования на Си могут стать уязвимости программного обеспечения, что может сказаться на безопасности его использования.
У Си достаточно высокий порог вхождения, что затрудняет его использование в обучении в качестве первого языка программирования[48]. Наконец, за более чем 40 лет существования, язык успел несколько устареть, и в нём достаточно проблематично использовать многие современные приёмы и парадигмы программирования.
Недостатки отдельных элементов языка
Примитивная поддержка модульности
В языке Си нет понятия модуля и соответствующих механизмов. В качестве модулей могут выступать отдельные файлы и библиотеки. Для использования данных из другого файла или библиотеки требуется объявление прототипа функции либо экспорта переменной, а описание структур и типов данных другого файла, если они требуются, необходимо дублировать в рамках вызывающего файла. Подобные экспортируемые данные принято выносить в отдельные заголовочные файлы. Подключение заголовочных файлов через макроподстановку #include
позволяет имитировать модульные механизмы. Однако если экспортируемые данные по ошибке будут отличаться от тех, что используются в подключаемых файлах или библиотеках, то могут возникать как ошибки этапа компоновки, так и всевозможные ошибки времени исполнения: от порчи стека и кучи до ошибок сегментирования. Поскольку директива #include
лишь подставляет текст одного файла в другой, включение большого количества заголовочных файлов приводит к тому, что многократно возрастает фактический объём кода, попадающего на компиляцию, что является причиной относительно низкой скорости работы компиляторов языка Си. Необходимость согласования описаний в основном модуле и заголовочных файлах затрудняет сопровождение программы.
Предупреждения вместо ошибок
Стандарт языка даёт программисту большую свободу действий, равно как и высокие шансы на допущение ошибок. Многое из того, что программисту нельзя разрешать делать, дозволено языком по умолчанию, а исправление ошибок возносится на плечи программиста с помощью системы предупреждений. Несмотря на то, что современные компиляторы позволяют переводить все предупреждения в класс ошибки, по умолчанию они выдают лишь предупреждения, никак не помогая программисту в написании качественного и безопасного кода, поскольку предупреждения могут игнорироваться, если программа работает удовлетворительно.
Так, например, попытка использования функции malloc
без подключения заголовочного файла stdlib.h
до стандарта C99 могла привести к порче стека, поскольку вызов функции без указания её прототипа интерпретировался как вызов функции, возвращающей int
вместо void *
, но при этом выдавалось всего лишь предупреждение, в то время как размеры типов могли отличаться.
Высокий порог вхождения
Спецификация языка занимает более 500 страниц текста, которые необходимо прочесть, чтобы полностью овладеть языком и знать все его возможности. При этом на практике из этих возможностей ежедневно программистом будет использоваться лишь малая часть. Однако некоторые совсем не очевидные вещи требуется знать, чтобы писать безошибочный и качественный код. Так, автоматическое приведение операндов целочисленных выражений к типу int
может приводить в ряде случаев к трудно предсказуемым результатам, например, при использовании бинарных операторов:
unsigned char x = 0xFF;
unsigned char y = (~x | 0x1) >> 1; // Интуитивно здесь ожидается 0x00
printf("y = 0x%hhX\n", y); // Будет выведено 0x80, если sizeof(int) > sizeof(char)
Сам по себе язык является достаточно сложным, в связи с чем неопытным программистам будет достаточно сложно писать качественный код. В сочетании с тем, что язык даёт большую свободу действий и позволяет компилировать программы с ошибками, выдавая лишь предупреждения, сложность языка может приводить к появлению многочисленных ошибок и уязвимостей.
Отсутствие контроля инициализации переменных
Автоматически и динамически создаваемые объекты по умолчанию не инициализируются и после создания содержат значения, оставшиеся в памяти от ранее находившихся там объектов. Такое значение полностью непредсказуемо, оно меняется от одной машины к другой, от запуска к запуску, от вызова функции к вызову. Если программа из-за случайного пропуска инициализации использует такое значение, то результат будет непредсказуемым и может проявиться не сразу. Современные компиляторы пытаются диагностировать эту проблему статическим анализом исходного кода, хотя в общем случае статическим анализом данную проблему решить крайне сложно. Для выявления данных проблем на этапе тестирования в ходе исполнения программы могут использоваться дополнительные инструменты: Valgrind и MemorySanitizer[53].
Отсутствие контроля над адресной арифметикой
Источником опасных ситуаций служит совместимость указателей с числовыми типами и возможность использования адресной арифметики без строгого контроля на этапах компиляции и исполнения. Это даёт возможность получить указатель на любой объект, включая исполняемый код, и обратиться по этому указателю, если только механизм защиты памяти системы этому не воспрепятствует.
Неправильное использование указателей может порождать неопределённое поведение программы и приводить к серьёзным последствиям. К примеру, указатель может быть неинициализированным или, в результате неверных арифметических операций, указывать в произвольное место памяти. На одних платформах работа с таким указателем может вызвать принудительную остановку программы, на других это может привести к порче произвольных данных в памяти; последняя ошибка опасна тем, что её последствия непредсказуемы и могут проявиться в произвольный момент времени, в том числе намного позже момента собственно ошибочного действия.
Доступ к массивам в Си также реализован посредством адресной арифметики и не предполагает средств проверки корректности обращения к элементам массива по индексу. Например, выражения a[i]
и i[a]
идентичны и просто транслируются к виду *(a + i)
, а проверка на выход за границы массива не проводится. Обращение по индексу, превышающему верхнюю границу массива, приводит к обращению к данным, размещённым в памяти после массива. Частный случай такой ошибки называется ошибкой переполнения буфера. Когда подобное обращение происходит ошибочно, оно может привести к непредсказуемому поведению программы, а на некоторых архитектурах — даже к краху операционной системы. Нередко данная особенность используется в эксплоитах, используемых для нелегального доступа к памяти другого приложения или памяти ядра операционной системы.
Динамически выделяемая память
Системные функции для работы с динамической памятью (malloc()
, calloc()
, realloc()
, free()
) дают разработчику полный контроль над используемой памятью, но не обеспечивают контроля за правильностью и своевременностью выделения и освобождения. Соблюдение правильного порядка работы с динамической памятью полностью возлагается на программиста. Его ошибки, соответственно, могут приводить к обращению по некорректным адресам, например, к участкам преждевременно освобожденной памяти, либо к утечке памяти, когда память не освобождена, но указатель на неё утрачен, и память невозможно ни освободить, ни использовать до завершения работы программы. Такое возможно, например, если разработчик забыл вызвать free()
(или вызывающую free()
функцию), когда это требовалось.
Также функции для работы с памятью предствлены так, что побуждают к ошибочному использованию. Одной из частых ошибок является отсутствие проверок результата работы функций выделения памяти (malloc()
, calloc()
и прочие) на NULL
, в то время как память может не выделиться, если её не хватает, или если был запрошен слишком большой объём. Запрос слишком большого объёма памяти, например, может произойти из-за приведения числа -1
, полученного в результате каких-либо ошибочных математических операций, к беззнаковому типу size_t
, с последующими операциями над ним. Ещё одной проблемой функций работы с памятью является неопределённое поведение по запросу выделения памяти нулевого размера памяти. Функции выделения памяти могут вернуть как NULL
, так и действительное значение указателя, в зависимости от конкретной реализации[54].
Некоторые конкретные реализации и сторонние библиотеки предоставляют такие средства, как подсчёт ссылок и слабые ссылки[55], умные указатели[источник не указан 2085 дней], а также ограниченные формы сборки мусора[источник не указан 2085 дней], но все эти средства не являются стандартными, что, естественно, ограничивает их применение.
Неудобные и небезопасные нуль-терминированные строки
Для языка стандартными являются нуль-терминированные строки , соответственно все стандартные функции работают именно с ними, однако такой формат создавался в эпоху, когда компактность была важнее производительности. Между тем такой формат уже давно не актуален, т. к. вместе со строкой проще хранить размер. Нуль-терминированные же строки являются частым источником ошибок[56], поскольку часть функций языка может не добавить в конце строки терминирующий ноль[57], либо программист сам по ошибке может его не добавить[58], а в некоторых функциях и вовсе отсутствуют проверки на размер целевого буфера[56].
Хранение же размера вместе со строкой позволяет получать срезы строк и работать с ними как с обычными строками, что в случаях обработки текста может приводить к упрощению программного кода из-за отсутствия необходимости добавления терминирующего нуля в конец подстрок (например, упростилась бы функция strtok_r()
). Также передача строки из функции в функцию может приводить либо к частым вызовам функции strlen()
для выделения памяти, либо к периодическим вызовам realloc()
для увеличения размера буфера, что может также сказываться на производительности. К примеру, конкатенация строк может предполагать выделение памяти под новую строку, а для расчёта размера итоговой строки требуется знать размеры исходных строк. Однако стандарт языка не предусматривает ни типа данных, где хранился бы размер, ни функций для работы с такими строками.
Небезопасная реализация функций с переменным числом аргументов
Поддерживая функции с переменным числом аргументов, Си не содержит ни средств определения числа и типов фактических параметров, переданных такой функции, ни механизма безопасного доступа к ним. Информирование функции о составе фактических параметров лежит на программисте, а для доступа к их значениям необходимо отсчитать правильное количество байтов от адреса последнего фиксированного параметра в стеке либо вручную, либо пользуясь набором макросов va_arg
из заголовочного файла stdarg.h
. При этом необходимо учитывать работу механизма автоматического неявного приведения типов при вызове функций[59], согласно которому целочисленные типы аргументов размером менее int
приводятся к int
(или unsigned int
), а float
приводится к double
. Ошибка в вызове или в работе с параметрами внутри функции проявится только во время исполнения программы, приводя к непредсказуемым последствиям, от чтения неверных данных до порчи стека.
При этом данный небезопасный механизм широко используется семейством функций printf()
и scanf()
, которые не могут распознать, правильно ли программист передал аргументы в соответствии с форматом строки. Многие современные компиляторы проверяют список аргументов для каждого их вызова, генерируя предупреждения в случаях, когда список аргументов не соответствует строке формата. Однако в общем случае подобная проверка невозможна, так как каждая функция с переменным числом аргументов получает информацию о списке аргументов и обрабатывает этот список аргументов по-своему. Невозможно статически проконтролировать даже все вызовы функции printf()
, поскольку строка формата может создаваться в программе динамически.
Отсутствие унификации обработки ошибок
Язык Си не имеет какого-либо встроенного механизма контроля ошибок. В стандартной библиотеке присутствуют лишь примитивные инструменты:
- переменная
errno
из заголовочного файла errno.h (в случае POSIX — макрос), - функции для вывода или получения сообщений об ошибках согласно кодам
errno
.
Большинство функций стандартной библиотеки вместо кода ошибки возвращает маркер -1
, а сам код требуется получать из errno
. Подобный подход не даёт никаких преимуществ при обработке ошибок и вынуждает писать много однотипного кода. Это неудобство попытался исправить стандарт POSIX, в функциях которого принято возвращать код ошибки напрямую. Но в результате получается несколько разных способов возвращения кода ошибки, что повышает риск ошибок при написании программ в результате человеческого фактора.
Также в стандартной библиотеке есть набор функций, в которых может произойти ошибка, но возвращаемое значение не позволяет однозначно определить, была ли ошибка или возвращено корректное значение. Такие функции выставляют errno
в случае ошибки, поэтому требуется сбрасывать errno
в значение 0
перед их исполнением, в противном случае в errno
может содержаться код ошибки от исполнения предыдущей функции. При этом часто требуется анализировать и возвращаемое функцией значение, и сам код errno
, что значительно усложняет код[60].
В стандартной библиотеке коды errno
обозначаются через макроопределения и могут хранить в себе одинаковые значения, что не даёт возможности анализировать коды ошибок через оператор switch
. При этом в языке в качестве типа данных и для хранения кода ошибки, и для флага ошибки используется тип int
, какого-либо специального типа данных для хранения кода ошибки в обязательной части стандарта нет. Отдельный тип errno_t
для хранения кода ошибки появился лишь в расширении K стандарта C11 и может не поддерживаться компиляторами[32].
Другой проблемой языка Си является то, что выставление errno
во многих функциях, таких как fopen()
, fread()
и fwrite()
, не стандартизировано и возложено на конкретные реализации стандартной библиотеки, что не позволяет делать полностью кроссплатформенный код только лишь средствами языка. Решением данной проблемы может быть создание приложений, ориентированных на стандарт POSIX, где явно сказано, что функции должны выставлять errno
по ошибке, а также указаны некоторые из вариантов возможных ошибок .
Способы преодоления недостатков языка
Недостатки Си давно и хорошо известны, и с момента появления языка предпринималось множество попыток повысить качество и безопасность кода на Си, не принося в жертву его возможности. Компиляторы, как правило, с помощью дополнительных опций компиляции проводят ограниченный статический анализ кода и выдают предупреждения при обнаружении потенциальных ошибок. Некоторые современные компиляторы поддерживают опции встраивания в код дополнительных проверок: выхода за пределы массива, защиту стека при вызове функций, защиту от выхода за пределы выделяемой динамической памяти, защиту от чтения неинициализированных переменных, выявление случаев неопределённого поведения и т. п. Однако дополнительные проверки могут сказаться на производительности итогового приложения.
Существуют специальные программные средства для статического анализа кода на Си для выявления не-синтаксических ошибок. Их применение не гарантирует безошибочности программ, но позволяет выявить значительную часть типичных ошибок и потенциальных уязвимостей. Максимальный эффект данных средств достигается не при эпизодическом использовании, а при применении в составе отработанной системы постоянного контроля качества кода, например, в системах непрерывной интеграции и развёртывания. Однако статические анализаторы могут выдавать ложные результаты, поэтому иногда может требоваться аннотирование кода специальными комментариями, чтобы исключить ложные срабатывания системы на определённых участках кода, формально подпадающих под критерии ошибочных.
Стандарты безопасного программирования
Выпущено значительное количество исследований о правильном программировании на Си, от небольших статей до объёмных книг. Для поддержания качества кода на Си принимаются корпоративные или отраслевые правила и стандарты. Так, известен стандарт MISRA C, первоначально разработанный Motor Industry Software Reliability Association для использования Си в создании программного обеспечения встроенных систем транспортных средств. Сейчас MISRA C используется во многих отраслях, в том числе в военной, медицинской и аэрокосмической. Редакция 2013 года содержит 16 директив и 143 правила, включающие требования к коду и ограничения на использование определённых языковых средств. Так, например, MISRA C запрещает использование функций с переменным числом параметров. На рынке имеется около десятка инструментов проверки кода на соответствие MISRA C и несколько компиляторов Си, проверяющих ограничения этого стандарта на этапе компиляции.
Также существует стандарт безопасного программирования на Си CERT C Coding Standard, разрабатываемый координационным центром CERT. Стандарт направлен на повышение безопасности и надёжности работы компьютерных систем[61]. В нём приводятся правила и рекомендации для разработчиков, включая примеры неправильного и правильного кода по каждому отдельно взятому случаю.
Стандарт POSIX
Другим подходом к устранению некоторых недостатков языка стал стандарт POSIX. Данный стандарт стандартизирует установку errno
многими функциями, позволяя обрабатывать ошибки, возникающие, например, в функциях работы с файлами, а также вводит потокобезопасные аналоги некоторых функций стандартной библиотеки, безопасные варианты которых в стандарте языка присутствуют лишь в расширении K[62].
См. также
Примечания
- ↑ 1 2 http://www.bell-labs.com/usr/dmr/www/chist.html
- ↑ Programming Community Index for April 2012
- ↑ ISO/IEC 9899:2011 — Information technology — Programming languages — C
- ↑ 1 2 3 ISO/IEC. ISO/IEC9899:2017. Programming languages — C (2017). Архивировано 30 декабря 2018 года.
- ↑ ISO/IEC 9899:2018 - Information technology -- Programming languages -- C . www.iso.org.
- ↑ 1 2 Using the GNU Compiler Collection (GCC): C Dialect Options . gcc.gnu.org. Дата обращения: 3 декабря 2018.
- ↑ A garbage collector for C and C++ (англ.)
- ↑ Object-Oriented Programming With ANSI-C (англ.)
- ↑ Подбельский, Фомин, 2004, с. 82.
- ↑ Романов, Си/Си++, 1.5. Алгоритм. Операторы.
- ↑ Does C support function overloading? | GeeksforGeeks
- ↑ The GNU C Reference Manual . www.gnu.org. Дата обращения: 21 мая 2017.
- ↑ Fundamental types - cppreference.com (англ.). en.cppreference.com. Дата обращения: 21 мая 2017.
- ↑ Width of Type (The GNU C Library) (англ.). www.gnu.org. Дата обращения: 7 декабря 2018.
- ↑ 1 2 Joint Technical Committee ISO/IEC JTC 1. ISO/IEC 9899:201x. Programming languages — C. — ISO/IEC, 2011. — С. 14. — 678 с.
- ↑ Type Conversion Macros: GLib Reference Manual (англ.). developer.gnome.org. Дата обращения: 14 января 2019.
- ↑ The wchar_t mess - GNU libunistring . www.gnu.org. Дата обращения: 2 января 2019.
- ↑ Defect Report Summary for C11 . www.open-std.org. Дата обращения: 2 января 2019.
- ↑ Standard Enumerations: GTK+ 3 Reference Manual (англ.). developer.gnome.org. Дата обращения: 15 января 2019.
- ↑ Object properties: GObject Reference Manual (англ.). developer.gnome.org. Дата обращения: 15 января 2019.
- ↑ Using the GNU Compiler Collection (GCC): Common Type Attributes (англ.). gcc.gnu.org. Дата обращения: 19 января 2019.
- ↑ OpenSSL_version (англ.). www.openssl.org. Дата обращения: 9 декабря 2018.
- ↑ Version Information: GTK+ 3 Reference Manual (англ.). developer.gnome.org. Дата обращения: 9 декабря 2018.
- ↑ PRE10-C. Wrap multistatement macros in a do-while loop - SEI CERT C Coding Standard - Confluence (англ.). wiki.sei.cmu.edu. Дата обращения: 9 декабря 2018.
- ↑ PRE01-C. Use parentheses within macros around parameter names - SEI CERT C Coding Standard - Confluence (англ.). wiki.sei.cmu.edu. Дата обращения: 9 декабря 2018.
- ↑ Указатели в C абстрактнее, чем может показаться . www.viva64.com. Дата обращения: 30 декабря 2018.
- ↑ ERR02-C. Avoid in-band error indicators - SEI CERT C Coding Standard - Confluence (англ.). wiki.sei.cmu.edu. Дата обращения: 4 января 2019.
- ↑ FIO34-C. Distinguish between characters read from a file and EOF or WEOF - SEI CERT C Coding Standard - Confluence (англ.). wiki.sei.cmu.edu. Дата обращения: 4 января 2019.
- ↑ Coding Style (англ.). The systemd System and Service Manager. github.com.
- ↑ Error Reporting: GLib Reference Manual (англ.). developer.gnome.org. Дата обращения: 1 февраля 2019.
- ↑ Eina: Error (англ.). docs.enlightenment.org. Дата обращения: 1 февраля 2019.
- ↑ 1 2 DCL09-C. Declare functions that return errno with a return type of errno_t - SEI CERT C Coding Standard - Confluence . wiki.sei.cmu.edu. Дата обращения: 21 декабря 2018.
- ↑ 1 2 FLP32-C. Prevent or detect domain and range errors in math functions - SEI CERT C Coding Standard - Confluence (англ.). wiki.sei.cmu.edu. Дата обращения: 5 января 2019.
- ↑ MEM12-C. Consider using a goto chain when leaving a function on error when using and releasing resources - SEI CERT C Coding Standard - Confluence (англ.). wiki.sei.cmu.edu. Дата обращения: 4 января 2019.
- ↑ ERR04-C. Choose an appropriate termination strategy - SEI CERT C Coding Standard - Confluence (англ.). wiki.sei.cmu.edu. Дата обращения: 4 января 2019.
- ↑ MEM31-C. Free dynamically allocated memory when no longer needed - SEI CERT C Coding Standard - Confluence (англ.). wiki.sei.cmu.edu. Дата обращения: 6 января 2019.
- ↑ Using the GNU Compiler Collection (GCC): Common Function Attributes . gcc.gnu.org. Дата обращения: 21 мая 2017.
- ↑ Siddhesh Poyarekar - The GNU C Library version 2.29 is now available (англ.). sourceware.org. Дата обращения: 2 февраля 2019.
- ↑ Alpine Linux has switched to musl libc | Alpine Linux (англ.). alpinelinux.org. Дата обращения: 2 февраля 2019.
- ↑ Особенности библиотеки CRT . docs.microsoft.com. Дата обращения: 2 февраля 2019.
- ↑ Emscripten LLVM-to-JavaScript compiler
- ↑ Flash C++ Compiler
- ↑ Проект Clue на сайте SourceForge.net
- ↑ Axiomatic Solutions Sdn Bhd
- ↑ TIOBE Index (англ.). www.tiobe.com.
- ↑ Stroustrup, Bjarne Evolving a language in and for the real world: C++ 1991-2006 .
- ↑ Annex 0: Compatibility. 1.2. [[C++]] and ISO C . Working Paper for Draft Proposed International Standard for Information Systems — Programming Language C++ (2 декабря 1996). — см. 1.2.1p3 (параграф 3 в разделе 1.2.1). Дата обращения: 6 июня 2009. Архивировано 22 августа 2011 года.
- ↑ 1 2 Столяров, 2010, 1. Предисловие, p. 79.
- ↑ Летопись языков. Си . Издательство «Открытые системы». Дата обращения: 8 декабря 2018.
- ↑ Allen I. Holub. Enough Rope to Shoot Yourself in the Foot: Rules for C and C++ Programming. — McGraw-Hill, 1995. — 214 с. — ISBN 9780070296893.
- ↑ Using the GNU Compiler Collection (GCC): Warning Options . gcc.gnu.org. Дата обращения: 8 декабря 2018.
- ↑ Diagnostic flags in Clang — Clang 8 documentation . clang.llvm.org. Дата обращения: 8 декабря 2018.
- ↑ MemorySanitizer — Clang 8 documentation (англ.). clang.llvm.org. Дата обращения: 8 декабря 2018.
- ↑ MEM04-C. Beware of zero-length allocations - SEI CERT C Coding Standard - Confluence (англ.). wiki.sei.cmu.edu. Дата обращения: 11 января 2019.
- ↑ Object memory management: GObject Reference Manual . developer.gnome.org. Дата обращения: 9 декабря 2018.
- ↑ 1 2 CERN Computer Security Information . security.web.cern.ch. Дата обращения: 12 января 2019.
- ↑ CWE - CWE-170: Improper Null Termination (3.2) (англ.). cwe.mitre.org. Дата обращения: 12 января 2019.
- ↑ STR32-C. Do not pass a non-null-terminated character sequence to a library function that expects a string - SEI CERT C Coding Standard - Confluence (англ.). wiki.sei.cmu.edu. Дата обращения: 12 января 2019.
- ↑ EXP47-C. Do not call va_arg with an argument of the incorrect type - SEI CERT C Coding Standard - Confluence (англ.). wiki.sei.cmu.edu. Дата обращения: 8 декабря 2018.
- ↑ ERR34-C. Detect errors when converting a string to a number - SEI CERT C Coding Standard - Confluence . wiki.sei.cmu.edu. Дата обращения: 21 декабря 2018.
- ↑ SEI CERT C Coding Standard - SEI CERT C Coding Standard - Confluence . wiki.sei.cmu.edu. Дата обращения: 9 декабря 2018.
- ↑ CON33-C. Avoid race conditions when using library functions - SEI CERT C Coding Standard - Confluence (англ.). wiki.sei.cmu.edu. Дата обращения: 23 января 2019.
Литература
- Керниган Б., Ритчи Д. Язык программирования Си = The C programming language. — 2-е изд. — М.: Вильямс, 2007. — С. 304. — ISBN 0-13-110362-8.
- Гукин Д. Язык программирования Си для «чайников» = C For Dummies. — М.: Диалектика, 2006. — С. 352. — ISBN 0-7645-7068-4.
- Подбельский В. В., Фомин С. С. Программирование на языке Си. — 2-е доп. изд. — М.: Финансы и статистика, 2004. — 600 с. — ISBN 5-279-02180-6.
- Прата С. Язык программирования С: Лекции и упражнения = C Primer Plus. — М.: Вильямс, 2006. — С. 960. — ISBN 5-8459-0986-4.
- Прата С. Язык программирования C (C11). Лекции и упражнения, 6-е издание = C Primer Plus, 6th Edition. — М.: Вильямс, 2015. — 928 с. — ISBN 978-5-8459-1950-2.
- Столяров А. В. Язык Си и начальное обучение программированию // Сборник статей молодых учёных факультета ВМК МГУ. — Издательский отдел факультета ВМК МГУ, 2010. — № 7. — С. 78—90.
- Шилдт Г. C: полное руководство, классическое издание = C: The Complete Reference, 4th Edition. — М.: Вильямс, 2010. — С. 704. — ISBN 978-5-8459-1709-6.
- Языки программирования Ада, Си, Паскаль = Comparing and Assessong Programming Languages Ada, C, and Pascal / А. Фьюэр, Н. Джехани. — М.: Радио и Саязь, 1989. — 368 с. — 50 000 экз. — ISBN 5-256-00309-7.
- Axel-Tobias Schreiner. Object oriented programming with ANSI-C. — Hanser, 2011. — 223 p. — ISBN 3-446-17426-5.
Ссылки
- ISO/IEC JTC1/SC22/WG14 official home (англ.). — Официальная страница международной рабочей группы по стандартизации языка программирования Си. Дата обращения: 20 февраля 2009. Архивировано 22 августа 2011 года.
- WG14 N1124 (англ.). ISO/IEC 9899 — Programming languages — C — Approved standards. ISO/IEC JTC1/SC22/WG14 (6 мая 2005). — Стандарт ISO/IEC 9899:1999 (C99) + ISO/IEC 9899:1999 Cor. 1:2001(E) (TC1 — Technical Corrigendum 1 от 2001 года) + ISO/IEC 9899:1999 Cor. 2:2004(E) (TC2 — Technical Corrigendum 2 от 2004 года). Дата обращения: 20 февраля 2009. Архивировано 22 августа 2011 года.
- C — The ISO Standard — Rationale, Revision 5.10 (англ.) (апрель 2004). — Обоснование и пояснения для стандарта C99. Дата обращения: 20 февраля 2009. Архивировано 22 августа 2011 года.
- Романов Е. Си/Си++. От дилетанта до профессионала . ermak.cs.nstu.ru. Дата обращения: 25 мая 2015.
Для улучшения этой статьи желательно:
|