Глава 2 Организация кода

2.0. Введение

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

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

Если вы еще не используете пространства имен, вы, возможно, по крайней мере, слышали о них и уже используете одно из них: пространство имен

std
, которое является пространством имен, содержащим стандартную библиотеку. Исходя из моего опыта, пространства имен используются не настолько часто, насколько следовало бы но это не потому, что они очень сложны или их использование требует больших усилии. Рецепт 2.3 объясняет, как с помощью пространств имен сделать код модульным.

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

Пример 2.1. Заголовочный файл

#ifndef MYCLASS_H__ // защита #include, рецепт 2.0

#define MYCLASS_H__


#include 


namespace mylib { // пространства имен, рецепт 2.3

 class AnotherClass; // предварительное объявление класса, рецепт 2.2

 class Logger;

 extern Logger* gpLogger; // объявление внешнего хранилища, рецепт 2.1

 class MyClass {

 public:

  std::string getVal() const;

  // ...

 private:

  static int refCount_;

  std::string val_;

 };

}


// Встраиваемые определения, рецепт 2.4

inline std::string MyClass::getVal() const {

 return(val_);

}


#include "myclass.inl"

} // namespace


#endif // MYCLASS_H__

После написания заголовочного файла также вам будет нужен файл реализации, под которым я понимаю файл .cpp, содержащий не только объявления, но и определения. Файл реализации оказывается менее сложным, чем заголовочный файл, но ради полноты пример 2.2 содержит пример реализации файла, идущего в комплекте с заголовочным файлом из примера 2.1.

Пример 2.2. Файл реализации

#include "myclass.h"


namespace mylib {

 MyClass::refCount_ = 0; // статическое определение, рецепт 8.4

 MyClass::foo() { // реализация метода

  // ...

 };

}

Конечно, файлы реализации будут полны обдуманных, хорошо написанных комментариев, но ради простоты я оставляю этот вопрос за скобками.

2.1. Обеспечение единственности подключения заголовочного файла

Проблема

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

Решение

В заголовочном файле с помощью

#define
определите макрос и содержимое заголовочного файла подключайте только тогда, когда макрос еще не был определен. Используйте такую комбинацию директив препроцессора
#ifndef
,
#define
и
#endif
, как я делаю в примере 2.1:

#ifndef MYCLASS_H__ // защита #include

#define MYCLASS_H__

// Здесь поместите все. что требуется...

#endif // MYCLASS_H__

Когда препроцессор сканирует такой заголовочный файл, одной из первых встреченных им вещей будет директива

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

Обсуждение

Если вы не используете эту методику, которая называется защитой заголовка, то вы, вероятно, уже видели ошибки компиляции «symbol already defined» (символ уже определен), которые являются следствием отсутствия защитных мер против множественных определений. C++ не позволяет определять один и тот же символ несколько раз, и если вы это сделаете (целенаправленно или случайно), то получите ошибку компилятора. Включение защиты предотвращает такие ошибки, и она стала стандартной методикой.

Определяемый с помощью

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

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

#ifndef MYCLASS_H__

#include "myclass.h"


#endif

Это сокращает процесс включения, поскольку, если макрос

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

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

2.2. Обеспечение единственности экземпляра переменной при большом количестве исходных файлов

Проблема

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

Решение

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

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

Пример 2.3. Использование ключевого слова extern


// global.h

#ifndef GLOBAL_H__ // см. рецепт 2.0

#define GLOBAL_H__


#include 


extern int x;

extern std::string s;

#endif


// global.cpp

#include 


int x = 7;

std::string s = "Kangaroo";


// main.cpp

#include 

#include "global.h"


using namespace std;

int main() {

 cout << "x = " << x << endl;

 cout << "s = " << s << endl;

}

Обсуждение

Ключевое слово

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

Пример 2.3 не слишком впечатляет, но он хорошо иллюстрирует вопрос. Две мои глобальные переменные объявляются в global.cpp:

int x = 7;

std::string s = "Kangaroo";

Мне требуется доступ к ним из других файлов реализации, так что я поместил в заголовочный файл global.h объявление

extern
для них:

extern int x;

extern std::string s
;

Разница между объявлением и определением очень важна. В C++ можно объявить что-либо несколько раз, при условии совпадения объявлений, но определить что-либо можно только один раз. Это называется правилом одного определения (на самом деле в некоторых случаях определить объект можно несколько раз, но только если определения абсолютно идентичны — обычно это бессмысленно). Ключевое слово

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

Нельзя сказать, что использование

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

2.3. Снижение числа #include с помощью предварительного объявления классов

Проблема

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

Решение

Чтобы избежать ненужных зависимостей при компиляции, везде, где только возможно, используйте предварительное объявление классов. Пример 2.4. является коротким примером предварительного объявления класса.

Пример 2.4. Предварительное объявление класса


// myheader.h

#ifndef MYHEADER_H__

#define MYHEADER_H__


class A; // заголовок для А включать не требуется


class В {

public:

 void f(const A& a);

 // ...

private:

 A* a_;

};

#endif

Где-то в другом месте имеется заголовочный файл и, вероятно, файл реализации, который объявляет и определяет класс

А
, но в файле myheader.h подробности класса
А
меня не волнуют: все, что мне требуется знать, — это то, что
А
— это класс.

Обсуждение

Предварительное объявление класса — это способ игнорировать подробности, о которых не требуется беспокоиться. В примере 2.4 myheader.h не должен знать о классе

А
ничего, кроме того, что он существует и что это класс.

Рассмотрим, что случится, если с помощью

#include
включить заголовочный файл для
А
, или, что более реально, заголовочный файл для полудюжины или более классов, присутствующих в реальном заголовочном файле. Тогда файл реализации (myheader.cpp) будет включать заголовочный файл myheader.h, так как он содержит все объявления. До сих пор все было хорошо. Но если вы измените один из заголовочных файлов, включаемых в myheader.h (или один из заголовочных файлов, включаемых одним из этих файлов), то потребуется перекомпилировать все файлы реализации, включающие myheader.h.

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

К несчастью, использовать предварительное объявление можно не всегда. Класс

В
в примере 2.4 использует только указатели или ссылки на
A
, так что ему достаточно только предварительного объявления. Однако если бы в определении класса
В
я использовал функцию-член (метод) или переменную
А
или если бы создавал объект типа
А
, а не только указатель или ссылку на него, то предварительного объявления окажется недостаточно. Причиной этого является то, что файлы, включающие myheader.h, должны знать размер
В
, и если
A
является членом
В
, то компилятор, чтобы определить размер
В
, должен знать размер
А
. Указатель или ссылка на что-либо всегда имеют один и тот же размер, так что в случае использования указателей или ссылок подробности об
А
компилятор не интересуют, и, следовательно, заголовочный файл не требуется

Неудивительно, что если включить в myheader.h какое-либо определение, использующее членов

A
, то потребуется включить через
#includ
e заголовок для
А
. Это требуется для того, чтобы компилятор мог проверить сигнатуру используемой функции-члена
А
или тип переменной-члена
А
. Вот иллюстрация кода, требующего
#include
.

#include "a.h"


class B {

public:

 void f(const A& a) {

  foo_ = a.getVal(); // требуется знать, допустимо ли a.getVal

 }

}

// ...

В общем случае используйте предварительное объявление тогда, когда это позволяет снизить количество

#include
, что отражается на времени компиляции.

2.4. Предотвращение конфликта имен с помощью пространств имен

Проблема

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

Решение

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

Пример 2.5. Использование пространств имен


// Devices.h

#ifndef DEVICES_H__

#define DEVICES_H__


#include 

#include 


namespace hardware {

 class Device {

 public:

  Device(): uptime_(0), status_("unknown") {}

  unsigned long getUptime() const;

  std::string getStatus() const;

  void reset();

 private:

  unsigned long uptime_;

  std::string status_;

 };


 class DeviceMgr {

 public:

  void getDeviceIds(std::list& ids) const;

  Device getDevice(const std::string& id) const;

  // Other stuff...

 };

}


#endif // DEVICES_H__


// Devices.cpp

#include "Devices.h"

#include 

#include 


namespace hardware {

 using std::string;

 using std::list;


 unsigned long Device::getUptime() const {

  return(uptime__);

 }


 string Device::getStatus() const {

  return(status_);

 }


 void DeviceMgr::getDeviceIds(list& ids) const {}


 Device DeviceMgr::getDevice(const string& id) const {

  Device d;

  return(d);

 }

}


// DeviceWidget.h

#ifndef DEVICEWIDGET_H__ #define DEVICEWIDGET_H__

#include "Devices.h"


namespace ui {

 class Widget {/*... */ };


 class DeviceWidget : public Widget {

 public:

  DeviceWidget(const hardware::Device& dev) : device_(dev) {}

  // Some other stuff

 protected:

  hardware::Device device_;

 };

}

#endif // DEVICEWIDGET_H__


// main.cpp

#include 

#include "DeviceWidget.h"

#include "Devices.h"


int main( ) {

 hardware::Device d;

 ui::DeviceWidget myWidget(d);

 // ...

}

Обсуждение

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

Вначале рассмотрим файл Devices.h. Он содержит пару классов, которые управляют элементами оборудования, —

Device
и
DeviceMgr
. Однако они не должны находиться в глобальном пространстве имен (что означало бы, что их имена видны в любом месте программы), так что я поместил их в пространство имен
hardware
.

#ifndef DEVICES_H__ // см. рецепт 2.0

#define DEVICES_H__


#include 

#include 


namespace hardware {

 class Device {

  // ...

 };


 class DeviceMgr {

  // ...

 };


}

#endif // DEVICES_H__

Этот механизм прост: «оберните» все, что требуется поместить в пространство имен, в блок

namespace
.

Приведенный выше фрагмент — это объявление

Device
и
DeviceMgr
, но нам также требуется подумать об их реализациях, которые находятся в файле Devices.cpp. И снова оберните все в блок
namespace
— он будет добавлен к уже имеющемуся содержимому этого пространства имен.

#include "Devices.h"

#include 

#include 


namespace hardware {

 using std::string;

 using std::list;

 // Реализация Device и DeviceMgr

}

В данный момент пространство имен

hardware
содержит все, что требуется. Все, что осталось, — это где-то его использовать. Для этого имеется несколько способов. Способ, который был использован в примере 2.5, состоит в указании полного имени класса
Device
, включая пространство имен, как здесь.

#ifndef DEVICEWIDGET_H_

#define DEVICEWIDGET_H_


#include "Devices.h"


namespace ui {

 class Widget { /* ... */ };

 class DeviceWidget : public Widget {

 public:

  DeviceWidget(const hardware::Device& dev) : device_(dev) {}

  // Other stuff...

 protected:

  hardware::Device device_;

 };

}

#endif // DEVICEWIDGET_H__

Также я использую этот способ в

main
в main.cpp.

int main() {

 hardware::Device d;

 ui::DeviceWidget myWidget(d);

}

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

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

using
можно создать псевдоним.

using hardware::Device;


int main() {

 Device d; // Пространство имен не требуется

 ui::DeviceWidget myWidget(d);

}

В последующем коде вместо ввода полного имени можно просто сослаться на этот псевдоним. Или можно с помощью

using
импортировать все содержимое пространства имен, а не только один содержащийся в нем тип.

using namespace hardware;


int main() {

 Device d:

 ui::DeviceWidget myWidget(d);

}

Этот вариант вы, вероятно, уже использовали, или, по крайней мере, видели в примерах, при использовании стандартной библиотеки (эту методику используют многие примеры в этой книге). Вся стандартная библиотека находится в пространстве имен

std
, так что очень часто вы увидите такое:

using namespace std;

Импорт всего пространства имен часто является плохой идеей и обычно рассматривается как плохой стиль. В примерах в этой книге мы импортируем полное содержимое пространства имен

std
только с целью повышения ясности кода и обычно рекомендуем не делать этого в реальных программах.

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

Чтобы разделить содержимое пространства имен на более мелкие группы, можно использовать вложенные пространства имен. Например, пространство имен

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

namespace hardware {

 namespace net {

  // сетевые классы

 }

 namespace devices {

  // классы устройств

 }

}

Теперь доступ к элементам, содержащимся в пространстве имен, стал несколько более сложным.

// В каком-либо другом файле...

using hardware::devices::Device;

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

using
. Последние два длинны по названиям, но просты.

Псевдоним пространства имен — это то, что означает его название: имя (возможно, короткое), которое используется для замены имени (возможно, длинного) пространства имен. Если вы не хотите использовать выражение

using
, но также не хотите вводить при каждом использовании класса огромное полное имя, создайте для него псевдоним.

using dev = hardware::devices;

// ...

dev::Device d;

Затем этот псевдоним используется при ссылке на элементы соответствующего пространства имен.

C++ также предоставляет автоматический поиск в пространствах имен, к которым относятся параметры функций. Так, например, следующий код описывает аргументы в пространстве имен (

dev
— это пространство имен, в котором объявлен
Device
):

void f(dev::Device& d) {

 register(d); // на самом деле это dev::register

}

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

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

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

using
. Рассмотрим такой пример.

namespace mylib {

 void foo(mt);

 void foo(double);

 void foo(std::string);

 // Другие перегрузки foo( )...

}


// В каком-то другом файле...

using mylib::foo; // Какой вариант используется?

Объявление

using
соответствует всем перегрузкам
foo
, так что писать отдельную директиву для каждой перегрузки не требуется. Другим преимуществом этой записи является то, что если добавляется еще одна перегрузка
foo
, то весь код, содержащий объявление вида
mylib::foo
, видит ее автоматически (конечно, при компиляции кода, содержащего это объявление), так как объявление
using
включает и ее.

Конечно, использовать пространства имен следует обдуманно, а иначе у вас или тех, кто будет их использовать, появятся неожиданные ошибки компиляции. Вот несколько популярных советов по использованию пространств имен.

Как можно реже используйте

using namespace xxx

Как я объяснял ранее, импорт всего пространства имен увеличивает вероятность конфликта имен — либо сразу, либо в будущем (в используемое вами пространство имен может быть добавлено что-то, что приведет к конфликту). Это также снижает степень модульности, предоставляемую пространствами имен.

Не используйте оператор

using
в заголовочных файлах

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

Не помещайте объявления

using
или определения перед директивами
#include

Если это сделать, тогда то, что указано в директиве

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

При выполнении этих правил использование пространств имен в новом проекте или добавление их в существующий проект должно быть относительно просто.

2.5. Включение встраиваемого файла

Проблема

Имеется несколько функций-членов или самостоятельных функций, которые требуется сделать встраиваемыми (inline), но вы не хотите определять их все в определении класса (или даже после него) в заголовочном файле. Это позволит хранить объявление и реализацию по отдельности.

Решение

Создайте файл .inl и с помощью

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

Пример 2.6. Использование встраиваемого файла


// Value.h

#ifndef VALUE_H__

#define VALUE_H__


#include 


class Value {

public:

 Value (const std::string& val) : val_(val) {}

 std::string getVal() const;

private:

 std::string val_;

};


#include "Value.inl"


#endif VALUE_H__


// Value.inl

inline std::string Value::getVal() const {

 return(val_);

}

Это решение не требует пояснений,

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

Загрузка...