Си (язык программирования)

Си(отлат.буквыCангл. языка➤) —компилируемыйстатически типизированныйязык программированияобщего назначения, разработанный в 1969—1973 годах сотрудникомBell LabsДеннисом Ритчикак развитие языкаБи. Первоначально был разработан для реализацииоперационной системыUNIX, но впоследствии былперенесённа множество других платформ. Согласно дизайну языка, его конструкции близко сопоставляются типичныммашинным инструкциям, благодаря чему он нашёл применение в проектах, для которых был свойствененязык ассемблера, в том числе как воперационных системах, так и в различномприкладном программном обеспечениидля множества устройств — отсуперкомпьютеровдовстраиваемых систем. Язык программирования Си оказал существенное влияние на развитие индустрии программного обеспечения, а егосинтаксисстал основой для таких языков программирования, какC++,C#,JavaиObjective-C.
Язык программирования Си разрабатывался в период с1969по1973 годыв лабораторияхBell Labs, и к 1973 году на этот язык была переписана большая частьядраUNIX, первоначально написанного на ассемблереPDP-11/20. Название языка стало логическим продолжением старого языка «Би», многие особенности которого были положены в основу.
По мере развития язык сначала стандартизировали какANSI C, а затем этот стандарт был принят комитетом по международной стандартизацииISOкак ISO C, ставший также известным под названием C90. В стандартеС99язык получил новые возможности, такие как массивы переменной длины и встраиваемые функции. А в стандартеC11в язык добавили реализацию потоков и поддержку атомарных типов; поддержка массивов переменной длины стала необязательной. 31 октября 2024 года официально вышел стандарт C23.
Язык Си разрабатывался как язык системного программирования, для которого можно создать однопроходныйкомпилятор. Стандартная библиотека также невелика. Как следствие данных факторов — компиляторы разрабатываются сравнительно легко. Поэтому данный язык доступен на самых различных платформах. К тому же, несмотря на свою низкоуровневую природу, язык ориентирован на переносимость. Программы, соответствующие стандарту языка, могут компилироваться под различные архитектуры компьютеров.
Целью языка было облегчение написания больших программ с минимизацией ошибок по сравнению с ассемблером, следуя принципампроцедурного программирования, но избегая всего, что может привести к дополнительным накладным расходам, специфичным для языков высокого уровня.
Основные особенности Си:
В то же время в Си отсутствуют:
Часть отсутствующих возможностей может имитироваться встроенными средствами (например, сопрограммы можно имитировать с помощью функцийsetjmpиlongjmp), часть добавляется с помощью сторонних библиотек (например, для поддержки многозадачности и для сетевых функций можно использовать библиотекиpthreads, sockets и тому подобные; существуют библиотеки для поддержки автоматической сборки мусора), часть реализуется в некоторых компиляторах в виде расширений языка (например, вложенные функции вGCC).
Существует несколько громоздкая, но вполне работоспособная методика, позволяющая реализовывать на Си механизмыООП, базирующаяся на фактической полиморфности указателей в Си и поддержке в этом языке указателей на функции. Механизмы ООП, основанные на данной модели, реализованы в библиотекеGLibи активно используются во фреймворкеGTK+. GLib предоставляет базовый классGObject, возможности наследования от одного классаи реализации множества интерфейсов.
После появления язык был хорошо принят, потому что он позволял быстро создаватькомпиляторыдля новых платформ, а также позволял программистам довольно точно представлять, как выполняются их программы. Благодаря близости к языкам низкого уровня программы на Си работали эффективнее написанных на многих других языках высокого уровня, и лишь оптимизированный вручную код на ассемблере мог работать ещё быстрее, потому что давал полный контроль над машиной. На сегодняшний день развитие компиляторов и усложнениепроцессоровпривело к тому, что вручную написанный ассемблерный код (кроме разве что очень коротких программ) практически не выигрывает по сравнению с кодом, генерируемым компиляторами, при этом Си продолжает оставаться одним из наиболее эффективных языков высокого уровня.
Синтаксис языка Си достаточно сложный, а семантика неоднозначная. Основными двумя особенностями языка на момент его появления были унифицирование работы с массивами и указателями, а также схожесть того, как что-либо объявляется, с тем, как это в дальнейшем используется в выражениях. Однако в последующем эти две особенности языка были в числе наиболее критикуемых, и обе являются сложными для понимания среди начинающих программистов. Стандарт языка, определяя его семантику, не стал слишком сильно ограничивать реализации языка компиляторами, но этим самым сделал семантику недостаточно определённой. В частности, в стандарте есть 3 типа недостаточно определённой семантики: определяемое реализацией поведение, не заданное стандартом поведение и неопределённое поведение.
Размер целочисленных типов данных варьируется от не менее 8 до не менее 32 бит. СтандартC99увеличивает максимальный размер целого числа — не менее 64 бит. Целочисленные типы данных используются для хранения целых чисел (типcharтакже используется для хранения ASCII-символов). Все размеры диапазонов представленных ниже типов данных минимальны и на отдельно взятой платформе могут быть больше.
Как следствие минимальных размеров типов стандарт требует, чтобы для размеров целочисленных типов выполнялось условие:
1=sizeof(char)≤sizeof(short)≤sizeof(int)≤sizeof(long)≤sizeof(longlong).
Таким образом, размеры некоторых типов по количеству байт могут совпадать, если будет удовлетворяться условие по минимальному количеству бит. Дажеcharиlongмогут иметь одинаковый размер, если один байт будет занимать 32 бита или более, но такие платформы будут очень редки или не будут существовать. Стандарт гарантирует, что типcharвсегдаравен 1 байту. Размер байта в битах определяется константойCHAR_BITиз заголовочного файлаlimits.h, уPOSIX-совместимых систем равен 8 битам.
Минимальный диапазон значений целых типов по стандарту определяется с-(2-1)по2-1для знаковых типов и с0по2-1— для беззнаковых, где N — разрядность типа. Реализация компиляторов может расширять этот диапазон по своему усмотрению. На практике для знаковых типов чаще используется диапазон с-2по2-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, с той лишь разницей, что первые дают программисту выбрать между скоростью и размером.
Также со стандарта C99 добавлены типыintmax_tиuintmax_t, соответствующие самым большим знаковому и беззнаковому типам соответственно. Данные типы удобны при использовании в макросах для хранения промежуточных или временных значений при операциях над целочисленными аргументами, так как позволяют уместить значения любого типа. Например, эти типы используются в макросах сравнения целочисленных значений библиотеки модульного тестированияCheckдля языка Си.
В Си существует несколько дополнительных целочисленных типов для безопасной работы с типом данных указателей:intptr_t,uintptr_tиptrdiff_t. Типыintptr_tиuintptr_tиз стандарта C99 предназначены для хранения соответственно знакового и беззнакового значений, которые по размеру могут уместить в себе указатель. Эти типы часто применяются для хранения произвольного целого числа в указателе, например, как способ избавиться от лишнего выделения памяти при регистрации функций обратной связилибо при использовании сторонних связных списков, ассоциативных массивов и прочих структур, в которых данные хранятся по указателю. Типptrdiff_tиз заголовочного файлаstddef.hпредназначен для безопасного хранения разности двух указателей.
Для хранения размера предусмотрен беззнаковый типsize_tиз заголовочного файлаstddef.h. Данный тип способен уместить максимально возможное количество байт, доступное по указателю, и обычно используется для хранения размера в байтах. Значение именно этого типа возвращает операторsizeof.
Преобразования целочисленных типов могут происходить как явно, с помощью оператора приведения типов, так и неявно. Значения типов, меньших по размеру, чемint, при участии в каких-либо операциях или при передаче в вызов функции автоматически приводятся к типуint, а в случае невозможности преобразования — к типуunsigned int. Зачастую подобные неявные приведения необходимы, чтобы результат вычисления оказался правильным, но иногда приводят к интуитивно-непонятным ошибкам в вычислениях. Например, если в операции участвуют числа типаintиunsigned int, а знаковое значение отрицательно, то преобразование отрицательного числа к беззнаковому типу приведёт к переполнению и возникновению очень большого положительного значения, что может привести к неверному результату операций сравнения.
Также автоматическое приведение типов сработает, если в выражении используется два или более разных целочисленных типа. Стандарт определяет ряд правил, согласно которым выбирается такое преобразование типов, которое может дать правильный результат вычислений. Разным типам назначены разные ранги в рамках преобразования, а сами ранги основаны на размере типа. При участии в выражении разных типов обычно выбирается приведение этих значений к типу большего ранга.
Числа с плавающей запятойв языке Си представлены тремя основными типами:float,doubleиlong double.
Вещественные числа имеют представление, сильно отличающее их от целых. Константы вещественных чисел разных типов, записанные в десятичном представлении, могут быть не равны друг другу. Например, условие0.1 == 0.1fбудет ложным из-за потери точности у типаfloat, в то время как условие0.5 == 0.5fбудет истинным, поскольку эти числа конечны в двоичном представлении. Однако условие с приведением(float) 0.1 == 0.1fтакже будет истинным, поскольку при приведении типа к менее точному теряются разряды, из-за которых в этих двух константах есть различия.
Арифметические операции с вещественными числами также являются неточными и зачастую имеют некоторую плавающую погрешность. Наибольшая погрешность будет возникать при операциях над значениями, близкими к минимально возможному для конкретного типа. Также погрешность может оказаться большой при вычислениях над одновременно очень маленькими (≪ 1) и очень большимипо модулючислами (≫ 1). В ряде случаев погрешность может быть снижена изменением алгоритмов и методик вычислений. Например, при замене многократного сложения умножением погрешность может снизиться во столько раз, сколько изначально было операций сложения.
Также в заголовочном файлеmath.hприсутствуют два дополнительных типаfloat_tиdouble_t, которые соответствуют как минимум типамfloatиdoubleсоответственно, но могут быть отличными от них. Типыfloat_tиdouble_tдобавлены в стандартеC99, а их соответствие основным типам определяется значением макросаFLT_EVAL_METHOD.
Хотя как такового специального типа для строк в Си не предусмотрено, в языке активно используются нуль-терминированные строки.ASCII-строки объявляются как массив типаchar, последним элементом которого должен быть символ с кодом0('\0'). В этом же формате принято хранить и строки в форматеUTF-8. Однако все функции, работающие с ASCII-строками, рассматривают каждый символ как байт, что ограничивает применение стандартных функций при использовании данной кодировки.
Несмотря на широкое распространение идеи нуль-терминированных строк и удобство их использования в некоторых алгоритмах, у них есть несколько серьёзных недостатков.
В современных условиях, когда производительность кода приоритетнее расхода памяти, может оказаться эффективнее и проще использовать структуры, содержащие в себе как саму строку, так и её размер➤, например:
Альтернативным вариантом хранения размера строки с низким потреблением памяти может оказаться подход добавления в начало строки её размера в форматеразмера переменной длины. Подобный подход применяется впротокольных буферах, однако только на этапе передачи данных, но не их хранения.
Строковые литералы в Си по своей сути являются константами. При объявлении заключаются в двойные кавычки, а терминирующий0добавляются компилятором автоматически. Допускается два способа присваивания строкового литерала: по указателю и по значению. При присваивании по указателю в переменную типаchar *заносится указатель на неизменяемую строку, то есть формируется константная строка. Если же заносить строковый литерал в массив, то происходит копирование строки в область стека.
Поскольку строки являются обычными массивами символов, вместо литералов можно использовать инициализаторы, если каждый символ умещается в 1 байт:
Однако на практике такой подход имеет смысл только в крайне редких случаях, когда к ASCII-строке требуется не добавлять терминирующий ноль.
Альтернативой обычным строкам могут служить широкие строки, в которых каждый символ хранится в специальном типеwchar_t. Данный тип по стандарту должен быть способен уместить в себе все символы самой большой из существующихлокалей. Функции для работы с широкими строками описаны в заголовочном файлеwchar.h, а функции для работы с широкими символами описаны в заголовочном файлеwctype.h.
При объявлении строковых литералов для широких строк используется модификаторL:
В форматированном выводе используется спецификатор%ls, однако спецификатор размера, если задан, указывается в байтах, а не в символах.
Типwchar_tзадумывался для того, чтобы в него мог поместиться любой символ, а широкие строки — для хранения строк любой локали, но в результате API оказался неудобным, а реализации — платформозависимыми. Так, на платформеWindowsв качестве размера типаwchar_tбыло выбрано 16 бит, а позже появился стандарт UTF-32, таким образом типwchar_tна платформе Windows уже не способен уместить в себе все символы из кодировки UTF-32, в результате чего теряется смысл данного типа. В то же время на платформах Linuxи macOS данный тип занимает 32 бита, поэтому для реализациикроссплатформенныхзадач типwchar_tне подходит.
Существует много разных кодировок, в которых отдельный символ может быть запрограммирован разным количеством байт. Такие кодировки называются многобайтовыми. К ним относится также иUTF-8. В Си существует набор функций для преобразования строк из многобайтовых в рамках текущей локали в широкие и наоборот. Функции для работы с многобайтовыми символами имеют префикс либо суффиксmbи описаны в заголовочном файлеstdlib.h. Для поддержки многобайтовых строк в программах на языке Си, такие строки должны поддерживаться на уровне текущейлокали. Для явного задания кодировки можно менять текущую локаль с помощью функцииsetlocale()из заголовочного файлаlocale.h. Однако задание кодировки для локали должно поддерживаться используемой стандартной библиотекой. Так, например, стандартная библиотекаGlibcполностью поддерживает кодировку UTF-8 и способна преобразовывать текст во множество других кодировок.
Начиная со стандарта 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-битных строк. Примеры объявления строковых литералов для многобайтовых строк:
Следует иметь в виду, что функцияc16rtomb()для преобразования из 16-битной строки в многобайтовую работает не так, как задумывалось, и в стандарте C11 оказалась неспособной переводить из UTF-16 в UTF-8. Исправление работы данной функции может зависеть от конкретной реализации компилятора.
Перечисленияпредставляют собой набор именованных целочисленных констант и обозначаются с помощью ключевого словаenum. Если константе не сопоставлено число, то ей автоматически задаётся либо0для первой константы в списке, либо число на единицу бо́льшее, чем задано в предыдущей константе. При этом сам тип данных перечисления по факту может соответствовать любому знаковому или беззнаковому примитивному типу, в диапазон которого умещаются все значения перечислений; решение о выборе того или иного типа принимает компилятор. Однако явно заданные значения для констант должны быть выражениями типаint.
Тип перечисления может быть также анонимным, если не указано название перечисления. Константы, указанные в двух разных перечислениях, относятся к двум разным типам данных, независимо от того, являются ли перечисления именованными или анонимными.
На практике перечисления часто используются для обозначения состоянийконечных автоматов, для задания вариантов режимов работы или значений параметров, для создания целочисленных констант, а также для перечисления каких-либо уникальных объектов или свойств.
Структуры представляют собой объединение переменных разных типов данных в рамках одной области памяти; обозначаются ключевым словомstruct. Переменные внутри структуры называются полями структуры. С точки зрения адресного пространства поля всегда идут друг за другом в том же порядке, в котором указаны, но компиляторы могут выравнивать адреса полей для оптимизации под ту или иную архитектуру. Таким образом, фактически поле может занимать бо́льший размер, чем указано в программе.
Каждое поле имеет определённое смещение относительно адреса структуры и размер. Смещение можно получить с помощью макросаoffsetof()из заголовочного файлаstddef.h. При этом смещение будет зависеть от выравнивания и размера предыдущих полей. Размер поля обычно определяется выравниванием структуры: если размер выравнивания типа данных поля меньше значения выравнивания структуры, то размер поля определяется выравниванием структуры. Выравнивание типов данных можно получить с помощью макросаalignof()из заголовочного файлаstdalign.h. Размер самой структуры является совокупным размером всех её полей с учётом выравнивания. При этом некоторые компиляторы предоставляют специальные атрибуты, позволяющие упаковывать структуры, убирая из них выравнивания.
Полям структур можно явно задавать размер в битах через двоеточие после определения поля и указание количества бит, что ограничивает диапазон их возможных значений, несмотря на тип поля. Подобный подход может использоваться как альтернатива флагам и битовым маскам для обращения к ним. Однако указание количества бит не отменяет возможного выравнивания полей структур в памяти. Работа с битовыми полями имеет ряд ограничений: к ним невозможно применить операторsizeofили макросalignof(), на них невозможно получить указатель.
Объединениянеобходимы в тех случаях, когда требуется обращаться к одной и той же переменной как к разным типам данных; обозначаются ключевым словомunion. Внутри объединения может быть объявлено произвольное количество пересекающихся полей, которые по факту предоставляют доступ к одной и той же области памяти как к разным типам данных. Размер объединения выбирается компилятором исходя из размера самого большого поля в объединении. Следует иметь в виду, что изменение одного поля объединения приводит к изменению и всех других полей, но гарантированно правильным будет только значение того поля, которое менялось.
Объединения могут служить более удобной альтернативной приведению указателя к произвольному типу. К примеру, с помощью объединения, помещённого в структуру, можно создавать объекты с динамически меняющимся типом данных:
Массивыв языке Си примитивны и являются лишь синтаксическим абстрагированием надарифметикой указателей. Сам по себе массив является указателем на область памяти, поэтому вся информация о размерности массива и его границах может быть доступна только на этапе компиляции согласно описанию типа. Массивы могут быть как одномерными, так и многомерными, но обращение к элементу массива сводится к простому вычислению смещения относительно адреса начала массива. Поскольку массивы основаны на адресной арифметике, с ними возможно работать без использования индексов. Так, например, следующие два примера считывания с потока ввода 10 чисел идентичны друг другу:
Длина массивов с заранее известным размером вычисляется на этапе компиляции. В стандартеC99появилась возможность объявлять массивы переменной длины, у которых длина может задаваться на этапе выполнения. Под такие массивы выделяется память из области стека, поэтому их необходимо использовать с осторожностью, если их размер может задаваться извне программы. В отличие от выделения динамической памяти, превышение допустимого размера в области стека может повлечь непредсказуемые последствия, а отрицательная длина массива —неопределённое поведение. Начиная сC11массивы переменной длины являются опциональными для компиляторов, а отсутствие поддержки определяется наличием макроса__STDC_NO_VLA__.
Массивы фиксированного размера, объявляемые как локальные или глобальные переменные, можно инициализировать, задавая им начальное значение с помощью фигурных скобок и перечисления элементов массива через запятую. В инициализаторах глобальных массивов допускается использовать только такие выражения, которые вычисляются на этапе компиляции. Переменные, используемые в таких выражениях должны объявляться как константы, с модификаторомconst. У локальных массивов инициализаторы могут содержать выражения с вызовами функций и использованием других переменных, в том числе можно заносить указатель на сам объявляемый массив.
Со стандарта C99 последним элементом структур допускается объявлять массив произвольной длины, что широко используется на практике и поддерживается различными компиляторами. Размер такого массива зависит от объёма памяти, выделяемого под структуру. При этом нельзя объявлять массив таких структур и нельзя их помещать в другие структуры. В операциях над такой структурой массив произвольной длины обычно игнорируется, в том числе и при вычислении размера структуры, а выход за пределы массива влечёт за собой неопределённое поведение.
Язык Си не предусматривает какого-либо контроля выхода за пределы массива, поэтому программист сам должен следить за работой с массивами. Ошибки при обработке массивов не всегда явно влияют на ход исполнения программы, но могут приводить кошибкам сегментированияиуязвимостям➤.
Язык Си допускает создание собственных названий типов с помощью оператораtypedef. Альтернативные названия можно задавать как системным типам, так и пользовательским. Такие названия объявляются в глобальном пространстве имён и не конфликтуют с названиями типов структур, перечислений и объединений.
Альтернативные названия могут применяться как для упрощения кода, так и для создания уровней абстракции. Например, некоторые системные типы можно сократить для повышениячитабельностикода или унифицирования его написания в пользовательском коде:
Примером абстрагирования могут служить названия типов в заголовочных файлах операционных системах. Так, стандартPOSIXопределяет типpid_t, предназначенный для хранения числового идентификатора процесса. На самом деле данный тип является альтернативным названием для какого-либо примитивного типа, например:
Поскольку типы с альтернативными названиями являются лишь синонимами оригинальным типам, то между ними сохраняется полная совместимость и взаимозаменяемость.
Препроцессор работает до компиляции и преобразует текст файла программы согласно встреченным в нём или переданным в препроцессордирективам. Технически препроцессор может быть реализован по-разному, но логически его удобно представлять именно как отдельный модуль, целиком обрабатывающий каждый предназначенный для компиляции файл и формирующий текст, попадающий затем на вход компилятора. Препроцессор ищет в тексте строки, начинающиеся с символа#, вслед за которым должны следовать директивы препроцессора. Всё, что не относится к директивам препроцессора и не исключено из компиляции согласно директивам, передаётся на вход компилятора в неизменном виде.
В число возможностей препроцессора входит:
Важно понимать, что препроцессор обеспечивает только подстановку текста, не учитывая синтаксис и семантику языка. Так, например, макроопределения#defineмогут встречаться внутри функций или определений типов, а директивы условной компиляции могут приводить к исключению из компилируемого текста программы любой части кода, без оглядки на грамматику языка. Вызов параметрического макроса также отличается от вызова функции, поскольку не происходит анализа семантики аргументов, разделяемых запятой. Так, например, в аргументы параметрического макроса невозможно передать инициализацию массива, поскольку его элементы тоже разделяются запятой:
Макроопределения часто используются для обеспечения совместимости с разными версиями библиотек, у которых изменилсяAPI, включая те или иные участки кода в зависимости от версии библиотеки. Для этих целей библиотеки часто предоставляют макроопределения с описанием своей версии, а иногда и макросы с параметрами для сравнения текущей версии с заданной в рамках препроцессора. Также макроопределения применяются дляусловной компиляцииотдельных кусков программы, например для включения поддержки какого-либо дополнительного функционала.
Макроопределения с параметрами широко используются в Си-программах для создания аналоговобобщённых функций. Ранее они также применялись для реализации встраиваемых функций, но начиная со стандарта С99 эта необходимость исчезла благодаря добавлениюinline-функций. Однако в связи с тем, что макроопределения с параметрами функциями не являются, но вызываются аналогичным образом, по ошибке программиста могут возникать неожиданные проблемы, включая отработку только части кода из макроопределенияи неправильные приоритеты выполнения операций. В качестве примера ошибочного кода можно привести макрос возведения числа в квадрат:
В приведённом выше примере ошибкой является то, что содержимое аргумента макроса подставляется в текст как есть, без учёта приоритетов операций. В таких случаях необходимо использоватьinline-функции либо явно расставлять приоритеты операций в выражениях, использующих параметры макроса, с помощью круглых скобок:
Программа представляет собой набор файлов с кодом на языке Си, которые могут компилироваться вобъектные файлы. Объектные файлы затем проходят этапкомпоновкидруг с другом, а также со внешними библиотеками, в результате чего получается итоговыйисполняемый файлилибиблиотека. Связь файлов друг с другом, равно как и с библиотеками, требует описания прототипов используемых функций, внешних переменных и необходимых типов данных в каждом файле. Такие данные принято выносить в отдельныезаголовочные файлы, которые подключаются с помощьюдирективы#includeв тех файлах, где требуется та или иная функциональность, и позволяют организовывать систему, похожую на систему модулей. Модулем в таком случае может выступать:
Поскольку директива#includeлишь подставляет текст другого файла на этапепрепроцессора, многократное подключение одного и того же файла может приводить к ошибкам этапа компиляции. Поэтому в таких файлах используетсязащита от повторного включенияс помощьюмакрокоманд#defineи#ifndef.
Текст файла исходного кода на языке Си состоит из набора глобальных определений данных, типов и функций. Глобальные переменные и функции, объявленные со спецификаторамиstaticиinline, доступны только в пределах того файла, в котором они объявлены, либо при включении одного файла в другой через директиву#include. При этом функции и переменные, объявленные в заголовочном файле со словомstatic, будут создаваться заново при каждом подключении заголовочного файла к очередному файлу с исходным кодом. Глобальные переменные и прототипы функции, объявленные со спецификатором extern, считаются подключаемыми из других файлов. То есть их допускается использовать в соответствии с описанием; предполагается, что после сборки программы они будут связаныкомпоновщикомс оригинальными объектами и функциями, описанными в своих файлах.
Глобальные переменные и функции, кромеstaticиinline, могут быть доступны из других файлов при условии их надлежащего объявления там со спецификаторомextern. Переменные и функции, объявленные с модификаторомstatic, также могут быть доступны в других файлах, но лишь при передаче их адреса по указателю. Объявления типовtypedef,structиunionне могут импортироваться в других файлах. При необходимости использования в других файлах они должны быть там продублированы либо вынесены в отдельный заголовочный файл. То же самое относится и кinline-функциям.
Для исполняемой программы стандартной точкой входа является функция с именемmain, которая не может быть статической и должна быть единственной в программе. Исполнение программы начинается с первого оператора функцииmain()и продолжается до выхода из неё, после чего программа завершается и возвращает операционной системе абстрактный целочисленный код результата своей работы.
В переменнуюargcпри вызове передаётся количество аргументов, переданных программе, включая и путь к самой программе, поэтому обычно переменная argc содержит значение не меньшее, чем 1. В переменнуюargvпередаётся сама строка запуска программы в виде массива текстовых строк, последним элементом которого являетсяNULL. Компилятор гарантирует, что на момент запуска функцииmain()все глобальные переменные в программе будут инициализированы.
В качестве результата функцияmain()может вернуть любое целое число в диапазоне значений типаint, которое будет передано операционной системе или другому окружению в качествекода возвратапрограммы. Стандарт языка не определяет смысла кодов возврата. Обычно операционная система, где работают программы, имеет те или иные средства, позволяющие получить значение кода возврата и проанализировать его. Иногда существуют определённые соглашения о значениях этих кодов. Общим является соглашение о том, что нулевое значение кода возврата сигнализирует об успешном завершении программы, а ненулевое представляет собой код возникшей ошибки. Заголовочный файлstdlib.hопределяет два общих макроопределенияEXIT_SUCCESSиEXIT_FAILURE, которые соответствуют успешному и неуспешному завершению работы программы. Коды возврата также могут использоваться в рамках приложений, включающих в себя множество процессов, для обеспечения взаимодействия между этими процессами, в случае чего приложение само определяет смысловое значение для каждого кода возврата.
В Си предусмотрено 4 способа выделения памяти, которые определяют время жизни переменной и момент её инициализации.
Все эти способы хранения данных пригодны в различных ситуациях и имеют свои преимущества и недостатки. Глобальные переменные не позволяют писатьреентерабельныеалгоритмы, а автоматическое выделение памяти не позволяет возвращать произвольную область памяти из вызова функции. Автоматическое выделение также не подходит для выделения больших объёмов памяти, поскольку может привести к порче стека или кучи. Динамическая память лишена этих недостатков, но имеет большие накладные расходы при её использовании и более сложна в использовании.
Там, где это возможно, предпочтительным является автоматическое или статическое выделение памяти: такой способ хранения объектов управляетсякомпилятором, что освобождает программиста от трудностей ручного выделения и освобождения памяти, как правило, служащего источником трудно отыскиваемыхошибокутечек памяти, ошибок сегментирования и повторного освобождения в программе. К сожалению, многие структуры данных имеют переменный размер во время выполнения программы, поэтому из-за того, что автоматически и статически выделенные области должны иметь известный фиксированный размер во время компиляции, очень часто требуется использовать динамическое выделение.
Для автоматически выделяемых переменных с помощью модификатораregisterможно давать подсказку компилятору о необходимости быстрого доступа к ним. Такие переменные могут помещаться в регистры процессора. Из-за ограниченного количества регистров и возможных оптимизаций компилятора переменные могут оказаться в обычной памяти, но тем не менее из программы на них невозможно будет получить указатель. Модификаторregisterявляется единственным, который можно указывать в аргументах функций.
Язык Си унаследовал линейную адресацию памяти при работе со структурами, массивами и выделенными областями памяти. Стандарт языка также допускает выполнение операций сравнения над нулевым указателем и над адресами в рамках массивов, структур и выделенных областей памяти. Также допускается работа с адресом элемента массива, следующим за последним, что сделано для облегчения написания алгоритмов. Однако сравнение указателей адресов, полученных для разных переменных (или областей памяти) не должно осуществляться, так как результат будет зависеть от реализации конкретного компилятора.
Представление памяти программы зависит от аппаратной архитектуры, отоперационной системыи от компилятора. Так, например, на большинстве архитектур стек растёт вниз, но существуют архитектуры, где стек растёт вверх. Граница между стеком и кучей может быть частично защищена от переполнения стека специальной областью памяти. А расположение данных и кода библиотек может зависеть от параметров компиляции. Стандарт Си абстрагируется над реализацией и позволяет писать переносимый код, однако понимание устройства памяти процесса помогает в отладке и написании безопасных и отказоустойчивых приложений.
При запуске программы из исполняемого файла в оперативную память импортируются инструкции процессора (машинный код) и инициализированные данные. В то же время в старшие адреса импортируются аргументы командной строки (доступные в функцииmain()со следующей сигнатурой во втором аргументеint argc, char ** argv) и переменные окружения.
Область неинициализированных данных содержит глобальные переменные (в том числе, объявленные какstatic), которые не были проинициализированы в программном коде. Такие переменные по умолчанию инициализируются нулями после старта программы. Область инициализированных данных — сегмент данных — тоже содержит глобальные переменные, но в эту область попадают те переменные, которым было задано начальное значение. Неизменяемые данные, включающие в себя переменные, объявленные с модификаторомconst, строковые литералы и другие составные литералы, помещаются в сегмент текста программы. Сегмент текста программы содержит также исполняемый код и доступен только на чтение, поэтому попытка изменения данных из этого сегмента приведёт к неопределённому поведению в видеошибки сегментации.
Область стека предназначена для размещения данных, связанных свызовом функций, и локальных переменных. Перед каждым запуском функции стек увеличивается для размещения в нём аргументов, передаваемых в функцию. В ходе своей работы функция может размещать в стеке локальные переменные и выделять в нём память под массивы переменной длины, а некоторые компиляторы предоставляют также средства выделения памяти в рамках стека посредством вызоваalloca(), который не входит в стандарт языка. После завершения работы функции стек уменьшается до того значения, которое было перед вызовом, однако этого может не происходить при некорректной работе со стеком. Память, выделенная динамически, предоставляется изкучи➤.
Немаловажной деталью является наличиеслучайного отступамежду стеком и верхней областью, а также между областью инициализированных данных икучей. Делается это в целях безопасности, например, для предотвращения встраивания в стек других функций.
Динамически подключаемыебиблиотеки и отображения файлов с файловой системы находятся между стеком икучей.
В Си отсутствуют какие-либо встроенные механизмы контроля ошибок, но существует несколько общепринятых способов их обработки средствами языка. В общем виде практика обработки ошибок языка Си в отказоустойчивом коде вынуждает писать громоздкие, часто повторяющиеся конструкции, в которых алгоритм совмещён с обработкой ошибок➤.
В языке Си активно используется специальная переменнаяerrnoиз заголовочного файлаerrno.h, в которую функции заносят код ошибки, возвращая при этом значение, являющееся маркером ошибки. Для проверки результата на ошибки результат сравнивают с маркером ошибки, и, если они совпадают, то можно проанализировать код ошибки, сохранённый вerrno, для корректировки работы программы или вывода отладочного сообщения. В стандартной библиотеке стандарт зачастую лишь определяет возвращаемые маркеры ошибок, а выставлениеerrnoзависит от конкретной реализации.
В качестве маркеров ошибок обычно выступают следующие значения:
Практика возвращения маркера ошибки, вместо кода ошибки, хоть и экономит количество передаваемых в функции аргументов, но в ряде случаев приводит к ошибкам в результате человеческого фактора. Например, программистами часто игнорируется проверка результата типаssize_t, а сам результат используется дальше в вычислениях, что приводит к трудно уловимым ошибкам, если возвращается-1.
Ещё сильнее способствует появлению ошибок возврат в качестве маркера ошибки корректного значения, что также вынуждает программиста делать больше проверок, а соответственно и писать больше однотипного повторяющегося кода. Такой подход практикуется в потоковых функциях, работающих с объектами типаFILE *: маркером ошибки является значениеEOF, одновременно являясь и маркером конца файла. Поэтому поEOFиногда приходится проверять поток символов как на конец файла с помощью функцииfeof(), так и наличие ошибки с помощьюferror(). При этом некоторые функции, которые могут вернутьEOFпо стандарту не обязаны выставлятьerrno➤.
Отсутствие единой практики обработки ошибок в стандартной библиотеке приводит к появлению собственных способов обработки ошибок и комбинированию часто используемых способов в сторонних проектах. Например, в проектеsystemdсовместили идеи возвращения кода ошибки и числа-1в качестве маркера — возвращается отрицательный код ошибки. А в библиотекеGLibввели в практику возвращение в качестве маркера ошибки значениебулева типа, в то время как подробная информация об ошибке помещается в специальную структуру, указатель на которую возвращается через последний аргумент функции. Схожее решение использует проектEnlightenment, в котором в качестве маркера тоже используется булев тип, но информация об ошибке возвращается по аналогии со стандартной библиотекой — через отдельную функцию, которую необходимо проверять, если был возвращён маркер.
Альтернативой маркерам ошибок является возвращение кода ошибки напрямую, а результата работы функции — через аргументы по указателю. По такому пути пошли разработчики стандарта POSIX, в функциях которого принято возвращать код ошибки в виде числа типаint. Однако возвращение значения типаintявно не даёт понять, что возвращается именно код ошибки, а не маркер, что может вести к ошибкам, если результат таких функций будет проверяться на значение-1. В расширении K стандарта C11 представлен специальный типerrno_tдля хранения кода ошибки. Существуют рекомендации использовать именно этот тип в пользовательском коде для возвращения ошибок, а если он не предоставлен стандартной библиотекой, то объявлять его самостоятельно:
Такой подход, помимо повышения качества кода, избавляет от необходимости использованияerrno, что позволяет делать библиотеки среентерабельнымифункциями без необходимости подключения дополнительных библиотек, таких какPOSIX Threadsдля правильного определенияerrno.
Более сложной является обработка ошибок в математических функциях из заголовочного файлаmath.h, в которых могут возникать 3 типа ошибок:
Предотвращение двух из трёх типов ошибок сводится к проверкам входных данных на область допустимых значений. Однако предсказать выход результата за пределы типа крайне сложно. Поэтому стандартом языка предусмотрена возможность анализа математических функций на ошибки. Начиная со стандарта C99 такой анализ возможен двумя способами, в зависимости от значения, хранимого в макросеmath_errhandling.
При этом способ обработки ошибок определяется конкретной реализацией стандартной библиотеки и может отсутствовать совсем. Поэтому в платформонезависимом коде может потребоваться проверка результата сразу двумя способами, в зависимости от значенияmath_errhandling.
Как правило возникновение ошибки требует завершения работы функции с возвращением индикатора ошибки. Если в функции ошибка может возникнуть в разных её частях, требуется освобождать ресурсы, выделенные в ходе её работы, чтобы предотвратить утечки. Хорошей практикой освобождения ресурсов считается их чистка в обратном порядке перед возвратом из функции, а в случае ошибок — освобождение в обратном порядке после основногоreturn. В отдельные части такого освобождения можно сделать переход с помощью оператораgoto. Подобный подход позволяет вынести не связанные с реализуемым алгоритмом участки кода за пределы самого алгоритма, повышая читабельность кода, и схож с работой оператораdeferиз языка программированияGo. Пример освобождения ресурсов приведён ниже, в разделе примеров➤.
Для освобождения ресурсов в рамках программы предусмотрен механизм обработчиков выхода из программы. Обработчики назначаются с помощью функцииatexit()и исполняются как по завершении функцииmain()через операторreturn, так и по исполнению функцииexit(). При этом обработчики не исполняются по функциямabort()и_Exit().
В качестве примера освобождения ресурсов по завершении программы можно привести освобождение памяти, выделенной под глобальные переменные. Несмотря на то, что память так или иначе освобождается по завершении работы программы операционной системой, и допускается не освобождать ту память, которая требуется на протяжении всей работы программы, явное освобождение предпочтительнее, так как облегчает поиск утечек памяти сторонними средствами и уменьшает шанс на возникновение утечек памяти в результате ошибки:
Недостатком данного подхода является то, что формат назначаемых обработчиков не предусматривает передачу произвольных данных в функцию, что позволяет создавать обработчики только для глобальных переменных.
Минимальная программа на Си, не требующая обработки аргументов, имеет следующий вид:
Допускается не писать операторreturnу функцииmain(). В таком случае, согласно стандарту, функцияmain()возвращает 0, исполняя все обработчики, назначенные на функциюexit(). При этом подразумевается, что программа успешно завершилась.
ПрограммаHello, world!приведена ещё в первом издании книги «Язык программирования Си» Кернигана и Ритчи:
Эта программа печатает сообщение «Hello, world!» на стандартномустройстве вывода.
Многие функции языка Си могут вернуть ошибку, не выполнив требуемых от них действий. Ошибки требуется проверять и правильно на них реагировать, в том числе часто требуется пробрасывать ошибку из функции на уровень выше для анализа. При этом функцию, в которой произошла ошибка, можно делатьреентерабельной, в таком случае по ошибке функция не должна изменять входные или выходные данные, что позволяет безопасно перезапускать её после исправления ошибочной ситуации.
В примере реализована функция чтения файла на языке Си, однако она требует соответствия функцийfopen()иfread()стандартуPOSIX, иначе они могут не выставлять переменнуюerrno, что сильно усложняет как отладку, так и написание универсального и безопасного кода. На платформах, не соответствующих POSIX, поведение данной программы будет неопределённым в случае ошибки➤. Освобождение ресурсов по ошибкам находится за основным алгоритмом для повышения читабельности, а переход осуществляется с помощьюgoto.
Некоторые компиляторы идут в комплекте с компиляторами других языков программирования (включаяC++) или являются составной частьюсреды разработки программного обеспечения.
Несмотря на то, чтостандартная библиотекавходит в стандарт языка, её реализации идут отдельно от компиляторов. Поэтому стандарты языка, поддерживаемые компилятором и библиотекой, могут различаться.
Поскольку язык Си не предоставляет средств для безопасного написания кода, а многие элементы языка способствуют появлению ошибок, написание качественного и отказоустойчивого кода можно гарантировать только с помощью создания автоматизированных тестов. Для упрощения такого тестирования существуют различные реализации сторонних библиотекмодульного тестирования.
Существует также много других систем для тестирования кода на Си, таких как AceUnit, GNU Autounit, cUnit и других, но они либо не осуществляют тестирование в изолированных окружениях, либо предоставляют мало возможностей, либо перестали развиваться.
По проявлениям ошибок не всегда можно сделать однозначный вывод о проблемном месте в коде, однако локализовать проблему часто помогают различные средства отладки.
Иногда, в целях переноса тех или иных библиотек, функций и инструментов, написанных на Си, в иную среду, требуется компиляция Си-кода на язык более высокого уровня или в кодвиртуальной машины, предназначенной для такого языка. Следующие проекты предназначены для этих целей:
Также для Си существуют и другие инструменты, облегчающие и дополняющие разработку, включаястатические анализаторыи утилиты для форматирования кода. Статический анализ помогает выявлять потенциальные ошибки и уязвимости. А автоматическое форматирование кода упрощает организацию совместной работы в системах контроля версий, минимизируя конфликты из-за стилевых правок.
Язык широко применяется при разработке операционных систем, на уровне прикладного интерфейса операционных систем, во встраиваемых системах, а также для создания высокопроизводительного или критического в плане обработки ошибок кода. Одной из причин широкого распространения для программирования на низком уровне является возможность писать кроссплатформенный код, который может по-разному обрабатываться на разном оборудовании и на разных операционных системах.
Возможность писать высокопроизводительный код обеспечивается за счёт полной свободы действий программиста и отсутствия строгого контроля со стороны компилятора. Так, например, на языке Си написаны первые реализации языковJava,Python,PerlиPHP. При этом во многих программах наиболее требовательные к ресурсам части принято писать на языке Си. Ядро программыMathematicaнаписано на Си, аMATLAB, изначально написанный наФортране, был переписан на Си в 1984 году.
Также Си иногда используется как промежуточный язык при компиляции более высокоуровневых языков. Например, по такому принципу работали первые реализации языковC++,Objective-CиGo, — код, написанный на этих языках, транслировался в промежуточное представление на языке Си. Современными языками, работающими по такому же принципу, являются языкValaиNim.
Ещё одной областью применения языка Си являются приложенияреального времени, которые требовательны по части отзывчивости кода и времени его исполнения. Такие приложения должны начинать исполнение действий в жёстко ограниченных временных рамках, а сами действия должны укладываться в определённый временной промежуток. В частности, стандартPOSIX.1предоставляет набор функций и возможностей для создания приложений реального времени, однако поддержка жёсткого реального времени должна быть также реализована и со стороныоперационной системы.
Язык Си был и остаётся одним из самых распространённых языков программирования в течение более чем сорока лет. Естественно, что его влияние можно проследить в той или иной мере во многих более поздних языках. Тем не менее среди языков, достигших определённого распространения, прямых потомков у Си немного.
Часть языков-потомков надстраивает Си дополнительными средствами и механизмами, добавляющими поддержку новых парадигм программирования (ООП,функциональное программирование,обобщённое программированиеи пр.). К таким языкам относятся, прежде всего,C++иObjective-C, а опосредованно — их потомкиSwiftиD. Также известны попытки улучшить Си, исправив его наиболее существенные недостатки, но сохранив его привлекательные черты. Среди них можно упомянуть исследовательский языкCyclone(и его потомокRust). Иногда оба направления развития объединяются в одном языке, примером может служитьGo.
Отдельно необходимо упомянуть о целой группе языков, которые в большей или меньшей мере унаследовали базовый синтаксис Си (использование фигурных скобок в качестве ограничителей блоков кода, описание переменных, характерные формы операторовfor,while,if,switchс параметрами в скобках, комбинированные операции++,--,+=,-=и другие), из-за чего программы на этих языках имеют характерный внешний вид, ассоциирующийся именно с Си. Это такие языки какJava,JavaScript,PHP,Perl,AWK,C#. В действительности структура и семантика этих языков сильно отличается от Си, и обычно они предназначены для тех сфер применения, где оригинальный Си никогда не использовался.
Язык программированияC++был создан из Си и унаследовал его синтаксис, дополнив его новыми конструкциями в духе языков Simula-67, Smalltalk, Modula-2, Ada, Mesa и Clu. Основными дополнениями стали поддержкаООП(описание классов, множественное наследование, полиморфизм, основанный на виртуальных функциях) и обобщённого программирования (механизм шаблонов). Но помимо этого в язык внесено множество самых различных дополнений. На данный момент C++ является одним из наиболее распространённых языков программирования в мире и позиционируется как язык общего назначения с уклоном в системное программирование.
Изначально C++ сохранял совместимость с Си, которая была заявлена как одно из преимуществ нового языка. Первые реализации C++ просто переводили новые конструкции в чистый Си, после чего код обрабатывался обычным Си-компилятором. Для сохранения совместимости создатели C++ отказались от исключения из него некоторых часто критикуемых особенностей Си, вместо этого создав новые, «параллельные» механизмы, которые рекомендуется применять при разработке нового кода на C++ (шаблоны вместо макроопределений, явное приведение типов вместо автоматического, контейнеры стандартной библиотеки вместо ручного динамического выделения памяти и так далее). Однако в дальнейшем языки развивались независимо, и сейчас Си и C++ последних выпущенных стандартов являются лишь частично совместимыми: не гарантируется успешная компиляция программы на Си компилятором C++, а в случае успеха нет гарантии, что откомпилированная программа будет работать правильно. Особенно неприятны некоторые тонкие семантические различия, которые могут приводить к разному поведению одного и того же кода, синтаксически корректного для обоих языков. Например, символьные константы (символы, заключённые в одинарные кавычки) имеют типintв Си и типcharвC++, так что объём памяти, занимаемый такими константами, в разных языках различается.Если программа чувствительна к размеру символьной константы, она будет работать по-разному, будучи откомпилирована трансляторами Си и C++.
Подобные различия затрудняют написание программ и библиотек, которые могли бы нормально компилироваться и работать одинаково и в Си и вC++, что, конечно, запутывает тех, кто программирует на обоих языках. Среди разработчиков и пользователей как Си, так и C++ есть сторонники максимального сокращения различий между языками, что объективно принесло бы ощутимую пользу. Существует, однако, и противоположная точка зрения, согласно которой совместимость не особенно важна, хоть и полезна, и усилия по уменьшению несовместимости не должны препятствовать улучшению каждого языка в отдельности.
Ещё одним вариантом расширения Си объектными средствами является языкObjective-C, созданный в 1983 году. Объектная подсистема была заимствована изSmalltalk, причём все элементы, связанные с этой подсистемой, реализованы в собственном синтаксисе, достаточно резко отличающемся от синтаксиса Си (вплоть до того, что в описании классов синтаксис объявления полей противоположен синтаксису описания переменных в Си: сначала пишется имя поля, затем его тип). В отличие от C++, Objective-C является надмножеством классического Си, то есть сохраняет совместимость с исходным языком; правильная программа на Си является правильной программой на Objective-C. Другим существенным отличием от идеологии C++ является то, что Objective-C реализует взаимодействие объектов путём обмена полноценными сообщениями, тогда как в C++ реализована концепция «отправка сообщения как вызов метода». Полноценная обработка сообщений является значительно более гибкой, к тому же она естественным образом сочетается с параллельными вычислениями. Objective-C, а также его прямой потомокSwiftявляются одними из самых популярных на платформах, поддерживаемыхApple.
Язык Си уникален с той точки зрения, что именно он стал первымязыком высокого уровня, всерьёз потеснившимассемблерв разработкесистемного программного обеспечения. Он остаётся языком, реализованным на максимальном количествеаппаратных платформ, и одним из самых популярныхязыков программирования, особенно в миресвободного программного обеспечения. Тем не менее язык имеет множество недостатков, он с момента появления подвергается критике многих специалистов.
Язык весьма сложен и наполнен опасными элементами, которые очень легко использовать неправильно. Своей структурой и правилами он никак не поддерживает программирование, нацеленное на создание надёжного и удобного в сопровождении программного кода, напротив, рождённый в эпоху прямого программирования под различные процессоры, язык способствует написаниюнебезопасногоизапутанногокода. Многие профессиональные программисты склонны считать, что язык Си—мощный инструмент для создания элегантных программ, но в то же время с его помощью можно создавать крайне некачественные решения.
Из-за различных допущений в языке программы могут компилироваться со множественными ошибками, что часто приводит кнепредсказуемому поведениюпрограммы. Современные компиляторы предоставляют опции для статического анализа кода, но даже они не способны выявить все возможные ошибки. Результатом неграмотного программирования на Си могут статьуязвимостипрограммного обеспечения, что может сказаться набезопасностиего использования.
У Си высокий порог вхождения. Спецификация его занимает более 500 страниц текста, которые необходимо изучить полностью, так как для создания безошибочного и качественного кода приходится учитывать многие неочевидные особенности языка. Например, автоматическое приведение операндов целочисленных выражений к типуintможет давать трудно предсказуемые результаты при использовании бинарных операторов:
Недостаточное понимание подобных нюансов может приводить к появлению многочисленных ошибок и уязвимостей. Ещё одним фактором, увеличивающим сложность освоения Си, является отсутствие обратной связи от компилятора: язык даёт программисту полную свободу действий и позволяет компилировать программы с явными логическими ошибками. Всё это затрудняет использование Сив обучениив качестве первого языка программирования
Наконец, за более чем 40 лет существования язык успел несколько устареть, и в нём достаточно проблематично использовать многие современные приёмы ипарадигмы программирования.
В синтаксисе Си нет модулей и механизмов их взаимодействия. Файлы исходного кода компилируются раздельно и должны включать прототипы импортируемых из других файлов переменных, функций и типов данных. Для этого используется включение заголовочных файлов через макроподстановку#include➤. В случае нарушения соответствия между файлами кода и заголовочными файлами могут возникать как ошибки этапа компоновки, так и всевозможные ошибки времени исполнения: от порчистекаикучидоошибок сегментирования. Поскольку директива#includeлишь подставляет текст одного файла в другой, включение большого количества заголовочных файлов приводит к тому, что многократно возрастает фактический объём кода, попадающего на компиляцию, что является причиной относительно низкой скорости работы компиляторов языка Си. Необходимость согласования описаний в основном модуле и заголовочных файлах затрудняет сопровождение программы.
Стандарт языка даёт программисту большую свободу действий и тем самым — высокие шансы на допущение ошибок. Многое из того, что чаще всего нельзя делать, дозволено языком, и компилятор в лучшем случае выдаёт предупреждения. Хотя современные компиляторы позволяют переводить все предупреждения в класс ошибок, эта возможность используется редко, гораздо чаще предупреждения игнорируются, если программа работает удовлетворительно.
Так, например, до стандарта C99 вызов функцииmallocбез подключения заголовочного файлаstdlib.hмог привести к порче стека, поскольку в отсутствие прототипа функция вызывалась как возвращающая типint, тогда как фактически она возвращала типvoid*(ошибка возникала, когда размеры типов на целевой платформе различались). Но даже при этом выдавалось всего лишь предупреждение.
Автоматически и динамически создаваемые объекты по умолчанию не инициализируются и после создания содержат значения, оставшиеся в памяти от ранее находившихся там объектов. Такое значение полностью непредсказуемо, оно меняется от одной машины к другой, от запуска к запуску, от вызова функции к вызову. Если программа из-за случайного пропуска инициализации использует такое значение, то результат будет непредсказуемым и может проявиться не сразу. Современные компиляторы пытаются диагностировать эту проблему статическим анализом исходного кода, хотя в общем случае статическим анализом данную проблему решить крайне сложно. Для выявления данных проблем на этапе тестирования в ходе исполнения программы могут использоваться дополнительные инструменты:ValgrindиMemorySanitizer.
Источником опасных ситуаций служит совместимость указателей с числовыми типами и возможность использования адресной арифметики без строгого контроля на этапах компиляции и исполнения. Это даёт возможность получить указатель на любой объект, включая исполняемый код, и обратиться по этому указателю, если толькомеханизм защиты памяти системыэтому не воспрепятствует.
Неправильное использование указателей может порождатьнеопределённое поведениепрограммы и приводить к серьёзным последствиям. К примеру, указатель может быть неинициализированным или, в результате неверных арифметических операций, указывать в произвольное место памяти. На одних платформах работа с таким указателем может вызвать принудительную остановку программы, на других это может привести к порче произвольных данных в памяти; последняя ошибка опасна тем, что её последствия непредсказуемы и могут проявиться в произвольный момент времени, в том числе намного позже момента собственно ошибочного действия.
Доступ к массивам в Си также реализован посредством адресной арифметики и не предполагает средств проверки корректности обращения к элементам массива по индексу. Например, выраженияa[i]иi[a]идентичны и просто транслируются к виду*(a + i), а проверка на выход за границы массива не проводится. Обращение по индексу, превышающему верхнюю границу массива, приводит к обращению к данным, размещённым в памяти после массива, что называютпереполнением буфера. Когда подобное обращение происходит ошибочно, оно может привести к непредсказуемому поведению программы. Нередко данная особенность используется вэксплоитах, используемых для нелегального доступа к памяти другого приложения или памяти ядра операционной системы.
Системные функции для работы с динамически выделяемой памятью не обеспечивают контроля за правильностью и своевременностью её выделения и освобождения, соблюдение правильного порядка работы с динамической памятью полностью возлагается на программиста. Его ошибки, соответственно, могут приводить к обращению по некорректным адресам, к преждевременному освобождению либо кутечкепамяти (последнее возможно, например, если разработчик забыл вызватьfree()или вызывающуюfree()функцию, когда это требовалось).
Одной из частых ошибок является отсутствие проверок результата работы функций выделения памяти (malloc(),calloc()и прочие) наNULL, в то время как память может не выделиться, если её не хватает, или если был запрошен слишком большой объём, например, из-за приведения числа-1, полученного в результате каких-либо ошибочных математических операций, к беззнаковому типуsize_t, с последующими операциями над ним➤. Ещё одной проблемой системных функций работы с памятью является неспецифицированное поведение при запросе выделения блока нулевого размера: функции могут вернуть какNULL, так и действительное значение указателя, в зависимости от конкретной реализации.
Некоторые конкретные реализации и сторонние библиотеки предоставляют такие средства, как подсчёт ссылок ислабые ссылки,умные указатели, а также ограниченные формысборки мусора, но все эти средства не являются стандартными, что, естественно, ограничивает их применение.
Для языка стандартными являютсянуль-терминированные строки➤, соответственно все стандартные функции работают именно с ними. Это решение приводит к значительной потере эффективности за счёт малозначительной экономии памяти (по сравнению с явным хранением размера): вычисление длины строки (функцияstrlen()) требует обхода в цикле всей строки от начала до конца, копирование строк также сложно оптимизировать из-за наличия терминирующего нуля. Из-за необходимости добавлять к данным строки терминирующий нуль становится невозможным эффективное получение подстрок в виде срезов и работа с ними как с обычными строками; выделение частей строк и манипуляции с ними обычно требуют ручного выделения и освобождения памяти, что дополнительно повышает вероятность ошибки.
Нуль-терминированные строки являются частым источником ошибок. Даже стандартные функции обычно не выполняют проверки на размер целевого буфераи могут не добавлять в конце строки нулевой символ, не говоря уже о том, что он может быть не добавлен или затёрт из-за ошибки программиста..
Поддерживаяфункции с переменным числом аргументов, Си не содержит ни средств определения числа и типов фактических параметров, переданных такой функции, ни механизма безопасного доступа к ним. Информирование функции о составе фактических параметров лежит на программисте, а для доступа к их значениям необходимо отсчитать правильное количество байтов от адреса последнего фиксированного параметра в стеке либо вручную, либо пользуясь набором макросовva_argиз заголовочного файлаstdarg.h. При этом необходимо учитывать работу механизма автоматического неявного повышения типов при вызове функций, согласно которому целочисленные типы аргументов размером менееintприводятся кint(илиunsigned int), аfloatприводится кdouble. Ошибка в вызове или в работе с параметрами внутри функции проявится только во время исполнения программы, приводя к непредсказуемым последствиям, от чтения неверных данных до порчи стека.
При этом стандартным средством форматированного ввода-вывода являются именно функции с переменным числом параметров (printf(),scanf()и другие), не способные проверить соответствие списка аргументов строке формата. Многие современные компиляторы проводят такую проверку для каждого их вызова, генерируя предупреждения при обнаружении несоответствия, однако в общем случае подобная проверка невозможна, так как каждая функция с переменным числом аргументов обрабатывает этот список по-своему. Невозможно статически проконтролировать даже все вызовы функцииprintf(), поскольку строка формата может создаваться в программе динамически.
Синтаксис Си не включает специального механизма обработки ошибок. Стандартная библиотека поддерживает лишь простейшие средства: переменная (в случаеPOSIX— макрос)errnoиз заголовочного файлаerrno.hдля установки кода последней ошибки и функции для получения сообщений об ошибках согласно кодам.➤Такой подход приводит к необходимости писать большой объём повторяющегося кода, смешивая основной алгоритм с обработкой ошибок, к тому же он не является потокобезопасным. Причём даже в этом механизме нет единого порядка:
В стандартной библиотеке кодыerrnoобозначаются через макроопределения и могут иметь одинаковые значения, что не даёт возможности анализировать коды ошибок через операторswitch. В языке нет специального типа данных для флагов и кодов ошибок, они передаются как значения типаint. Отдельный типerrno_tдля хранения кода ошибки появился лишь в расширении K стандарта C11 и может не поддерживаться компиляторами.
Недостатки Си давно и хорошо известны, и с момента появления языка предпринималось множество попыток повысить качество и безопасность кода на Си, не принося в жертву его возможности.
Практически все современные компиляторы Си позволяют проводить ограниченный статический анализ кода с выдачей предупреждений о потенциальных ошибках. Также поддерживаются опции встраивания в код проверок выхода за пределы массива, разрушения стека, выхода за пределы динамической памяти, чтения неинициализированных переменных, возможностей неопределённого поведения и т. п. Однако дополнительные проверки могут сказаться на производительности итогового приложения, поэтому чаще всего их применяют только на этапе отладки.
Существуют специальные программные средства для статического анализа кода на Си для выявления не-синтаксических ошибок. Их применение не гарантирует безошибочности программ, но позволяет выявить значительную часть типичных ошибок и потенциальных уязвимостей. Максимальный эффект данных средств достигается не при эпизодическом использовании, а при применении в составе отработанной системы постоянного контроля качества кода, например, в системах непрерывной интеграции и развёртывания. Также может требоваться аннотирование кода специальными комментариями, чтобы исключить ложные срабатывания анализатора на корректных участках кода, формально попадающих под критерии ошибочных.
Выпущено значительное количество исследований о правильном программировании на Си, от небольших статей до объёмных книг. Для поддержания качества кода на Си принимаются корпоративные и отраслевые стандарты. В частности:
Компенсации некоторых недостатков языка способствует набор стандартовPOSIX. Стандартизируется установкаerrnoмногими функциями, позволяя обрабатывать ошибки, возникающие, например, в функциях работы с файлами, а также вводятсяпотокобезопасныеаналоги некоторых функций стандартной библиотеки, безопасные варианты которых в стандарте языка присутствуют лишь в расширении K.