Найти тему
ZDG

Языки программирования 4: C

Оглавление

Язык C был создан чуть позже Паскаля Деннисом Ритчи из американской компании AT&T Bell Labs, и, выражаясь по-американски, "захватил мир как шторм".

Предыдущая часть:

Эффективный, компактный, очень гибкий в работе с памятью, он покорил меня с первого взгляда, и я сразу отказался от Бейсика и Паскаля, на которых писал до этого, оставив лишь ассемблер в запасе :)

Но для новичков он не так уж понятен. Рассмотрим некоторые его особенности.

Структура программы

Первое, что видит человек при знакомстве с C, это вот такой код:

Для сравнения код на Паскале:

-2

Программа на Паскале интуитивно понятна. В 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) везде. Корректность такого выражения зависит только от вас.

Далее, есть инструкции условной компиляции:

-3

Здесь инструкция #ifdef означает "if defined", или "если задано / существует", а именно, если задан флаг компиляции DEBUG. Названия флагов мы можем придумывать самостоятельно и передавать их в компилятор через командную строку. Допустим, если эту программу скомпилировать как есть:

gcc main.c

То на экран ничего не выведется, так как флаг DEBUG не был определён. Если же сделать так:

gcc -D DEBUG main.c

То препроцессор увидит флаг DEBUG и добавит в программу ту часть кода, которая находится в блоке #ifdef. Соответственно, на экране появится текст "Debug".

Таким образом, команды препроцессора позволяют получать фактически разные программы для разных сред или условий. Целые куски кода могут появляться или исчезать из программы, в зависимости от того, с какими флагами она скомпилирована.

Однако некоторые программы грешат чрезмерным использованием таких директив, из-за чего код превращается в кашу-малашу:

-4

stdio.h

Файлы с расширением .h – заголовочные, от слова header. Они содержат обычный код C, так что расширение не играет никакой роли. Мы просто вставляем в программу на C кусок такого же кода на C.

Зачем это делать?

Часть функций языка C – например, printf() – не описана в нём по умолчанию. Они находятся в "стандартной библиотеке", которая уже скомпилирована и в процессе компиляции присоединяется к главной программе. Есть и другие библиотеки, которые тоже присоединяются в случае необходимости – в том числе написанные вами.

Функции стандартной библиотеки делятся на группы "по интересам". Так, группа описаний stdio это "standard input-output", или стандартный ввод-вывод. За работу с памятью, системой и другие базовые вещи отвечает stdlib, за математические функции math, и т.д.

Чтобы вызывать какую-то функцию, вы должны заранее описать её. Это касается и ваших собственных функций. Например, вы написали две функции foo() и bar():

-5

В этом случае функция foo() в своём коде вызывает функцию bar(), которая ещё не определена (кстати, такая же ситуация есть и в Паскале). Можно поставить функцию bar() выше, но если и она тоже будет вызывать foo(), возникнет замкнутый круг, когда ни одна функция не может быть определена позже другой.

Выйти из ситуации можно, разместив сверху описание функции, но не саму функцию:

-6

Таким же образом размещаются описания функций из stdio и др. Вы подключаете те описания, которые вам потребуются. И вот чтобы не мусорить этими описаниями в главной программе и/или не переписывать каждый раз одно и то же, они и собираются в заголовочные файлы.

Можно создавать заголовочные файлы для своих функций, ну или использовать #include просто чтобы разбить программу на несколько файлов для удобства.

Синтаксис

О приятном. Синтаксис C минималистичен до предела. Так, вместо паскалевских begin и end пишутся просто скобки { }. Это было большим облегчением, так как в эпоху компьютеров Электроника МС-0511 с операционной системой RT11 или 286 с DOS лично я не знал ни про какие модные IDE, которые напечатают код за меня. Всё приходилось набирать руками, и сокращение количества символов было просто счастьем.

У подпрограмм нет ключевых слов вроде function или procedure. Подпрограмма отличается просто тем, что у неё есть скобки после имени.

-7

И сразу же можно заметить, что наша главная программа тоже является подпрограммой с именем main(). Ещё один кусочек головоломки для новичков встаёт на место. main() это действительно обычная функция, в которой выполняется вся программа. Она вызывается после запуска программы, и она же может вернуть результат. Куда? В операционную систему.

Поэтому у main() объявлен тип int и есть инструкция, возвращающая результат:

return 0;

В некоторых случаях нам не надо, чтобы функция возвращала результат (в Паскале это будет процедура). Тогда у функции будет тип void, то есть "пустой":

-8

Меня также очень радуют инструкции для модификации числовых значений. Например, такое стандартное действие в Паскале, чтобы прибавить 5 к переменной с длинным именем:

veryLongName := veryLongName + 5;

В C можно записать так:

veryLongName += 5;

Это просто супер-удобно.

Типы данных и память

В C каждая переменная, функция или параметр функции должны иметь строго определённый тип, и компилятор за этим следит.

Но с другой стороны всё, что волнует компилятор, это размер памяти, занимаемый переменной. Поэтому типы можно достаточно легко приводить к другим типам.

С числами проблем нет вообще – можно превратить символ в число и наоборот, можно сложить символ с числом, так как тип char, предназначенный для символов, на самом деле числовой. Можно делать из знакового значения беззнаковое, расширять или урезать размер.

Особую роль играют указатели. Рассмотрим некий адрес в памяти, по которому расположены 4 байта. Эти 4 байта мы можем трактовать как целое число длиной 32 бита. Или как строку из 4-х символов. Или как структуру с полями r, g, b, a, размером по одном байту. Или как массив из 4-х байт. Или как массив из двух 16-битных целых чисел.

Для этого надо просто создать указатель соответствующего типа и указать им на этот адрес.

-9

Результат это разные интерпретации одной и той же сущности:

-10

Языку C всё равно, чем мы считаем эти 4 байта. Как писал Карлос Кастанеда,

Когда воин научится видеть, он увидит, что человек – это светящееся яйцо, будь он нищий или король. А что можно изменить в светящемся яйце?

Память в C мы буквально видим и щупаем руками, как в ассемблере. У каждой переменной есть адрес, к каждому адресу можно добавить смещение, чтобы получить другой адрес. Это одно из главных достоинств языка, но и один из главных недостатков. Вся память, которую мы используем, должна управляться вручную.

Это не сильно отличается от обычного объявления переменных, массивов и пр. Но когда дело доходит до операций со строками (они же массивы), всё становится немного сложнее. Так, чтобы склеить вместе две строки, мы должны выделить буфер памяти достаточного размера, и в этот буфер скопировать сначала одну строку, потом другую. Сама операция делается в одну строчку с помощью функции strcat(), но выделить под неё буфер необходимо руками.

Когда это приходится делать часто, неизбежно возникают проблемы менеджмента памяти. Либо надо следить за всеми выделенными кусками, либо выделить один и использовать его постоянно, но опять же надо следить, чтобы он не оказался занят чем-то другим, и т.д.

Но эти проблемы есть во всех языках без сборщиков мусора.

Сборщик мусора придёт за тобой
ZDG26 июля 2020

Цикл 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 с нуля и до законченного продукта:

Читайте дальше: