Язык Си

Как самостоятельный язык, язык Си сейчас не слишком широко используется (в силу возраста и связанных с этим особенностей), но при этом является универсальным в следующем смысле: если из программы, написанной на языке 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. Традиционно вместе с библиотекой подпрограмм (файлом, предназначенном для «компиляции») поставляется файл с прототипами. Сделано это по двум причинам:

  1. Чтобы пользователь библиотеки не копипастил эти прототипы из исходника.
  2. Часто библиотеки поставляются даже не в виде исходника, а сразу в промежуточном формате.

В начале 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) в одну и ту же папку, сделать её «текущей рабочей» и вызвать компилятор.

Для компилятора gccclang) синтаксис следующий:

gcc -o имя_исполняемого_файла guess.c util.c

Промежуточные файлы при этом создаются во временной папке (а не в текущей), а разделение на этап «компиляции» и этап «сборки» скрыто.

Отметим, что сервис repl.it автоматически собирает программу из всех .c-файлов проекта.

Переменные

Управляющие конструкции

Функции

Указатели

Составные типы данных

Средства стандартной библиотеки

Препроцессор

Инструменты разработчика

Последовательности

Динамический массив

Связный список

Тексты

Длинная арифметика

Ассоциативные контейнеры

Упорядоченный массив

Бинарная динамизация

Протоколы последовательностей

Стек

Очередь

Очередь с приоритетом