Точка входа #
В отличие от большинства языков программирования, программа на C/C++ имеет конкретную точку входа - файл и место в программе, откуда начнётся её выполнение. Если это Си проект - главный файл называется main.c
, если C++ - main.cpp
. В этом файле должна быть функция int main() {}
- с неё начнётся выполнение программы (сначала выполнятся конструкторы объектов, но об этом позже). Если компилятор не найдет эту функцию - будет ошибка компиляции.
// === main.cpp
int main() {
// программа начинается здесь
}
Компиляция #
Процесс компиляции в C/C++ довольно сложный (препроцессинг, компиляция, ассемблирование, линковка..) и подробно в рамках данных уроков рассмотрен не будет. Достаточно знать, что:
- Препроцессор выполняет свои директивы и изменяет текст программы (дефайнит константы, подключает файлы, решает условные конструкции...)
- Компилятор компилирует исходные файлы отдельно друг от друга
- Линкер (компоновщик) собирает скомпилированные файлы в общую программу, разрешает зависимости, ищет определение функций и переменных и связывает их с местами использования в программе
Исходные и заголовочные файлы #
Именно компилируются в 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
Минусы:
- Если это крупная библиотека и она подключается в несколько исходных файлов - она каждый раз по сути будет компилироваться заново, что увеличивает время компиляции. Но для проекта, который может написать один человек, эту разницу ощутить не получится
- Отсутствие списка функций/методов, которым можно пользоваться в качестве документации. В то же время его можно создать в виде комментария в начале файла