Урок 12. Структура проекта
Привет! На этом уроке мы поговорим о структуре проектов на C++: из каких файлов должен состоять проект, как разбивать проект на файлы, зачем нужны заголовочные файлы и как организовать хранение файлов в отдельных директориях. В этом уроке будет много информации, которая обычно пропускается в книгах и онлайн-курсах. Увы, практически все источники информации по C++ посвящены изучению языка C++, но не работе с проектами на C++.
Хочу обратить твоё внимание, что в мире разработки на C++ нет документов, описывающих каноническую структуру проекта. Поэтому вся приведенная в этом уроке информация является обобщением моего опыта работы с множеством проектов на языках С и C++.
Структура проекта Ближе к делу. Как будет выглядеть структура нашего проекта к концу этой части
calculator/
├── src/
├── .gitignore
├── .clang-format
├── .clang-tidy
├── CMakeLists.txt
└── README.md
Много новых сущностей? Не волнуйся, сейчас я обо всех них расскажу.
src (сокращение от source) - директория, в которой находятся исходники нашего проекта
.gitignore - конфигурационный файл Git, в котором указаны файлы и директории, которые не должны попасть под версионный контроль. С этим файлом ты уже знаком по уроку, посвященному основам работы с Git
.clang-format - конфигурационный файл для утилиты автоматического форматирования кода clang-format. С ней ты познакомишься в одном из следующих уроков
.clang-tidy - конфигурационный файл для утилиты статического анализа кода clang-tidy. С ней ты тоже познакомишься в одном из следующих уроков
CMakeLists.txt - конфигурационный файл системы сборки CMake. С ней ты тоже познакомишься в одном из следующих уроков (кажется, я это говорю слишком часто в этом уроке)
README.md - очень важный файл с описанием проекта и командами для сборки, установки и запуска проекта. Первую версию README.md мы подготовим в конце этого урока.
Получается, что в рамках этого урока структура нашего проекта будет выглядеть как
calculator/
├── src/ # Исходные файлы проекта
├── .gitignore # Список файлов, игнорируемых Git
└── README.md # Описание проекта и инструкции по сборке
Зачем помещать файлы в отдельную директорию? Исключительно ради удобства разработчиков. В больших проектах количество файлов может достигать нескольких сотен и даже тысяч. Для удобства навигации файлы размещаются в отдельных директориях. Удобство несёт прямую коммерческую выгоду: если над большим проектом работает несколько человек (или даже команд), то они не блокируют работу друг друга и не конкурируют за одни и те же файлы.
Но у нас только один файл - main.cpp, разве стоит для него выделять отдельную директорию? Да, до этого урока весь код нашего проекта находился в одном файле, а в этом уроке мы разобьем main.cpp на несколько файлов. Разбивать будем исходя из следующих соображений:
- Функция main должна находиться в отдельном файле main.cpp. Это правило хорошего тона: изучение новых проектов всегда начинается с функции main. Пользователь твоего проекта будет тебе бесконечно благодарен, если в твоем проекте будет файл main.cpp, в котором будет находиться только вызов функции main.
- Математические функции мы вынесем в отдельный файл math.cpp. Математические функции абсолютно не зависят от остальной части нашего проекта. Более того, они написаны нами так, что могут быть использованы в любом другом проекте со своей бизнес логикой. В конечном итоге в следующих уроках мы сделаем из математических функций отдельную библиотеку.
- Функции реализующие бизнес логику нашего проекта будут располагаться в файле app.cpp.
Теперь наш проект имеет следующую структуру
calculator/
├── src/
│ ├── app.cpp
│ ├── main.cpp
│ └── math.cpp
├── .gitignore
└── README.md
Содержимое файла app.cpp. Подключаем iostream в файле app.cpp, потому как только в функциях этого файла используется вывод в консоль
#include <iostream>
struct Task
{
int value1;
char operation;
int value2;
int status;
int result;
};
void parse(int argc, char** argv, Task& task)
{
// TODO Добавить проверку, что argc равно 4
task.value1 = std::atoi(argv[1]);
task.operation = *(argv[2]);
task.value2 = std::atoi(argv[3]);
}
void calculate(Task& task)
{
task.status = 0;
switch(task.operation)
{
case '+':
task.result = addition(task.value1, task.value2);
break;
case '-':
task.result = subtraction(task.value1, task.value2);
break;
case '*':
task.result = multiplication(task.value1, task.value2);
break;
case '/':
task.status = division(task.value1, task.value2, task.result);
break;
case '^':
task.result = power(task.value1, task.value2);
break;
default:
task.status = 1;
}
}
void output(Task task)
{
if (task.status == 0)
{
std::cout << task.value1 << ' ' << task.operation << ' ' << task.value2 << " = " << task.result << '\n';
}
else if (task.status == -1)
{
std::cout << "Error! Division by zero!\n";
}
else if (task.status == 1)
{
std::cout << "Error! Unknown operation!\n";
}
else
{
std::cout << "Unknown error\n";
}
}
void run(int argc, char** argv)
{
Task task;
parse(argc, argv, task);
calculate(task);
output(task);
}
Содержимое файла main.cpp
int main(int argc, char** argv)
{
run(argc, argv);
}
Содержимое файла math.cpp
int addition(int a, int b)
{
return a + b;
}
int subtraction(int a, int b)
{
return a - b;
}
int multiplication(int a, int b)
{
return a * b;
}
int division(int a, int b, int& c)
{
if (b == 0)
{
return -1;
}
c = a / b;
return 0;
}
int power(int a, int b)
{
int c = 1;
for (int i = 0; i < b; ++i)
{
c = c * a;
}
return c;
}
Для того, чтобы собрать проект с такой структурой мы должны перечислить для компилятора все файлы, из которых состоит проект:
g++ src/app.cpp src/main.cpp src/math.cpp
Я буду запускать команду на компиляцию проекта из директории проекта calculator, поэтому для каждого файла я указал относительный путь до файла.
Увы, теперь наш проект не собирается! Давай разберемся в причине. Когда весь код нашего проекта находился в файле main.cpp все функции “знали” друг о друге: например, функция main знала, где находится функция run, а функция run, где находятся функции parse, calculate и output. После того, как мы разделили код на разные файлы эта связь была разорвана: например, функция main теперь ничего не знает о функции run, которая из неё вызывается. Почему? Потому что теперь в файле с функцией main больше нет объявления функции run!
Давай добавим объявление функции run в файл main.cpp
// Функция должна быть объявлена строго до момента её вызова
void run(int argc, char** argv);
int main(int argc, char** argv)
{
run(argc, argv);
}
По аналогии добавим объявление математических функций в файл app.cpp
// Объявляем математические функции
int addition(int a, int b);
int subtraction(int a, int b);
int multiplication(int a, int b);
int division(int a, int b, int& c);
int power(int a, int b);
struct Task
{
int value1;
char operation;
int value2;
int status;
int result;
};
void parse(int argc, char** argv, Task& task)
{
// TODO Добавить проверку, что argc равно 4
task.value1 = std::atoi(argv[1]);
task.operation = *(argv[2]);
task.value2 = std::atoi(argv[3]);
}
void calculate(Task& task)
{
task.status = 0;
switch(task.operation)
{
case '+':
task.result = addition(task.value1, task.value2);
break;
case '-':
task.result = subtraction(task.value1, task.value2);
break;
case '*':
task.result = multiplication(task.value1, task.value2);
break;
case '/':
task.status = division(task.value1, task.value2, task.result);
break;
case '^':
task.result = power(task.value1, task.value2);
break;
default:
task.status = 1;
}
}
void output(Task task)
{
if (task.status == 0)
{
std::cout << task.value1 << ' ' << task.operation << ' ' << task.value2 << " = " << task.result << '\n';
}
else if (task.status == -1)
{
std::cout << "Error! Division by zero!\n";
}
else if (task.status == 1)
{
std::cout << "Error! Unknown operation!\n";
}
else
{
std::cout << "Unknown error\n";
}
}
void run(int argc, char** argv)
{
Task task;
parse(argc, argv, task);
calculate(task);
output(task);
}
И выполним команду на сборку еще раз. Отлично! Теперь проект собрался.
Разделение файлов на h и cpp Честно говоря, это решение достаточно костыльное и может быть использовано только для проектов из пары файлов. В реальных проектах функции из математической библиотеки могут подключаться к десяткам и сотням других файлов - не прописывать же нам каждый раз в каждом из файлов объявление этих функций! Для решения этой проблемы в C++ есть механизм под названием заголовочные файлы (header files). Смысл его достаточно прост: объявление функций помещается в заголовочный файл, далее заголовочный файл подключается с помощью директивы #include. Что делает директива #include мы подробно разберем в следующем уроке, а пока просто запомни рецепт приготовления заголовочных файлов:
- в начале каждого заголовочного файла ты должен писать директиву #pragma once. Она избавит тебя от ошибки повторного включения заголовочного файла. С ней ты тоже познакомишься на следующем уроке:)
- далее идёт объявление функций, которое должно быть обязательно помещено в пространство имен.
Что такое пространство имен? Звучит мощно, но реализуется просто
namespace название_неймспейса
{
// содержимое неймспейса
}
Чтобы тебе было проще понять логику работы неймспейса, я запишу, как он работает под капотом. Когда мы пишем
namespace app
{
void run(int argc, char** argv);
}
это абсолютно аналогично записи
#pragma once
void app_run(int argc, char** argv);
Т.е. неймспейс - это синтаксический сахар, компилятор за нас добавляет уникальный префикс к имени сущностей, которые мы добавили в неймспейс. Зачем нужен неймспейс? Неймспейс помогает решить проблему коллизии имен функций - это когда одно и то же имя функции повторяется в нескольких cpp файлах. В проекте функций намного больше, чем пространств имен (пространства имен обычно можно посчитать по пальцам одной руки), поэтому придумать уникальное имя для пространства имен намного проще.
Напишем заголовочный файл для функции run. В директории src создадим файл app.h (h - сокращение header) со следующим содержимым:
#pragma once
namespace app
{
void run(int argc, char** argv);
}
Важно: не забывай про #pragma once в заголовочных файлах.
Подключим app.h в файл main.cpp
#include "app.h"
int main(int argc, char** argv)
{
app::run(argc, argv);
}
Для того, чтобы обратиться к функции из пространства имён, необходимо в начале написать название пространства имен, далее поставить два знака двоеточия, после чего вызов функции с аргументами. Как ты уже мог догадаться, объект cout находится в пространстве имён std - пространстве имен стандартной библиотеки C++.
Процедура, которую мы выполнили называется “разделение на h и cpp файлы”. Если мы планируем вызывать функции из cpp файла в других cpp файлах, мы должны вынести объявления этих функций в заголовочный файл, и, далее, подключить этот заголовочный файл ко всем cpp файлам, где эти функции вызываются.
Разделим файл math.cpp на объявление и реализацию. Файл math.h:
#pragma once
namespace math
{
int addition(int a, int b);
int subtraction(int a, int b);
int multiplication(int a, int b);
int division(int a, int b, int& c);
int power(int a, int b);
}
Я поместил математические функции в отдельное пространство имён.
Файл math.cpp
#include "math.h"
namespace math
{
int addition(int a, int b)
{
return a + b;
}
int subtraction(int a, int b)
{
return a - b;
}
int multiplication(int a, int b)
{
return a * b;
}
int division(int a, int b, int& c)
{
if (b == 0)
{
return -1;
}
c = a / b;
return 0;
}
int power(int a, int b)
{
int c = 1;
for (int i = 0; i < b; ++i)
{
c = c * a;
}
return c;
}
}
Внесем правки в файл app.cpp
#include "app.h"
#include "math.h"
#include <iostream>
namespace
{
struct Task
{
int value1;
char operation;
int value2;
int status;
int result;
};
void parse(int argc, char** argv, Task& task)
{
// TODO Добавить проверку, что argc равно 4
task.value1 = std::atoi(argv[1]);
task.operation = *(argv[2]);
task.value2 = std::atoi(argv[3]);
}
void calculate(Task& task)
{
task.status = 0;
switch(task.operation)
{
case '+':
task.result = math::addition(task.value1, task.value2);
break;
case '-':
task.result = math::subtraction(task.value1, task.value2);
break;
case '*':
task.result = math::multiplication(task.value1, task.value2);
break;
case '/':
task.status = math::division(task.value1, task.value2, task.result);
break;
case '^':
task.result = math::power(task.value1, task.value2);
break;
default:
task.status = 1;
}
}
void output(Task task)
{
if (task.status == 0)
{
std::cout << task.value1 << ' ' << task.operation << ' ' << task.value2 << " = " << task.result << '\n';
}
else if (task.status == -1)
{
std::cout << "Error! Division by zero!\n";
}
else if (task.status == 1)
{
std::cout << "Error! Unknown operation!\n";
}
else
{
std::cout << "Unknown error\n";
}
}
}
namespace app
{
void run(int argc, char** argv)
{
Task task;
parse(argc, argv, task);
calculate(task);
output(task);
}
}
Обрати внимание, что я подключил заголовочный файл app.h в файле app.cpp. Это правило хорошего стиля кода: компилятору будет проще проверить, что объявление функции run в файле app.h соответствует её определению в файле app.cpp.
Обрати внимание на неймспейс без имени - это анонимный неймспейс. Содержимое анонимного неймспейса доступно только в том cpp файле, в котором он объявлен.
Итоговая структура нашего проекта:
calculator/
├── src/ # Исходные файлы проекта
│ ├── app.cpp # Реализация бизнес-логики
│ ├── app.h # Заголовочный файл бизнес-логики
│ ├── main.cpp # Точка входа в программу
│ ├── math.cpp # Реализация математических функций
│ └── math.h # Заголовочный файл математических функций
├── .gitignore # Список файлов, игнорируемых Git
└── README.md # Описание проекта и инструкции по сборке
README.md Мы закончили работу над структурой нашего проекта, а, значит, настало время написать первую версию файла README.md. Напоминаю, что в файле README.md должна содержаться следующая информация:
- краткое описание проекта
- инструкция по сборке проекта
- инструкция по запуску проекта
Итак, само описание. Для начала, я думаю, этого будет достаточно.
Консольный калькулятор. Калькулятор поддерживает следующие действия: сложение, вычитание, умножение, деление и возведение в степень
Для сборки проекта в директории проекта выполнить:
g++ src/app.cpp src/main.cpp src/math.cpp -o calculator
Примеры команды для запуска:
./calculator 1 + 2
Обрати внимание на опцию компилятора -o - она позволяет указать имя исполняемого модуля.
Не забудь сделать коммит и зафиксировать проделанную сегодня работу;) До встречи на следующем уроке!