Правило 44: Размещайте независимый от параметров код вне шаблонов

Правило 44: Размещайте независимый от параметров код вне шаблонов

Шаблоны – чудесный способ сэкономить время и избежать дублирования кода. Вместо того чтобы вводить код 20 похожих классов, в каждом из которых по 15 функций-членов, вы набираете текст одного шаблона и поручаете компилятору сгенерировать 20 конкретных классов и все 300 необходимых вам функций. (Функции-члены шаблонов классов неявно генерируются, только когда программа к ним обращается, поэтому все 300 функций-членов вы получите, лишь если будете все их использовать.) Шаблоны функций не менее привлекательны. Вместо написания множества однотипных функций вы пишете один шаблон и позволяете компиляторам проделать все остальное. Ну разве не восхитительная технология?

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

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

Когда вы пишете функцию и обнаруживаете, что некоторая часть ее реализации мало чем отличается от реализации другой функции, разве вы дублируете код? Конечно, нет. Вы исключаете общую часть из обеих функций, помещаете ее в третью, а первые две вызывают эту третью функцию. Иными словами, вы анализируете эти две функции на предмет выявления общих и отличающихся частей, перемещаете общие части в новую функцию, а отличающиеся части оставляете на месте. Аналогично, если вы пишете класс и выясняется, что некоторые части этого класса в точности совпадают с частями другого класса, вы не станете их дублировать, а просто вынесете общие части в новый класс, а затем воспользуетесь наследованием или композицией (см. правила 32, 38 и 39), предоставив исходному классу доступ к общим средствам. Отличающиеся части исходных классов остаются на месте.

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

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

template<typename T, std::size_t n> // шаблон матрицы размерностью n x n,

class SquareMatrix { // состоящей из объектов типа T;

public: // см. ниже информацию о параметре size_t

...

void invert(); // обращение матрицы на месте

};

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

Теперь рассмотрим такой код:

SquareMatrix<double, 5> sm1;

...

sm1.invert(); // вызов SquareMatrix<double, 5>::invert()

SquareMatrix<double, 10> sm2;

...

sm2.invert(); // вызов SquareMatrix<double, 10>::invert()

Здесь будут конкретизированы две копии функции invert. Они не идентичны, потому что одна из них работает с матрицами 5x5, а другая – с матрицами 10x10, но во всем остальном, кроме констант 5 и 10, эти функции ничем не отличаются. Это – классический пример разбухания кода в результате применения шаблонов.

Что вы делаете, когда есть две функции, абсолютно одинаковые, за исключением того, что в одной используется константа 5, а в другой – 10? Естественно, вы создаете функцию, которая принимает параметр, а затем вызываете ее, один раз передавая в качестве параметра 5, а другой раз – 10. Вот первая попытка проделать тот же трюк в реализации шаблона SquareMatrix:

template<typename T> // базовый класс, не зависящий

class SquareMatrixBase { // от размерности матрицы

protected:

...

void invert(std::size_t matrixSize); // обратить матрицу заданной

... // размерности

};

template<typename T, std::size_t n>

class SquareMatrix: private SquareMatrixBase<T> {

private:

using SquareMatrixBase<T>::invert; // чтобы избежать сокрытия базовой

// версии invert; см. правило 33

public:

...

void invert() {this->invert(n);} // встроенный вызов версии invert

}; // из базового класса

// см. ниже – почему

// применяется “this->”

Как видите, параметризованная версия функции invert находится в базовом классе – SquareMatrixBase. Как и SquareMatrix, SquareMatrixBase – шаблон, но в отличие от SquareMatrix, он имеет только один параметр – тип объектов в матрице, но не имеет параметра size. Поэтому все матрицы, содержащие объекты заданного типа, будут разделять общий класс SquareMatrixBase. И, значит, все они разделят единственную копию функции invert из данного класса.

Назначение SquareMatrixBase::invert – помочь избежать дублирования кода в производных классах, поэтому using-объявление помещено в секцию protected, а не public. Дополнительные расходы на вызов этой функции нулевые, поскольку в производных классах ее вызовы invert встроены (встраивание неявное – см. правило 30). Во встроенных функциях применяется нотация «this->», потому что в противном случае, как следует из правила 43, имена функций из шаблонного базового класса (SquareMatrixBase<T>) будут скрыты от подклассов. Отметим также, что наследование SquareMatrix от SquareMatrixBase – закрытое. Это отражает тот факт, что базовый класс введен только для одной цели – упростить реализацию производных, и не означает наличия концептуального отношения «является» между SquareMatrixBase и SquareMatrix (о закрытом наследовании см. правило 39).

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

Один из возможных способов – добавить дополнительный параметр в функцию SquareMatrixBase::invert, скажем, указатель на начало участка памяти, где размещаются данные матрицы. Это будет работать, но, скорее всего, invert – не единственная функция в классе SquareMatrix, которая может быть написана так, что не будет зависеть от размерности, и перенесена в класс SquareMatrixBase. Если таких функций будет несколько, всем им понадобится знать, где находятся данные матрицы. Нам придется в каждую добавлять новый параметр, и получится, что мы многократно передаем SquareMatrixBase одну и ту же информацию. Как-то неправильно это.

Есть альтернатива – хранить указатель на данные матрицы в SquareMatrixBase. И там же можно хранить размерность матрицы. Получается такой код:

template<typename T>

class SquareMatrixBase {

protected:

SquareMatrixBase(std::size_t n, T pMem) // сохраняет размерность

:size(n), pData(pMem){} // и указатель на данные матрицы

void setData(T *ptr) { pData = ptr;} // присвоить значение pData

...

private:

std::size_t size; // размерность матрицы

T *pData; // указатель на данные матрицы

};

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

template<typename T, size_t size>

class SquareMatrix: private SquareMatrixBase {

public:

SquareMatrix() // передать базовому классу размерность

:SquareMatrixBase<t>(n, data) {} // матрицы и указатель на данные

...

private:

T data(n*n);

};

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

template<typename T, size_t size>

class SquareMatrix: private SquareMatrixBase {

public:

SquareMatrix() // присвоить указателю на данные

:SquareMatrixBase<t>(n, 0), // в базовом классе значение null

pData(new T(n*n)) // выделить память для данных матрицы,

{this->setDataPtr(pData.get();} // сохранить указатель на нее и передать

... // его копию базовому классу

private:

boost::scoped_array<T> pData; // о классе boost::scoped_array

}; // см. правило 13

Независимо от того, где хранятся данные, с точки зрения «разбухания» кода важно лишь, что теперь многие (быть может, все) функции-члены SquareMatrix оказываются просто встроенными вызовами их версий из базового класса, которые теперь будут разделяются всеми матрицами, содержащими данные одного и того же типа, независимо от их размера. В то же время объекты SquareMatrix разных размеров относятся к разным типам. Поэтому, несмотря на то что классы SquareMatrix<double, 5> и SquareMatrix<double, 10> пользуются одними и теми же функциями, определенными в SquareMatrixBase<double>, не получится передать функции, ожидающей параметра типа SquareMatrix<double, 10>, объект типа SquareMatrix<double, 5>. Хорошо, не правда ли?

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

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

Другой фактор, влияющий на эффективность, – это размеры объектов. Если вы не будете внимательны, то перенос независимых от размерности функций в базовый класс может привести к увеличению размера каждого объекта. Например, в только что приведенном коде для каждого объекта SquareMatrix имеется указатель на его данные в классе SquareMatrixBase, несмотря даже на то, что производный класс и так может получить эти данные. Это увеличивает размер каждого объекта SquareMatrix, по крайней мере, на размер указателя. Можно модифицировать класс так, чтобы необходимость в этих указателях отпала, но это компромисс. Например, если завести в базовом классе защищенный член для хранения указателя на данные матрицы, то мы пожертвуем инкапсуляцией (см. правило 22). Это также может привести к усложнению алгоритмов управления ресурсами. Если в базовом классе хранится указатель на данные матрицы, то память для этих данных может быть либо выделена динамически, либо физически находиться внутри объекта производного класса (как мы видели). Так как же базовый класс определит, следует ли удалять указатель? Ответы на такие вопросы существуют, но чем изощреннее ваш дизайн, тем сложнее все получается. В некоторый момент умеренное дублирование кода может даже показаться спасением.

В этом правиле мы обсуждаем только разбухание кода из-за параметров шаблонов, не являющихся типами, но и параметры-типы могут привести к тому же. Например, на многих платформах int и long имеют одно и то же двоичное представление, поэтому функции-члены, скажем, для vector<int> и vector<long>, могут оказаться идентичными – разбухание в чистом виде. Некоторые компоновщики объединяют идентичные реализации функций, а некоторые – нет, и, значит, некоторые шаблоны, конкретизированные для int и для long, на одних платформах приводят к разбуханию, а на других – нет. Аналогично на большинстве платформ все типы указателей имеют одинаковое двоичное представление, поэтому шаблоны с параметрами указательных типов (например, list<int*>, list<const int*>, list<SquareMatrix<long,3>*> и т. п.) зачастую могли бы использовать общие реализации всех функций-членов. Как правило, это означает, что функции-члены, которые работают со строго типизованными указателями (например, T*) должны внутри себя вызывать функции, работающие с нетипизированными указателями (то есть void*). В некоторых реализациях стандартной библиотеки C++ такой подход применен к шаблонам vector, deque и list и им подобным. Если вас беспокоит опасность разбухания кода из-за использования шаблонов, возможно, стоит поступить аналогично.

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

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

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

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

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