Правило 31: Уменьшайте зависимости файлов при компиляции

Правило 31: Уменьшайте зависимости файлов при компиляции

Рассмотрим самую обыкновенную ситуацию. Вы открываете свою программу на C++ и вносите незначительные изменения в реализацию класса. Заметьте, не в интерфейс класса, а просто в реализацию – только в закрытые члены. После этого вы начинаете заново собирать программу, рассчитывая, что это займет лишь несколько секунд. В конце концов, ведь вы модифицировали всего один класс. Вы щелкаете по кнопке Build или набираете make (либо какой-то эквивалент), и… удивлены, а затем – подавлены, когда обнаруживаете, что перекомпилируется и заново компонуется весь мир! Не правда ли, вам это скоро надоест?

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

class Person {

public:

Person(const std::string& name, const Date& birthday,

const Address& addr);

std::string name() const;

std::string birthDate() const;

std::string address() const;

...

private:

std::string theName; // деталь реализации

Date theBirthDate; // деталь реализации

Address theAddress; // деталь реализации

};

Класс Person нельзя скомпилировать, не имея доступа к определению классов, с помощью которых он реализуется, а именно string, Date и Address. Такие определения обычно предоставляются посредством директивы #include, поэтому весьма вероятно, что в начале файла, определяющего класс Person, вы найдете нечто вроде:

#include <string>

#include “date.h”

#include “address.h”

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

Можно задаться вопросом, почему C++ настаивает на размещении деталей реализации класса в определении класса. Например, почему нельзя определить Person следующим образом:

namespace std {

class string; // опережающее объявление

} // (некорректно – см. далее)

class Date; // опережающее объявление

class Address; // опережающее объявление

class Person {

public:

Person(const std::string& name, const Date& birthday,

const Address& addr);

std::string name() const;

std::string birthDate() const;

std::string address() const;

...

};

Если бы такое было возможно, то пользователи класса Person должны были перекомпилировать свои программы только при изменении его интерфейса.

Увы, при реализации этой идеи мы наталкиваемся на две проблемы. Первая: string – это не класс, а typedef (синоним шаблона basic_string<char>). Поэтому опережающее объявление string некорректно. Правильное объявление гораздо сложнее, так как в нем участвуют дополнительные шаблоны. Впрочем, это не важно, потому что вы в любом случае не должны вручную объявлять какие-либо части стандартной библиотеки. Вместо этого просто включите с помощью #include правильные заголовки и успокойтесь. Стандартные заголовки вряд ли станут узким местом при компиляции, особенно если ваша среда разработки поддерживает предкомпилированные заголовочные файлы. Если на компиляцию стандартных заголовков все же уходит много времени, то может понадобиться изменить дизайн и избежать использования тех частей стандартной библиотеки, которые включать нежелательно.

Вторая (и более существенная) неприятность, связанная с опережающим объявлением, состоит в том, что компилятору необходимо знать размер объектов во время компиляции. Рассмотрим пример:

int main()

{

int x; // определяем int

Person p(params); // определяем Person

...

}

Когда компилятор видит определение x, он понимает, что должен выделить достаточно места (обычно в стеке) для размещения int. Нет проблем: каждый компилятор знает, какова длина int. Встречая определение p, компилятор учитывает, что нужно выделить место для Person, но откуда ему знать, сколько именно места потребуется? Единственный способ получить эту информацию – справиться в определении класса, но если бы в определениях классов можно было опускать детали реализации, как компилятор выяснил бы, сколько памяти необходимо выделить?

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

int main()

{

int x; // определяем int

Person *p; // определяем указатель на Person

...

}

Это вполне законная конструкция на C++, поэтому вы и сами сможете имитировать «сокрытие реализации объекта за указателем». В случае класса Person это можно сделать, например, разделив его на два класса: один – для представления интерфейса, а другой – для его реализации. Если класс, содержащий реализацию, назвать Personlmpl, то Person должен быть написан следующим образом:

#include <string> // компоненты стандартной библиотеки

// не могут быть объявлены предварительно

#include <memory> // для tr1::shared_ptr; см. далее

class PersonImpl; // опережающее объявление PersonImpl

class Date; // опережающее объявление классов,

class Address; // используемых в интерфейсе Person

class Person {

public:

Person(const std::string& name, const Date& birthday,

const Address& addr);

std::string name() const;

std::string birthDate() const;

std::string address() const;

...

private: // указатель на реализацию:

std::tr1::shared_ptr<PersonImpl> pImpl; // см. в правиле 13 информацию

}; // о std::tr1::shared_ptr

Здесь главный класс (Person) не содержит никаких данных-членов, кроме указателя (в данном случае tr1::shared_ptr – см. правило 13) на свой класс реализации (Personlmpl). Такой дизайн часто называют «идиомой pimpl» («pointer to implementation» – указатель на реализацию). В подобных классах указатели часто называют pImpl, как в приведенном примере.

При таком дизайне пользователи класса Person не видят никаких деталей – дат, адресов и имен. Реализация может быть модифицирована как угодно, при этом перекомпилировать программы, в которых используется Person, не придется. Кроме того, поскольку пользователи не знают деталей реализации Person, они вряд ли напишут код, который каким-то образом будет зависеть от этих деталей. Вот это я и называю отделением интерфейса от реализации.

Ключом к этому разделению служит замена зависимости от определения (definition) на зависимость от объявления (declaration). Это и есть сущность минимизации зависимостей на этапе компиляции: когда это целесообразно, делайте заголовочные файлы самодостаточными; в противном случае используйте зависимость от объявлений, а не от определений. Все остальное вытекает из только что изложенной стратегии проектирования. Сформулируем три практических следствия:

Избегайте использования объектов, если есть шанс обойтись ссылками или указателями. Вы можете определить ссылки и указатели, имея только объявление типа. Определение объектов требует наличия определения типа.

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

class Date; // объявление класса

Date today(); // правильно, необходимость

void clearAppointments(Date d); // в определении Date отсутствует

Конечно, передача по значению – не очень хорошая идея (см. правило 20), но если по той или иной причине вы будете вынуждены ею воспользоваться, это никак не оправдает введения ненужных зависимостей. Не исключено, что возможность объявить функции today и clearAppoinments без определения Date повергла вас в удивление, но на самом деле это не так уж странно. Определение Date должно быть доступно в момент вызова этих функций. Да, я знаю, о чем вы думаете: зачем объявлять функции, которых никто не вызывает? Ответ прост. Дело не в том, что никто не вызывает их, а в том, что их вызывают не все. Например, если имеется библиотека, содержащая десятки объявлений функций, то маловероятно, что каждый пользователь вызывает каждую функцию. Перенося бремя ответственности за предоставление определений класса с ваших заголовочных файлов, содержащих объявления функций, на пользовательские файлы, содержащие их вызовы, вы исключаете искусственную зависимость пользователя от определений типов, которые им в действительности не нужны.

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

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

#include “datefwd.h” // заголочный файл, в котором объявлен

// (но не определен) класс Date

Date today(); // как раньше

void clearAppointments(Date d);

Файл с объявлениями назван «datefwd.h» по аналогии с заголовочным файлом <iosfwd> из стандартной библиотеки C++ (см. правило 54). <iosfwd> содержит объявления компонентов iostream, определения которых находятся в нескольких разных заголовках, включая <sstream>, <streambuf>, <fstream> и <iostream>.

Пример <iosfwd> поучителен еще и по другой причине. Из него следует, что совет этого правила относится в равной мере к шаблонным и обычным классам. Хотя в правиле 30 объяснено, что во многих средах разработки программ определения шаблонов обычно находятся в заголовочных файлах, но в некоторых продуктах допускается размещение определений шаблонов и в других местах, поэтому все же имеет смысл предоставить заголовочные файлы, содержащие только объявления, и для шаблонов. <iosfwd> – как раз пример такого файла.

В C++ есть также ключевое слово export, позволяющее отделить объявления шаблонов от их определений. К сожалению, поддержка компиляторами этой возможности ограничена, а практический опыт его применения совсем невелик. Сейчас еще слишком рано говорить, какую роль будет играть слово export в эффективном программировании на C++. Классы, подобные Person, в которых используется идиома pimpl, часто называют классами-дескрипторами (handle classes). Ответ на вопрос, каким образом работают такие классы, прост: они переадресовывают все вызовы функций соответствующим классам реализаций, которые и выполняют всю реальную работу. Например, вот как могут быть реализованы две функции-члена Person:

#include “Person.h” // поскольку мы реализуем класс Person,

// то должны включить его определение

#include “PersonImpl.h” // мы должны также включить определение класса

// PersonImpl, иначе не сможем вызывать его

// функции-члены; отметим, что PersonImpl имеет

// в точности те же функции-члены, что и

// Person: их интерфейсы идентичны

Person::Person(const std::string& name, const Date& birthday,

const Address& addr)

: pImpl(new Person(name, birthday, addr))

{}

std::string Person::name() const

{

return pImpl->name();

}

Обратите внимание на то, как конструктор Person вызывает конструктор Personlmpl (используя new – см. правило 16), и как Person::name вызывает PersonImpl::name. Это важный момент. Превращение Person в класс-дескриптор не меняет его поведения – изменяется только место, в котором это поведение реализовано.

Альтернативой подходу с использованием класса-дескриптора – сделать Person абстрактным базовым классом специального вида, называемым интерфейсным классом. Его назначение – специфицировать интерфейс для производных классов (см. правило 34). В результате он обычно не содержит ни данных-членов, ни конструкторов, но имеет виртуальный деструктор (см. правило 7) и набор чисто виртуальных функций, определяющих интерфейс.

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

Интерфейсный класс Person может выглядеть примерно так:

class Person {

public:

virtual ~Person();

virtual std::string name() const = 0;

virtual std::string birthDate() const = 0;

virtual std::string address() const = 0;

...

};

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

Конечно, пользователи интерфейсных классов должны иметь способ создавать новые объекты. Обычно они делают это, вызывая функцию, играющую роль конструктора для производных классов, экземпляры которых необходимо создать. Такие функции часто называют функциями-фабриками (см. правило 13), или виртуальными конструкторами. Они возвращают указатели (и лучше бы интеллектуальные, см. правило 18) на динамически распределенные объекты, которые поддерживают интерфейс интерфейсного класса. Нередко подобные функции объявляют как статические внутри интерфейсного класса:

class Person {

public:

...

static std::tr1::shared_ptr<Person> // возвращает tr1::shared_ptr

create(const std::string& name, // на новый экземпляр Person,

const Date& birthday, // инициализированный заданными

const Address& addr); // параметрами: см. в правиле 18,

... // почему возвращается

}; // tr1::shared_ptr

а используют так:

std::string name;

Date datefBirth;

Address address;

...

// создать объект, поддерживающий интерфейс Person

std::tr1::shared_ptr<Person> pp(Person::create(name, dateOfBrth, address));

...

std::cout << pp->name() // использовать объект через

<< “ родился ” // интерфейс Person

<< pp->birthDate()

<< “ и теперь живет по адресу ”

<< pp->address();

... // объект автоматически

// удаляется, когда pp выходит

// из контекста – см. правило 13

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

class RealPerson public Person {

public:

RealPerson(const std::string& name, const Date& birthday,

const Address& addr)

: theName(name), theBirthDate(birthday), theAddress(addr)

{}

virtual ~RealPerson() {}

std::string name() const; // реализация этих функций

std::string birthDate() const; // не показана, но ее

std::string address() const; // легко представить

private:

std::string theName;

Date theBirthDaye;

Address theAddress;

};

Имея класс RealPerson, очень легко написать Person::create:

std::tr1::shared_ptr<Person> create( const std::string& name,

const Date& birthday,

const Address& addr)

{

return std::tr1::shared_ptr<Person>(new RealPerson(name, birthday, addr));

}

Более реалистическая реализация Person::create должна создавать разные типы объектов классов-наследников, в зависимости, например, от дополнительных параметров функции, данных, прочитанных из файла или базы данных, переменных окружения и т. п.

RealPerson демонстрирует один из двух наиболее распространенных механизмов реализации интерфейсных классов: он наследует спецификации своего интерфейса от интерфейсного класса Person, а затем реализует функции этого интерфейса. Второй способ реализации интерфейсного класса предполагает использование множественного наследования (см. правило 40).

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

Применительно к классам-дескрипторам функции-члены должны использовать указатель на реализацию (pImpl), чтобы добраться до данных самого объекта. Для каждого обращения это добавляет один уровень косвенной адресации. Кроме того, к объему памяти, необходимому для хранения каждого объекта, нужно добавить размер указателя. И наконец, указатель на реализацию должен быть инициализирован (в конструкторе класса-дескриптора), чтобы он указывал на динамически распределенный объект реализации; следовательно, вы навлекаете на себя еще и накладные расходы, сопровождающие динамическое выделение памяти и последующее ее освобождение, а также возможность возникновения исключений bad_alloc (из-за недостатка памяти).

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

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

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

Что следует помнить

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

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

Данный текст является ознакомительным фрагментом.