среда, 7 декабря 2016 г.

[prog.c++] Разбор asfikon's "C vs C++". Часть 2.

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

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

Если вы решаете задачу, где действительно очень важна скорость (определение см далее), вы все равно не сможете использовать C++. Вы, вероятно, сможете писать на так называемом «C с классами» или «C с шаблонами». Эти диалекты языка C, бесспорно, имеют право на жизнь. И если вы называете «языком C++» эти диалекты, то я, пожалуй, с вами даже соглашусь — для задачи надо брать «язык C++», срочно! Только нужно при этом быть очень уверенным, что через год вы не выйдите за рамки «C с шаблонами». Эта действительно большая проблема на практике и она более детально описана далее.

Однако большинство людей под С++ понимают так называемый «современный C++», со счетчиками ссылок, классами, исключениями, шаблонами, лямбдами, STL, Boost, и так далее. То есть, тот C++, на котором вы пишите, почти как на Java, в котором никогда не встречаются обычные указатели, и вот это все. Если вам очень важна скорость, то писать на таком C++ вы не сможете. Если же он вам подходит, то лучше взять Java, Go или любой другой высокоуровневый язык по вкусу. В них все те же возможности реализованы намного лучше. А узкие места при необходимости, которой, впрочем, может и не возникнуть, вы всегда сможете переписать на C.

Автор пытается убедить читателя в том, что если нужна скорость, то:

  • придется ограничится неким подмножеством C++, которое-то и C++ом назвать нельзя, и которое будет всего лишь неким диалектом языка C;
  • в C++ существует большое количество вещей, негативно сказывающихся на производительности. К таким вещам, в частности, относятся счетчики ссылок, классы, исключения, шаблоны, лямбды, STL, Boost. Ну и, до кучи, если в вашей C++ программе не встречаются обычные указатели, то это тоже звоночек о том, что производительности вам не видать.

Первый тезис, о том, что есть некий диалект языка C под названием "C с классами" или "C с шаблонами" был разобран в прошлом посте. Возвращаться к этой теме не будем. Однако, позволим себе маленькое удовольствие подчеркнуть странное противоречие в оригинальной статье: автор сперва говорит, что если ограничить себя "C с шаблонами", то со скоростью в C++ проблем не будет. А уже в следующем абзаце причисляет шаблоны к тем вещам, которые производительность убивает. Вероятно, автор во втором случае подразумевал под шаблонами что-то совсем другое, но вот что?

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

  1. Счетчики ссылок. Вероятно, ув.тов.asfikon говорит про использование std::shared_ptr (и его аналогов). Действительно, работа с умными указателями, использующими подсчет ссылок, обходится не бесплатно. Причем у std::shared_ptr накладные расходы складываются из двух составляющих.

    Во-первых, это те самые инкременты/декременты счетчика, которые происходят при копировании умных указателей. Самое смешное, что непосредственно эти накладные расходы крайне невелики в большинстве случаев (если только у вас не происходит пинг-понг объекта std::shared_ptr между десятком-другим рабочих нитей). Особенно в C++11, где move semantic позволяет избежать копирования во многих случаях (добавляем сюда же возможности компиляторов по RVO и NRVO, которые в C++17 станут обязательными).

    Во-вторых, это размещение в памяти самого счетчика ссылок. std::shared_ptr<T> -- это неинтрузивный умный указатель. Неинтрузивность означает, что внутри самого объекта типа T нет счетчика ссылок, которым бы мог пользоваться std::shared_ptr. Поэтому, когда программист пишет в коде std::shared_ptr<T> obj(new T()), то в действительности происходит не одна аллокация памяти, а две: одна для самого объекта T, вторая для счетчика ссылок. Соответственно, при уничтожении объекта происходит две деаллокации. А это уже дорого, т.к. new/delete дорогие операции, особенно в многопоточных приложениях.

    Так что использование неинтрузивных умных указателей (в частности, std::shared_ptr) без понимания принципов их работы, действительно, способно отрицательно сказаться на скорости работы приложения.

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

    • кроме неинтрузивных умных указателей есть еще и интрузивные умные указатели (например, boost::intrusive_ptr), для использования которых требуется, чтобы в объекте типа T уже находился счетчик ссылок. В этом случае дополнительных аллокаций не происходит и при работе с таким умным указателем существуют накладные расходы только на операции инкремента/декремента счетчика. Более того, даже для std::shared_ptr есть вспомогательная функция std::make_shared, которая хороша тем, что позволяет разместить и объект T, и счетчик ссылок для него в одном блоке памяти (т.е. используется всего одна операция new при создании std::shared_ptr и одна операция delete при разрушении). Поэтому я бы сказал, что на практике проблема не в самих умных указателях с подсчетом ссылок, а в их чрезмерном и/или бездумном использовании. Это как с сильнодействующим лекарством: проблема не в лекарстве, а в дозировках и правильности приема;
    • зачем вообще используются умные указатели? Лично я сталкивался с двумя основными сценариями использования умных указателей. Первый сценарий практически всегда встречается в коде людей, перешедших в C++ из языков со сборкой мусора. Ну вот привык человек, что в Java он может написать return new SomeType(), а в C++ это прямой путь к утечкам памяти. И что с этим делать? Нужно либо продумывать политики владения и задумываться о контроле времени жизни. Что непривычно и сложно. Или же задействовать умные указатели, как советуют опытные разработчики. Только вот std::unique_ptr -- это какая-то непонятная хрень, не позволяющая разделять указатель между несколькими "владельцами". А вот std::shared_ptr -- это же практически то, что есть в Java. Значит берем std::shared_ptr и не паримся.

      В таком сценарии проблемы с производительностью просто неизбежны. Только не std::shared_ptr будет тому виной. А неприятие того факта, что в C++ нужно заботиться не только о том, сколько живет объект, но и о том, где он живет -- на стеке или в хипе. В Java разработчик просто пишет new SomeType(); и ни о чем больше не думает. А в C++ есть очень большая разница между SomeType a; и SomeType * a = new SomeType(). И если человек этой разницы не понимает, то он будет создавать объекты в хипе там, где можно было создавать их на стеке. И возвращать указатели на динамически созданные объекты вместо возврата по значению (с полным игнорированием таких вещей, как RVO/NRVO и move semantic). Кстати говоря, решение о том, что использовать -- стек или хип -- придется применять и в C. Поэтому если вы опытный Java или C# разработчик, но вынуждены заняться программированием на чистом C, то с большой долей вероятности в начале своей работы на C вы так же будете злоупотреблять чрезмерным использованием malloc-ов вместо работы со стеком.

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

      Скажем, если мы получаем новый HTTP-запрос, то мы можем создать арену памяти (или группу арен памяти), в которой будут размещаться объекты, необходимые нам для обработки запроса. А затем удалить эту арену после того, как отсылаем ответ на запрос и закрываем подключение клиента. Но далеко не все задачи ложатся на такую схему. Мы можем работать, например, над текстовым процессором, электронными таблицами или CAD-системой. И в процессе редактирования документа у нас может оказаться сложная структура данных в памяти, в которой какие-то объекты (например, текстовые абзацы) ссылаются на какие-то общие данные (например, на набор свойств абзаца текста -- шрифт, выравнивание, отступы и пр.). Т.к. редактирование объекта может длиться часами, в течении которых в документ вноситься множество правок, мы не можем заранее выделить какую-то арену, в которой уместятся все необходимые нам объекты. Скорее всего работа с хипом будет весьма активной. И подсчет ссылок, не смотря на все свои накладные расходы, является одним из лучших способов борьбы со сложностью задачи.

      Еще один важный аспект этого второго сценария -- необходимость вызова деструкторов части объектов. Например, объект может владеть тем или иным ресурсом (файлом, пайпом, сокетом, объектом графической подсистемы Windows и т.д.). Поэтому нам необходимо детерминировано освобождать этот ресурс именно тогда, когда в нем никто не нуждается. Подсчет ссылок так же упрощает решение этой задачи (нормальный GC с финализаторами объектов здесь не очень хорош, т.к. финализаторы могут быть вызваны далеко не сразу).

      Т.е., если у разработчика нормальное знание C++ и перед ним стоит сложная задача по учету времени жизни разделяемых объектов, то умные указатели, не смотря на все свои накладные расходы, дают разработчику возможность решения этой задачи с приемлемыми трудозатратами. Попытка отказаться от подсчета ссылок в пользу каких-то других механизмов ради скорости, скорее всего, приведет к серьезному усложнению решения. А вот приведет ли к выигрышу в этой самой скорости... Не факт.

    Ну и отдельно хотелось бы подчеркнуть такой маленький пикантный момент: там, где действительно скорость нужна настолько, что считается каждый такт, не используют динамическую память. Либо вообще, либо же во время проведения ресурсоемких вычислений и критичных ко скорости операций. Т.е. если в таких задачах динамическая память нужна, то она преллоцируется заранее, а затем никаких new/delete нет в принципе. Соответственно, нет никаких операций со счетчиками ссылок. А уж в плане стоимости обращения к объекту через голый указатель или через std::shared_ptr, нет вообще никаких различий. Но об этом пикантном моменте мы подробнее поговорим чуть ниже, когда дойдем до совершенно феерического примера с работой тормозов в автомобиле.

  2. Классы. На мой взгляд, классы в C++ используются, как минимум для трех вещей.

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

      В качестве примера возьмем простейшую реализацию стека для указателей на void (пример тупой, но сама тема обязывает). В коде нет обработки ошибок, для того, чтобы не увеличивать его объем:

      class void_ptr_stack {
      public :
         using void_ptr = void *;

         void_ptr_stack(size_t capacity)
            :  data_(new void_ptr[capacity])
            ,  capacity_(capacity)
            {}
         ~void_ptr_stack() {
            delete[] data_;
         }

         void_ptr_stack(const void_ptr_stack &) = delete;
         void_ptr_stack(void_ptr_stack &&) = delete;

         void push(void_ptr v) {
            data_[size_] = v;
            ++size_;
         }

         void_ptr top() const {
            return data_[size_ - 1];
         }

         void pop() {
            --size_;
         }

         size_t size() const {
            return size_;
         }

      private :
         void_ptr * data_;
         const size_t capacity_;
         size_t size_{};
      };

      Здесь, не смотря на то, что пользователь класса void_ptr_stack видит все потроха класса, получить просто так доступ к ним, без использования грязных хаков, он не может. Не может, например, создать экземпляр void_ptr_stack без указания его емкости. Не может просто так поменять значения его полей capacity_ или size_. Не может просто так копировать объекты void_ptr_stack. Т.е. пользователь класса вынужден пользоваться только методами из публичного интерфейса, а за все остальное компилятор будет бить по рукам.

      В C за полями структуры такого контроля нет. Если структура видна пользователю, то ничего, кроме здравого смысла разработчика, не запрещает прямого доступа к содержимому экземпляра структуры. Практика показывает, что здравому смыслу разработчиков доверять особо не стоит. Именно из-за этого в 70-80-х годах прошлого века и получило развитие структурное и модульное программирование, давшее точек повсеместному сокрытию данных.

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

      Так что подобное использование классов в C++ не привносит никаких дополнительных накладных расходов по сравнению с использованию структур в C.

      Более того, если внести в рассмотрение контроль за корректностью данных (т.е. обратиться к defensive programming), то для С ситуация ухудшается. Допустим, у нас есть некий API, который требует использование аргументов, удовлетворяющих некоторым условиям. Ну, скажем, указатели туда должны передаваться ненулевые, аргумент hour должен быть в диапазоне [0..23], а аргумент minute -- в диапазоне [0..59].

      Что мы будем иметь в чистом C? Что-то вот такое в реализации:

      typedef struct wallclock_alarm {/*bla-bla-bla*/} wallclock_alarm_t;

      wallclock_alarm_t * alarm_make() {...}

      int alarm_define(wallclock_alarm_t * what, int hour, int minute) {
         if(!what) return E_NULL_ALARM_OBJECT;
         if(!(hour >= 0 && hour <= 23)) return E_INVALID_HOUR;
         if(!(minute >= 0 && minute <= 59)) return E_INVALID_MINUTE;
         ...
      }

      int alarm_suspend(wallclock_alarm_t * what) {
         if(!what) return E_NULL_ALARM_OBJECT;
         ...
      }

      int alarm_resume(wallclock_alarm_t * what) {
         if(!what) return E_NULL_ALARM_OBJECT;
         ...
      }

      int alarm_redefine(wallclock_alarm_t * what, int hour, int minute) {
         if(!what) return E_NULL_ALARM_OBJECT;
         if(!(hour >= 0 && hour <= 23)) return E_INVALID_HOUR;
         if(!(minute >= 0 && minute <= 59)) return E_INVALID_MINUTE;
         ...
      }

      И что-то вот такое в использовании:

      wallclock_alarm_t a = alarm_make();
      alarm_define(a, 715);
      if(not_sure_yet()) {
         alarm_suspend(a);
         think_more();
         alarm_resume();
      }
      think_yet_more();
      if(is_it_too_late()) {
         alarm_redefine(a, 725);
      }

      Мы получим проверки при каждом обращении к API-шной функции. Даже для тех значений, которые уже были проверенны. Тогда как в C++ у нас есть возможности избежать лишних проверок. Как раз за счет классов, в которых мы можем контролировать инварианты.

      Можно, например, сделать так:

      class wallclock_alarm {
      public :
         wallclock_alarm() { /* bla-bla-bla */ }

         void define(int hour, int minute) { // Не нужно проверять this на null.
            if(!(hour >= 0 && hour <= 23)) ...;
            if(!(minute >= 0 && minute <= 59)) ...;
            ...
         }

         void suspend() { // Не нужно проверять this на null.
            ...
         }

         void resume() { // Нe нужно проверять this на null.
            ...
         }

         void redefine(int hour, int minute) { // Не нужно проверять this на null.
            if(!(hour >= 0 && hour <= 23)) ...;
            if(!(minute >= 0 && minute <= 59)) ...;
            ...
         }
      ...
      }

      auto a = std::make_unique<wallclock_alarm>();
      a->define(715);
      if(not_sure_yet()) {
         a->suspend();
         think_more();
         a->resume();
      }
      think_yet_more();
      if(is_it_too_late()) {
         a->redefine(725);
      }

      И актуальных проверок, которые выполняются в коде, уже поубавится.

      А можно пойти еще дальше, и совместить использование C++ных классов с проверкой инвариантов с чисто С-шным подходом к организации API (ну, специально для тех, кто не приемлет ООП):

      template<typename T>
      class not_null {
         T v_;
      public :
         not_null(T v) {
            if(!v) throw invalid_argument(...);
            v_ = v;
         }

         T get() const { return v_; }
      };

      template<int L, int R>
      class bounded_int {
         int v_;
      public :
         bounded_int(int v) {
            if(!(v >= L && v <= R)) throw invalid_argument();
            v_ = v;
         }

         int get() const { return v_; }
      };

      using hour = bounded_int<023>;
      using minute = bounded_int<059>;

      struct wallclock_alarm { /*bla-bla-bla*/ };

      not_null<wallclock_alarm *> alarm_make() {...}

      int alarm_define(not_null<wallcloak_alarm *> what, hour h, minute m) {
         // Никаких проверок больше не нужно.
         ...
      }

      int alarm_suspend(not_null<wallclock_alarm *> what) {
         // Никаких проверок больше не нужно.
         ...
      }

      int alarm_resume(not_null<wallclock_alarm *> what) {
         // Никаких проверок больше не нужно.
         ...
      }

      int alarm_redefine(not_null<wallclock_alarm *> what, hour h, minute m) {
         // Никаких проверок больше не нужно.
         ...
      }

      auto a = alarm_make();
      alarm_define(a, 715);
      if(not_sure_yet()) {
         alarm_suspend(a);
         think_more();
         alarm_resume();
      }
      think_yet_more();
      if(is_it_too_late()) {
         alarm_redefine(a, 725);
      }

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

      Конечно же, в последних примерах экономия будет копеечная. Ну что такое пара изъятых из кода if-ов? Слезы. Однако, если C++у пеняют за потерю производительности на виртуальных вызовах, то почему бы и эти пару копеек не посчитать ;)

    • Во-вторых, для реализации идиомы RAII. Характерный пример -- это std::unique_ptr или std::lock_guard из стандартной библиотеки современного C++.

      В этом случае программист использует то, что у C++ных классов могут быть деструкторы, которые обязательно вызываются при разрушении объекта. И этот факт используется для того, чтобы очистить захваченные ранее ресурсы. Ну там память освободить. Отпустить захваченный мьютекс. Закрыть открытый файл. И т.д.

      Т.е. в случае использования классов для RAII мы в деструкторах все равно выполняем все те же самые операции, которые вынуждены были бы выполнять программируя на чистом С. Так что совершенно непонятно, откуда здесь взяться дополнительным накладным расходам, особенно при использовании современных оптимизирующих компиляторов, которые для вызова деструкторов при выходе из функции смогут построить более оптимальный код, чем для вручную написанной портянки кода в стиле goto cleanup. Кстати, на счет портянок. В С-шном коде зачастую встречается что-то вроде:

      int f() {
         int h1 = 0, h2 = 0, h3 = 0;
         int r = -1;
         if(-1 == (h1 = try_acquire_resource1()))
            goto cleanup;
         if(-1 == (h2 = try_acquire_resource2()))
            goto cleanup;
         if(-1 == (h3 = try_acquire_resource3()))
            goto cleanup;
         ...
         r = 0;
      cleanup:
         if(h3)
            free_resource3(h3);
         if(h2)
            free_resource2(h2);
         if(h1)
            free_resource1(h1);
         return r;
      }

      Если кто-то думает, что программирование в подобном стиле ведет к оптимальному коду, то у меня для вас плохие новости :) Все эти первоначальные значения для дескрипторов ресурсов и проверки для них в секции cleanup -- это же не бесплатно. Это как раз те накладные расходы, от которых избавляет RAII. Так что написанный с использованием RAII код легко окажется не только проще в понимании и сопровождении, но еще и тупо эффективнее.

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

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

      Но главное, все-таки, не в этом. А в том, что если в задаче появляется необходимость в динамическом полиморфизме, то от него вы никуда не денетесь. Вот, скажем, для std::vector-а или std::deque не нужен динамический полиморфизм. Поэтому там виртуальных методов нет. А для basic_streambuf динамический полиморфизм нужен, поэтому там виртуальные методы есть. И попытка сделать подобное решение с универсальными буферами ввода-вывода для разных типов каналов в чистом C все равно приведет к изобретению того же самого динамического полиморфизма, только на структурах и на указателях на функции. Кто не верит, тот пусть посмотрит, как в OpenSSL это реализуется посредством BIO_* функций (например, можно посмотреть на этот заголовочный файл, а затем на детали реализации некоторых функций BIO_*): тот же самый ООП с динамическим полиморфизмом и косвенными вызовами. Только слепленный вручную.

      Так что если для задачи динамический полиморфизм нужен, то он и в C будет. С такими же, если не большими, накладными расходами. Другое дело, что некоторые C++разработчики, пришедшие в C++ из языков, где все методы являются виртуальными по-умолчанию, могут принести с собой свои старые привычки. И начать лепить virtual направо и налево. Тут да, тут накладные расходы будут выше.

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

    Так что небольшое резюме по поводу влияния классов на производительность. Об отрицательном влиянии можно говорить, пожалуй, лишь когда классы используются для динамического полиморфизма. Но и тут нужно брать не абсолютные показатели -- мол, в C++ с классами на задаче X у вас будут вот такие вот накладные расходы из-за виртуальных вызовов. Тут нужно сравнивать с решением этой же задачи средствами C. С большой долей вероятности, в C-шном решении будут присутствовать те же самые косвенные вызовы. И, как следствие, производительность принципиально отличаться не будет.

Пожалуй, на этом вторую часть следует закончить. А оставшиеся пункты (т.е. исключения, шаблоны, лямбды, и Boost с STL) будем разбирать в последующих частях.

Продолжение...

Комментариев нет: