2.11 – Защита заголовков

Добавлено 15 апреля 2021 в 18:46

Проблема повторяющегося определения

В уроке «2.6 – Предварительные объявления и определения» мы отметили, что идентификатор переменной или функции может иметь только одно определение (правило одного определения). Таким образом, программа, которая определяет идентификатор переменной более одного раза, вызовет ошибку компиляции:

int main()
{
    int x; // это определение переменной x
    int x; // ошибка компиляции: повторяющееся определение
 
    return 0;
}

Точно так же программы, которые определяют функцию более одного раза, также вызовут ошибку компиляции:

#include <iostream>
 
int foo() // это определение функции foo
{
    return 5;
}
 
int foo() // ошибка компиляции: повторяющееся определение
{
    return 5;
}
 
int main()
{
    std::cout << foo();
    return 0;
}

Хотя эти программы легко исправить (удалить повторяющееся определение), с помощью заголовочных файлов довольно легко попасть в ситуацию, когда определение в заголовочный файл включается более одного раза. Это может произойти, если заголовочный файл включает с #include другой заголовочный файл (что является обычным явлением).

Рассмотрим следующий академический пример:

square.h:

// Мы не должны включать определения функций в заголовочные файлы,
// но для примера сделаем это
int getSquareSides()
{
    return 4;
}

geometry.h:

#include "square.h"

main.cpp:

#include "square.h"
#include "geometry.h"
 
int main()
{
    return 0;
}

Эта, казалось бы, невинно выглядящая программа не компилируется! Вот что происходит. Во-первых, main.cpp включает square.h, что копирует определение функции getSquareSides в main.cpp. Затем main.cpp включает geometry.h, который сам включает square.h. Это копирует содержимое square.h (включая определение функции getSquareSides) в geometry.h, которое затем копируется в main.cpp.

Таким образом, после разрешения всех директив #include файл main.cpp будет выглядеть так:

int getSquareSides()  // из square.h
{
    return 4;
}
 
int getSquareSides() // из geometry.h (через square.h)
{
    return 4;
}
 
int main()
{
    return 0;
}

Повторяющиеся определения и ошибка компиляции. Каждый файл по отдельности нормальный. Однако, поскольку main.cpp дважды включает через #include содержимое square.h, мы столкнулись с проблемами. Если для geometry.h требуется getSquareSides(), а для main.cpp требуются и geometry.h, и square.h, как бы вы решили эту проблему?

Защита заголовка

Хорошей новостью является то, что мы можем избежать указанной выше проблемы с помощью механизма, называемого защитой заголовка (или защитой включения). Защита заголовка – это директивы условной компиляции, которые имеют следующую форму:

#ifndef SOME_UNIQUE_NAME_HERE
#define SOME_UNIQUE_NAME_HERE
 
// здесь идут ваши объявления (и некоторые типы определений)
 
#endif

Когда этот заголовок включается, препроцессор проверяет, был ли ранее определен SOME_UNIQUE_NAME_HERE. Если это первый раз, когда мы включаем этот заголовок, SOME_UNIQUE_NAME_HERE не будет определен. Следовательно, он с помощью #define определяет SOME_UNIQUE_NAME_HERE и включает содержимое файла. Если заголовок включен в тот же файл повторно, SOME_UNIQUE_NAME_HERE уже будет определен из первого раза, когда содержимое заголовка было включено, и в этот раз содержимое заголовка будет проигнорировано (благодаря #ifndef).

Все ваши заголовочные файлы должны иметь защиту заголовков. Имя SOME_UNIQUE_NAME_HERE может быть любым, но по соглашению устанавливается равным полному имени заголовочного файла, набранному заглавными буквами, с использованием подчеркивания вместо пробелов и знаков препинания. Например, у square.h будет защита заголовка будет следующей:

square.h:

#ifndef SQUARE_H
#define SQUARE_H
 
int getSquareSides()
{
    return 4;
}
 
#endif

Даже заголовочные файлы стандартной библиотеки используют защиту заголовков. Если бы вы взглянули на заголовочный файл iostream из Visual Studio, вы бы увидели:

#ifndef _IOSTREAM_
#define _IOSTREAM_
 
// тут идет содержимое файла
 
#endif

Для продвинутых читателей


В больших программах возможно наличие двух отдельных заголовочных файлов (включенных из разных каталогов) с одинаковыми именами (например, directoryA\config.h и directoryB\config.h). Если для защиты включения используется только имя файла (например, CONFIG_H), эти два файла могут в конечном итоге использовать одно и то же защитное имя. Если это произойдет, любой файл, который включает (прямо или косвенно) оба файла config.h, не получит содержимое включаемого файла, который будет включен вторым. Это, вероятно, вызовет ошибку компиляции.
Из-за этой возможности конфликтов защитных имен многие разработчики рекомендуют использовать более сложные/уникальные имена в защитах заголовков. Некоторые хорошие предложения – это соглашение об именах <ПРОЕКТ>_<ПУТЬ>_<ФАЙЛ>_H, <ФАЙЛ>_<БОЛЬШОЕ СЛУЧАЙНОЕ ЧИСЛО>_H или <ФАЙЛ>_<ДАТА СОЗДАНИЯ>_H.

Обновление нашего предыдущего примера с помощью защиты заголовков

Вернемся к примеру со square.h, добавив в него защиту заголовков. Чтобы быть последовательными мы также добавим защиту заголовков и в geometry.h.

square.h:

#ifndef SQUARE_H
#define SQUARE_H
 
int getSquareSides()
{
    return 4;
}
 
#endif

geometry.h:

#ifndef GEOMETRY_H
#define GEOMETRY_H
 
#include "square.h"
 
#endif

main.cpp:

#include "square.h"
#include "geometry.h"
 
int main()
{
    return 0;
}

После того, как препроцессор разрешит все включения, эта программа будет выглядеть так:

main.cpp:

#ifndef SQUARE_H // square.h включен из main.cpp,
#define SQUARE_H // SQUARE_H получает здесь определение
 
// и весь этот контент будет включен
int getSquareSides()
{
    return 4;
}
 
#endif // SQUARE_H
 
#ifndef GEOMETRY_H // geometry.h включен из main.cpp
#define GEOMETRY_H
#ifndef SQUARE_H // square.h включен из geometry.h, SQUARE_H уже определен выше
#define SQUARE_H // поэтому ничего из этого контента не было включено
 
int getSquareSides()
{
    return 4;
}
 
#endif // SQUARE_H
#endif // GEOMETRY_H
 
int main()
{
    return 0;
}

Как видно из примера, второе включение содержимого square.h (из geometry.h) игнорируется потому, что SQUARE_H уже был определен при первом включении. Следовательно, функция getSquareSides включается только один раз.

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

Обратите внимание, что цель защиты заголовков – предотвратить получение файлом исходного кода более одной копии защищенного заголовка. По замыслу, защита заголовков не препятствует включению данного заголовочного файла (однократно) в отдельные исходные файлы. Это также может вызвать непредвиденные проблемы. Рассмотрим следующую возможность:

square.h:

#ifndef SQUARE_H
#define SQUARE_H
 
int getSquareSides()
{
    return 4;
}
 
// предварительное объявление для getSquarePerimeter
int getSquarePerimeter(int sideLength);
 
#endif

square.cpp:

#include "square.h"  // square.h включается сюда один раз
 
int getSquarePerimeter(int sideLength)
{
    return sideLength * getSquareSides();
}

main.cpp:

#include "square.h" // square.h также включается один раз и сюда
#include <iostream>
 
int main()
{
    std::cout << "a square has " << getSquareSides() << " sides\n";
    std::cout << "a square of length 5 has perimeter length " << getSquarePerimeter(5) << '\n';
 
    return 0;
}

Обратите внимание, что square.h включается как из main.cpp, так и из square.cpp. Это означает, что содержимое square.h будет включено один раз в square.cpp и один раз в main.cpp.

Давайте разберемся, почему это происходит более подробно. Когда square.h включается из square.cpp, SQUARE_H определяется до конца square.cpp. Это определение предотвращает повторное включение square.h в square.cpp (что является целью защиты заголовков). Однако после завершения square.cppSQUARE_H больше не считается определенным. Это означает, что когда препроцессор начинает работу над main.cpp, SQUARE_H в main.cpp изначально не определен.

Конечным результатом является то, что и square.cpp, и main.cpp получают копию определения getSquareSides. Эта программа будет компилироваться, но компоновщик будет жаловаться на то, что ваша программа имеет несколько определений для идентификатора getSquareSides!

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

square.h:

#ifndef SQUARE_H
#define SQUARE_H
 
// предварительное объявление для getSquareSides
int getSquareSides();

// предварительное объявление для getSquarePerimeter
int getSquarePerimeter(int sideLength); 
 
#endif

square.cpp:

#include "square.h"
 
// фактическое определение getSquareSides
int getSquareSides()
{
    return 4;
}
 
int getSquarePerimeter(int sideLength)
{
    return sideLength * getSquareSides();
}

main.cpp:

#include "square.h" // square.h также включается один раз сюда
#include <iostream>
 
int main()
{
    std::cout << "a square has " << getSquareSides() << "sides\n";
    std::cout << "a square of length 5 has perimeter length " << getSquarePerimeter(5) << '\n';
 
    return 0;
}

Теперь, когда программа скомпилирована, функция getSquareSides будет иметь только одно определение (через square.cpp), так что компоновщик будет счастлив. Файл main.cpp может вызывать эту функцию (даже если она находится в square.cpp), потому что он включает square.h, в котором есть предварительное объявление для этой функции (компоновщик соединит вызов getSquareSides из main.cpp с определение getSquareSides в square.cpp).

Разве мы не можем просто избежать определений в файлах заголовков?

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

В будущем мы покажем вам довольно много случаев, когда в файл заголовка необходимо поместить определения, не являющиеся функциями. Например, C++ позволяет вам создавать свои собственные типы. Эти пользовательские типы обычно определяются в файлах заголовков, чтобы эти определения можно было распространить на исходные файлы, которые должны их использовать. Без защиты заголовков ваши исходные файлы могут иметь несколько идентичных копий этих определений, что приведет к ошибке компиляции повторяющихся определений.

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

#pragma once

Многие компиляторы поддерживают более простую альтернативную форму защиты заголовков с помощью директивы #pragma:

#pragma once
 
// здесь ваш код

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

Однако #pragma once не является официальной частью языка C++, и не все компиляторы поддерживают ее (хотя большинство современных компиляторов поддерживает).

В целях совместимости мы рекомендуем придерживаться традиционной защиты заголовков. Она не требуют много работы и гарантированно поддерживаются всеми компиляторами.

Резюме

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

Обратите внимание, что дублирование объявлений – это нормально, поскольку объявление может быть объявлено несколько раз без инцидентов, но даже если ваш заголовочный файл состоит только из объявлений (без определений), всё равно рекомендуется включать защиту заголовков.

Обратите внимание, что защита заголовков не предотвращает копирование содержимого заголовочного файла (один раз) в отдельные файлы проекта. Это хорошо потому, что нам часто нужно ссылаться на содержимое заданного заголовка из разных файлов проекта.

Небольшой тест

Вопрос 1

Добавьте защиту заголовка в этот заголовочный файл:

add.h:

int add(int x, int y);

#ifndef ADD_H
#define ADD_H
 
int add(int x, int y);
 
#endif

Теги

C++ / CppДля начинающихОбучениеПрограммирование

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.