Язык Си
Как самостоятельный язык, язык Си сейчас не слишком широко используется (в силу возраста и связанных с этим особенностей), но при этом является универсальным в следующем смысле: если из программы, написанной на языке X, нужно вызвать подпрограмму, написанную на языке Y, язык Y делает вид, что эта подпрограмма написана на Си, а язык X вызывает эту подпрограмму, ничего не зная об устройстве языка Y.
Эти два механизма:
- эмуляция того, что подпрограмма написана на Си
- вызов подпрограммы, написанной на Си
называются FFI (Foreign Function Interface) и поддерживаются почти всеми значимыми реализациями языков программирования.
Единственное исключение: в браузерном окружении роль универсального языка играет не Си, а Javascript.
Средства разработки
У языка Си есть три основных реализации:
Первые две — наборы утилит командной строки. Третья — полноценная среда разработки (но утилиты командной строки тоже есть).
Также возможность запустить программу на языке Си предоставляют сервисы типа repl.it и ideone.
Отметим, что использование среды разработки при программировании на Си не даёт почти никаких преимуществ перед использованием обычного текстового редактора. Единственное полезное свойство текстового редактора, действительно влияющее на удобство использования: (хотя бы минимальная) подсветка синтаксиса.
Литература
Есть классическая книга The C Programming Language под авторством Brian Kernighan и Dennis Ritchie (второй из них по совместительству является одним из создателей языка Си). У неё есть переводы на русский язык.
Наиболее актуальный справочник по современному Си — сайт cppreference.com.
Введение
Тривиальная программа
Программа, которую можно запустить, и которая не совершает никаких действий, кроме успешного завершения, выглядит так:
int main(void) {
return 0;
}
Стандартный способ запуска (в POSIX-окружениях) следующий:
- сохранить текст программы в файл с суффиксом
.c
(например,trivial.c
) - запустить компилятор с правильными аргументами (например,
gcc trivial.c -o trivial
) - запустить полученный исполняемый файл (
./trivial
)
Структура программы
Программа на языке Си представляет собой последовательность определений. Каждое из них определяет хотя бы одно из:
- глобальную переменную
- тип данных
- подпрограмму
В частности, вышеприведённая тривиальная программа состоит из одного
определения, определяющего подпрограмму с названием main
.
Подпрограммы задаются последовательностью команд (или, как их ещё называют,
предложений). Вышеприведённая подпрограмма main
состоит
из одной команды return 0;
, смысл которой «завершить работу
текущей подпрограммы с результатом 0
».
Часть int ...(void)
определения этой подпрограммы задаёт её тип:
ограничения на то, как эту подпрограмму можно использовать. Конкретно
этот тип означает, что:
- результатом подпрограммы
main
является нечто типаint
- входов у этой подпрограммы нет
Для того, чтобы все эти слова приобрели какой-нибудь осязаемый смысл, приведём пример нетривиальной программы.
Игра «угадай число»
Замысел следующий: программа загадывает случайное число в пределах от 1 до 100 и предлагает его отгадать. Если игрок вводит неправильное число, программа сообщает, больше или меньше это число, чем загаданное. Если же игрок вводит загаданное число, программа поздравляет его и завершает свою работу.
Весь код разместим в одном файле, который назовём guess.c
(это название
никакой роли не играет).
Начнём со следующего:
#include <stdio.h>
int main(void) {
printf("Угадай число!\n");
int number;
while (1) {
int items_read = scanf("%d", &number);
if (items_read == 0) { getchar(); continue; }
if (items_read == 1) { break; }
printf("Произошла ошибка чтения\n");
return 1;
}
printf("Вы ввели число %d\n", number);
return 0;
}
Разберём эту программу построчно.
Директивы препроцессора
Первая же строчка
#include <stdio.h>
технически вообще не относится к языку Си. Это — так называемая директива препроцессора. Препроцессор — специальная программа, которая предобрабатывает текст программы в соответствии со встреченными в нём директивами.
В частности, директива #include
предписывает вставить вместо неё
содержимое указанного текстового файла (в данном случае stdio.h
).
В POSIX-окружениях вывод препроцессора можно увидеть, запустив
команду cpp guess.c
.
В файле stdio.h
(который размещён в специальном месте, известном компилятору)
содержатся определения подпрограмм printf
, scanf
и getchar
, которые
мы использовали для ввода-вывода. Впрочем, с очень большой вероятностью,
посмотрев на выхлоп cpp guess.c
, Вы увидите там частичные определения
вида
int getchar(void);
Эти определения указывают только тип подпрограммы, но не соответствующий ей набор действий. Остальная часть определения (тело подпрограммы) расположена где-то в другом (также известном компилятору) месте.
Формулы
Далее идёт определение подпрограммы main
, тело которой начинается
с команды
printf("Угадай число!\n");
Результатом исполнения этой команды является появление в терминале
фразы «Угадай число!»: в этом нет ничего неожиданного.
А вот синтаксическая структура здесь весьма интересна: эта команда
относится к классу «вычислить значение формулы» и имеет
синтаксис ФОРМУЛА ;
(пробелы в языке Си несущественны и необходимы лишь
там, где они разделяют, например, тип и название переменной/подпрограммы).
Формулы (или, как их ещё называют, выражения) — особая грамматическая категория. Формулы встречаются в составе большинства команд. Они составляются из идентификаторов, литералов и операторов. В частности, рассматриваемая нами формула составлена из:
- идентификатора
printf
- текстового литерала
"Угадай число!\n"
- оператора
...(...)
Набор операторов в языке Си фиксирован: это всякие +
, -
, *
, /
и прочее.
Литералы бывают текстовые ("Hello"
), символьные ('H'
), числовые (42
)
и составные (о последних мы сейчас говорить не будем).
Они используются для обозначения объектов с заранее известным значением.
При желании можно считать литералы просто операторами арности 0
(то есть с нулём входов) со специальным синтаксисом.
Идентификаторы используются для обозначения объектов, процедура вычисления которых описана в программе (переменные и подпрограммы). Также идентификаторы используются для обозначения типов данных.
У (почти) любой формулы есть значение. В частности, значением формулы
printf("Hello!")
является число 6
(длина напечатанного текста в байтах)
типа int
. Само же появление текста на экране является побочным эффектом
вычисления. Многие побочные эффекты невозможно использовать в дальнейших
вычислениях (например, очень непросто, а иногда даже и невозможно обратно
достать с экрана напечатанный туда текст).
Значение большинства формул вычисляется по следующему алгоритму:
- сначала вычисляются значения всех подформул
- затем эти значения используются для вычисления значений формулы
Исключений в языке Си ровно четыре: формулы, составленные при помощи
логических операторов &&
, ||
и ? :
(эти операторы мы рассмотрим
в рамках соответствующего раздела) и формулы,
составленные при помощи унарного &
(о нём — ниже).
Переменные
Затем следует определение переменной number
:
int number;
Определение переменной в простейшем случае выглядит как ТИП ИДЕНТИФИКАТОР;
.
Переменную можно рассматривать как именованную область памяти.
Чтобы считать текущее значение переменной, достаточно просто в формуле использовать её название.
Чтобы записать в переменную какое-то значение, используется команда присваивания. Например, команда
number = 2+3;
вычисляет значение формулы 2+3
и записывает в переменную number
.
Обратите внимание на то, что допустимы на первый взгляд цикличные команды вида
number = number + 1;
Работают они только засчёт того, что команда присваивания:
- сначала вычисляет значение «правой части»
- только затем записывает его в переменную
Тип переменной — средство ограничения набора корректных программ.
У каждой формулы есть не только значение (определяемое значениями подформул),
но и тип (определяемый типами подформул). В переменную типа X
можно
записывать только значения формул, имеющих тип X
или же тип, который
неявно преобразуется к типу X
(в частности, все числовые типы в языке
Си могут быть неявно преобразованы друг к другу).
Что интересно, почти все типы (за исключением т.н. структур и объединений) в языке Си являются числовыми. Это — один из основных источников трудноуловимых ошибок.
В частности, тип int
— целое число в пределах от \(-2^N\)
до \(2^N - 1\) для некоторого \(N\) (обычно 15 или 31).
Управляющие конструкции
Далее в программе идёт цикл while
:
while (1) {
...
}
У него следующий синтаксис: while (УСЛОВИЕ) ПРЕДЛОЖЕНИЕ
. Он
повторяет выполнение предложения до тех пор, пока условие не
станет ложным.
Заметим две важные вещи:
- условия имеют числовой тип; истинным считается что угодно, кроме нуля
- предложения бывают составными: составное предложение оформляется парой фигурных скобок, внутрь которых заключена последовательность предложений
Про составные предложения полезно понимать следующее:
- переменные, определённые внутри составного предложения, можно использовать только внутри него
- определение подпрограммы состоит из типа подпрограммы, её названия и (обязательно) составного предложения
Указатели
Первая же строчка внутри цикла
int items_read = scanf("%d", &number);
содержит определение переменной с начальным значением. Значение переменной обязательно задавать перед первым же чтением (но программа соберётся, даже если его не задать — а вот поведение такой программы будет весьма непредсказуемым).
Если внимательно посмотреть на правую часть определения, то в нём
подпрограмма scanf
применяется к формулам "%d"
(это — самый
обычный текстовый литерал, содержимое которого релевантно для scanf
) и
&number
(а вот это — пример применения вышеупомянутого оператора &
).
Подформулой унарного &
может быть одно из двух:
- имя переменной или функции
- формула, внешней операцией которой является унарный
*
Тот же класс формул (за исключением имён функций) допускается в качестве левых частей команды присваивания.
Значением формулы вида &foo
является указатель на данные, описываемые
формулой foo
— адрес этих данных в «памяти программы»
(с точки зрения программиста память — это последовательность байт,
пронумерованных натуральными числами, начиная от 1 и заканчивая чем-то
достаточно большим).
С указателем можно делать следующее:
- унарным
*
можно получить данные по указателю - к указателю можно прибавить целое число: если
foo
указатель, аbar
— число, тоfoo + bar
иbar + foo
— указатели на единицу данных того же типа, что и*foo
, сдвинутую наbar
единиц относительно*foo
В этом разделе мы не будем ничего делать с указателями, кроме как передавать
их функции scanf
, которая пытается считать что-то со стандартного входа
и записать считанные данные в память по переданным ей адресам. В частности,
scanf("%d", &number)
пытается считать целое число и, если считывание произошло успешно, записывает
это число в переменную number
.
В языке Си, в отличие, например, от Паскаля или Си++, нет возможности
передать в функцию именно переменную: правила вычисления значения формул
едины для всех формул. Большинство современных языков программирования пошли
именно по этому пути: тот факт, что переменная не может измениться без
явного &
или =
, облегчает понимание программы.
Подробнее про указатели можно прочитать в соответствующем разделе, а мы пойдём дальше.
Ещё немного управляющих конструкций
После чтения числа идёт следующий код:
if (items_read == 0) { getchar(); continue; }
if (items_read == 1) { break; }
printf("Произошла ошибка чтения\n");
return 1;
Результатом scanf
является:
- либо количество успешно считанных и распознанных единиц данных
- либо специальное число
EOF
при ошибке чтения (именно чтения, а не распознавания)
При ошибке распознавания scanf
просто останавливается (из входного потока при
этом изымаются все символы до того который вызвал ошибку распознавания,
не включая ошибочный символ).
Команды continue;
и break;
управляют текущим циклом: первая —
переходит к следующей его итерации, вторая — переходит к его концу.
Подпрограмма getchar
считывает ровно один байт со стандартного входа.
Команда return 1;
приводит к завершению текущей подпрограммы (то есть,
в нашем случае — int main(void)
). После return
может стоять
любая формула. Значение этой формулы станет значением завершаемой подпрограммы.
Для main
используется следующая договорённость:
- значение
0
означает, что выполнение всей программы прошло без ошибок - любое другое значение означает, что программа завершила работу в результате какой-то ошибки
Интерпретация этого значения остаётся на совести операционной системы.
Соответственно, в зависимости от результата scanf
мы делаем одно из:
- если не удалось распознать целое число, убираем один байт со стандартного входа и пытаемся ещё раз считать целое число
- если удалось распознать целое число, завершаем цикл
- в противном случае завершаем работу программы
Ветвление осуществляется при помощи конструкции if
с тем же синтаксисом,
что и у while
.
Ввод-вывод
Стандартная библиотека ввода-вывода использует т.н. форматированный ввод-вывод: подпрограммы ввода и вывода получают на вход некий шаблон, в соответствии с которым и работают.
Например, printf
в шаблоне "Вы ввели число %d\n"
находит специальную
последовательность %d
и заменяет её на целое число
(переданное printf
вторым входом). Подробнее про синтаксис шаблонов
printf
можно почитать в описании этой подпрограммы.
Аналогичен синтаксис шаблонов подпрограммы ввода scanf
. Стоит лишь отметить,
что scanf
игнорирует пробелы (то есть %d%d
— два целых числа,
отделённые друг от друга пробельными символами).
Следует следить, чтобы количество дополнительных входов и типы этих входов соответствовали спецпоследовательностям. Компилятор, конечно, выдаёт предупреждения о несоответствии, но не любой и не всегда.
В заключение отметим, что \n
— это не спецпоследовательность
форматированного ввода-вывода, а часть синтаксиса текстовых литералов.
Называются такие штуки «экранирующими последовательностями».
Чаще всего можно встретить:
\n
— переход на следующую строчку\r
— возврат «каретки» в начало строчки\\
— обратная косая черта\"
— двойная кавычка
Выделяем кусок программы в подпрограмму
Теперь рассмотрим следующую модификацию вышеприведённой программы:
#include <stdio.h>
int read_number(int *number) {
while (1) {
int items_read = scanf("%d", number); // здесь пропал &
if (items_read == 0) { getchar(); continue; }
if (items_read == 1) { return 0; } // а здесь теперь return вместо break
return 1;
}
}
// двумя косыми чёртами выделяется комментарий: он полностью игнорируется компилятором
/* Ещё
бывают многострочные
комментарии. */
int main(void) {
printf("Угадай число!\n");
int number;
if (read_number(&number)) {
printf("Произошла ошибка чтения\n");
return 1;
}
printf("Вы ввели число %d\n", number);
return 0;
}
Нам необходимо будет многократно просить ввести число, а писать нетривиальный цикл внутри нетривиального цикла — сомнительной полезности действие (результат становится малочитаемым).
Поэтому часть программы, считывающую число, мы изолировали в отдельную
подпрограмму read_number
.
У определения подпрограмм синтаксис приблизительно следующий:
ТИП_РЕЗУЛЬТАТА имя(ТИП_ВХОДА1 имя_входа1, ТИП_ВХОДА2 имя_входа2, ...) {
ТЕЛО_ПОДПРОГРАММЫ
}
Есть одно исключение: если у подпрограммы нет входов, вместо списка входов
нужно написать слово void
. То же слово нужно написать вместо типа результата,
если у подпрограммы нет результата (а используется она только ради побочных
эффектов).
Обратите внимание на то, что (в отличие от Си++) пустой список входов означает не отсутствие входов, а произвольное их количество!
В заключение скажем, что определение
int read_number(int *number) {
...
}
не попадает под вышеописанную схему: int *number
(говорящая, что
number
— это указатель на данные типа int
) — это конструкция,
более сложная, чем ТИП имя
. Подробнее про такие конструкции,
опять же, — в соответствующем разделе.
Загадываем число
Теперь, наконец, можно уже давать много попыток угадать число:
#include <stdio.h>
int read_number(int *number) {
while (1) {
int items_read = scanf("%d", number);
if (items_read == 0) { getchar(); continue; }
if (items_read == 1) { return 0; }
return 1;
}
}
int main(void) {
printf("Угадай число!\n");
int to_guess = 42; // пока загаданное число "зашито" в коде
int number;
while (!read_number(&number)) {
printf("Вы ввели число %d", number);
if (number > to_guess) {
printf(": ваше число больше загаданного!\n");
} else if (number < to_guess) {
printf(": ваше число меньше загаданного!\n");
} else {
printf(" и угадали!\n");
return 0;
}
}
printf("Произошла ошибка чтения\n");
return 1;
}
В этой программе из нового лишь «гребёнка» вида
if (...) { ... }
else if (...) { ... }
else if (...) { ... }
...
else if (...) { ... }
else { ... }
Формально с точки зрения грамматики это — нагромождение предложений вида
if (ФОРМУЛА) ПРЕДЛОЖЕНИЕ1 else ПРЕДЛОЖЕНИЕ2
Семантика подобных предложений банальна:
- вычисляется значение формулы
- если оно истинно, выполняется
ПРЕДЛОЖЕНИЕ1
- в противном случае выполняется
ПРЕДЛОЖЕНИЕ2
Теперь нам осталось загадывать случайное число вместо 42.
По-настоящему загадываем число
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int read_number(int *number) {
while (1) {
int items_read = scanf("%d", number);
if (items_read == 0) { getchar(); continue; }
if (items_read == 1) { return 0; }
return 1;
}
}
int main(void) {
srand(time(0));
printf("Угадай число от 1 до 100!\n");
int to_guess = 1 + (rand() % 100);
int number;
while (!read_number(&number)) {
printf("Вы ввели число %d", number);
if (number > to_guess) {
printf(": ваше число больше загаданного!\n");
} else if (number < to_guess) {
printf(": ваше число меньше загаданного!\n");
} else {
printf(" и угадали!\n");
return 0;
}
}
printf("Произошла ошибка чтения\n");
return 1;
}
Здесь используется классическая идиома. Один раз в начале программы инициализируется генератор псевдослучайных чисел текущим временем:
srand(time(0));
После этого случайное целое неотрицательное число можно получить
подпрограммой rand
. В нашем случае мы берём его по модулю 100:
rand() % 100
Отметим, что a % b
является остатком a
по модулю b
только при
неотрицательных значениях a
. При отрицательных значениях a
результатом
является разность этого остатка и абсолютной величины b
.
Разбиваем программу на файлы
В конце этого раздела продемонстрируем, как разбить программу на несколько файлов. Для этого сделаем три файла.
Файл guess.c
:
// файлы, которые следует искать в служебной папке компилятора, обрамляются уголками:
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
// файлы, которые следует искать в той же папке, обрамляются кавычками:
#include "util.h"
int main(void) {
srand(time(0));
printf("Угадай число от 1 до 100!\n");
int to_guess = 1 + (rand() % 100);
int number;
while (!read_number(&number)) {
printf("Вы ввели число %d", number);
if (number > to_guess) {
printf(": ваше число больше загаданного!\n");
} else if (number < to_guess) {
printf(": ваше число меньше загаданного!\n");
} else {
printf(" и угадали!\n");
return 0;
}
}
printf("Произошла ошибка чтения\n");
return 1;
}
Файл util.c
:
#include <stdio.h>
int read_number(int *number) {
while (1) {
int items_read = scanf("%d", number);
if (items_read == 0) { getchar(); continue; }
if (items_read == 1) { break; }
return 1;
}
return 0;
}
Наконец, файл util.h
:
#ifndef UTIL_H
#define UTIL_H
int read_number(int *number);
#endif
По сути, мы сделали три вещи:
- вынесли подпрограмму
read_number
в отдельный файлutil.c
- в
main.c
добавили строчкуint read_number(int *number);
- вынесли эту строчку в отдельный файл
util.h
, обрамив непонятной на первый взгляд конструкцией препроцессора#ifndef ... #define ... #endif
Чтобы понять, зачем так сделано, полезно знать традиционную процедуру сборки программы на Си:
- каждый файл программы (
guess.c
иutil.c
) независимо от остальных файлов «компилируется» в некий промежуточный формат (обычно промежуточные файлы имеют названия, заканчивающиеся на.o
) - из нескольких промежуточных файлов «собирается» единая программа
Ключевой момент состоит в том, что в любом файле можно использовать функции и переменные, в нём не определённые. Все такие зависимости разрешаются на этапе «сборки». Но для успешной «компиляции» нужно хотя бы знать тип этих функций/переменных.
Для объявления типа функции используется т.н. прототип:
ТИП_РЕЗУЛЬТАТА имя_функции(...); // тут точка-с-запятой вместо тела
Для объявления типа глобальной переменной используется конструкция
extern ТИП_ПЕРЕМЕННОЙ имя_переменной;
Отметим также, что прототипы встречаются и в случае циклических зависимостей внутри одного файла:
int is_odd(int n); // прототип is_odd
int is_even(int n) {
if (n == 0) { return 1; }
return is_odd(n-1);
// программа читается сверху вниз _один раз_, поэтому к этому моменту
// должен быть объявлен тип подпрограмммы is_odd
}
int is_odd(int n) {
if (n == 0) { return 0; }
return is_even(n-1);
}
Осталось поговорить об util.h
. Традиционно вместе с библиотекой подпрограмм
(файлом, предназначенном для «компиляции») поставляется
файл с прототипами. Сделано это по двум причинам:
- Чтобы пользователь библиотеки не копипастил эти прототипы из исходника.
- Часто библиотеки поставляются даже не в виде исходника, а сразу в промежуточном формате.
В начале util.h
есть строчки
#ifndef UTIL_H
#define UTIL_H
А в конце стоит
#endif
Директива #define
определяет макрос — идентификатор, все вхождения
которого в программу заменяются на указанный текст.
Например, вместо printf("%d\n", 2 + 3);
можно написать
#define PRINT printf(
#define NUMBER "%d\n",
#define END );
PRINT NUMBER 2 + 3 END
При помощи #ifdef
или #ifndef
можно проверить наличие/отсутствие
определённого макроса с указанным названием. Если такое условие не выполняется,
то весь кусок файла между #ifdef
или #ifndef
и #endif
удаляется.
Поэтому #ifndef ... #define ... #endif
гарантирует, что содержимое
файла попадёт в компилируемый файл не более одного раза, независимо от того,
сколько раз util.h
был вставлен при помощи #include
.
Конечно, если в таком .h
-файле находятся только прототипы, множественные
включения особо не вредят (но злоупотребление может привести к тому, что
во время компиляции закончится оперативная память компьютера). А вот
если там есть что-то ещё (определения переменных или функций), множественное
включение приведёт к ошибке компиляции.
Собираем программу
Для сборки программы следует поместить все три файла
(guess.c
, util.c
, util.h
) в одну и ту же папку, сделать её
«текущей рабочей» и вызвать компилятор.
Для компилятора gcc
(и clang
) синтаксис следующий:
gcc -o имя_исполняемого_файла guess.c util.c
Промежуточные файлы при этом создаются во временной папке (а не в текущей), а разделение на этап «компиляции» и этап «сборки» скрыто.
Отметим, что сервис repl.it автоматически собирает
программу из всех .c
-файлов проекта.