Система Orphus

 

Поиск по сайту

 

Статьи » Метапрограммирование. Шаблоны выражений (expression templates). Часть 1

Шаблоны выражений (expression templates). Часть 1

C++ Expression Templates

An Introduction to the Principles of Expression Templates

Предварительная версия статьи, опубликованной в C/C++ Users Journal, март 2003 г.

© Клаус Крэфт и Анжелика Лангер

Оглавление

  • Введение в основы шаблонов выражений
  • Как все начиналось...
  • Первый пример вычислений во время компиляции - факториал
  • Другой пример вычислений во время компиляции - квадратный корень
  • Переходим к шаблонам выражений
  • Первый шаблон выражений - скалярное произведение
  • Версия скалярного произведения с вычислением во время компиляции
  • Другой шаблон выражений - арифметические выражения
  • Создающие функции
  • Дальнейшее совершенствование решения на основе шаблонов выражений
  • Свойства traits
  • Повторное использование объектов выражений

Введение в основы шаблонов выражений

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

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

Заметим сразу: библиотеки шаблонов выражений сложны для понимания. По этой причине почти каждое объяснение шаблонов выражений, которые мы прочитали до сих пор, было довольно непростым. Амбициозная цель этой статьи – пояснить сложные вопросы так, чтобы они могли быть легко поняты, без погружения во все детали, с чем бы вы столкнулись в противном случае, например, при изучении исходного кода библиотеки шаблонов. Мы постараемся передать сущность некоторых принципов шаблонов выражений, однако полный охват всех аспектов выходит за рамки данной статьи.

Как все начиналось...

Я очень хорошо помню тот день, когда мой коллега Эрвин Унрух (Erwin Unruh), пришел на одно из заседаний комитета стандартизации С++ и гордо представил программу, которая не компилировалась, однако при этом она расчитывала простые числа. Программа при компиляции выдавала сообщения об ошибках, и с каждым сообщением об ошибке печаталось следующее простое число. Конечно, бессмысленно писать программы, которые не компилируются, но эта программа была преднамеренно настроена на невозможность компиляции для демонстрации вычислений во время компиляции. Исполняемого файла не было. Вычисления простых чисел происходили за счет побочного эффекта компиляции.

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

Как работает шаблонное метапрограммирование?

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

Первый пример вычислений во время компиляции – факториал

В первом примере давайте подсчитаем факториал во время этапа компиляции. Факториал n (математическая нотация которого n!) является продуктом всего ряда от 1 до n, факториал 0 равен 1. Обычно, без использования шаблонов, вы должны подсчитать факториал с помощью рекурсивных вызовов функций. Вот возможный вариант реализации:

int factorial (int n) 
{ return (n==0) ? 1: n*factorial(n-1); } 

Эта функция вызывает себя рекурсивно до тех пор, пока n не уменьшится до 0. Ее можно использовать следующим образом:

cout << factorial(4) << endl; 

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

template <int n> 
struct factorial { 
  enum { ret = factorial<n-1>::ret * n }; 
}; 

Этот шаблон не имеет ни данных, ни функций-членов, он просто определяет безымянный перечислимый тип с единственным значением. (Как окажется в дальнейшем, это значение перечисления factorial::ret будет служить в качестве возвращаемого значения наших вычислений на этапе компиляции). Для того чтобы вычислить значение этого перечисления компилятор должен конкретизировать еще одну версию шаблона factorial, а именно версию для n-1, и это то, c чего начинается рекурсивная конкретизация экземпляров шаблонов.

Обратите внимание, аргумент шаблона factorial - необычный: это не тип в чистом виде, а просто константное значение типа int. Как правило, шаблоны имеют аргументы типа, как в шаблонe <class T> class X {...}, где T является "меткой" конкретного типа. Эта "метка" позже будет заменена конкретным типом, когда из шаблона будет генерироваться класс, например, X<int>. В нашем примере мы имеем шаблонный аргумент в виде константного значения типа int. Это означает, что этот шаблон должен конкретизироваться константным значением типа int. С помощью этого шаблона пользователь мог бы вычислить факториал n следующим образом:

cout << factorial<4>::ret << endl;

Компилятор будет рекурсивно конкретизировать factorial<4>, factorial<3> и так далее, пока ... На самом деле, до каких пор? Когда эта рекурсия остановится? Каждый рекурсии необходим останов. При использовании методики шаблонов конец рекурсии определяется с помощью шаблона специализации. В нашем примере специализация должна определяться для случая n=0:

template <> 
struct factorial<0> { 
  enum { ret = 1 }; 
}; 

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

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

Итак, чего мы добились используя шаблоннyю версию вычисления факториала? Выражение factorial<4>::ret всего лишь сведется к числу 24 (которое является результатом 4!), а на стадии выполнения в исполняемом файле вы вообще не обнаружите вычислений. Вместо этого во всех местах исходного кода, где встречается factorial<4>::ret будет просто константное значение 24.

Другой пример вычислений во время компиляции – квадратный корень

Давайте попробуем изучить еще один пример вычисления значения во время компиляции. На этот раз нашей целью будет аппроксимация квадратного корня из N, или, точнее, мы хотим найти целое значение большее и наиболее близкое к квадратному корню из N. Пример: квадратный корень из 10 равен 3,1622776601, и мы бы хотели найти целое значение 4, которое является следующим целым числом, большим 3,1622776601. При выполнении расчетов на этапе выполнения можно вычислить нужное значение с помощью библиотечных функций С ceil(sqrt(N)). Однако при этом мы хотим использовать приближенное значение квадратного корня в качестве размера массива, который мы должны объявить, но такое объявление, как

int array[ceil(sqrt(N))]; 

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

Помните, что мы делали в нашем первом примере для вычислений во время компиляции: мы использовали преимущества рекурсивной конкретизации шаблонов. Опять же, мы будем использовать рекурсивную конкретизацию шаблонов для аппроксимации желаемого значения. Мы снова определим шаблон класса, который принимает "нетиповой" шаблонный аргумент, а именно N и возвращает результат в виде вложенного значения. Назовем шаблон класса для аппроксимации нашего квадратного корня Root. Тогда мы могли бы объявить массив с размером, равным квадратному корню из 10 следующим образом

int array[Root<10>::ret];

Вот шаблон Root:

template <size_t N, size_t Low=1, size_t Upp=N> 
struct Root { 
  static const size_t ret = 
                  Root<N,(down?Low:mean+1),(down?mean:Upp)>::ret;
  static const size_t mean = (Low+Upp)/2; 
  static const bool   down = ((mean*mean)>=N); 
}; 

Здесь мы не будем вникать в подробности. Просто пару комментариев: шаблон принимает три "нетиповых" аргумента, два из которых имеют значения по умолчанию. Тремя аргументами являются:

  • значение, для которого должен быть рассчитан квадратный корень и
  • нижняя и верхняя границы интервала, в котором будет расположен результат. Значениями по умолчанию являются 1 и N, так как квадратный корень из N – это значение где-то между 1 и N.

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

Когда заканчивается рекурсия? Опять, конец рекурсии задается с помощью специализации шаблона, которая не требует дальнейшей конкретизации шаблона. Вот специализация шаблона Root:

template <size_t N, size_t Mid> 
struct Root<N,Mid,Mid> 
{ static const size_t ret = Mid; }; 

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

В нашем примере рекурсия будет конкретизировать шаблон для следующих аргументов:

Root<10,1,10> 
Root<10,1,5> 
Root<10,4,5> 
Root<10,4,4> 

и результатом будет значение 4, как и ожидалось.

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

Переходим к шаблонам выражений

До сих пор мы производили вычисления значений на этапе компиляции, что прекрасно, но не слишком интересно. Теперь мы будем более амбициозными: рассчитаем более сложные выражения во время компиляции. На первом шаге мы реализуем версию расчета скалярного произведения векторов на этапе компиляции. Скалярное произведение двух векторов равно сумме произведений соответствующих элементов. Пример: скалярное произведение двух 3-мерных векторов (1, 2, 3) и (4, 5, 6) будет 1 * 4 + 2 * 5 + 3 * 6, что равно 32. Цель состоит в том, чтобы создать шаблоны выражений для расчета скалярного произведения векторов произвольной размерности, как в следующем примере:

int a[4] = {1,100,0,-1};
int b[4] = {2,2,2,2};
cout << dot<4>(a,b);

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

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

Интеграл

аппроксимируется вычислением выражения х/(1+х) для n равноотстоящих точек в интервале [1.0,5.0]. Функция, вычисляющая интегралы для произвольных арифметических выражений, может выглядеть подобным образом, если только нам удастся реализовать шаблоны выражений, которые могут использоваться многократно для разных значений:

template <class ExprT> 
double integrate (ExprT e,double from,double to,size_t n) 
{  double sum=0, step=(to-from)/n; 
   for (double i=from+step/2; i<to; i+=step) 
       sum+=e.eval(i); 
   return step*sum; 
} 

ExprT в данном примере, так или иначе, представляет выражение, такое как х/(1+х). Мы увидим, как именно это работает позднее в этой статье.

Первый шаблон выражений – скалярное произведение

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

Паттерн Composite дает возможность представить отношение ”часть-целое”, с помощью которого клиент может игнорировать различия между отдельными объектами и композицией объектов. Ключевыми элементами паттерна являются leaf (лист, примитивный объект) и composite (композиция, составной объект).

  • Leaf определяет поведение примитивных объектов в композиции.
  • Composite определяет поведение компонентов, состоящих из листьев.

Примерами composite являются синтаксические деревья, агрегаты, рекурсивные структуры и рекурсивные алгоритмы. Вот пример типичной структуры composite:

Типичная структура составного объекта

Рис.1. Типичная структура составного объекта

Книга по GOF-паттернам предлагает объектно-ориентированную реализацию паттерна Composite с абстрактным базовым классом, который определяет операцию, общую для leaf и composite и два производных класса, которые представляют leaf и composite соответственно.

Uml-диаграмма классов паттерна Composite

Рис.2. Uml-диаграмма классов паттерна Composite

Скалярное произведение векторов может рассматриваться как частный случай паттерна Composite. Скалярное произведение можно разделить на leaf (а именно скалярное произведение двух векторов размерности 1) и composite (а именно скалярное произведение двух векторов размерности N-1).

Структура составного объекта для скалярного произведения

Рис.3. Структура паттерна Composite для скалярного произведения

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

Uml-диаграмма классов скалярного произведения на базе паттерна Composite

Рис.4. Uml-диаграмма классов скалярного произведения на базе паттерна Composite

Реализация прямолинейна и показана в листингах 1-3. В листинге 4 показана вспомогательная функция, которая облегчает использование этих классов, а листинг 5 в итоге демонстрирует, как мы можем рассчитать скалярное произведение двух векторов.

Листинг 1: Базовый класс

template <class T> 
class DotProduct  { 
   public: 
      virtual ~DotProduct () {} 
      virtual T eval() = 0; 
}; 

Листинг 2: Composite

template <class T> 
class CompositeDotProduct  : public DotProduct<T> { 
public: 
  CompositeDotProduct (T* a, T* b, size_t dim)  
  :s(new SimpleDotProduct<T>(a,b)) 
  ,c((dim==1)?0:new CompositeDotProduct<T>(a+1,b+1,dim-1)) 
  {} 
  virtual ~CompositeDotProduct () { delete c; delete s; } 
  virtual T eval() 
  { return (s->eval() + ((c)?c->eval():0)); } 
protected: 
  SimpleDotProduct<T>* s; 
  CompositeDotProduct<T> * c; 
}; 

Листинг 3: Leaf

template <class T> 
class SimpleDotProduct : public DotProduct<T> { 
   public: 
      SimpleDotProduct (T* a, T* b) : v1(a), v2(b) {} 
      virtual T eval() { return (*v1)*(*v2); } 
   private: 
      T* v1; T* v2; 
}; 

Листинг 4: Вспомогательная функция

template <class T> T dot(T* a, T* b, size_t dim)  
{ return (dim==1) 
         ? SimpleDotProduct<T>(a,b).eval() 
         : CompositeDotProduct<T>(a,b,dim).eval(); 
} 

Листинг 5: Использование реализации скалярного произведения

int a[4] = {1,100,0,-1}; 
int b[4] = {2,2,2,2}; 
cout << dot(a,b,4); 

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

Конструктор и вычислительная функция

SimpleDotProduct<T>::SimpleDotProduct (T* a, T* b) :v1(a), v2(b) {} 
virtual T SimpleDotProduct<T>::eval() { return (*v1)*(*v2); } 

будет заменена функцией, которая принимает аргументы:

T SimpleDotProduct::eval(T* a, T* b, size_t dim) { return (*a)*(*b); }

Упрощенная реализация производного класса показана в листинге 6; базовый класс останется неизменным, как показано в листинге 1, вспомогательная функция должна быть скорректирована.

Листинг 6: Упрощенная объектно-ориентированная реализация скалярного произведения векторов

template <class T> 
class CompositeDotProduct  : public DotProduct <T> { 
public: 
  virtual T eval(T* a, T* b, size_t dim) 
  { return  SimpleDotProduct<T>().eval(a,b,dim)  
    + ((dim==1) ? 0 
      : CompositeDotProduct<T>().eval(a+1,b+1,dim-1));  
  } 
}; 
  
template <class T> 
class SimpleDotProduct  : public DotProduct <T> { 
public: 
  virtual T eval(T* a, T* b, size_t dim)  
  { return (*a)*(*b); } 
}; 

Рис. 5 показывает диаграмму классов для упрощенной версии.

Uml-диаграмма классов упрощенной версии вычисления скалярного произведения.

Рис.5. Uml-диаграмма классов упрощенной версии вычисления скалярного произведения.

Версия скалярного произведения с вычислением во время компиляции

Теперь возьмем объектно-ориентированный подход и переведем его в решение, работающее во время компиляции. Два класса, представляющие leaf и composite имеют общий базовый класс, который определяет их операции. Это хорошо известная объектно-ориентированная техника: общности выражается с помощью общего базового класса. В шаблонном программировании общности выражаются в терминах именования и общности имен. То, что является виртуальной функцией в объектно-ориентированном подходе станет простой невиртуальной функцией, которая имеет определенное название. Два производных класса, больше не будут наследовать от общего базового класса. Вместо этого они будут автономными классами, которые имеют функции-члены с теми же именами и совместимыми сигнатурами. То есть, мы исключим базовый класс.

Далее, мы реализуем composite, как шаблон класса, который использует структурную информацию в качестве аргументов шаблона. Эта структурная информация представляет размерность векторов. Помните, что мы делали для расчета факториала и квадратного корня: аргумент функции реализации во время исполнения стал аргументом шаблона реализации во время компиляции. Мы будем делать нечто подобное и здесь: в объектно-ориентированном подходе размерность векторов передается в качестве аргумента для функции; мы будем передавать ее в качестве аргумента шаблона в реализации на стадии компиляции. Таким образом, размерность векторов станет ”нетиповым” аргументом шаблона класса composite.

Leaf будет реализован в виде специализации шаблона класса composite для размерности N = 1. Как и прежде мы заменим рекурсию стадии выполнения рекурсией стадии компиляции: мы заменим рекурсивный вызов виртуальной вычислительной функции рекурсивной шаблонной конкретизацией статической вычислительной функции. Вот диаграмма реализации скалярного произведения векторов на этапе компиляции:

Uml-диаграмма классов реализации скалярного произведения, вычисляемого на этапе компиляции

Рис.6. Uml-диаграмма классов реализации скалярного произведения, вычисляемого на этапе компиляции

Реализация показана в листинге 7. Использование реализации показано в листинге 8.

Листинг 7: Реализация скалярного произведения, вычисляемого на этапе компиляции

template <size_t N, class T> 
class DotProduct  { 
public: 
  static T eval(T* a, T* b) 
  { return  DotProduct<1,T>::eval(a,b)  
          + DotProduct<N-1,T>::eval(a+1,b+1);  
  } 
};  
template <class T> 
class DotProduct<1,T> { 
public: 
  static T eval(T* a, T* b) 
  { return (*a)*(*b); } 
}; 

Листинг 8: Использование реализации скалярного произведения

template <size_t N, class T> 
inline T dot(T* a, T* b)  
{ return DotProduct<N,T>::eval(a,b); } 
int a[4] = {1,100,0,-1}; 
int b[4] = {2,2,2,2}; 
cout << dot<4>(a,b); 

Обратите внимание на разницу между выражениями dot(a,b,4) в реализации вычислений на этапе исполнения и dot<4>(a,b) в реализации вычислений на этапе компиляции:

dot(a,b,4) сводится к вычислению CompositeDotProduct<size_t>().eval(a,b,4), что приводит к следующим рекурсивным вызовам на стадии исполнения:

SimpleDotProduct<size_t>().eval(a,b,1)
CompositeDotProduct<size_t>().eval(a+1,b+1,3)
SimpleDotProduct<size_t>().eval(a+1,b+1,1)
CompositeDotProduct<size_t>().eval(a+2,b+2,2)
SimpleDotProduct<size_t>().eval(a+2,b+2,1)
CompositeDotProduct<size_t>().eval(a+3,b+3,1)
SimpleDotProduct<size_t>().eval(a+3,b+3,1)

что приводит в итоге к 7 вызовам виртуальных функций.

dot<4>(a,b) с другрой стороны, сводится к вычислению DotProduct<4,size_t>::eval(a,b), что запускает конкретизацию шаблонов, которая последовательно разворачивается следующим образом:

DotProduct<4,size_t>::eval(a,b) 

приводит к

DotProduct<1,size_t>::eval(a,b) + DotProduct<3,size_t>::eval(a+1,b+1)

что приводит к вычислению

(*a)*(*b) + DotProduct<1,size_t>::eval(a+1,b+1) + 
  DotProduct<2,size_t>::eval(a+2,b+2) 

что приводит к вычислению

(*a)*(*b) + (*a+1)*(*b+1) + DotProduct<1,size_t>::eval(a+2,b+2) + 
  DotProduct<1,size_t>::eval(a+3,b+3) 

что приводит к вычислению

(*a)*(*b) + (*a+1)*(*b+1) + (*a+2)*(*b+2) + (*a+3)*(*b+3)

Видимым в исполняемом коде будет только результирующее выражение (*a)*(*b) + (*a+1)*(*b+1) + (*a+2)*(*b+2) + (*a+3)*(*b+3); рекурсивная конкретизация шаблонов уже была выполнена во время компиляции.

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

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

Реализация скалярного произведения векторов может не слишком впечатляет, ведь мы могли бы достичь такой же высокой производительности разворачиванием выражения скалярного произведения вручную. Но методы, продемонстрированные здесь для реализации скалярного произведения, могут быть обобщены на арифметические операции над многомерными матрицами. Реальное преимущество решения заключается в его выразительности. Представьте себе такое выражение, как a*b+c, где a, b и c являются матрицами 10x20. Вероятно, Вы не хотели бы разворачивать результирующее выражение вручную, если компилятор может делать это автоматически и надежно.

Шаблоны выражений (expression templates). Часть 2

Источник: http://www.angelikalanger.com/Articles/Cuj/ExpressionTemplates/ExpressionTemplates.htm