Язык C был создан чуть позже Паскаля Деннисом Ритчи из американской компании AT&T Bell Labs, и, выражаясь по-американски, "захватил мир как шторм".
Предыдущая часть:
Эффективный, компактный, очень гибкий в работе с памятью, он покорил меня с первого взгляда, и я сразу отказался от Бейсика и Паскаля, на которых писал до этого, оставив лишь ассемблер в запасе :)
Но для новичков он не так уж понятен. Рассмотрим некоторые его особенности.
Структура программы
Первое, что видит человек при знакомстве с C, это вот такой код:
Для сравнения код на Паскале:
Программа на Паскале интуитивно понятна. В C же, кроме строчки Hello World, новичку понятно примерно ничего. Поэтому зачастую учащиеся начинают писать программы на C, просто копируя данную структуру и не вникая в детали. Если же разобрать, что на самом деле происходит, каждой строчке потребуется обширное объяснение.
#include
Это визитная карточка C. Всё, что начинается с решётки, является не программным кодом, а директивой препроцессора. Препроцессор это программа, которая запускается на первом этапе компиляции перед собственно компилятором, и её задача – подготовить код для реальной компиляции.
В данном случае директива #include включает в текст программы текст из другого файла с названием stdio.h. То есть прямо в этом месте появятся инструкции из другого файла, после чего программа будет уже скомпилирована. К самому файлу stdio.h мы вернёмся позже, а пока рассмотрим ещё пару основных возможностей препроцессора.
Инструкция #define позволяет заместить одно выражение другим. Например, так можно создать константу:
#define SIZE 1000
Далее везде, где будет написано SIZE, препроцессор сделает замену на 1000.
Инструкция #define воспринимает всё написанное буквально, поэтому можно написать что-то типа
#define SUM (x + y)
И SUM будет буквально заменяться на (x + y) везде. Корректность такого выражения зависит только от вас.
Далее, есть инструкции условной компиляции:
Здесь инструкция #ifdef означает "if defined", или "если задано / существует", а именно, если задан флаг компиляции DEBUG. Названия флагов мы можем придумывать самостоятельно и передавать их в компилятор через командную строку. Допустим, если эту программу скомпилировать как есть:
gcc main.c
То на экран ничего не выведется, так как флаг DEBUG не был определён. Если же сделать так:
gcc -D DEBUG main.c
То препроцессор увидит флаг DEBUG и добавит в программу ту часть кода, которая находится в блоке #ifdef. Соответственно, на экране появится текст "Debug".
Таким образом, команды препроцессора позволяют получать фактически разные программы для разных сред или условий. Целые куски кода могут появляться или исчезать из программы, в зависимости от того, с какими флагами она скомпилирована.
Однако некоторые программы грешат чрезмерным использованием таких директив, из-за чего код превращается в кашу-малашу:
stdio.h
Файлы с расширением .h – заголовочные, от слова header. Они содержат обычный код C, так что расширение не играет никакой роли. Мы просто вставляем в программу на C кусок такого же кода на C.
Зачем это делать?
Часть функций языка C – например, printf() – не описана в нём по умолчанию. Они находятся в "стандартной библиотеке", которая уже скомпилирована и в процессе компиляции присоединяется к главной программе. Есть и другие библиотеки, которые тоже присоединяются в случае необходимости – в том числе написанные вами.
Функции стандартной библиотеки делятся на группы "по интересам". Так, группа описаний stdio это "standard input-output", или стандартный ввод-вывод. За работу с памятью, системой и другие базовые вещи отвечает stdlib, за математические функции math, и т.д.
Чтобы вызывать какую-то функцию, вы должны заранее описать её. Это касается и ваших собственных функций. Например, вы написали две функции foo() и bar():
В этом случае функция foo() в своём коде вызывает функцию bar(), которая ещё не определена (кстати, такая же ситуация есть и в Паскале). Можно поставить функцию bar() выше, но если и она тоже будет вызывать foo(), возникнет замкнутый круг, когда ни одна функция не может быть определена позже другой.
Выйти из ситуации можно, разместив сверху описание функции, но не саму функцию:
Таким же образом размещаются описания функций из stdio и др. Вы подключаете те описания, которые вам потребуются. И вот чтобы не мусорить этими описаниями в главной программе и/или не переписывать каждый раз одно и то же, они и собираются в заголовочные файлы.
Можно создавать заголовочные файлы для своих функций, ну или использовать #include просто чтобы разбить программу на несколько файлов для удобства.
Синтаксис
О приятном. Синтаксис C минималистичен до предела. Так, вместо паскалевских begin и end пишутся просто скобки { }. Это было большим облегчением, так как в эпоху компьютеров Электроника МС-0511 с операционной системой RT11 или 286 с DOS лично я не знал ни про какие модные IDE, которые напечатают код за меня. Всё приходилось набирать руками, и сокращение количества символов было просто счастьем.
У подпрограмм нет ключевых слов вроде function или procedure. Подпрограмма отличается просто тем, что у неё есть скобки после имени.
И сразу же можно заметить, что наша главная программа тоже является подпрограммой с именем main(). Ещё один кусочек головоломки для новичков встаёт на место. main() это действительно обычная функция, в которой выполняется вся программа. Она вызывается после запуска программы, и она же может вернуть результат. Куда? В операционную систему.
Поэтому у main() объявлен тип int и есть инструкция, возвращающая результат:
return 0;
В некоторых случаях нам не надо, чтобы функция возвращала результат (в Паскале это будет процедура). Тогда у функции будет тип void, то есть "пустой":
Меня также очень радуют инструкции для модификации числовых значений. Например, такое стандартное действие в Паскале, чтобы прибавить 5 к переменной с длинным именем:
veryLongName := veryLongName + 5;
В C можно записать так:
veryLongName += 5;
Это просто супер-удобно.
Типы данных и память
В C каждая переменная, функция или параметр функции должны иметь строго определённый тип, и компилятор за этим следит.
Но с другой стороны всё, что волнует компилятор, это размер памяти, занимаемый переменной. Поэтому типы можно достаточно легко приводить к другим типам.
С числами проблем нет вообще – можно превратить символ в число и наоборот, можно сложить символ с числом, так как тип char, предназначенный для символов, на самом деле числовой. Можно делать из знакового значения беззнаковое, расширять или урезать размер.
Особую роль играют указатели. Рассмотрим некий адрес в памяти, по которому расположены 4 байта. Эти 4 байта мы можем трактовать как целое число длиной 32 бита. Или как строку из 4-х символов. Или как структуру с полями r, g, b, a, размером по одном байту. Или как массив из 4-х байт. Или как массив из двух 16-битных целых чисел.
Для этого надо просто создать указатель соответствующего типа и указать им на этот адрес.
Результат это разные интерпретации одной и той же сущности:
Языку C всё равно, чем мы считаем эти 4 байта. Как писал Карлос Кастанеда,
Когда воин научится видеть, он увидит, что человек – это светящееся яйцо, будь он нищий или король. А что можно изменить в светящемся яйце?
Память в C мы буквально видим и щупаем руками, как в ассемблере. У каждой переменной есть адрес, к каждому адресу можно добавить смещение, чтобы получить другой адрес. Это одно из главных достоинств языка, но и один из главных недостатков. Вся память, которую мы используем, должна управляться вручную.
Это не сильно отличается от обычного объявления переменных, массивов и пр. Но когда дело доходит до операций со строками (они же массивы), всё становится немного сложнее. Так, чтобы склеить вместе две строки, мы должны выделить буфер памяти достаточного размера, и в этот буфер скопировать сначала одну строку, потом другую. Сама операция делается в одну строчку с помощью функции strcat(), но выделить под неё буфер необходимо руками.
Когда это приходится делать часто, неизбежно возникают проблемы менеджмента памяти. Либо надо следить за всеми выделенными кусками, либо выделить один и использовать его постоянно, но опять же надо следить, чтобы он не оказался занят чем-то другим, и т.д.
Но эти проблемы есть во всех языках без сборщиков мусора.
Цикл for
Если в Паскале цикл for работает строго как счётчик, то в C это универсальная конструкция, которая по факту может делать что угодно.
int i;
for (i = 0; i < 10; i++) { ... }
В заголовке цикла есть три части: инициализация (i = 0), условие повторения (i < 10), и модификация счётчика (i++). Можно подумать, что переменная i выступает как счётчик цикла, увеличиваясь на 1. На деле же все эти три части совершенно не связаны друг с другом. В инициализации цикла мы можем написать вообще любое выражение, а также несколько выражений через запятую, например:
for (a = b + c, i = 0; i < 10; i++)
Операция a = b + c вообще не относится к счётчику цикла. Но участвует в инициализации. Таким же образом в качестве условия повторения можно написать что угодно, не связанное со счётчиком i, ну и вместо приращения счётчика также что угодно. Например:
for (i = 0; a < 10; a += b, b++)
Вопрос, зачем так делать, можно не задавать. Если в этом нет необходимости, то естественно достаточно обойтись простым циклом for с простым счётчиком. Можно также написать цикл вообще без ничего, он будет бесконечным:
for ( ; ; )
Заключение
Язык C – чёткий, дерзкий и крайне опасный. Программирование на нём может приносить как удовольствие, так и боль. Программируя на C, вы понимаете, что всё иллюзия, кроме памяти.
Язык C – инфлюенсер. C-подобный синтаксис имеют PHP, Java, JS, Rust, C# и другие.
Можно продолжать его описывать очень долго, но объём данного материала ограничен. Вообще у меня много статей, так или иначе касающихся C, и есть целый цикл про написание игры Minesweeper с нуля и до законченного продукта:
Читайте дальше: