Сергей Деревяго

Не копируйте!



1. Введение

Если вам сейчас не до смеха, лучше сразу откройте Производительность и потратьте минут пять на чтение. Оно того стоит.

Остальных же предупреждаю: Ваш взгляд на Мир C++ уже не останется прежним!

Дело в том, что я нашел изящное решение Большой Проблемы. Но оно сотрясает Основы...

Нет смысла рассказывать раньше времени, потому что вы не поверите. Я бы и сам не поверил. Так что будем читать по порядку.

С уважением, Сергей Деревяго.


2. Посмеемся вместе

Я спешу посмеяться над всем C++, иначе мне пришлось бы заплакать.

2.1. Чудовище в ссылке

Да-да, я знаю, что все сильно заняты. Мы сразу возьмем коня за рога.

А для начала давайте посмотрим на мощный финал раздела Item 27: "Familiarize yourself with alternatives to overloading on universal references" из широко известной в узких кругах книги Scott Meyers "Effective Modern C++":

class Person {
public:
  template<
    typename T,
    typename = std::enable_if_t<
      !std::is_base_of<Person, std::decay_t<T>>::value
      &&
      !std::is_integral<std::remove_reference_t<T>>::value
    >
  >
  explicit Person(T&& n)
  : name(std::forward<T>(n))
  {
    static_assert(
      std::is_constructible<std::string, T>::value,
      "Parameter n can't be used to construct a std::string"
    );
  }

private:
  std::string name;
};

Ой!.. Что это было??

Ну, все это Чудовищное Говно, грубо говоря, призвано научить детей рисовать модные конструкторы класса Person, эффективно инициализирующие одно единственное поле name. Ни больше, ни меньше.

Стыдно сказать, но раньше бы мы написали

class Person {
public:
  explicit Person(const std::string& n) : name(n) {}

private:
  std::string name;
};

и были бы счастливы. А почему, собственно, стыдно? А потому, что с простым и понятным кодом невозможно всучить населению книгу по имени "Effective Modern C++"!

Нет, король там конечно не голый. Король там усердно обвалян в том самом Чудовищном™, а после красиво завернут в блестящий фантик... Конфетка!

И ведь надо признать, что у них получилось: ни разу не видевшие настоящих конфет дети охотно берут то, что дали. Еще и друзьям советуют.

Но только не нам. Такой хоккей нам не нужен! Уж лучше мы будем давиться "Good Old C++", где под фантиком шоколад. Хоть и меньше блестит обертка.

А сейчас ближе к делу. Чисто технически, что же нас не устраивает?

  1. Прежде всего, параметры конструктора Person -- это его интерфейс! Выставляя наружу сам факт существования поля std::string в классе Person, мы делаем пользователя зависимым от деталей реализации. Как следствие, мы не сможем его без проблем заменить, например, на ders::text. Т.к. или пользователю придется передавать другие аргументы (неудобно!), или автору продолжать создавать промежуточный string, а потом его конвертировать в text (неэффективно!).
  2. С другой стороны, вы правда уверены что "эффективный" конструктор Person(T&& n) вместо привычного Person(const std::string& n) существенно повлияет на производительность всего приложения? Ну-ну...
  3. И в завершение про Чудовищное™. Даже сам автор книги искренне признаётся: "The resulting error message is likely to be, er, impressive. With one of the compilers I use, it’s more than 160 lines long." Гмм... Так и конфетка на кафель грохнется! Вслед за челюстью.

Так вот. Все эти бодрые enable_if_t<>, is_base_of<>, decay_t<>, is_integral<>, remove_reference_t<> и forward<> вокруг одного бедного имени... Никого не забыл? Как бы нам от них разом избавиться. Чтобы как в Сказке: дал коршня и разлетелись!

Увы, но в лоб не получится. Ведь эти гадости следствие, а не причина! Они неизбежно заводятся следом за паразитами. А паразитов я вам сейчас подсвечу:

class Person {
public:
  template<
    typename T,
    typename = std::enable_if_t<
      !std::is_base_of<Person, std::decay_t<T>>::value
      &&
      !std::is_integral<std::remove_reference_t<T>>::value
    >
  >
  explicit Person(T&& n)
  : name(std::forward<T>(n))
  {
    static_assert(
      std::is_constructible<std::string, T>::value,
      "Parameter n can't be used to construct a std::string"
    );
  }

private:
  std::string name;
};

Да, именно эти клопы-кровососы нагло впиваются в беззащитное тело кода! По нашей собственной глупости. А потом на них виснут enable_if<>, decltype(auto) и те остальные Чудовища™. Но все гениальное просто:

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

Но это же НЕВОЗМОЖНО!

А если подумать?.. А если подумать, то очень возможно. Но сначала пройдемся по ссылкам.

Как же так получилось, что все эти годы они безнаказанно портили нашу кровь? Открываем раздел 3.7 References книги Bjarne Stroustrup "The Design and Evolution of C++" и внимательно смотрим на текст:

References were introduced primarily to support operator overloading. Doug McIlroy recalls that once I was explaining some problems with a precursor to the current operator overloading scheme to him. He used the word reference with the startling effect that I muttered "Thank you," and left his office to reappear the next day with the current scheme essentially complete. Doug had reminded me of Algol68.

C passes every function argument by value, and where passing an object by value would be inefficient or inappropriate the user can pass a pointer. This strategy doesn't work where operator overloading is used. In that case, notational convenience is essential because users cannot be expected to insert address-of operators if the objects are large. For example:

    a = b - c;
is acceptable (that is, conventional) notation, but
    a = &b - &c;
is not. Anyway, &b-&c already has a meaning in C, and I didn't want to change that.

It is not possible to change what a reference refers to after initialization. That is, once a C++ reference is initialized it cannot be made to refer to a different object later; it cannot be re-bound. I had in the past been bitten by Algol68 references where rl=r2 can either assign through rl to the object referred to or assign a new reference value to rl (re-binding rl) depending on the type of r2. I wanted to avoid such problems in C++.

If you want to do more complicated pointer manipulation in C++, you can use pointers. Because C++ has both pointers and references, it does not need operations for distinguishing operations on the reference itself from operations on the object referred to (like Simula) or the kind of deductive mechanism employed by Algol68.

I made one serious mistake, though, by allowing a non-const reference to be initialized by a non-lvalue...

"Я совершил одну серьезную ошибку", гы. Да их там тысячи!

Ну ладно, десятки серьезных ошибок испортили вкус C++ еще с самых пеленок. Да что говорить, там буквально вся книга о том как не надо изобретать! Точнее, тащить из других языков что попало. Но это Тема отдельной статьи.

А что же про ссылки? Ну, вы сами все видели: ссылки были добавлены для перегрузки операторов и только. И если бы Страуструп был способен изобрести/подглядеть толковую схему перегрузки операторов на указателях -- мы б никогда не нащупали эти гроздья крючков в собственной гмм... программе. Да, можно сказать и так.

Хуже того! Начав свою жизнь в узкой области операторов, ссылочки живенько разбежались по тельцу. А затем еще более темные головы, азартно почесываясь, изобрели rvalue references... Так и случилось Чудовищное™.

Вы, кстати, читали книгу Nicolai M. Josuttis "C++ Move Semantics - The Complete Guide"? Если нет, то очень рекомендую: 250 страниц про rvalue references! Не, ну буквально: вся книга строго по теме. Все двести страниц!

Просто представьте. Собрались вы запилить будку Шарику. Ну вот это вот все: режем доски, крутим дырки, гвозди заколачиваем... Тянете руку к пакету с гвоздями -- ан нет! Вот тебе, друг, книга на двести страниц "Как доставать гвозди". Не держать и не забивать. Двести честных страниц с примерами, чтобы правильно отхватить болта! А?

Дурь? Нельзя тратить двести страниц на мелочь?? А почему тогда можно на move semantics?! Это ж какие там гвозди, что хрен ухватишь! А попробуй еще забей... В общем, не удивлюсь, если вам сейчас кажется, что двойные крючки есть Вершина Чудовищного™. Самое днище.

Пристегните ремни, нам постучали снизу.

Та-дам! На сцену выходит раздел 10.4.2 "Why && for Both Ordinary Rvalues and Universal References?":

Universal/forwarding references use the same syntax as ordinary rvalue references, which is a serious source of trouble and confusion. So why did we not introduce a specific syntax for universal references?

An alternative proposal, for example, might have been (and is sometimes discussed as a possible fix):

  • Use two ampersands for ordinary rvalue references:
    void foo(Coll&& arg) // arg is an ordinary rvalue reference
    
  • Use three ampersands for universal references:
    template<typename Coll>
    void foo(Coll&&& arg) // arg is universal/forwarding reference
    
However, these three ampersands might just look too ridiculous (whenever I show this option people laugh). Unfortunately, it would have been better to use the three ampersands because it would make code more intuitive.

"Эти три амперсанда могут выглядеть слишком нелепо" -- да чо уж там, давай четыре! Гулять так гулять.


2.2. Большая Ложь

Итак, решив не использовать ссылки, мы больше не можем определять конструкторы копирования и перемещения. Как много мы потеряли?

Как бы дико оно не звучало, мы только приобрели!

Не верите? Тогда самое время опять обратиться к Истокам. Без предрассудков.

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

В силу того, что такие конструкторы являются прямым следствием C++ value semantics, проиллюстрируем суть явления простым наглядным примером. Давайте представим, что некое многолетнее дарование (малолетнее, как вариант) решило набросить очередную либу в многострадальный boost.org: использовать value semantics для чтения файлов!

  1. Перед тем как открыть файл на чтение, библиотека копирует его на сторону и открывает копию файла. Нам же нужно свое значение!
  2. Далее чтение копии идет своим чередом. Все как обычно.
  3. А после закрытия файла она удаляет копию. Чай не дурные.
Прекрасно? Шикарно! Все корректно работает многие годы. Дарование почивает на лаврах, а пользователи... начинают все громче роптать, что файлы лучше бы не копировать. Ну что же, вот вам еще Решение!

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

Прекрасно? Шикарно! Теперь многие годы все учат друг друга делать модный rename... Но не лучше ли сразу читать нужный файл?! Что-что говорите, value semantics не велит? Пожмем ей руку и пошлем вслед за ссылками!

Теперь вы тоже видите Большую Ложь. И так и хочется спросить прищурив глаз: автоматически генерируемые конструкторы копирования -- это глупость или предательство? Обширные страницы текста, десятки детальных примеров без четкого предупреждения "копирование -- Зло!", глупость или предательство?!


2.3. Сдвиг по фазе

Ну хорошо, копировать не надо. Что тогда?

Тогда допустим, что надо копировать и придем к противоречию. ОК, раз move constructor (переименование файла) работает быстрее copy constructor (копирование файла), то мы попробуем его оптимизировать.

Как же устроен класс, идеально подходящий для реализации конструктора перемещения? Да, идиома pImpl. Она самая:

class Widget {
public:
  // ...

  Widget(Widget&& rhs)
  {
    pImpl=rhs.pImpl;
    rhs.pImpl=0;
  }

private:
  struct Impl;
  Impl* pImpl;
};

Если все наши данные живут в отдельной структуре, то перемещение -- это просто присваивание указателя. Красота!

Красота? Ну так давайте заодно улучшим и Widget::Impl, тоже превратив ее в указатель на имплементацию:

struct Widget::Impl {
  struct Impl2;
  Impl2* pImpl2;
};

Тем самым мы получаем... глупость? А разве Widget::Impl не глупость?! Что мешает пользователю Widget самому себе создать указатели и "перемещать" их в любую удобную сторону?

Смотрите, мы один раз на куче создаем объект Widget и никуда его больше не двигаем. Перемещаем и копируем лишь указатели на объект: обычные или умные. И дело сделано! Не нужны больше эти конструкторы нашему классу (а значит и ссылки). Они давно уже реализованы в умных указателях. Раз и навсегда.

А что же копирование? Бывают же случаи, когда нужна копия?

Конечно бывают. Но не только лишь все. И тут обычно оказывается, что у класса уже есть подходящий конструктор, с помощью которого мы можем создать "копию" передавая те же аргументы, что когда-то оригиналу. А если нет, то лучше добавить функцию copy(), возвращающую умный указатель на созданную на куче копию. Для интерфейсов ее называют clone().

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


2.4. Петля диапазона

Блаженны узкие специалисты, ибо не ведают, что творят.

Ну как можно подозревать создателей C++ в элементарной безграмотности?! Наш вариант -- подозревать с особым цинизмом!

Ща развернем еще одну конфетку, несите краги и противогаз!

А поможет нам в этом деле книга Nicolai M. Josuttis "The C++ Standard Library. Second Edition". Раздел 3.1.4 "Range-Based for Loops". Да, это полный 3.14...

To multiply each element elem of a vector vec by 3 you can program as follows:
    std::vector<double> vec;
    ...
    for ( auto& elem : vec ) {
        elem *= 3;
    }
Here, declaring elem as a reference is important because otherwise the statements in the body of the for loop act on a local copy of the elements in the vector (which sometimes also might be useful).

This means that to avoid calling the copy constructor and the destructor for each element, you should usually declare the current element to be a constant reference. Thus, a generic function to print all elements of a collection should be implemented as follows:

    template <typename T>
    void printElements (const T& coll)
    {
        for (const auto& elem : coll) {
            std::cout << elem << std::endl;
        }
    }
Here, the range-based for statement is equivalent to the following:
    {
        for (auto _pos=coll.begin(); _pos != coll.end(); ++_pos) {
            const auto& elem = *_pos;
            std::cout << elem << std::endl;
        }
    }

А? Моща?!

Только заюзал range-based loop и все друзья с нами: copy constructor и/или reference! И не выпихнешь, сомкнули морды!

Дорогие создатели! Не кажется ли вам, что человеку нужен просто итератор? Тот самый, который _pos. А если вдруг еще понадобится *_pos, то мы и сами его сделаем. Нет?

А вот нет! Дают только *_pos. А раз так, пусть сами давятся своей петлей!

Резюмирую: что бы ни писали в букварях, не давайте детям range-based for loop! У них там ошибка. Видимо в ДНК.


3. Правила правописания

В этой главе мы установим правила правописания C++ и дадим осмысленный пример программы.

3.1. Итоговые правила

Так что там в итоге? В итоге все просто:
  1. Здравый смысл. Если в вашем конкретном случае правило противоречит здравому смыслу, то победить должен Здравый Смысл!
  2. Не используйте ссылки! Они неизбежно приводят к Чудовищному™.
  3. Как следствие, не копируйте! Запрещайте конструкторы копирования и операторы присваивания.
  4. И не используйте range-based for loop!
Посмотрим, что это значит на примере параметров функции. Удивительно, но факт: все прямо следует из той пары строчек: "C passes every function argument by value, and where passing an object by value would be inefficient or inappropriate the user can pass a pointer. This strategy doesn't work where operator overloading is used." Советы хорошие, очень жаль их никто не читает. Включая автора.

Короче,

  1. Не используйте ссылки. Они могут быть только там, где без них вообще невозможно: перегрузка операторов и специальных функций типа swap().
  2. Передавайте встроенные типы по значению (int, double, void*...).
  3. Передавайте небольшие объекты по значению. Размером с пару-тройку указателей (std::complex, ders::strsz...).
  4. Передавайте указатель на большие объекты:
Гмм, выглядит просто. Почему ж так не пишут с рождения? Да, на то есть Причины:
  1. С самого начала C++, размещение объектов на куче работало медленно. Оно и сейчас плохо работает, поэтому я использую ders::mem_pool.
  2. Не существовало умных указателей с подсчетом ссылок. Сейчас они есть, но работают тоже медленно. Поэтому я использую ders::sh_ptr.
  3. А сейчас тот топор, без которого каши не сваришь. Еще раз подчеркиваю: только благодаря появлению быстрых кэширующих аллокаторов и быстрых умных указателей мы можем повсеместно создавать объекты на куче и копировать вместо них указатели! Без mem_pool и sh_ptr это было бы не эффективно.

3.2. inchk: пример программы

Теоретически, между теорией и практикой нет разницы. Но на практике она есть.

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

#include "file1.hpp"
#include <ders/file2.hpp>
#include <file3.hpp>

То нет смысла включать <file3.hpp>, если он уже был прямо или косвенно включен в "file1.hpp" или <ders/file2.hpp>. Короче, все очень просто. Пока не начнешь.

А потом вдруг оказывается, что задача со звездочкой! И даже корректный учет комментариев не является тривиальной задачей (например, строка "/*" -- это не комментарий).

Или вот есть нюанс: все хедеры derslib первым делом включают <ders/config.hpp>, но это не бессмысленный повтор! Т.к. можно использовать только один заголовок и config.hpp там обязан присутствовать.

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

Итак, программа inchk предназначена для поиска повторно включаемых хедеров в исходных файлах.

Параметры командной строки:
inchk -Iinclude/dir [...] source/file.cpp [...]
-Iinclude/dir директория для поиска заголовочных файлов. как и компилятору, ей можно задать несколько директорий.
source/file.cpp исходный файл для анализа. можно задать несколько.

Примеры использования:
inchk.exe -I..\derslib\inc *.cpp ..\derslib\src\*.cpp
./inchk -I../derslib/inc ../derslib/src/posix/*.cpp ../derslib/src/thread/*.cpp

Как вы уже догадались, текущий derslib не содержит повторов. Например, вот анализ самого incfile.cpp:

incfile.cpp:
9: "incfile.hpp" -> incfile.hpp
    10: <vector>
    11: <ders/text.hpp> -> ..\derslib\inc\ders\text.hpp
        10: <ders/config.hpp> -> ..\derslib\inc\ders\config.hpp
        11: <string.h>
        12: <ders/ptr.hpp> -> ..\derslib\inc\ders\ptr.hpp
            10: <ders/config.hpp> -> ..\derslib\inc\ders\config.hpp
            11: <type_traits>
            12: <ders/mp_new.hpp> -> ..\derslib\inc\ders\mp_new.hpp
                10: <ders/config.hpp> -> ..\derslib\inc\ders\config.hpp
                11: <new>
                12: <typeinfo>
                13: <utility>
                14: <assert.h>
                15: <ders/mem_pool.hpp> -> ..\derslib\inc\ders\mem_pool.hpp
                    10: <ders/config.hpp> -> ..\derslib\inc\ders\config.hpp
                    11: <stddef.h>
10: <ders/dir.hpp> -> ..\derslib\inc\ders\dir.hpp
    10: <ders/config.hpp> -> ..\derslib\inc\ders\config.hpp
    11: <vector>
    12: <ders/exception.hpp> -> ..\derslib\inc\ders\exception.hpp
        10: <ders/config.hpp> -> ..\derslib\inc\ders\config.hpp
        11: <exception>
        12: <system_error>
        13: <ders/tx_buf.hpp> -> ..\derslib\inc\ders\tx_buf.hpp
            10: <ders/config.hpp> -> ..\derslib\inc\ders\config.hpp
            11: <stdint.h>
            12: <ders/text.hpp> -> ..\derslib\inc\ders\text.hpp
11: <ders/fd_file.hpp> -> ..\derslib\inc\ders\fd_file.hpp
    10: <ders/config.hpp> -> ..\derslib\inc\ders\config.hpp
    11: <ders/exception.hpp> -> ..\derslib\inc\ders\exception.hpp
    12: <ders/rdwr.hpp> -> ..\derslib\inc\ders\rdwr.hpp
        10: <ders/config.hpp> -> ..\derslib\inc\ders\config.hpp
        11: <system_error>
        12: <ders/text.hpp> -> ..\derslib\inc\ders\text.hpp
12: <ders/hash.hpp> -> ..\derslib\inc\ders\hash.hpp
    10: <ders/config.hpp> -> ..\derslib\inc\ders\config.hpp
    11: <unordered_map>
    12: <unordered_set>
    13: <ders/mp_allocator.hpp> -> ..\derslib\inc\ders\mp_allocator.hpp
        10: <ders/config.hpp> -> ..\derslib\inc\ders\config.hpp
        11: <memory>
        12: <ders/mem_pool.hpp> -> ..\derslib\inc\ders\mem_pool.hpp
    14: <ders/text.hpp> -> ..\derslib\inc\ders\text.hpp

Но предыдущая версия имела проблемы:

mtprog\derslib\src\cycl_buf.cpp:
15: <ders/cycl_buf.hpp> -> mtprog\derslib\inc\ders\cycl_buf.hpp
    18: <ders/config.hpp> -> mtprog\derslib\inc\ders\config.hpp
    19: <ders/sh_ptr.hpp> -> mtprog\derslib\inc\ders\sh_ptr.hpp
        18: <ders/config.hpp> -> mtprog\derslib\inc\ders\config.hpp
        19: <ders/destroy.hpp> -> mtprog\derslib\inc\ders\destroy.hpp
            19: <ders/config.hpp> -> mtprog\derslib\inc\ders\config.hpp
            20: <typeinfo>
            21: <assert.h>
            22: <ders/hard_asrt.hpp> -> mtprog\derslib\inc\ders\hard_asrt.hpp
                18: <ders/config.hpp> -> mtprog\derslib\inc\ders\config.hpp
            23: <ders/mem_pool.hpp> -> mtprog\derslib\inc\ders\mem_pool.hpp
                19: <ders/config.hpp> -> mtprog\derslib\inc\ders\config.hpp
                20: <new>
                21: <stddef.h>
16: <assert.h>
warning: mtprog\derslib\src\cycl_buf.cpp:16: <assert.h> already included
17: <string.h>

mtprog\derslib\src\data_queue.cpp:
15: <ders/data_queue.hpp> -> mtprog\derslib\inc\ders\data_queue.hpp
    20: <ders/config.hpp> -> mtprog\derslib\inc\ders\config.hpp
    21: <ders/data_io.hpp> -> mtprog\derslib\inc\ders\data_io.hpp
        20: <ders/config.hpp> -> mtprog\derslib\inc\ders\config.hpp
        21: <ders/cycl_buf.hpp> -> mtprog\derslib\inc\ders\cycl_buf.hpp
            18: <ders/config.hpp> -> mtprog\derslib\inc\ders\config.hpp
            19: <ders/sh_ptr.hpp> -> mtprog\derslib\inc\ders\sh_ptr.hpp
                18: <ders/config.hpp> -> mtprog\derslib\inc\ders\config.hpp
                19: <ders/destroy.hpp> -> mtprog\derslib\inc\ders\destroy.hpp
                    19: <ders/config.hpp> -> mtprog\derslib\inc\ders\config.hpp
                    20: <typeinfo>
                    21: <assert.h>
                    22: <ders/hard_asrt.hpp> -> mtprog\derslib\inc\ders\hard_asrt.hpp
                        18: <ders/config.hpp> -> mtprog\derslib\inc\ders\config.hpp
                    23: <ders/mem_pool.hpp> -> mtprog\derslib\inc\ders\mem_pool.hpp
                        19: <ders/config.hpp> -> mtprog\derslib\inc\ders\config.hpp
                        20: <new>
                        21: <stddef.h>
        22: <ders/text.hpp> -> mtprog\derslib\inc\ders\text.hpp
            20: <ders/config.hpp> -> mtprog\derslib\inc\ders\config.hpp
            21: <ders/sh_ptr.hpp> -> mtprog\derslib\inc\ders\sh_ptr.hpp
16: <ders/destroy.hpp> -> mtprog\derslib\inc\ders\destroy.hpp
warning: mtprog\derslib\src\data_queue.cpp:16: <ders/destroy.hpp> already included

Самое время проверить свои проекты, проблемы как правило есть!


4. Производительность

Добро пожаловать в самый практичный раздел статьи!

Даже если вы не согласны с теорией, цифры говорят за себя. В этом разделе находятся тесты производительности самых базовых возможностей derslib. Как вы скоро увидите, за прошедшие 15 лет производительность стандартных библиотек C++ заметно подтянулась и речь уже не идет про десятки и сотни раз выигрыша. Тем не менее, и пятикратное ускорение -- это Много! Это все еще другой Качественный уровень, делающий возможным невозможное (для отдельных встраиваемых платформ).

Замеры производились на двух разных ноутбуках. Время работы в секундах, а строки вида std/ders -- отношение времени: то самое количество раз, во сколько ускорились функции.

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


4.1. perf1: производительность sh_ptr

Давайте посмотрим что будет, если вместо std::shared_ptr использовать ders::sh_ptr:

perf1/main.cpp
void start_std(latch* l1, latch* l2)
{
    shared_ptr<int> sp(new int(5));

    vector<shared_ptr<int>> v;
    v.reserve(M);

    l1->arrive_and_wait();
    for (int i=0; i<N; i++) {
        for (int j=0; j<M; j++) v.push_back(sp);
        v.clear();
    }
    l2->arrive_and_wait();
}

void start_ders(latch* l1, latch* l2)
{
    mem_pool mpo, *mp=&mpo;
    sh_ptr<int> sp(mp, mp_new<int>(mp, 5));

    vector<sh_ptr<int>> v;
    v.reserve(M);

    l1->arrive_and_wait();
    for (int i=0; i<N; i++) {
        for (int j=0; j<M; j++) v.push_back(sp);
        v.clear();
    }
    l2->arrive_and_wait();
}

Усредненные результаты запусков представлены в следующей таблице:

Ubuntu 16, g++ 5.4.0 Windows 10, g++ 12.2.0
num_threads 1 2 4 1 2 4
std 1.244 1.274 2.456 1.398 1.413 1.584
ders 0.279 0.295 0.391 0.259 0.249 0.286
std/ders 4.5 4.3 6.3 5.4 5.7 5.5

Как можно видеть, функция ускорилась в 4-6 раз! Это цена atomic increment, на всякий случай добавленного в std::shared_ptr авторами. Ну, это как возить с собой мешок песка. Мало ли.

К слову сказать, я уже объяснял, почему mutex-ы на всякий случай -- это плохая идея.


4.2. perf2: производительность mp_allocator

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

Нам заранее стыдно, но все же попробуем:

perf2/main.cpp
void start_std(latch* l1, latch* l2)
{
    list<int> lst;

    l1->arrive_and_wait();
    for (int i=0; i<N; i++) {
        for (int j=0; j<M; j++) lst.push_back(j);
        for (int j=0; j<M; j++) lst.pop_front();
    }
    l2->arrive_and_wait();
}

void start_ders(latch* l1, latch* l2)
{
    mem_pool mpo;
    mp_allocator<int> mpa(&mpo);
    list<int, mp_allocator<int>> lst(mpa);

    l1->arrive_and_wait();
    for (int i=0; i<N; i++) {
        for (int j=0; j<M; j++) lst.push_back(j);
        for (int j=0; j<M; j++) lst.pop_front();
    }
    l2->arrive_and_wait();
}

Не зря старались!

Ubuntu 16, g++ 5.4.0 Windows 10, g++ 12.2.0
num_threads 1 2 4 1 2 4
std 2.318 2.340 3.496 3.272 3.476 5.059
ders 0.450 0.468 0.654 0.404 0.422 0.589
std/ders 5.2 5.0 5.3 8.1 8.2 8.6

Функция ускорилась в 5-8 раз. А все потому, что ders::mem_pool не дергает глобальный operator new() по малейшему поводу, а выделяет память большими блоками. Очевидно, но до сих пор эффективно!

И не забудьте передать привет Уважаемым Профессионалам. Хотя... Они там давно с приветом.


4.3. perf3: дайте два!

А теперь оба окурка вы давите вместе!

Ну то есть, раз ders::sh_ptr обеспечивает четырехкратное ускорение, а ders::mp_allocator -- пятикратное, то надо их скомбинировать для двадцатикратного:

perf3/main.cpp
void start_std(latch* l1, latch* l2)
{
    shared_ptr<int> sp(new int(5));
    list<shared_ptr<int>> lst;

    l1->arrive_and_wait();
    for (int i=0; i<N; i++) {
        for (int j=0; j<M; j++) lst.push_back(sp);
        for (int j=0; j<M; j++) lst.pop_front();
    }
    l2->arrive_and_wait();
}

void start_ders(latch* l1, latch* l2)
{
    mem_pool mpo, *mp=&mpo;
    sh_ptr<int> sp(mp, mp_new<int>(mp, 5));

    mp_allocator<sh_ptr<int>> mpa(mp);
    list<sh_ptr<int>, mp_allocator<sh_ptr<int>>> lst(mpa);

    l1->arrive_and_wait();
    for (int i=0; i<N; i++) {
        for (int j=0; j<M; j++) lst.push_back(sp);
        for (int j=0; j<M; j++) lst.pop_front();
    }
    l2->arrive_and_wait();
}

Смотрим на результаты:

Ubuntu 16, g++ 5.4.0 Windows 10, g++ 12.2.0
num_threads 1 2 4 1 2 4
std 2.893 2.904 4.184 6.145 8.235 7.632
ders 0.486 0.503 0.673 0.613 0.676 0.755
std/ders 6.0 5.8 6.2 10.0 12.2 10.1

И... эх! Так оно не работает. Хоть и вышло быстрее, но конечно не в двадцать раз.

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


4.4. perf4: работа с файлами

Напоследок самое вкусное!

Вы не поверите, но этой песне почти четверть века! Многие мои читатели еще не родились, когда я уже вел переписку со Страуструпом по поводу производительности fstream: "главная сила языка C -- в его способности считывать символы и решать, что с ними ничего не надо делать -- причем выполнять это быстро. Это действительно важное достоинство, которое нельзя недооценивать, и цель C++ -- не утратить его".

Боже мой, сколько пафоса!

Особенно когда знаешь, что fstream тормозили с рождения. И дела потом шли только хуже.

Изначально, в том самом разделе C++ 3rd: комментарии, более двадцати лет назад, была намеряна одиннадцатикратная разница! Но годы шли и появилась надобность проверить производительность многопоточных приложений. И она порой достигала тридцатикратной разницы!! Обратите при этом внимание, что многопоточная производительность derslib сравнивалась с изначально более быстрыми C-шными потоками FILE, а не тормознутыми fstream.

Посмотрим, что стало сейчас:

perf4/main.cpp
void start_c(ThreadData* td)
{
    FILE* f=fopen(td->path, "rb");
    if (!f) {
        td->err="can't open file";
        return;
    }

    td->lch1->arrive_and_wait();
    for (int ch; (ch=getc(f))!=EOF; )
        td->sum+=ch;
    td->lch2->arrive_and_wait();

    fclose(f);
}

void start_cpp(ThreadData* td)
{
    ifstream f(td->path, ios_base::in|ios_base::binary);
    if (!f) {
        td->err="can't open file";
        return;
    }

    td->lch1->arrive_and_wait();
    for (int ch; (ch=f.get())!=EOF; )
        td->sum+=ch;
    td->lch2->arrive_and_wait();
}

void start_ders(ThreadData* td)
{
    mem_pool mpo, *mp=&mpo;
    
    error_code ec;
    fd_file f(mp, strsz(td->path), md_rdo, &ec);
    if (ec) {
        td->err="can't open file";
        return;
    }

    buf_rd bf(mp, &f);

    td->lch1->arrive_and_wait();
    for (int ch; (ch=bf.get())!=-1; )
        td->sum+=ch;
    td->lch2->arrive_and_wait();
}

Удивительный результат!

Ubuntu 16, g++ 5.4.0, 141 MB Windows 10, g++ 12.2.0, 142 MB
num_threads 1 2 4 1 2 4
c 2.185 2.209 9.076 5.179 5.223 19.742
cpp 0.759 0.793 3.704 1.323 1.365 3.066
ders 0.234 0.253 0.877 0.358 0.368 0.590
c/ders 9.3 8.7 10.3 14.5 14.2 33.5
cpp/ders 3.2 3.1 4.2 3.7 3.7 5.2

Как можно видеть, Уважаемые Профессионалы блестяще справились с задачей -- они тупо замедлили C-шные FILE! И если derslib стабильно быстрее fstream в 3-5 раз, то классический FILE уже может отстать в 33 с половиной раза!! Это какой-то позор...

Но все же итоги хорошие: обязательно используйте ders::fd_file c buf_rd/buf_wr для буферизованного чтения/записи вместо fstream и особенно FILE! Это заметно ускорит многопоточные приложения.


5. Библиотека derslib

Представленный ниже вариант derslib является дальнейшим развитием кода статьи Многопоточное программирование, использующим возможности C++14.

Поддержка давно устаревшего стандарта была выбрана не случайно, т.к. абсолютное большинство C++ разработчиков, так или иначе, вынуждено иметь дело со старым кодом и компиляторами. А минимальной платформой является Ubuntu 16 LTS, g++ 5.4.0 (чему нет оправдания).

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

Ну и, конечно, реальный код -- лучший учитель! Если что-то не ясно, смотрите как сделан inchk.


5.1. config.hpp

Служебный файл библиотеки. Он не должен включаться клиентским кодом.

Содержит зависящие от платформы define-ы, устраняющие ошибки компиляции.


5.2. dir.hpp

Здесь определены функции для работы с путями и директориями. А именно: Обратите внимание, что для каждой операции name() определена пара функций:
  1. name(... ,std::error_code* ec) -- возвращает ошибку в ec.
  2. ex_name(...) -- возбуждает FileException.

5.3. exception.hpp

Здесь определены классы возбуждаемых исключений.

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

Но время не стоит на месте, и на сегодняшний день мы возбуждаем исключения с помощью функции newExc() в виде объекта sh_ptr<Exception>:

exc/main.cpp
#include <ders/fd_file.hpp>
#include <vector>

using namespace std;
using namespace ders;

class MyException : public Exception {
 public:
    MyException(mem_pool* mp, FileLine loc, strsz msg) : Exception(mp, loc, msg) {}

    strsz className() const override { return strsz("MyException"); }
    void delete_this(mem_pool* mp) const override { delete_ptr(mp, this); }
};

void g(mem_pool *mp, int i)
{
    try {
        switch (i) {
        case 1: 
            throw newExc<MyException>(mp, mp, FLLN, "Hello from g()");
        case 2: {
            vector<int> v;
            v.at(0);
        }
        case 3: 
            throw 3;
        case 4: 
            throw newExc<ExitMsgException>(mp, mp, FLLN, "Goodbye from g()", i);
        }
    }
    catch (...) { throw newExc<Exception>(mp, FLLN, "Problems in g()", recatch(mp, FLLN)); }
}

void f(mem_pool *mp, int i)
{
    try { g(mp, i); }
    catch (...) { throw newExc<Exception>(mp, FLLN, "Problems in f()", recatch(mp, FLLN)); }
}

int main()
{
    mem_pool mpo, *mp=&mpo;
    fd_file out(mp, fd_out);

    for (int i=1; i<=4; i++) {
        try { f(mp, i); }
        catch (...) { 
            auto se=recatch(mp, FLLN, false);
            out.ex_write(tx_buf(mp)+"\tException #"+i+":\n"+toTextAll(get(se))+"\n");

            if (auto em=se->is<ExitMsgException>()) {
                out.ex_write(tx_buf(mp)+"\t"+em->message()+"\n");
                exit(em->exitCode());
            }
        }
    }
}

Результатом работы является:

	Exception #1:
ders::Exception [main.cpp:45], message: Problems in f()
ders::Exception [main.cpp:39], message: Problems in g()
MyException [main.cpp:28], message: Hello from g()
	Exception #2:
ders::Exception [main.cpp:45], message: Problems in f()
ders::Exception [main.cpp:39], message: Problems in g()
ders::StdException [main.cpp:39], type="St12out_of_range", message: vector::_M_range_check: __n (which is 0) >= this->size() (which is 0)
	Exception #3:
ders::Exception [main.cpp:45], message: Problems in f()
ders::Exception [main.cpp:39], message: Problems in g()
ders::ExternalException [main.cpp:39], message: Unknown exception
	Exception #4:
ders::ExitMsgException [main.cpp:36], exitCode=4, message: Goodbye from g()
	Goodbye from g()

Итак, в файле определены следующие классы исключений:


5.4. fd_file.hpp

Здесь определен класс fd_file -- тонкая обертка над file descriptor файлами.

Он реализует интерфейсы readable/writable и при посимвольном чтении/записи работает во много раз быстрее стандартных потоков fstream и FILE.

Обратите внимание, что для каждой операции name() в классе определена пара функций:

  1. name(... ,std::error_code* ec) -- возвращает ошибку в ec.
  2. ex_name(...) -- возбуждает FileException.

5.5. hash.hpp

Здесь определены классы mp_unordered_map и mp_unordered_set -- стандартные unordered контейнеры, использующие mp_allocator.

5.6. latch.hpp

Здесь определен класс latch -- простая задвижка для одновременного старта потоков (thread).

Используется, главным образом, для тестирования производительности.


5.7. mem_pool.hpp

Это самый базовый, основной файл библиотеки. Он определяет класс mem_pool -- кэширующий распределитель памяти (memory pool). Его особенностями являются:
  1. Для небольших объектов mem_pool выделяет память блоками и самостоятельно нарезает ее на кусочки требуемого размера. Выделенные блоки памяти освобождаются только в деструкторе ~mem_pool.
  2. При освобождении небольшого объекта память возвращается в кэш. Таким образом, повторное выделение памяти после освобождения отрабатывает очень быстро.
  3. Объекты большого размера напрямую выделяются/освобождаются с помощью operator new()/operator delete().
Здесь также определен класс mp_buf -- RAII объект для выделения и автоматического освобождения блока памяти заданного размера.

5.8. mp_allocator.hpp

Здесь определен класс mp_allocator -- STL аллокатор, использующий mem_pool.

Он работает в несколько раз быстрее стандартного.


5.9. mp_new.hpp

Файл, определяющий аналоги new/delete: функции mp_new() и mp_delete(). Они работают через mem_pool.

Функция mp_delete(mem_pool* mp, T* ptr) определяет размер объекта как sizeof(T). Если вы удаляете объект производного класса через указатель на один из его интерфейсов, то интерфейс должен быть унаследован от deletable. В этом случае будет вызвана виртуальная функция delete_this(), которая знает реальный размер.


5.10. perm_asrt.hpp

Здесь определен макрос perm_assert() -- постоянный assert(), не зависящий от NDEBUG.

5.11. ptr.hpp

Здесь определены умные указатели sh_ptr и un_ptr -- концептуальные аналоги std::shared_ptr и std::unique_ptr, работающие через mem_pool.

Их особенностями являются:

  1. Они в несколько раз быстрее!
  2. Их стандартные функции вроде get() и release() являются функциями-друзьями, а не функциями-членами.
    Как показала практика, путаница с вызовами ptr.pool() вместо ptr->pool() была обычным делом. Сейчас это невозможно, т.к. пул указателя возвращается с помощью выражения pool(ptr).
  3. Нет аналога std::weak_ptr.
Для удобства использования также определены функции make_sh() и make_un(). Это только удобство, никакого уменьшения количества аллокаций (как std::make_shared) они не дают.

И еще обратите внимание. В случае возникновения bad_alloc в конструкторе sh_ptr(mem_pool* mp, T* ptr=0), переданный объект будет потерян! Это сознательная оптимизация, т.к. пользователи могут использовать промежуточный un_ptr только там, где действительно нужно. А потом из него создавать sh_ptr.

По этой причине, make_sh() работает через make_un(), а не голый mp_new(). Тем самым, функция make_sh() не теряет свои объекты.


5.12. rdwr.hpp

Здесь определены классы buf_rd и buf_wr, работающие через интерфейсы readable и writable соответственно.

Они реализуют буферизованное посимвольное чтение/запись, позволяющее многократно ускорить работу с файлами.


5.13. text.hpp

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

Его главный класс, естественно, text -- пример реального класса без конструктора копирования и оператора присваивания. В общем, если вы хотели бы видеть "строку без копирования" -- это тот самый случай! Фактически, text -- это ядро строки, главный функционал. А полным функционалом является конструкция sh_ptr<text>, т.е. sh_text.

Концептуально,

Класс strsz обладает следующими особенностями:
  1. Фактически, это const char* плюс длина. Самый общий аналог строки для определения параметров функции.
  2. Как и const char*, он не владеет тем, на что указывает. Для владения нужно использовать text (завернутый в указатель).
  3. strsz не использует strlen() для определения длины "строковых литералов". Да, пришлось повозиться.
  4. Для strsz определены все операции поиска и сравнения.
Класс text обладает следующими особенностями:
  1. Он владеет своей строкой, самостоятельно выделяя/освобождая память посредством mem_pool.
  2. Обеспечивает изменение строки и/или ее длины.
  3. Определяет функцию str(), возвращающую strsz.
  4. Определяет функцию c_str(), возвращающую const char*.

5.14. tx_buf.hpp

Здесь определен класс tx_buf -- текстовый буфер для создания длинной строки из отдельных фрагментов.

Он также конвертирует в текст встроенные типы (int, void*...).


6. Заключение

Ну вот, вы все видели.

И не спешите подумать, мол, замахнулся тут на Святое! Не замахнулся, а дал коршня.

Попало и Страуструпу. Но так уж выходит, что я уже более двадцати лет нахожу в его книгах ошибки. А он у меня ни одной.

А если серьезно, то мы здесь не гуманитарии! Нам Факты дороже, чем Мнения. Любые.

И факт в том, что ссылки -- Большая Проблема! Но без них можно жить припеваючи.


Copyright © С. Деревяго, 2023

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