Урок 9

Заголовочные файлы. Организация проекта, сборка с учётом заголовочных файлов.

В прошлом уроке, при доработке файла graphics.c - добавлении в него (по сути, в весь проект) новой функции draw_line() возникла ситуация, когда в использующем функцию файле main.c понадобилось добавить объявление этой новой функции. То есть, при добавлении функции в проект - нужно добавить её объявление в каждый файл, где она будет использоваться. Это ещё полбеды, так как в момент добавления использования функции - программист знает, в каком файле он её использует (он в нём и работает в этот момент). Но вот когда (а такие ситуации возникают, причём относительно часто) изменится сигнатура функции - это чревато ошибками. Объявление функции будет изменено в файле, где и менялось её определение функции - это естественно. Но в этот момент нужно изменить объявление функции по всему проекту, где она использована - а держать в голове все места вызова всех функций даже слегка подросшего проекта - нереально и бессмысленно.

Так, если в текущей реализации изменить функцию draw_line() - к примеру добавить аргумент, отвечающий за цвет линии - то объявление функции изменится: вместо void draw_pixel(int x, int y, char p) станет, к примеру, void draw_pixel(int x, int y, char p, Color col)
В таком случае, конечно, нужно будет изменить определение функции draw_line() - реализовать цветное отображение линии. Но тогда и в файле main.c нужно изменить объявление! Ведь объявление - это обещание компилятору, что вот именно такая функция где-то потом встретится. Но функция изменилась, и компилятор, а потом и линковщик - не смогут её найти. И линковщик выдаст ошибку линковки - не смог, мол, найти функцию. Ошибки линковки - они менее наглядные, чем ошибки компиляции, ошибка компиляции вылезает, зачастую, очень близко к тому месту, где программист неправ. Ошибки линковки же случаются "сильно позже", когда весь код уже успешно скомпилирован - и поэтому искать, где же именно, в каком из множества файлов проекта и где именно в них ошибка, приходится, как правило, намного дольше.
Если бы при изменении функции объявление функции менялось "автоматически" во всех файлах, где функция используется - то несоответствие вызова функции её объявлению замечал бы (и завершался с указанием на ошибку) компилятор, и вызов функции можно было бы сразу исправлять именно там, где этот вызов конфликтует с новым объявлением - именно в том файле, в той строке, куда и указывает ошибка компиляции.

Для отслеживания объявлений функций (и для избавления от необходимости вручную объявлять все используемые в файле функции) в языке C используются заголовочные файлы. Заголовочные файлы, в соответствии с назаванием, играют роль "заголовков" программы. В заголовочных файлах обычно указываются объявления функций - и это позволяет освободить от этих объявлений обычные .c-файлы с кодом.
В общем случае в заголовочных файлах (файлы с расширением .h, сокращение от "header") содержится произвольный текст, который препроцессор будет подставлять каждый раз в текст программы, когда будет встречать директиву #include "somefile.h" или #include <somefile.h>. В четвёртом уроке уже встречалась директива #define - которая выполняла слегка похожую роль, но если define задавала текстовую метку, которая позже, будучи встреченой в тексте, подменялась на произвольную строку, include же меняется на полный текст файла непосредственно в том месте, где директива встречается.

Чаще всего в заголовочный файл собираются объявления функций, которые могут понадобиться в других файлах. Это, конечно, не обязательное правило, и использоваться заголовочные файлы могут и иначе, но это стандартное, общепринятое поведение - и в первую очередь любой программист предполагает именно такое применение директивы include, встречая её в чужом коде.

Заголовочные файлы принято создавать в паре с файлами кода. Так существующий уже файл graphics.c обзаведётся своей второй половинкой graphics.h:

graphics.h
int get_width();
int get_height();

void draw_pixel(int x, int y, char p);
char check_pixel(int x, int y);

void draw_line(int x1, int y1, int x2, int y2, char sprite);

void render_scene();

void gameover_score_to_cout(int score);

void init_graphics();
void init_screen();
void finalize_graphics();
В заголовочный файл выносятся объявления всех тех функций, вызов которых программист ожидает от внешнего кода. Функции, которые продполагаются для использования только внутри данной единицы трансляции, в заголовочном файле не указываются, чтобы программа снаружи на эти файлы не полагалась.
Объявлять же функции из других единиц трансляции непосредственно в файле с кодом, а не используя заголовочный файл - повод задуматься, что в архитектуре программы проблемы, которые стоит осознать и решить лучше раньше, чем слишком поздно.

Если вынести объявления всех вызываемых в main.c функций в заголовочные файлы, main.c станет выглядеть так:

main.c
#include "graphics.h"
#include "input.h"
#include "world.h"

int main_cycle = 0;             /* stop program if 0 */
int t = 0;


int main()
{
    init_graphics();
    init_world();
    
    t = 0;
    main_cycle = 1;             /* Initializing runprogram flag */
    /* Main cycle */
    while(main_cycle)
    {
        draw_background();
        handle_player();
        render_scene();
        t++;
    }

    finalize_graphics();
    finalize_world();

    return 0;
}

Остальные файлы проекта могут измениться соответствующим образом:

input.h
void init_player_input(void (*f)());
world.h
void init_world();
void draw_background();
void handle_player();
void finalize_world();
world.c
#include "graphics.h"
#include "input.h"

#define DAMPING 1000  

#define GAP_WIDTH 3  

#define START_POSITION_X 0.25f

#define GRAVITY 0.02f  
#define START_POSITION_Y 0.5f;
#define ENGINE_FORCE 0.6f
#define PLAYER_SPRITE '>'
#define PARTICLE_SPRITE '.'
#define VOID_SIZE 0.3f

int void_size;                  /* column gap in pixels */
float x_player;                 /* player current x coord */
float y_player;                 /* player current y coord */
float character_velocity;       /* main character vertical velocity */ 
int score;                      /* achieved player score */
extern int t;
extern int main_cycle;


void add_force()
{
...

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

gcc -M main.c
Компилятор вернёт в текстовом виде цель и зависимости объектного файла-цели, который должен получится из файла с кодом main.c:
main.o: main.c /usr/include/stdc-predef.h graphics.h input.h world.h

Странный файл /usr/include/stdc-predef.h наврядли будет меняться во время работы над проектом, а если и будет - программист заинтересован в пересборке проекта для внесения в программу своих изменений, а не изменений какого-то системного загловочного файла. Для вывода только пользовательских файлов-зависимостей используется ключ -MM вместо ключа -M:

gcc -MM main.c
вернёт
main.o: main.c graphics.h input.h world.h
Отличает компилятор системные заголовочные файлы от пользовательских очень просто: во-первых, системными являются те, которые компилятор включает в файл сам, без явного указания директивой include; а во-вторых, и это самое важное - системные файлы для компилятора те, которые указаны в угловых скобках после директивы include, пользовательские - указанные в двойных кавычках. Эту разницу стоит запомнить. Все файлы, которые созданы внутри проекта - такие, как graphics.h, input.h и world.h - принято указывать в двойных кавычках. Системные заголовочные файлы, как, к примеру, stdio.h и signal.h - указывают в угловых скобках. Заголовочные файлы библиотек (ещё раз, включение заголовочного файла в файл кода программы ещё не есть подключение библиотеки, это только объявление библиотечных функций! Если библиотека не header-based, конечно, то есть, размещающаяся целиком в заголовочном файле) принято указывать в угловых скобках, если эта библиотека установлена в системе, и в двойных кавычках - если исходный код библиотеки должен компилироваться вместе с проектом и размещён внутри директории проекта. Хотя любая рекомендация, касающаяся оформления кода - не является абсолютной, нужно выработать (на основании общепринятых концепций) свой стиль, следовать ему, не смешивая с другими стилями - и при этом, приходя на другой, чужой проект - перенимать стиль этого чужого проекта, не делая по своему, привычному.
Стилистическое разграничение - это не единственное отличие между #include "somefile.h" и #include <somefile.h>. Файлы, указанные в угловых скобках, так как они подразумеваются "системными", компилятор (препроцессор, на самом деле) ищет, в первую очередь, в системных директориях (каких - зависит от соостветсвующей переменной окружения). Поиск файлов, указанных в двойных кавычках начинается в директории, в которой располагается сам файл. На директории, в которых препроцессор ищет заголовочные файлы, можно так же повлиять с помощью ключей компилятора -iquote, -I, -isystem и -idrafter - с указанными после них директориями. Основной ключ, который нужно помнить в первую очередь -I, остальные специфичны.

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

Makefile
...

$(OBJECT_FILES_DIRECTORY)%.o: %.c
	$(COMPILER) $(CFLAGS) $^ -c -o $@

...
c испоьзованием проанализированных компилятором зависимостей.

Добавление ключа -MM просто призывает компилятор вывести текст с информацией о зависимостях в консоль - и в make, таким образом, эта информация никак не попадёт. Чтобы информацию о зависимостях передать в make-файл, можно записать сгенерированный список зависимостей в файл - и использовать файл зависимостей в make-файле. Для указания компилятору gcc о необходимости создания файла со списком зависимостей, используется ключ -MMD вместо -MM:

Makefile
...

$(OBJECT_FILES_DIRECTORY)%.o: %.c
	$(COMPILER) -MMD $(CFLAGS) $< -c -o $@

...
Таким образом одновременно с созданием объектных файлов компилятор создаст (и положит в ту же директорию, куда он положит объектный файл) файл зависимостей - с названием, идеинтичным оригинальному файлу с кодом, но с расширением .d, а не .c

Теперь чтобы использовать полученные файлы зависимостей в make-файле, используется директива include. Эта make-директива похожа на директиву препроцессора языка Си #include - она выполняет ту же функцию, подставляет полный текст указанного ей файла в место, где она встречается в make-файле:

Makefile
...

-include $(OBJECT_FILES_DIRECTORY)/main.d
-include $(OBJECT_FILES_DIRECTORY)/input.d
-include $(OBJECT_FILES_DIRECTORY)/world.d
-include $(OBJECT_FILES_DIRECTORY)/graphics.d

...
Символ "-" перед include служит указанием утилите make не генерировать ошибку, если сооответсвующего файла не существует, а молча продолжить работу (До самой первой компиляции файлов зависимостей не существует, и без символа "-" процесс сборки бы осталнавливался, не начавшись)

Теперь всё работает, как задумано: при изменении файла с кодом - будут, кроме перекомпиляции соответсвующего объектного файла и пересборки результирующего файла программы, перегенирированы и .d-файлы зависимостей. При изменении заголовочного файла - так же, будут перекомпилированы все файлы, в которые этот файл входит (за это как раз и отвечает новое правило, содержащееся в .d-файле и подключенное через include

Но в make-файле мало того, что присутсвуют 4 почти одинаковых строки (дублирование кода!) - проблема в том, что при каждом добавлении каждого нового .c-файла нужно будет добавлять включение и его зависимости. Это лишнее действие, которое нужно держать в голове - а ведь утилиты сборки и существуют, чтобы эти действия автоматизировать:

Makefile
...

-include $()/$(PROJECT_C_FILES:.c=.d)
DEPENDENCY_FILES := $(PROJECT_C_FILES:%.c=$(OBJECT_FILES_DIRECTORY)/%.d)

...

-include $(DEPENDENCY_FILES)

...
Чтобы не повторять четыре раза практически одну и ту же строчку, используется тот факт, что файлы с зависимостями именуются так же, как и файлы исходного кода - но с изменившимся расширением. Двоеточие после имени переменной показывает, что последующие указания применяются к содержимому переменной, а указание простое - заменить все вхождения .c на .d - в итоге и получается список файлов зависимостей.

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

[Директория проекта]
   ├── src              // исходный код
   │    ├── horizon     // исходный c-код
   │    ├── includes    // заголовочные файлы
   │    ├── deps        // сгенерированные d-файлы
   │    ...
   │
   ...
   │
   └── Makefile

Чтобы дать компилятору знать, что заголовочные файлы теперь лежат не в той же директории, откуда он берёт файлы с кодом - нужно ему директорию с заголовочным файлом передать с ключом -I:

Makefile
...

INCLUDE_DIRECTORY := src/includes
DEPENDENCY_DIR := src/deps
DEPENDENCY_FILES := $(PROJECT_C_FILES:%.c=$(DEPENDENCY_DIR)/%.d)

...

CFLAGS += -I$(INCLUDE_DIRECTORY)

...

-include $(DEPENDENCY_FILES)

...

$(OBJECT_FILES_DIRECTORY)%.o: %.c
	$(COMPILER) -MMD -MF$(DEPENDENCY_DIR)/$(*).d $(CFLAGS) $(<) -c -o $@
...
Ключ -MF указывает компилятору путь, по которому сохранять сгенерированный из-за ключа -MMD файл. Переменная $(*) содержит в себе stem - то, что в шаблонной цели скрывается под %.

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

Самостоятельная работа:
  • Посмотрите какие-нибудь опен-сорс проекты. По каким принципам в их репозиториях структурированы файлы?
  • Ознакомьтесь c GNU make system (autotools), CMake. Что это и зачем?