Директивы #include и условной компиляции

Практический курс по C/C++: https://stepik.org/course/193691

Пришло время ближе познакомиться с директивой #include, которую мы прописывали с самых первых программ. Что она делает, я также кратко уже озвучивал и говорил, что как только макропроцессор встречает эту директиву, то вставляет содержимое указанного файла на ее место. В частности, в программе:

#include <stdio.h>
 
int main(void)
{
    printf("Hello, World!\n");
    return 0;
}

после обработки текстовым препроцессором вместо первой строчки будет подставлено содержимое файла stdio.h. В данном примере это нужно, чтобы мы могли использовать библиотечную функцию printf() в своей программе. Но в самом файле stdio.h нет реализации этой функции, а только ее описание (прототип) в виде:

int printf(const char* format, ...);

Кстати, если вместо директивы прописать в явном виде сигнатуру функции printf():

int printf(const char* format, ...);
 
int main(void)
{
    printf("Hello, World!\n");
    return 0;
}

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

В целом, этого понимания работы директивы #include вполне достаточно для программирования на Си. Отмечу только один нюанс. Имя файла после директивы #include можно заключать или в угловых скобках, или в кавычках. Например, если ее записать в виде:

#include "stdio.h"

то ничего не изменится и программа также успешно откомпилируется. Но отличия в работе директивы все же есть. Согласно стандарту C99 файл прописанный в угловых скобках сначала ищется в системных каталогах, и если не будет найден, то в текущем рабочем каталоге проекта. Если же прописываются двойные кавычки, то поиск работает наоборот, сначала файл ищется в рабочем каталоге проекта, а затем только в системных каталогах.

В действительности, эти правила не всегда соблюдаются и компиляторы могут реализовывать свою логику поиска файлов. Мало того, в последних стандартах языка Си алгоритм поиска файлов несколько изменен и стал таким неопределенным, что разработчики компиляторов этот пункт часто просто игнорируют. Благо, нам, как программистам на Си важно лишь, что макропроцессор будет искать указанный файл и добавлять его содержимое в наш проект. А правило использования угловых скобок и кавычек в сообществе разработчиков стало таким:

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

Например, давайте в нашем проекте создадим подкаталог tmp и в нем разместим файл с именем printf.h и содержимым:

int printf(const char *format, ...);

Затем, подключим этот файл в программе следующим образом:

#include "tmp/printf.h"
 
int main(void)
{
    printf("Hello, World!\n");
    return 0;
}

Как видите, мы использовали здесь двойные кавычки, т.к. файл printf.h наш собственный и, кроме того, указали подкаталог tmp относительно рабочего каталога, т.к. файл printf.h находится в этом подкаталоге.

Директивы условной компиляции

Следующий набор директив, который мы разберем, - это, так называемые, директивы условной компиляции:

#if, #endif, #elif, #else, #ifdef, #ifndef, #elifdef, #elifndef

В основном они используются, чтобы оставить или убрать определенный фрагмент текста программы в зависимости от какого-либо условия. Например, пишется программа, которую предполагается компилировать  с использованием компилятора Си, а также с использованием компилятора С++. Но в этих языках программирования имеются некоторые отличия в используемых конструкциях и часть программного кода должна различаться в зависимости от языка. Так вот, чтобы написать универсальный текст программы и иметь возможность компилировать его как Си, так и С++, можно воспользоваться условными директивами следующим образом:

#define LANG_C
 
#if defined(LANG_C)
#   include <stdio.h>
#else
#   include <iostream>
#endif
 
int main(void)
{
         int x=5;
#ifdef LANG_C
         printf("%d\n", x);
#else
         std::cout << x << std::endl;
#endif
 
    return 0;
}

Смотрите, вначале определено макроимя LANG_C с помощью директивы #define. Затем, прописана директива #if, в которой проверяется условие: определено ли макроимя LANG_C в текущем модуле. Если это так (как в нашем примере), то макропроцессор оставляет в программе все, что записано после этой директивы либо до следующей условной директивы, либо до директивы #endif. В приведенном примере, остается строчка «#   include <stdio.h>» и удаляется строка «#   include <iostream>». Соответственно, директива #include также, затем, обрабатывается макропроцессором. В итоге, после обработки, у нас получается следующий текст программы:

#   include <stdio.h>
 
int main(void)
{
         int x=5;
         printf("%d\n", x);
 
    return 0;
}

Разумеется, директива #include здесь также впоследствии преобразуется макропроцессором. А директива #ifdef – это сокращенный вариант записи конструкции #if defined.

По сути, директивы условной компиляции #if, #else, #endif работают подобно условным операторам if-else, о которых мы с вами уже говорили. Но, конечно же, есть и отличия. Первый важный момент: в условиях директив можно использовать исключительно целочисленные литералы и макроимена. С этими элементами можно выполнять все булевы операции сравнения:

==, !=, <, >, <=, >=

логические связки:

&&, ||, !

все бинарные арифметические и битовые операции:

+, -, *, /, %, &, |, ^

и применять оператор defined, которые возвращает 1, если указанное макроимя существует и 0 – в противном случае. Есть еще несколько экзотических конструкций, вроде условной тернарной операции, которые допустимо прописывать в условиях директив, но в основном используются те операции, что перечислены выше. Обратите внимание, никаких переменных, функций и прочих конструкций, значение которых определяется в процессе работы программы, здесь применять нельзя.

Второй важный момент. Директивы условной компиляции не образуют своих собственных внутренних блоков. Поэтому для указания того, что попадает внутрь таких директив, в конце обязательно следует прописать директиву #endif – метку, означающую конец текущей директивы условной компиляции.

Третий важный момент. Директивы препроцессора анализируют программу как текст (на уровне лексем). Это означает, что они не учитывают области видимости: локальные, глобальные и т.п. Поэтому все директивы принято записывать с самого начала строки (с левого края). В частности, именно поэтому символ # у директив include записан на одном уровне с другими директивами, т.к. никакого реального вложения здесь нет, и это мы подчеркиваем оформлением. То же самое при записи директив внутри функции main(). Для всех этих директив функции не имеют никакого значения – это просто текст. Поэтому все они прописаны с самого начала строки.

Использование директив условной компиляции в заголовочных файлах

Если мы посмотрим на содержимое какого-либо стандартного заголовочного файла, например, того же stdio.h, то вначале увидим такие строчки:

#ifndef _INC_STDIO
#define _INC_STDIO

А ниже обязательно будет записана директива:

#endif

С какой целью эти директивы здесь используются? На самом деле это защита от повторного включения содержимого заголовочного файла в текущий модуль. Например, если мы в программе дважды напишем строчки:

#include <stdio.h>
#include <stdio.h>

То программа скомпилируется и отработает без ошибок, так как содержимое файла stdio.h было добавлено макропроцессором в текущий модуль только один раз. Почему так произошло? Смотрите. Когда файл stdio.h подключался первый раз, то макроимя _INC_STDIO отсутствовало и условие директивы #ifndef оказалось истинным, так как #ifndef – это аналог #if !defined. Раз условие истинно, то все, что определено до директивы #endif включается в файл, в том числе и директива «#define _INC_STDIO», которая определяет макроимя _INC_STDIO. Теперь оно существует в текущем модуле. Это значит, при повторном включении файла stdio.h условие директивы #ifndef окажется ложным и фрагмент дублироваться не будет.

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

Я думаю, в целом понятна работа директив условной компиляции. В заключение этого занятия приведу расшифровку всех этих директив:

Директива

Описание

#if

Проверка произвольного условия.

#else

Определение ветки «иначе».

#endif

Директива (метка) завершения фрагмента для условия.

#ifdef

Сокращение от if defined. Позволяет делать проверку на наличие макроимени в текущем модуле.

#ifndef

Сокращение от if !defined. Позволяет делать проверку на отсутствие макроимени в текущем модуле.

#elif

Сокращение от else if. Позволяет делать проверку по ветке «иначе».

#elifdef

Сокращение от else if defined. Для реализации проверки наличия макроимени по ветке «иначе».

#elifndef

Сокращение от else if !defined. Для реализации проверки отсутствия макроимени по ветке «иначе».

В целом, все они работают аналогичным образом и должны быть теперь вам понятны.

Практический курс по C/C++: https://stepik.org/course/193691

Видео по теме