Заголовочные файлы и файлы реализации
Все примеры, которые я использовал до этого, использовали только один файл, который компилировался через g++
. Несмотря на то, что большие программы можно написать в одном файле и скомпилировать его, такие программы редко бывают удобны для последующей работы. Код, написанный в одном многометровом файле запутывает и усложняет т.н. «рефакторинг».
Существуют помимо файлов исходников (или реализации), стандартно имеющие расширение .cpp, файлы, называемые заголовочными; они имеют расширение .h. Это позволяет разбить программу на фрагменты, логически и функционально связанные; размещать их в разных папках, которые объединяют логически несколько файлов в один пакет и т.д.
С заголовными файлами мы уже встречались, когда подключали, например, библиотеки для работы со строками следующим образом: #include
. Напомню, что препроцессорная директива #include
имеет 2 способа указания на внешние заголовные файлы: с помощью треугольных скобок (<>) и с помощью кавычек («»). Принципиальное различие состоит в том, что при указании <>
компилятор будет пытаться искать заголовочные файлы в библиотеках, которые указаны в ОС как источники библиотек C++. При разработке собственных файлов необходимо использовать кавычки; они указывают компилятору, что заголовочный файл нужно искать относительно той директории, в которой находится компилируем исходный файл.
Возьмём последний пример из статьи про классы.
main.cpp
#include <iostream> #include <string.h> using namespace std; class Person { string lastname; string firstname; string middlename; public: Person(string lastname, string firstname, string middlename) : lastname(lastname), firstname(firstname), middlename(middlename) { cout << "The new person has born!\n"; } friend ostream& operator<< (ostream &out, Person &p); }; ostream& operator<<(ostream& os, Person &p) { os << p.lastname << " " << p.firstname << " " << p.middlename; return os; } int main(int argc, char *argv[]) { Person p = Person("Pelevin", "Maksim", "Sergeevich"); cout << p; }
Вынесем класс в заголвочный файл, у нас получится следующее:
person.h
#include <string> using namespace std; class Person { string lastname; string firstname; string middlename; public: Person(string lastname, string firstname, string middlename) : lastname(lastname), firstname(firstname), middlename(middlename) { cout << "The new person has born!\n"; } friend ostream& operator<< (ostream &out, Person &p); }; ostream& operator<<(ostream& os, Person &p) { os << p.lastname << " " << p.firstname << " " << p.middlename; return os; }
main.cpp
#include <iostream> #include "person.h" int main(int argc, char const *argv[]) { Person p = Person("Pelevin", "Maksim", "Sergeevich"); std::cout << p << std::endl; return 0; }
Собираем проект g++ main.cpp
и получим точно такую же программу, как если бы сборка происходила из одного и того же файла. Это всё из-за того, что препроцессорная директива #include
ищет нужные нам заголовочные файлы и включает их в тело программы. Таким образом нам удалось вынести целую сущность в отдельный файл! Теперь же необходимо избавиться от реализации в заголовчном файле. Это мотивировано тем, что некоторые программы могут быть исопльзованы сторонними людьми, поэтому они должны подключать заголовчный файл, наподобие того, как подключается файл со строками, а вот реализация для посторонних должна быть скрыта. Такие файлы комплириуются в статические или динамические библиотеки (последние знакомы всем жителями ОС Виндоус как .dll), но объяснение этих нюансов выходит за рамки этой статьи.
Выносим реализацию в отдельный файл и получаем проект из 3-х файлов (main.cpp не изменяется):
person.h
#include <string> #include <iostream> using namespace std; #ifndef PERSON_H #define PERSON_H class Person { string lastname; string firstname; string middlename; public: Person(string lastname, string firstname, string middlename); friend ostream& operator<< (ostream &out, Person &p); }; #endif
person.cpp
#include <string> #include <iostream> #include "person.h" using namespace std; Person::Person(string lastname, string firstname, string middlename) : lastname(lastname), firstname(firstname), middlename(middlename) { cout << "The new person has born!\n"; } ostream& operator<<(ostream& os, Person &p) { os << p.lastname << " " << p.firstname << " " << p.middlename; return os; }
При реализации класса нам обязательно указывать название функции так: [Возвращаемое значеие] [Имя класса]::[Имя функции и аргументы]
.
#ifndef PERSON_H
, #define PERSON_H
и #endif
нужны для того, чтобы заголовончый файл при компиляции был включён в тело программы только 1 раз. Эти директивы прочитываются следующим образом: если не определён [название], тогда определить [название] и закончить проверку. Без этих директив, компилятор попытается подключить один и тот же заголовочный файл столько раз, сколько файлов реализаций его подключают; в нашем случае, это будет 2 раза: из файла main.cpp и файла person.cpp.
Для того, чтобы программа скомпилировалась, g++ нужно передавать в качестве аргументов оба файла реализации: g++ main.cpp person.cpp
. Кстати, на этом этапе вы можете попытаться создать библиотеку person, скомпилированную в объектный файл. Поступим следующим образом:
g++ -c person.cpp g++ main.cpp person.o
Вы можете передать другому человеку только скомпилированный файл person.o и заголовочный файл person.h, и он сможет написать main.cpp и собрать программу (при условии, что у вас один и тот же компилятор)! Встаёт вопрос, как обеспечить сборку проекта из тысячи файлов? Не перечислять же их в аргументах, в самом деле. Для этих целей используются системы автоматической сборки, одной из которых является способ сборки с makefile. Прочитайте статью Makefile для самых маленьких, чтобы научится это делать.