«Лень‑матушка вперёд нас родилась»

В этой статье я хочу рассказать о технике «Expression Templates» и её применении в библиотеке simstr (статья). Вкратце — это техника организации вычисления выражений оптимальным способом без временных промежуточных результатов путем построения из выражения посредством шаблонных перегрузок операторов одного объекта «вычислителя», тип которого выводится из операций в выражении, и который потом вычисляет результат.

Немного непонятно, не правда ли? Давайте попробуем разобраться.

Общий принцип

Как известно, «хороший программист — ленивый программист». Именно лень толкает нас на поиск оптимальных решений и экономию ресурсов. А человек, проводящий много времени с компьютером — волей‑неволей начинает его «одушевлять» и беспокоится о нём. Поэтому не знаю, как у вас, а у меня сердце кровью обливается, когда я вижу, что для получения конечного результата тем способом, который написан в программе, бедному процессору придётся выполнять много лишней работы, зазря тратить тактики и бестолку гонять туда‑сюда байтики. Такую личную неприязнь испытываю, даже кушать не могу.

Для примера, давайте рассмотрим каноничный код:

// Примитивный вектор
struct vec3 {
    double x_, y_, z_;
    vec3(double _x, double _y, double _z) : x_(_x), y_(_y), z_(_z) {
        std::cout << "Construct vec3, this=" << this << ", x=" << x() << ", y=" << y() << ", z=" << z() <<"\n";
    }
    double x() const {return x_;}
    double y() const {return y_;}
    double z() const {return z_;}
};

// Перегрузка сложения
vec3 operator+(const vec3& a, const vec3& b) {
    return {a.x_ + b.x_, a.y_ + b.y_, a.z_ + b.z_};
}
// Перегрузка умножения
vec3 operator*(const vec3& a, double m) {
    return {a.x_ * m, a.y_ * m, a.z_ * m};
}

int main() {
    vec3 a{1, 2, 3}, b{4, 5, 6}, c{7, 8, 9};
    // Апофеоз - можно красиво пользоваться
    vec3 result = a * 10.1 + b * 3.2 + c * 1.2;
    std::cout << "&result = " << &result << ", x=" << result.x() << ", y=" << result.y() << ", z=" << result.z() << "\n";
    return 0;
}

Внимание на 24ую строку. Всё вот прямо как по учебнику. Однако, пытливому уму всегда интересно, а насколько хорошо «унутре» оно работает? Запускаем — проверяем. Вот вывод программы с моими комментариями:

Construct vec3, this=000000CE8C76F468, x=1, y=2, z=3 // Создаём вектор a
Construct vec3, this=000000CE8C76F450, x=4, y=5, z=6 // Создаём вектор b
Construct vec3, this=000000CE8C76F438, x=7, y=8, z=9 // Создаём вектор c
Construct vec3, this=000000CE8C76F408, x=8.4, y=9.6, z=10.8 // Умножение вектора c на 1.2 во временный вектор 1
Construct vec3, this=000000CE8C76F3D8, x=12.8, y=16, z=19.2 // Умножение вектора b на 3.2 во временный вектор 2
Construct vec3, this=000000CE8C76F3C0, x=10.1, y=20.2, z=30.3 // Умножение вектора a на 10.1 во временный вектор 3
Construct vec3, this=000000CE8C76F3F0, x=22.9, y=36.2, z=49.5 // Сложение временных векторов 2 и 3 во временный вектор 4
Construct vec3, this=000000CE8C76F420, x=31.3, y=45.8, z=60.3 // Сложение временного вектора 4 и вектора c в результат
&result = 000000CE8C76F420, x=31.3, y=45.8, z=60.3

Как видно, у нас в ходе вычисления создаётся аж 4 временных объекта. В данном примере класс vec3 тривиальный, поэтому накладными расходами можно пренебречь, но оптимальные вычисления всё же были бы такими:

    vec3 a{1, 2, 3}, b{4, 5, 6}, c{7, 8, 9};
    vec3 result = {
      a.x_ * 10.1 + b.x_ * 3.2 + c.x_ * 1.2,
      a.y_ * 10.1 + b.y_ * 3.2 + c.y_ * 1.2,
      a.z_ * 10.1 + b.z_ * 3.2 + c.z_ * 1.2
    };

А если конструктор или деструктор класса будет посложнее, чем у этого примитивного вектора? Например, он аллоцирует/освобождает память, копирует данные (напоминает чем‑то std::string, смекаешь?:‑)

Возможно ли добиться того, чтобы писать код было удобно, как в варианте 1, а выполнялось бы так же оптимально, как в варианте 2?

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

// Придумаем шаблонный класс, содержащий какую-то векторную операцию
template<typename T>
struct vec3_op {
    T t_;
    double x() const {return t_.x();}
    double y() const {return t_.y();}
    double z() const {return t_.z();}
};
// Доработаем наш класс вектора
struct vec3 {
    double x_, y_, z_;
    vec3(double _x, double _y, double _z) : x_(_x), y_(_y), z_(_z) {
        std::cout << "Construct vec3, this=" << this << ", x=" << x() << ", y=" << y() << ", z=" << z() <<"\n";
    }
    // Научим наш вектор инициализироваться из какой-то векторной операции
    template<typename T>
    vec3(const vec3_op<T>& init) :  x_(init.x()), y_(init.y()), z_(init.z()) {
        std::cout << "Construct vec3 from init, this=" << this << ", x=" << x() << ", y=" << y() << ", z=" << z() <<"\n";
    }
    double x() const {return x_;}
    double y() const {return y_;}
    double z() const {return z_;}
};
// Создадим операцию, которая складывает две какие-то операции
template<typename A, typename B>
struct vec3_sum_op {
    vec3_sum_op(const A& a, const B& b) : a_(a), b_(b){}
    const A& a_;
    const B& b_;
    double x() const {return a_.x() + b_.x();}
    double y() const {return a_.y() + b_.y();}
    double z() const {return a_.z() + b_.z();}
};
// Создадим операцию, которая умножает какую-то операцию на число
template<typename A>
struct vec3_mul_op {
    vec3_mul_op(const A& a, double b) : a_(a), b_(b){}
    const A& a_;
    double b_;
    double x() const {return a_.x() * b_;}
    double y() const {return a_.y() * b_;}
    double z() const {return a_.z() * b_;}
};
// И вот она шаблонная магия
// Создаем оператор сложения, который берёт две какие-то векторные операции,
// и создает новую векторную операцию, которая содержит композицию - сумму
// двух операций.
template<typename A, typename B>
vec3_op<vec3_sum_op<A, B>> operator+(const vec3_op<A>& a, const vec3_op<B>& b) {
    return vec3_op<vec3_sum_op<A, B>>{vec3_sum_op<A, B>{a.t_, b.t_}};
}
// Аналогично создаем оператор умножения какой-то векторной операции на число,
// который создает новую векторную операцию, которая содержит композицию - умножение
// векторной операции на число
template<typename A>
vec3_op<vec3_mul_op<A>> operator*(const vec3_op<A>& a, double b) {
    return vec3_op<vec3_mul_op<A>>{vec3_mul_op<A>{a.t_, b}};
}
// Ну и нам нужно уметь складывать векторную операцию с простым вектором
template<typename A>
auto operator+(const vec3_op<A>& a, const vec3& b) {
    return vec3_op<vec3_sum_op<A, vec3>>{{a.t_, b}};
}
// И наоборот
template<typename A>
auto operator+(const vec3& a, const vec3_op<A>& b) {
    return vec3_op<vec3_sum_op<vec3, A>>{{a, b.t_}};
}
// И теперь перепишем наши операторы сложения векторов и умножения вектора на число
// в другом виде, с тем, чтобы они возвращали не вектор, а векторную операцию.
auto operator+(const vec3& a, const vec3& b) {
    return vec3_op<vec3_sum_op<vec3, vec3>>{{a, b}};
}
auto operator*(const vec3& a, double b) {
    return vec3_op<vec3_mul_op<vec3>>{{a, b}};
}
// Обратите внимание - результаты всех этих операторов сложения и умножения сами тоже
// является векторной операцией, а значит, к ним дальше по цепочке можно снова
// применять эти же operator+ и operator*

int main() {
    vec3 a{1, 2, 3}, b{4, 5, 6}, c{7, 8, 9};
    vec3 result = a * 10.1 + b * 3.2 + c * 1.2;
    std::cout << "&result = " << &result << ", x=" << result.x() << ", y=" << result.y() << ", z=" << result.z() << "\n";
    return 0;
}

Запускаем и получаем:

Construct vec3, this=00000069DE8FF6E8, x=1, y=2, z=3
Construct vec3, this=00000069DE8FF6D0, x=4, y=5, z=6
Construct vec3, this=00000069DE8FF6B8, x=7, y=8, z=9
Construct vec3 from init, this=00000069DE8FF6A0, x=31.3, y=45.8, z=60.3 // Вызывается конструктор из векторной операции
&result = 00000069DE8FF6A0, x=31.3, y=45.8, z=60.3

Ура! Заработало! Как видим, никаких промежуточных временных объектов, всё сразу считается в конечном результате! С помощью нескольких умных строк кода мы смогли с одной стороны облегчить труд многострадальному процессору, а с другой — нисколько не усложнили жизнь себе — мы по прежнему можем просто писать выражения с векторными операциями, как раньше. К примеру, можно написать так:

vec3 result = (a + b * 3.0) * 12.3 + c * 11 + (c * 1.1 + a + b);

и получить выполнение:

Construct vec3, this=00000074B58FF288, x=1, y=2, z=3
Construct vec3, this=00000074B58FF270, x=4, y=5, z=6
Construct vec3, this=00000074B58FF258, x=7, y=8, z=9
Construct vec3 from init, this=00000074B58FF240, x=249.6, y=312.9, z=376.2
&result = 00000074B58FF240, x=249.6, y=312.9, z=376.2

По-прежнему никаких промежуточных временных объектов. Правда, здорово?

Кратко резюмируем, как это достигается:

  • за счет создания «объектов‑операций», которые запоминают свои операнды (копией или по ссылке)

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

  • «Материализация» созданного «ленивого» выражения в конечный результат

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

Дерево-инициатор
Дерево-инициатор

Возможно, кто-то скажет — «и что, поменяли создание временных промежуточных объектов‑векторов на создание временных промежуточных объектов‑операций, не велика разница. А если std::cout убрать из конструктора, компилятор всё и так оптимизирует».

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

Попробуем упростить

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

Обратим внимание на сам шаблон vec3_op. По сути, он просто задаёт общий интерфейс для операций, и просто хранит сам оператор. Ну и также его цель — служить ограничением для типов в переопределениях operator+ и operator*, чтобы эти перегрузки применялись только к нужным нам типам. Кроме того, мы видим, что интерфейс операций, задаваемых шаблоном vec3_op поддерживают и сами vec3_sum_op, vec3_mul_op, vec3, то есть они сами по себе тоже являются операциями.

Получается, нам надо просто как‑то ограничить типы, которые могут применятся в операторах теми типами, у которых имеются функции‑члены double x(), double y(), double z(). До С++20 это можно реализовать с помощью SFINAE, а начиная с C++20 — гораздо удобнее с помощью концептов. Напишем сначала вариант для С++17:

// Это один из способов проверять без ошибок компиляции, имеется ли у класса нужные функции-члены
template<typename T, typename = void, typename = void, typename = void>
struct has_vec_op : std::false_type{};

template<typename T>
struct has_vec_op<T,
    std::enable_if_t<std::is_same_v<double, decltype(std::declval<const T&>().x())>>,
    std::enable_if_t<std::is_same_v<double, decltype(std::declval<const T&>().y())>>,
    std::enable_if_t<std::is_same_v<double, decltype(std::declval<const T&>().z())>>> : std::true_type{};

// Ну а это проверка, что тип поддерживает нужные нам x(), y(), z();
template<typename T>
using is_vec_op_t = std::enable_if_t<has_vec_op<T>::value>;

struct vec3 {
    double x_, y_, z_;
    vec3(double _x, double _y, double _z) : x_(_x), y_(_y), z_(_z) {
        std::cout << "Construct vec3, this=" << this << ", x=" << x() << ", y=" << y() << ", z=" << z() <<"\n";
    }
    // Научим наш вектор инициализироваться любым типом, у которого есть x(), y(), z()
    template<typename T, typename = is_vec_op_t<T>>
    vec3(const T& init) : x_(init.x()), y_(init.y()), z_(init.z()) {
        std::cout << "Construct vec3 from init, this=" << this << ", x=" << x() << ", y=" << y() << ", z=" << z() <<"\n";
    }
    double x() const {return x_;}
    double y() const {return y_;}
    double z() const {return z_;}
};

// Создадим операцию, которая складывает две какие-то операции
template<typename A, typename B>
struct vec3_sum_op {
    vec3_sum_op(const A& a, const B& b) : a_(a), b_(b){}
    const A& a_;
    const B& b_;
    double x() const {return a_.x() + b_.x();}
    double y() const {return a_.y() + b_.y();}
    double z() const {return a_.z() + b_.z();}
};

// Создадим операцию, которая умножает какую-то операцию на число
template<typename A>
struct vec3_mul_op {
    vec3_mul_op(const A& a, double b) : a_(a), b_(b){}
    const A& a_;
    double b_;
    double x() const {return a_.x() * b_;}
    double y() const {return a_.y() * b_;}
    double z() const {return a_.z() * b_;}
};

// И вот она шаблонная магия
// Создаем оператор сложения, который берёт два операнда любых типов, у которых есть
// x(), y(), z(), и создает композицию - сумму двух операций.
template<typename A, typename B, typename = is_vec_op_t<A>, typename = is_vec_op_t<B>>
vec3_sum_op<A, B> operator+(const A& a, const B& b) {
    return {a, b};
}

// Аналогично создаем оператор умножения на число какого-то типа, у которого есть
// x(), y(), z(), который создает композицию - умножение его на число
template<typename A, typename = is_vec_op_t<A>>
vec3_mul_op<A> operator*(const A& a, double b) {
    return {a, b};
}
// Обратите внимание - результаты этих операторов сложения и умножения сами тоже
// является векторной операцией, а значит, к ним дальше по цепочке можно снова
// применять эти же operator+ и operator*.
// Кроме того, теперь нам не нужна отдельная перегрузка операторов для операндов
// типа vec3, так как vec3 сам удовлетворяет условию is_vec_op_v и применим к этим
// перегрузкам

int main() {
    vec3 a{1, 2, 3}, b{4, 5, 6}, c{7, 8, 9};
    vec3 result = a * 10.1 + b * 3.2 + c * 1.2;
    std::cout << "&result = " << &result << ", x=" << result.x() << ", y=" << result.y() << ", z=" << result.z() << "\n";
    return 0;
}

А это вариант с концептами для C++20:

// Опишем концепт векторного выражения
template<typename A>
concept VecOp = requires(const A& a) {
    // Это любой тип, для которого допустимы эти выражения
    { a.x() } -> std::same_as<double>;
    { a.y() } -> std::same_as<double>;
    { a.z() } -> std::same_as<double>;
};

struct vec3 {
    double x_, y_, z_;
    vec3(double _x, double _y, double _z) : x_(_x), y_(_y), z_(_z) {
        std::cout << "Construct vec3, this=" << this << ", x=" << x() << ", y=" << y() << ", z=" << z() <<"\n";
    }
    // Научим наш вектор инициализироваться любым типом, удовлетворяющим концепту VecOp
    template<VecOp T>
    vec3(const T& init) : x_(init.x()), y_(init.y()), z_(init.z()) {
        std::cout << "Construct vec3 from init this=" << this << ", x=" << x() << ", y=" << y() << ", z=" << z() <<"\n";
    }
    double x() const {return x_;}
    double y() const {return y_;}
    double z() const {return z_;}
};

// Создадим операцию, которая складывает две какие-то операции
template<typename A, typename B>
struct vec3_sum_op {
    vec3_sum_op(const A& a, const B& b) : a_(a), b_(b){}
    const A& a_;
    const B& b_;
    double x() const {return a_.x() + b_.x();}
    double y() const {return a_.y() + b_.y();}
    double z() const {return a_.z() + b_.z();}
};

// Создадим операцию, которая умножает какую-то операцию на число
template<typename A>
struct vec3_mul_op {
    vec3_mul_op(const A& a, double b) : a_(a), b_(b){}
    const A& a_;
    double b_;
    double x() const {return a_.x() * b_;}
    double y() const {return a_.y() * b_;}
    double z() const {return a_.z() * b_;}
};

// И вот она шаблонная магия
// Создаем оператор сложения, который берёт два операнда, удовлетворяющих концепту
// VecOp и создает композицию - сумму двух операций.
template<VecOp A, VecOp B>
vec3_sum_op<A, B> operator+(const A& a, const B& b) {
    return {a, b};
}

// Аналогично создаем оператор умножения на число какого-то типа, удовлетворяющего
// концепту VecOp, который создает композицию - умножение его на число
template<VecOp A>
vec3_mul_op<A> operator*(const A& a, double b) {
    return {a, b};
}
// Обратите внимание - результаты этих операторов сложения и умножения сами тоже
// является векторной операцией, а значит, к ним дальше по цепочке можно снова
// применять эти же operator+ и operator*.
// Кроме того, теперь нам не нужна отдельная перегрузка операторов для операндов
// типа vec3, так как vec3 сам удовлетворяет концепту VecOp и применим к этим
// перегрузкам

int main() {
    vec3 a{1, 2, 3}, b{4, 5, 6}, c{7, 8, 9};
    vec3 result = a * 10.1 + b * 3.2 + c * 1.2;
    std::cout << "&result = " << &result << ", x=" << result.x() << ", y=" << result.y() << ", z=" << result.z() << "\n";
    return 0;
}

Руководствуясь этими принципами, можно создавать Expression Templates для выполнения разных операций с разными типами. Например, так устроена библиотека std::ranges, для реализации «ленивых вычислений». Отличаться для разных типов обычно будут только способы, как запустить «материализацию» выражения в конечный результат. Для примера рассмотрим применение этой техники в simstr.

Использование в simstr

В библиотеке simstr техника «expression templates» используется для реализации эффективной конкатенации строк.

Недостатки конкатенации std::string

Если мы посмотрим на стандартные строки, то там конкатенировать можно std::string, std::string_view и C‑строки. Рассмотрим простой пример:

std::string elem(const std::string& s1, const std::string& s2) {
    return "<" + s1 + " class=\"" + s2 + "\">";
}

int main(int argc, char** argv) {
    auto r = elem("div", "block");
    size_t l = r.length(), c = r.capacity();
    std::cout << r << ", l = " << l << ", c = " << c << "\n";
    return 0;
}

В функции elem выполняется несколько последовательных конкатенаций.
Первая ("<" + s1) создает временный объект std::string, в котором выделяется место для хранения символов (либо во внутреннем буфере при SSO, либо аллоцируется) и символы из двух операндов копируются в это место друг за другом. Далее выполняется следующая конкатенация, создающая новый временный объект std::string, при этом применяется более оптимальный вариант — так как первый операнд r‑value, вызывается другой вариант оператора сложения, который к исходной временной строке добавляет новую, и перемещает её в следующий временный результат, примерно так:

// Вариант, оптимизированный для конкатенации временного объекта и C-строки
std::string operator+(std::string&& __lhs, const char* __rhs) {
    return std::move(__lhs.append(__rhs));
}

То есть всё выражение соответствует примерно такому псевдо‑коду:

std::string elem(const std::string& s1, const std::string& s2) {
    std::string t1 = "<" + s1;
    t1 += " class=\"";
    std::string t2 = std::move(t1);
    t2 += s2;
    std::string t3 = std::move(t3);
    t3 += "\">";
    return std::move(t3);
}

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

Как сделать такую конкатенацию оптимально? Примерно так:

std::string elem(const std::string& s1, const std::string& s2) {
    std::string res;
    res.reserve(s1.size() + s2.size() + 11);
    res = "<";
    res += s1;
    res += " class=\"";
    res += s2;
    res += "\">";
    return res;
}

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

Подход simstr

В этой библиотеке я совместил удобство записи выражений для конкатенации и его оптимальное выполнение. То есть пишем, как показано в варианте 1, а выполняется, как в варианте 2.

Чтобы этого добиться, выполнение выражения нужно разбить на два этапа:

  • сначала вычисляем конечную длину получаемого выражения

  • копируем символы в выделенное место

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

Строковое выражение — это объект любого типа, который имеет определённый интерфейс. У этого типа имеется

  • typename symb_type — задаёт тип символов, которые оно генерирует

  • метод size_t length() const — возвращает длину генерируемой им строки

  • метод symb_type* place(symb_type* ptr) const — помещает символы в предоставленный буфер и возвращает указатель после размещённых символов

В терминах концептов это задается так:

template<typename A>
concept StrExpr = requires(const A& a) {
    typename A::symb_type;
    { a.length() } -> std::convertible_to<size_t>;
    { a.place(std::declval<typename A::symb_type*>()) } -> std::same_as<typename A::symb_type*>;
};

Все типы строковых объектов, которые есть в simstr, поддерживают эти методы и поэтому сами являются строковыми выражениями.

Для конкатенации двух любых строковых выражений имеется шаблонный тип strexprjoin. Он сохраняет ссылки на два строковых выражения, и в качестве длины возвращает сумму их длин, а при размещении символов — размещает сначала первое, потом второе выражение:

template<StrExpr A, StrExprForType<typename A::symb_type> B>
struct strexprjoin {
    using symb_type = typename A::symb_type;
    const A& a;
    const B& b;
    constexpr strexprjoin(const A& a_, const B& b_) : a(a_), b(b_){}
    // Суммируем длины
    constexpr size_t length() const noexcept {
        return a.length() + b.length();
    }
    // Размещаем первое, потом второе выражение
    constexpr symb_type* place(symb_type* p) const noexcept {
        return b.place(a.place(p));
    }
};

template<StrExpr A, StrExprForType<typename A::symb_type> B>
inline auto operator+(const A& a, const B& b) {
    return strexprjoin<A, B>{a, b};
}

Так как strexprjoin сам тоже удовлетворяет концепту StrExpr, к нему далее также применим этот же operator+, позволяя создавать цепочки, строящее «дерево» из операндов‑строковых выражений. Пример:

// operator""_ss преобразует const char литерал в объект simple_str_nt<char>.
// Такой объект является строковым выражением и для него срабатывает конкатенация
// в strexprjoin с объектом ssa (simple_str<char>). 
stringa elem(ssa s1, ssa s2) {
    return "<"_ss + s1 + " class=\""_ss + s2 + "\">"_ss;
}

int main(int argc, char** argv) {
    auto r = elem("div", "block");
    std::cout << r << ", l = " << r.length() << "\n";
    return 0;
}

В этом примере строковым выражением является "<"_ss + s1 + " class=""_ss + s2 + "">"_ss. Его значением в этом конкретном случае является объект типа (запишу „лесенкой“, чтобы понятнее было)

simstr::strexprjoin<
    simstr::strexprjoin<
        simstr::strexprjoin<
            simstr::strexprjoin<simstr::simple_str_nt<char>,simstr::simple_str<char>>,
            simstr::simple_str_nt<char>>,
        simstr::simple_str<char>>,
    simstr::simple_str_nt<char>
>

Полученное в итоге строковое выражение само по себе не делает конкатенацию, оно знает «как», но не знает «куда». Чтобы его вычислить, нам нужен объект‑приёмник. В примере выше это объект stringa, который возвращается из функции. В simstr все строковые типы, которые могут владеть строками, наследуются от str_storable и умеют инициализироваться из строкового выражения. Для этого объект‑приёмник сначала вызывает у объекта‑строкового выражения метод length() и получает размер необходимого буфера для символов. Затем он выделяет место нужного размера, и вызывает у объекта строкового выражения метод place(). Ну и дописывает в конце 0.

Инициализация строкового объекта строковым выражением
Инициализация строкового объекта строковым выражением

Мутабельные строковые объекты (lstring) кроме инициализации, также умеют ещё и изменять свое содержимое из строковых выражений, то есть их можно использовать в +=, change, prepend, insert и т. п.. Принцип тот же — сначала запрашивается длина, выделяется место для сохранения, выделенное место последовательно заполняется символами.

Наворачиваем дальше

Теперь, когда стал понятен базовый принцип функционирования строковых выражений в simstr, попробуем развить это систему, так как в неё заложена большая гибкость. Ведь помимо самих строковых объектов и strexprjoin мы можем создавать любые произвольные типы, удовлетворяющие концепту StrExpr, и которые можно будет использовать в операциях конкатенации. Кроме того, мы можем создавать другие перегрузки operator+ между строковыми выражениями и какими‑то типами, чтобы использовать их напрямую в конкатенациях.

Работаем с литералами

Для начала, попробуем избавится от лишних ""_ss в операциях конкатенации, чтобы можно было просто использовать строковые литералы. Создадим тип, который конкатенирует строковое выражение и строковый литерал.

expr_literal_join — это шаблонный класс, запоминающий ссылку на строковое выражение и константный литерал. При запросе длины выдает сумму длин строкового выражения и литерала, а при размещении — размещает строковое выражение и литерал. Длина литерала определяется сразу при компиляции.

template<bool first, typename K, size_t N, typename A>
struct expr_literal_join {
    using symb_type = K;
    const K (&str)[N + 1];
    const A& a;
    constexpr size_t length() const noexcept {
        return N + a.length();
    }
    constexpr symb_type* place(symb_type* p) const noexcept {
        if constexpr (N != 0) {
            if constexpr (first) {
                std::char_traits<K>::copy(p, str, N);
                return a.place(p + N);
            } else {
                p = a.place(p);
                std::char_traits<K>::copy(p, str, N);
                return p + N;
            }
        } else {
            return a.place(p);
        }
    }
};

Вручную этот класс создавать не придётся, он будет невидимо работать «под капотом». Для этого создадим к нему перегрузки оператора сложения для строкового выражения и литерала, а также для литерала и строкового выражения:

// const_lit_for<K, T>::Count - специальная штука, которая ограничивает тип T
// только констатными литералами с символами типа K.
// Это применяется для того, чтобы компилятор не приводил неконстатные массивы
// символов к этому типу
template<StrExpr A, typename K = typename A::symb_type, typename T, size_t N = const_lit_for<K, T>::Count>
constexpr inline auto operator+(const A& a, T&& s) {
    return expr_literal_join<false, K, (N - 1), A>{s, a};
}

template<StrExpr A, typename K = typename A::symb_type, typename T, size_t N = const_lit_for<K, T>::Count>
constexpr inline auto operator+(T&& s, const A& a) {
    return expr_literal_join<true, K, (N - 1), A>{s, a};
}

Теперь предыдущий код можно переписать так:

stringa elem(ssa s1, ssa s2) {
    return "<" + s1 + " class=\"" + s2 + "\">";
}

Работаем с числами

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

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

// Складываем строковое выражение и число
template<StrExpr A, FromIntNumber T>
inline constexpr auto operator+(const A& a, T s) {
    return strexprjoin_c<A, expr_num<typename A::symb_type, T>>{a, s};
}
// Складываем число и строковое выражение
template<StrExpr A, FromIntNumber T>
inline constexpr auto operator+(T s, const A& a) {
    return strexprjoin_c<A, expr_num<typename A::symb_type, T>, false>{a, s};
}

Теперь можно делать например так:

stringa elem(ssa tag, ssa className, unsigned counter) {
    return "<" + tag + " class=\"" + className + "\" id=\"elem" + counter + "\">";
}

Как в javascript, только предсказуемо и типизировано.

Должен быть кто-то один

Тут стоит отметить — чтобы всё работало, один из операндов сложения должен быть строковым выражением. Не получится, например, сложить литерал и число, надо что‑то из них явно преобразовать в строковое выражение:

"test = " + count;              // Не сработает
"test = "_ss + count;           // Сработает, _ss преобразует литерал в simple_str_nt
"test = " + e_num<char>(count); // Сработает, e_num<char> - возвращает строковое выражение
eea + "test = " + count;        // Сработает, eea это строковое выражение, дающее пустую строку

Условная конкатенация

Бывают ситуации, когда в зависимости от условия, нам надо прибавлять к строке или одно, или другое. Для этого разработаны e_choice и e_if.

  • e_choice(условие, строковое выражение 1, строковое выражение 2) — это строковое выражение, которое если условие истинно, генерирует результат из строкового выражения 1, иначе из строкового выражения 2

  • e_if(условие, строковое выражение 1) — это строковое выражение, которое если условие истинно, генерирует результат из строкового выражения 1, иначе генерирует пустую строку.

Примеры:

res = "Column is " + e_choice(col.name_.is_empty(), "?column?", col.name_) + ", value = " + value;
...
result = shost + e_if(!sserv.is_empty(), ":" + sserv);
...
r += "E" + e_if(adjusted_exponent > 0, "+") + adjusted_exponent;
...
it->second = type_name + "(" + precision + e_if(scale != 0, ","_ss + scale) + ")";

Генераторы символов

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

  • expr_spaces<typename K, size_t N, size_t S = ' '>{} — генерирует последовательность из N символов S типа K. Значение символа и количество являются константами компиляции. Есть упрощенные формы

    • e_spca<N>() — генерирует N char пробелов.

    • e_spcw<N>() — генерирует N wchar_t пробелов.

  • expr_pad<typename K>{len, symbol} — генерирует последовательность из len символов symbol типа K. len и symbol могут задаваться в рантайм. Укороченная запись — e_c(количество, символ).

Слияния и замены

В конкатенациях можно применять операцию e_join(Контейнер, "разделитель"). Этот оператор сливает строки из контейнера в одну, используя заданный разделитель. Контейнер — любой, поддерживающий range for.

result = header + e_join(lines, "\n") + footer;

Также существуют операторы для замен:

  • e_repl(Источник, "Найти", "Заменить") — генерируют строку, в которой все вхождения «Найти» в Источнике заменяются на «Заменить». Строки для поиска и замены задаются литералами на этапе компиляции.

  • expr_replaced<typename K>{Источник, Найти, Заменить) — генерируют строку, в которой все вхождения Найти в Источнике заменяются на Заменить. Строки для поиска и замены задаются переменными в рантайм

  • e_repl_const_symbols(Источник, символ1, замена1, ...) — генерируют строку, в которой все вхождения заданных символов в Источнике заменяются на соответствующие замены. Символы для поиска и строки для замены задаются константными при компиляции.

  • expr_replace_symbols<typename K, bool UseVectorForReplace = false>{Источник, вектор_замен) — генерирует строку, в которой вхождения символов из вектора замен заменяются на соответствующие им замены. Символы и замены могут задаваться рантайм.

Примеры:

result = header + e_repl(source, "\r\n", "\n") + footer;
...
script_text += "'" + e_repl(name, "'", "\\'") + "'" + e_choice(last, "]", ",");
...
out += "<p>" + e_repl_const_symbols(text,
    '\"', "&quot;",
    '<', "&lt;",
    '\'', "&#39;",
    '&', "&amp;") + "</p>";

std::string и std::string_view

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

stringa func(std::string_view key, const std::string& value) {
    return "Key is "_ss + key + ", value is " + value;
}

utf конвертация

В simstr все владеющие строки могут инициализироваться из строк с другим типом символов, автоматически конвертируя их из UTF-8, UTF-16, UTF-32 в нужную кодировку. А для возможности использовать строки одной кодировки в конкатенациях строк с другой кодировкой, существует оператор e_utf<ТипСимвола>(Источник). Генерирует строку из Источника, конвертированного в нужную кодировку символов.

stringa result = "< " + e_utf<char>(utf16text) + ">";

Разработка своих строковых выражений

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

Возьмем реальный пример. В проекте требовалось бинарные данные, полученные из sqlite в виде пары {указатель, длина} добавлять в utf-16 строку в виде base64 кодировки.

Был создан такой класс:

struct expr_str_base64 {
    using symb_type = u16s;
    ssa text; // Бинарные данные
    size_t l; // Длина результата
    size_t length() const noexcept {
        return l;
    }
    u16s* place(u16s* ptr) const noexcept;
    expr_str_base64(ssa t);
};
expr_str_base64::expr_str_base64(ssa t) : text(t) {
    l = (text.len + 2) / 3 * 4;
}

u16s* expr_str_base64::place(u16s* ptr) const noexcept {
    static constexpr u8s alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

    const unsigned char* t = (const unsigned char*)text.str;

    size_t i = 0;
    if (text.len > 2) {
        for (; i < text.len - 2; i += 3) {
            *ptr++ = alphabet[(t[i] >> 2) & 0x3F];
            *ptr++ = alphabet[((t[i] & 0x3) << 4) | ((int)(t[i + 1] & 0xF0) >> 4)];
            *ptr++ = alphabet[((t[i + 1] & 0xF) << 2) | ((int)(t[i + 2] & 0xC0) >> 6)];
            *ptr++ = alphabet[t[i + 2] & 0x3F];
        }
    }
    if (i < text.len) {
        *ptr++ = alphabet[(t[i] >> 2) & 0x3F];
        if (i == (text.len - 1)) {
            *ptr++ = alphabet[((t[i] & 0x3) << 4)];
            *ptr++ = '=';
        } else {
            *ptr++ = alphabet[((t[i] & 0x3) << 4) | ((int)(t[i + 1] & 0xF0) >> 4)];
            *ptr++ = alphabet[((t[i + 1] & 0xF) << 2)];
        }
        *ptr++ = '=';
    }
    return ptr;
}

Теперь везде, где надо прибавить строку, сконвертированную в base64, можно использовать expr_str_base64

Например:

void addBlob(ssa v) {
    vtText << u"{\"#\",87126200-3e98-44e0-b931-ccb1d7edc497,{1,{#base64:" +
      expr_str_base64(v) + u"}}},";
    currentCol++;
}

Заключение

Спасибо за внимание, надеюсь теперь Expression Templates будут для вас понятными, а их применение в simstr — удобным.

Комментарии (5)


  1. bfDeveloper
    12.08.2025 16:46

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


    1. orefkov Автор
      12.08.2025 16:46

      Да, примерно так и планировалось, очень хотелось максимально оптимизировать построение строк. Хотя, конечно, не все задачи пока можно им сделать, но я работаю над этим :)
      В бенчмарках как раз последний бенчмарк Build Full Func Name на такой типичный билдер строк.


  1. pavlushk0
    12.08.2025 16:46

    Это велосипед на замену stringstream и format?


    1. orefkov Автор
      12.08.2025 16:46

      Как-то вы странно вопрос ставите. Почему сразу "на замену"? Этот как говорить "шуруповёрт на замену перфоратору, стамеска на замену рубанку". Для каждой задачи лучше подбирать подходящий инструмент.
      И эта либа - просто ещё один инструмент в ряду многих. И как же он может быть на замену format, если сам format там используется? format просто отличная вещь для своих целей, не надо его заменять. stringstream тоже решает свои задачи, хотя судя по бенчмаркам, не очень быстро.


    1. orefkov Автор
      12.08.2025 16:46

      Дополню предыдущий ответ.
      Вот тут результат бенчмарка как раз сравнение std::stringstream, std::format и строк simstr на задаче Сформировать "текст число текст".