Немного об языке Си

Средства разработки

У языка Си есть три основных реализации:

Первые две — наборы утилит командной строки. Третья — полноценная среда разработки (но утилиты командной строки тоже есть).

Также возможность запустить программу на языке Си предоставляют сервисы типа 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 — имя переменной (или подпрограммы).

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

В частности,

scanf("%d", &number)

пытается считать целое число и, если считывание произошло успешно, записывает это число в переменную number.

В языке Си, в отличие, например, от Паскаля или Си++, нет возможности передать в подпрограмму именно переменную: правила вычисления значения формул едины для всех формул. Большинство современных языков программирования пошли именно по этому пути: тот факт, что переменная не может измениться без явного & или =, облегчает понимание программы.

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

Ещё немного управляющих конструкций

После чтения числа идёт следующий код:

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-файлов проекта.