View Categories

Организация проекта, библиотеки

Точка входа #

В отличие от большинства языков программирования, программа на C/C++ имеет конкретную точку входа - файл и место в программе, откуда начнётся её выполнение. Если это Си проект - главный файл называется main.c, если C++ - main.cpp. В этом файле должна быть функция int main() {} - с неё начнётся выполнение программы (сначала выполнятся конструкторы объектов, но об этом позже). Если компилятор не найдет эту функцию - будет ошибка компиляции.

// === main.cpp

int main() {
    // программа начинается здесь
}

Компиляция #

Процесс компиляции в C/C++ довольно сложный (препроцессинг, компиляция, ассемблирование, линковка..) и подробно в рамках данных уроков рассмотрен не будет. Достаточно знать, что:

  1. Препроцессор выполняет свои директивы и изменяет текст программы (дефайнит константы, подключает файлы, решает условные конструкции...)
  2. Компилятор компилирует исходные файлы отдельно друг от друга
  3. Линкер (компоновщик) собирает скомпилированные файлы в общую программу, разрешает зависимости, ищет определение функций и переменных и связывает их с местами использования в программе

Исходные и заголовочные файлы #

Именно компилируются в C/C++ только исходные файлы (source), они имеют расширение .c для C и .cpp для C++. Исходных файлов в проекте может быть несколько - они компилируются отдельно друг от друга. Исходный файл также называется единицей трансляции (translation unit).

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

Библиотека - независимый "модуль", набор файлов, который можно подключить в свой проект и использовать его функции, переменные и прочие сущности

Чтобы использовать код из одного исходного файла в другом, например в заглавном main - нужен заголовочный файл (header) - он имеет расширение .h для C и .hpp для C++. Этот файл содержит по сути список того, что находится в парном к нему c/cpp файле.

C++ совместим с C, поэтому очень часто можно встретить именно .h файлы (а не .hpp), которые идут в паре со своими .cpp файлами или просто подключаются в .cpp проект

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

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

  • Можно компилировать проект по частям
  • Использовать отдельные "прекомпиленные" модули
  • Не компилировать ещё раз то, что уже было скомпилировано и не имеет изменений - для ускорения процесса сборки проекта
  • Позволяет создавать библиотеки с закрытым исходным кодом

Так как имеет место быть разделение на "исполняемый код" и "просто список" - появляются два важных термина - объявление и определение.

Определение #

Определение, реализация (definition) - непосредственно исполняемый код: создание переменных (в этом месте выделяется память), создание объектов с вызовом конструктора, реализация функций. Определение обычно находится в исходном файле:

// === file.cpp - исходный код
// под переменную будет выделена память
unsigned int var;

// вызов конструктора у объектов
Class object(123);

// реализация, код функции
int func(int param) {
    return 0;
}

Объявление #

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

// === file.h
// объявляются типы
enum Enum {
    const1,
};

// переменная не создаётся
extern unsigned int var;

// конструктор не вызывается
extern Class object;

// функция без реализации
int func(int param);

Поиск определения объявленных сущностей по разным файлам программы называется линковкой, ей занимается отдельная утилита - линкер

Нельзя объявлять одну сущность несколько раз - будет ошибка компиляции:

// === file.h
extern unsigned int var;
extern unsigned int var; // ошибка

Исходный файл #

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

// === main.cpp
// объявление и определение
int var;

// объявление и определение
int func(int param) {
    return 0;
}

int main() {
    var = 1;
    func(3);
}

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

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

// === main.cpp
// объявление. Без него будет ошибка компиляции
int func(int param);

int main() {
    func(3);    // вызов до определения
}

// определение (реализация)
int func(int param) {
    return 0;
}

Второй исходный файл #

Давайте перенесём переменную и функцию в отдельный исходный файл, в котором сразу их определим:

// === func.cpp
int var;

int func(int param) {
    return 0;
}

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

// === main.cpp
// без объявления будет ошибка компиляции
extern unsigned int var;
int func(int param);

int main() {
    var = 1;    // меняем переменную, которая находится в func.cpp
    func(3);    // вызов функции из func.cpp
}

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

// === func.cpp
int var;

int func(int param) {
    return 0;
}
// === main.cpp
// ошибка
int var;

// ошибка
int func(int param) {
    return 0;
}

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

Скрываем код #

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

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

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

// === func.cpp
// приватно
static int var;         // #1

static void init() {    // #2
    var = 0;
}

// публично
void setVar(int v) {    // #3
    var = v;
}

int getVar() {
    return var;
}
// === main.cpp
void setVar(int v); // объявление, чтобы использовать #3
void init();        // не будет ошибкой, объявит НОВУЮ функцию в этом файле

//extern int var;   // ошибка - static #1 нельзя extern
int var;            // #4 - не будет ошибкой, создаст НОВУЮ переменную в этом файле

int main() {
    setVar(1);      // вызвали функцию #3 из func.cpp
    //init();       // ошибка, определие функции #2 не найдено, оно static в другом файле
    var = 456;      // меняем переменную #4 (из main.cpp)
}

В то же время в самом исходном файле можно разделить объявление и реализацию для статических функций:

// === func.cpp
// объявление
static void foo();
static void bar(int a);

// определение
static void foo() {
}
static void bar(int a) {
}

Исходный и заголовочный файлы #

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

// === lib.h - объявления
extern int var;
int func(int param);
// === lib.cpp - определения
int var;

int func(int param) {
    return 0;
}

Получаем два файла - исходный и заголовочный. Их имена необязательно должны совпадать - так делается для удобства, чтобы понимать, какой заголовочный файл к какому исходному относится.

Подключение #

Следующий шаг - подключить заголовочный файл в исходный файл при помощи директивы #include - она просто вставит заголовочный файл как текст. Директиве нужно указать путь к файлу, и тут есть два варианта:

  • Путь в "кавычках" - компилятор будет искать файл по пути относительно текущего исходного файла. То есть заголовочный файл можно положить рядом с ним или в соседнюю папку. Если компилятор не найдет файл - продолжит поиск в каталогах библиотек - системных и пользовательских
  • Путь в <угловых скобках> - компилятор сразу будет искать файл в каталогах библиотек

Допустим, что библиотека из примера выше лежит рядом с main.cpp - подключим её в код:

// добавит в код содержимое lib.h - все объявления
#include "lib.h"

int main() {
    func(123);
}

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

// === lib.h - объявления
extern int var;
int func1(int param);
void func2();
// === lib.cpp - определения
#include "lib.h"

int var;

int func1(int param) {
    // вызов до определения функции,
    // но мы уже объявили её выше, компилятор знает о её существовании
    func2();
    return 0;
}

void func2() {
}

Составные типы #

Важный момент: при extern каких-то данных в исходный файл он должен знать о типе этих данных. Если это стандартные встроенные типы - то он о них знает автоматически. Если это структура/класс/перечисление/объединение, то их нужно объявить соответствующим образом. Для этого и нужен заголовочный файл: в нём можно объявить например структуру и пользоваться именно типом этой структуры в других исходных файлах. А если нужно именно создать экземпляр структуры - это делается в исходном файле:

// === lib.h
// объявление структуры
struct Data {
    int a;
};

// объявление экземпляра структуры
extern Data data;   // определено в cpp - #1
// === lib.cpp
#include "lib.h"
// тут мы знаем о типе Data - объявлено в подключенном lib.h
Data data;          // #1 - определено тут
// === main.cpp
#include "lib.h"

// тут доступен тип Data из lib.h
Data foo;

// и доступна переменная data #1 из lib.cpp через extern в lib.h
data.a = 0;

У объектов классов при разделении на объявление и определение конструктор вызывается только там, где непосредственно создаётся объект, то есть в определении. В объявлении указывается тип и имя, как и у всех других типов данных:

// lib.h
extern Class obj;   // объявление
// lib.cpp
Class obj(...);     // определение, вызов конструктора

Таким образом сделаны основные системные библиотеки Arduino, например Wire - в .cpp определён объект, а в .h находится класс и extern

Повторное подключение #

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

// === lib.h
extern int var;
int func1(int param);
void func2();
// === myLibrary.h
#include "lib.h"
// === main.cpp
#include "lib.h"
#include "myLibrary.h"

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

define-ifdef #

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

// === lib.h
#ifndef MY_LIB_h
#define MY_LIB_h

// код библиотеки

#endif

Если заголовок не объявлен - объявить его и вставить код. Если уже объявлен - пропустить. Как нетрудно догадаться - для каждой библиотеки нужно придумать уникальный заголовок в define. Такую конструкцию можно часто встретить в библиотеках.

pragma once #

Эта директива является "нестандартной", но поддерживается большинством компиляторов. Достаточно просто добавить эту строчку в самое начало заголовочного файла и всё:

// === lib.h
#pragma once

// код библиотеки

Гораздо проще и удобнее использовать этот вариант, в текущее время вы вряд-ли встретите компилятор, который не знает про pragma once.

define-константы #

define - константы, как и все остальные директивы препроцессора, работают только в исходном файле, в своей единице трансляции. Это означает, что такую константу в исходном файле не видно из другого исходного файла!

// === lib.cpp
#define MY_CONST 123
// === main.cpp
// здесь MY_CONST не существует!

Заголовочный файл вставляется в исходный как текст, поэтому define константа "переедет" в исходный файл, в который подключена:

// === lib.h
#pragma once
#define MY_CONST 123
// === test.cpp
#include "lib.h"
// MY_CONST существует здесь
// === test.cpp
#include "lib.h"
// MY_CONST существует здесь

Проброс define-констант #

Заголовочный файл вставляется в исходный просто в виде текста, что позволяет делать довольно грязные вещи: если создать в исходном файле define-константу выше подключения заголовочного файла, то эту константу станет "видно" внутри заголовочного!

// === lib.h
// здесь видно SOME_CONST_1
// === main.cpp
#define SOME_CONST_1 123

#include "lib.h"

#define SOME_CONST_2 456    // будет уже не видно, она ниже

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

// === lib.h

// 1. Просто флаг, объявлено или нет

#ifdef USE_FOO
// код, если объявлено
#endif

#ifndef USE_FOO
// код, если НЕ объявлено
#endif

// 2. Константа со значением

// если не объявлено в исходном - установить значение по умолчанию
#ifndef SOME_SETTING
#define SOME_SETTING 1      // по умолчанию 1
#endif

// int var = SOME_SETTING;  // SOME_SETTING всегда существует, по умолчанию или переопределена в исходнике
// === main.cpp
#define SOME_SETTING 3
#define USE_FOO

#include "lib.h"

Но есть два момента, о которых нужно помнить:

  • Если это не header-only библиотека, то указанные таким образом константы не попадут в исходный файл самой библиотеки, так как дефайн-константы одного исходного файла не видно в другом исходном файле:
// === lib.h
// здесь видно SOME_SETTING
// === lib.cpp
#include "lib.h"
// здесь НЕ видно SOME_SETTING !!!
// === main.cpp
#define SOME_SETTING
#include "lib.h"
  • Если библиотека подключается в несколько исходных файлов с разными настройками - компилятор может ругаться:
// === lib.h
#ifdef SOME_SETTING
// какой-то код
#else
// взаимоисключающий код
#endif
// === test.cpp
//#define SOME_SETTING
#include "lib.h"
// === main.cpp
#define SOME_SETTING
#include "lib.h"

Теперь в программе существует две "версии" библиотеки, и насколько большие будут проблемы зависит от того, насколько они разные.

Пространство имён #

В C++ добавился такой полезный инструмент, как пространство имён namespace. Он позволяет объединить под одним уникальными именем набор функций и переменных без использования префиксов:

namespace тег {
// объявления или определения
}

На Си часто можно встретить библиотеки вида:

// === disp.h
#pragma once

void disp_clear();
void disp_update();

На C++ набор инструментов можно обернуть в namespace:

// === disp.h
#pragma once

namespace disp {
void clear();
void update;
}

Для реализации можно использовать два варианта - так же обернуть её в namespace, либо указывать тег для каждой сущности индивидуально:

  • Общий namespace:
// === disp.cpp
#include "disp.h"

namespace disp {

void clear() {
}

void update() {
}

}
  • Индивидуально:
// === disp.cpp
#include "disp.h"

void disp::clear() {
}

void disp::update() {
}

Вызов таких функций начинается с тег::

// === main.cpp
#include "disp.h"

int main() {
    disp::clear();
    disp::update();
}

Также можно глобально в рамках текущего исходного файла или внутри текущей функции "использовать" этот тег при помощи using и обращаться уже без него:

// === main.cpp
#include "disp.h"
using disp;     // для текущего файла

int main() {
    clear();
    update();
}
// === main.cpp
#include "disp.h"

int main() {
    using disp; // для текущей функции
    clear();
    update();
}

Несколько исходных файлов #

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

// === lib.h
#pragma once
extern int var;
void func();
// === lib1.cpp
#include "lib.h"
int var;
// === lib2.cpp
#include "lib.h"
void func() {
    var = 3;    // компилятор знает о var
}

Одиночный заголовочный файл #

Библиотека или "модуль" могут существовать и без исходного файла - только в заголовочном, такие библиотеки называются header-only. Такой подход будет работать для сущностей, которые не создаются в памяти прямо здесь и сейчас:

  • Структур, классов, перечислений, объединений
  • Шаблонных функций
  • #define - констант и макросов

И не будет работать для:

  • Функций
  • Глобальных переменных

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

// === lib.h
#pragma once

#define MY_CONST 123    // define-константа
int var;                // переменная
const int cnst = 3;     // константа
void func() {}          // функция
// === main.cpp
#include "lib.h"
// === test.cpp
#include "lib.h"

После подключения библиотеки это превратится в:

// === main.cpp
#define MY_CONST 123
int var;
const int cnst = 3;
void func() {}
// === test.cpp
#define MY_CONST 123    // нет ошибки
int var;                // ошибка компиляции
const int cnst = 3;     // возможна ошибка компиляции
void func() {}          // ошибка компиляции

Всё верно, #pragma once здесь ничем не поможет - он работает в рамках одного исходного файла, одной единицы трансляции. А у нас их несколько. В то же время, с #define константой ничего плохого не случится - #define собственно и работает только внутри исходного файла.

const-константа, объявленная таким образом, может не приводить к ошибке компиляции, так как будет оптимизирована компилятором в число. А может приводить

Классы и структуры #

Классы и структуры подробно рассмотрены в отдельном уроке. Здесь рассмотрим вариант, когда класс реализуется при объявлении и помещается в заголовочный файл:

// === lib.h
#pragma once

class MyClass {
    public:
    void foo() {}
};

struct MyStruct {
    int i;
};

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

static #

При большом желании можно объявить переменные и функции как static - тогда они будут существовать только в своих исходных файлах и не будут пересекаться:

// === lib.h
#pragma once

#define MY_CONST 123    // define-константа
static int var;         // переменная
static const int cnst;  // константа
static void func() {}   // функция
  • Функция займёт кратно больше места в постоянной памяти, так как фактически это уже другая функция, дубликат
  • Переменные тоже займут кратно больше места в оперативной памяти - под каждое вхождение будет выделена отдельная память - это будут независимые переменные

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

Weak #

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

Если в программе определено несколько одинаковых сущностей с атрибутом weak:

  • Компилятор просто выберет из них одну, остальные объявит
  • Если в одном месте есть "сильное" определение (без weak) - то будет использовано оно
  • Если есть несколько "сильных" определений - будет ошибка компиляции

Суть простая - если переменную или функцию хочется разместить именно в заголовочном файле (header-only библиотека) и чтобы оно корректно работало при подключении в несколько исходных файлов, нужно пометить их как __attribute__((weak)):

// === lib.h
#pragma once

int __attribute__((weak)) var = 0;

void __attribute__((weak)) func() {
}

__attribute__((weak)) применяется только при определении (реализации) функции

Слабое определение позволяет делать ещё одну интересную вещь - переопределять себя. Например, если в "библиотеке" функция определена как слабая, то можно определить её "сильной" в другом месте программы, и в библиотеке будет использоваться новая реализация:

// === lib.h
void func1();   // #1 объявлена (атрибут не указывается)

void __attribute__((weak)) func2() {    // #2 определена "слабо"
}
// === lib.cpp
void __attribute__((weak)) func1() {    // #1 определена "слабо"
}

void foo() {
    func1();    // #1 будет использоваться слабая реализация из lib.cpp
    func2();    // #2 будет использоваться сильная реализация из main.cpp
}
// === main.cpp
#include "lib.h"

void func2() {  // #2 переопределена сильно
}

int main() {
    func1();    // #1 будет использоваться слабая реализация из lib.cpp
    func2();    // #2 будет использоваться сильная реализация из main.cpp
}

Плюсы и минусы #

Плюсы организации "модуля" в одном заголовочном файле:

  • Уменьшает количество файлов
  • Уменьшает количество кода
  • Убирает дублирующийся код - объявления
  • Ускоряет процесс разработки
  • Позволяет пробрасывать define

Минусы:

  • Если это крупная библиотека и она подключается в несколько исходных файлов - она каждый раз по сути будет компилироваться заново, что увеличивает время компиляции. Но для проекта, который может написать один человек, эту разницу ощутить не получится
  • Отсутствие списка функций/методов, которым можно пользоваться в качестве документации. В то же время его можно создать в виде комментария в начале файла
0 0 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
Прокрутить вверх