Урок 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:
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 станет выглядеть так:
#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;
}
Остальные файлы проекта могут измениться соответствующим образом:
void init_player_input(void (*f)());
void init_world();
void draw_background();
void handle_player();
void finalize_world();
#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.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 "somefile.h"
и #include <somefile.h>
. Файлы, указанные в угловых скобках, так как они подразумеваются "системными", компилятор (препроцессор, на самом деле) ищет, в первую очередь, в системных директориях (каких - зависит от соостветсвующей переменной окружения). Поиск файлов, указанных в двойных кавычках начинается в директории, в которой располагается сам файл. На директории, в которых препроцессор ищет заголовочные файлы, можно так же повлиять с помощью ключей компилятора -iquote, -I, -isystem и -idrafter - с указанными после них директориями. Основной ключ, который нужно помнить в первую очередь -I, остальные специфичны.
Вместо того, чтобы вручную указывать в Makefile заголовочные файлы - и создать при этом отдельное правило для каждого .c-файла - можно переписать существующее шаблонное правило:
...
$(OBJECT_FILES_DIRECTORY)%.o: %.c
$(COMPILER) $(CFLAGS) $^ -c -o $@
...
Добавление ключа -MM просто призывает компилятор вывести текст с информацией о зависимостях в консоль - и в make, таким образом, эта информация никак не попадёт. Чтобы информацию о зависимостях передать в make-файл, можно записать сгенерированный список зависимостей в файл - и использовать файл зависимостей в make-файле. Для указания компилятору gcc о необходимости создания файла со списком зависимостей, используется ключ -MMD вместо -MM:
...
$(OBJECT_FILES_DIRECTORY)%.o: %.c
$(COMPILER) -MMD $(CFLAGS) $< -c -o $@
...
Теперь чтобы использовать полученные файлы зависимостей в make-файле, используется директива include. Эта make-директива похожа на директиву препроцессора языка Си #include
- она выполняет ту же функцию, подставляет полный текст указанного ей файла в место, где она встречается в make-файле:
...
-include $(OBJECT_FILES_DIRECTORY)/main.d
-include $(OBJECT_FILES_DIRECTORY)/input.d
-include $(OBJECT_FILES_DIRECTORY)/world.d
-include $(OBJECT_FILES_DIRECTORY)/graphics.d
...
Теперь всё работает, как задумано: при изменении файла с кодом - будут, кроме перекомпиляции соответсвующего объектного файла и пересборки результирующего файла программы, перегенирированы и .d-файлы зависимостей. При изменении заголовочного файла - так же, будут перекомпилированы все файлы, в которые этот файл входит (за это как раз и отвечает новое правило, содержащееся в .d-файле и подключенное через include
Но в make-файле мало того, что присутсвуют 4 почти одинаковых строки (дублирование кода!) - проблема в том, что при каждом добавлении каждого нового .c-файла нужно будет добавлять включение и его зависимости. Это лишнее действие, которое нужно держать в голове - а ведь утилиты сборки и существуют, чтобы эти действия автоматизировать:
...
-include $()/$(PROJECT_C_FILES:.c=.d)
DEPENDENCY_FILES := $(PROJECT_C_FILES:%.c=$(OBJECT_FILES_DIRECTORY)/%.d)
...
-include $(DEPENDENCY_FILES)
...
Сейчас все исходные файлы - как написанные вручную код и заголовочные файлы, так и сгенерированные файлы зависимостей - лежат вперемешку в src/
Для облегчения навигации в структуре проекта, стоит разделить исходный код, заголовочные файлы, файлы зависимостей.
[Директория проекта]
├── src // исходный код
│ ├── horizon // исходный c-код
│ ├── includes // заголовочные файлы
│ ├── deps // сгенерированные d-файлы
│ ...
│
...
│
└── Makefile
Чтобы дать компилятору знать, что заголовочные файлы теперь лежат не в той же директории, откуда он берёт файлы с кодом - нужно ему директорию с заголовочным файлом передать с ключом -I:
...
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 $@
...
Остаётся только вспомнить, что, хоть make и позволяет держать структуру проекта полностью под контролем, но делает это засчёт необходимости ручной работы над многими нюансом сборки. Помимо изучения make стоит познакомиться и с другими инструментами автоматизации сборки, иметь представление о их плюсах и минусах.
Самостоятельная работа:
- Посмотрите какие-нибудь опен-сорс проекты. По каким принципам в их репозиториях структурированы файлы?
- Ознакомьтесь c GNU make system (autotools), CMake. Что это и зачем?