пятница, 16 декабря 2016 г.

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

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

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

Итак, приступим.

Сперва разберем пару пунктов, в которых ув.тов.asfikon позволяет себе использование оборота "где-то это все сделано лучше" (выделение курсивом из оригинального поста):

То есть, тот C++, на котором вы пишите, почти как на Java, в котором никогда не встречаются обычные указатели, и вот это все. Если вам очень важна скорость, то писать на таком C++ вы не сможете. Если же он вам подходит, то лучше взять Java, Go или любой другой высокоуровневый язык по вкусу. В них все те же возможности реализованы намного лучше.
Серьезно, возьмите лучше Go, Java, или любой другой язык по вкусу. Сборка мусора там сделана намного лучше (напомню, что счетчики ссылок плохо работают для большого количества быстро умирающих объектов), прочее управление ресурсами, управление зависимостями, инструменты разработки (IDE и прочие), генерики, лямбды, неймспейсы, исключения, автоматический вывод типов, классы и интерфейсы там сделаны намного лучше.

Лучше бы, конечно, автор исходного набора мифов сравнивал мягкое с твердым, а теплое с холодным. A то получается, как в анекдоте про то, что армяне лучше, чем грузины. Лучше чем? Чем грузины.

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

А вот что касается остальных пунктов, как то: сборка мусора, прочее управление ресурсами, генерики, лямбды, неймспейсы, вывод типов, классы и интерфейсы... Для того, чтобы их можно было воспринимать всерьез, надо бы определить целевую функцию и какие-то объективные критерии. А то ведь несложно сказать, что генерики в Java лучше, чем шаблоны в C++. Но что дальше? Если для решения задачи вам нужен нативный язык без сборки мусора (т.е. C, C++, Ada или Rust), то какой вам толк от того, что, допустим, в Java генерики лучше, чем C++ные шаблоны? Аналогично и по остальным пунктам. Хотя пассаж "Сборка мусора там сделана намного лучше" применительно к языку, в котором сборки мусора нет, сам по себе заставляет задуматься о том, что весь список следует пустить в /dev/null.

Идем дальше:

Позвольте пояснить, что я имею ввиду под задачами, где очень важна скорость. Вы едете на машине. Вдруг в нескольких метрах впереди выбегает человек. На принятие решения водителю в среднем требуется около одной секунды. Нога мелено перемещается с педали газа на педаль тормоза. Затем медленно вдавливает тормоз в пол. Расстояние между автомобилем и человеком в это время сокращается. Наконец, сигнал от педали тормоза летит в бортовой компьютер автомобиля. И вот тут ни в коем случае программа не может сказать «о, счетчик ссылок обнулился, пойду-ка я собирать мусор по всему дереву» или даже «секундочку, я только схожу в vtable… как, ее нет в L1? ой…». Программа должна обработать сигнал как можно быстрее, тут же ударив по тормозным дискам. Ни о каких смартпоинтерах и прочих видах автоматического управления памятью, ровно как и о развесистых иерархиях классов, в таких задачах и речи быть не может.

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

Проблема задач реального времени вовсе не в скорости работы кода. А в предсказуемости и гарантиях укладывания этого самого времени в заранее определенные рамки. Для простоты: в систему реального времени приходит какой-то внешний сигнал, реакция на этот сигнал должна быть получена не более чем за N миллисекунд (или микросекунд, или секунд). Не суть важен размер N. Важно то, что в N нужно уложиться. Если не уложился, то либо полный писец (в случае жесткого реального времени), либо частичный (в случае мягкого реального времени). Так что дело не в скорости, а в гарантиях.

Пример с автомобильными тормозами выглядит еще более дебильным из-за того, что подобные системы реального времени, как правило, работают с заданным темпом. Ну, скажем, с частотой 100Hz. Или 200Hz.

Частота в 200Hz означает, что все операции, которые требуется системе выполнить на очередном такте, должны уложиться в 5ms. А 5ms для современной вычислительной техники, работающей на гигагерцовых частотах, это просто вечность. О какой стоимости промаха мимо кэша можно здесь говорить? Даже когда компьютеры работали на частотах в десятки мегагерц на тактах длиной в 5ms никто не заморачивался промахами мимо кэша при обращении к vtable. Тогда хватало других проблем. Да и сейчас хватает.

Вот в чем ув.тов.asfikon прав, так это в том, что когда требуются гарантии по временам работы определенных участков кода, тогда динамическая память не используется совсем. Причем не используется она не только в C++. Но и в C. И в Ada. И в других языках, которые применяли или пытаются применять в задачах реального времени.

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

А на самом деле это полный бред, над которым любой, кто имеет представление о реальном времени, просто поржет и не станет читать дальше. Не верите мне, тогда просто погуглите о том, что такое hard real-time.

Но давайте посмотрим на еще один фрагмент, касающийся скорости:

Из менее драматичных примеров можно привести любую систему, где не работает правило «90% времени выполняется 10% кода, эти 10% и будем оптимизировать». Если код, который выполняется всего лишь 10% времени, ускорить на 1/20, суммарная производительность вырастит на жалкие 0.5%. Но в масштабах компании вроде Google или широко используемого приложения вроде PostgreSQL или Nginx эти 0.5% ускорения могут означать миллионы долларов экономии.

Тут, видимо, автор попытался поговорить о том, в чем хоть что-то понимает. Но не все так просто. Дело в том, что тот же Google для решения задач, от которых требуется производительность, давно и успешно применяет C++. Как и Facebook, например. Как и конкуренты того же PostgreSQL (Oracle, MSSQL Server, MySQL/MariaDB -- это если перечислять широко известные РСУБД). Так что убедительно выглядит разве что Nginx. Но тут, видимо, просто специфика предметной области сказалась. В общем, если глянуть на задачи, в которых скорость исполнения важна, то там и C, и C++ находят себе применение более чем успешно.

Смотрим дальше:

Я могу привести еще много примеров такого рода. Но идея, надеюсь, ясна. Если решаемая вами задача такова, что написать 90% кода на высокоуровневом языке и 10% на C никак нельзя, то ни на каком «C++, который почти как Java, только компилируемый в машинный код» вы писать не сможете.

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

А вот то, что там где нужна скорость, то на C++ писать как на Java не получится, вот это почти правда. Почти потому, что на C++ вообще не нужно писать как на Java. И как на C# не нужно писать. И как на Ruby не нужно. И как на Haskell не нужно. В C++ есть свои подходы к разработке (коих множество, на самом деле), которые ведут и к надежному, и к быстрому коду. А попытка писать на C++, как на Java, ни к чему хорошему не приведет.

Идем дальше:

Согласно книге The Design and Evolution of C++, язык C++ был создан Страустропом, как «C для крупных проектов». Однако практика показывает, что на C вполне успешно разрабатываются очень даже крупные проекты. А от многих возможностей языка C++ отказываются даже в проектах, которые не являются такими уж крупными. Потому что возможности эти зачастую не упрощают разработку, а лишь усложняют ее. То есть, C++ не только плохо решает изначальную проблему, но и усложняет ее решение, да и проблемы то, оказывается, не было вовсе.

Признаю, что книгу "Дизайн и эволюция языка C++" я читал давно, поэтому не помню, говорилось ли там о "С для крупных проектов". Мне, почему-то, всегда вспоминаются слова Страуструпа о том, что целью C++ было сделать Simula, которая бы работала со скоростью C. Что же до размеров проектов, что наличие крупных проектов на языке X вовсе не означает, что язык Y для этого не предназначен. Так, наличие больших проектов на C вовсе не является доказательством того, что C++ с этой задачей справляется хуже. Кроме того, если поинтересоваться, когда же стартовали очень крупные проекты на C, да хоть тот же PostgreSQL, то окажется, что C++ тогда либо еще не вышел в свет, либо еще не обзавелся достаточным арсеналом. Например, те же пространства имен, которые в C++ и были добавлены как раз для упрощения разработки крупных проектов, появились в C++ гораздо позже.

Тема с отказом от возможностей C++ вообще очень спекулятивная. Есть как объективные причины (например, запрет исключений в задачах реального времени), так и субъективные. Бывают случаи, когда те же шаблоны и исключения не используются просто по историческим причинам, из-за того, что на старте проекта их поддержка в компиляторах оставляла желать лучшего (привет, Google!). Или какие-то команды отказываются от шаблонов из-за "сложности" этих самых шаблонов. Или же правила диктуют люди, которые перешли в C++ из Java... Что, в прочем, не отменяет факта наличия больших проектов на C++, измеряемых миллионами строк кода. С учетом того, что аналогичный функционал на C требовал бы в 2-3 раза больше кода, такие проекты, будь они реализованы на C, требовали бы совсем других капиталовложений.

Вообще, когда я слышу о том, что C лучше подходит для разработки больших проектов, а в качестве примеров-доказательств приводятся большие Open-Source проекты, вроде ядра Linux-а, PostgreSQL или GIMP-а, то мне очень хочется, спросить, доводилось ли говорящему брать на себя ответственность за выполнение большого коммерческого проекта на C? С жестким бюджетом, дедлайнами, обычными разработчиками, которые работают просто за зарплату, с нормальными приемо-сдаточными испытаниями, с гарантией на разработанное ПО?

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

Ну да ладно, пойдем дальше:

Лямбды и прочие ништяки не всегда получается использовать на работе, так как о C++11 там только мечтают. К сожалению, многие реальные проекты на C++ в наше время — это страшный легаси с C++98, самописным STL, форкнутым Boost, Visual Studio 6 и CVS. Может быть, я тут немного и преувеличиваю, но идея, надеюсь, ясна.

Автор преувеличивает. Полная поддержка C++11, конечно же, пока еще доступна не всем. Но и Visual Studio 6 -- это уже какие-то совсем древние копролиты. Там, где до сих пор живет VC++ 6.0, там еще и на C89 могли не успеть перейти. И придется разработчику в такой конторе сидеть на православном K&R, убеждая себя в том, что с поддержкой C++ вокруг все гораздо хуже.

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

Идем дальше. Тут интереснее, тут про сложность.

Сложность кода. Если вы видели код на C++, реальный, а не из учебника, то знаете, что он часто он оказывается действительно очень непрост для восприятия. Из недавних примеров мне вспоминается GLM. Так выглядит его исходный код (только один из файлов, их там еще очень много), а так выглядит код на Си, который делает вообще все, что мне было нужно на самом деле.

Прошу читателей не полениться и заглянуть по ссылкам в оба исходника. Во-первых, сравнить код универсальной библиотеки с заточенным под конкретную частную задачу кодом -- это мощно. Это говорит об уровне аргументации. Во-вторых, претензии по поводу сложности восприятия C++ного кода выглядят странно. Для чтения исходников на конкретном языке нужно иметь достаточный уровень владения этим языком. Да, если ты с шаблонами на "вы", то понять будет сложно. Хотя, объективно, приведенный пример как раз таки вполне себе простой и понятный. Реально сложных шаблонных наворотов можно без проблем найти в том же Boost-е. Так что, да, C++ более сложный язык, чем C. Он таким и создавался. Поэтому и требования к разрабочику C++ предъявляет более строгие. Только это плата за мощность, которая станет доступной после соответствующего обучения. Ну, а если учить язык всерьез лень, то можно обойтись более простым подмножеством. И написать тот же самый почти-что-C-шный код. Только без макросов. И с меньшим количеством копипасты.

Но дальше про сложность C++ и простоту еще интереснее:

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

Давайте заглянем в эту статью и посмотрим соответствующие примеры. Вот простой код без chrono:

#ifdef _WIN32

#include <windows.h>

uint64_t
getCurrentTimeMs()
{
  FILETIME filetime;
  GetSystemTimeAsFileTime(&filetime);

  uint64_t nowWindows = (uint64_t)filetime.dwLowDateTime
    + ((uint64_t)(filetime.dwHighDateTime) << 32ULL);

  uint64_t nowUnix = nowWindows - 116444736000000000ULL;
  return nowUnix / 10000ULL;
}

#else // Linux, MacOS, etc

#include <sys/time.h>

uint64_t
getCurrentTimeMs()
{
  struct timeval tv;
  gettimeofday(&tv, NULL);

  return ((uint64_t)tv.tv_sec) * 1000 + ((uint64_t)tv.tv_usec) / 1000;
}

#endif

Автор утверждает, что это лучше, чем вот это:

#include <chrono>

auto startTime = std::chrono::high_resolution_clock::now();
// ...
auto currentTime = std::chrono::high_resolution_clock::now();
float startDeltaTimeMs = std::chrono::duration_cast<
  std::chrono::milliseconds>(currentTime - startTime).count();

31 строка против 7. Написать в 4 раза больше кода на чистом C лучше. Ну OK.

Не OK то, что тут есть явная подтасовка. Во-первых, C++ный код с chrono не является аналогом C-шного. Т.к. C++ный код используется для замера времени выполнения какого-то куска кода, тогда как C-шный код позволяет получить только текущее Unix-время в миллисекундах. Соответственно, на C++ тоже самое можно было бы сделать хотя бы вот так:

uint64_t
getCurrentTimeMs() 
{
   using namespace std::chrono;
   return duration_cast< milliseconds >(
         system_clock::now().time_since_epoch() ).count();
}

Ну, если хотелось делать засечки времени, но не хотелось набирать слишком длинные названия, то что мешало сделать хотя бы вот так:

#include <chrono>

using hrclock = std::chrono::high_resolution_clock;
using std::chrono::duration_cast;
using std::chrono::milliseconds;
// ...
auto startTime = hrclock::now();
// ...
auto currentTime = hrclock::now();
float startDeltaTimeMs = duration_cast<milliseconds>(currentTime - startTime).count();

Полагаю, причины, как минимум, две. Нежелание учить матчасть. И желание набросить на вентилятор.

Впрочем, есть еще одно объяснение. Ув.тов.asfikon не боится трудностей и не ищет легкий путей. Иначе сложно объяснить еще один пример из заметки, на которую ув.тов.asfikon ссылался:

static void
calculateStatusLineBufferData(const char* text, GLuint fontVBO)
{
  unsigned int pos = 0;
  char c;
  for(;;)
  {
    c = text[pos];
    if(c == '\0')
      break;

    float uLeft = fontTextureCoordULeft(c);
    float uRight = fontTextureCoordURight(c);
    float vTop = fontTextureCoordVTop(c);
    float vBottom = fontTextureCoordVBottom(c);
    float x1 = (FONT_RENDER_SIZE/2)*pos;
    float x2 = (FONT_RENDER_SIZE/2)*pos + (FONT_RENDER_SIZE/2);

    // Triangle 1: 3 * X, Y, U, V

    globStatusLineBufferData[pos*6*4 + 0*4 + 0] = x1;
    globStatusLineBufferData[pos*6*4 + 0*4 + 1] = 0.0f;
    globStatusLineBufferData[pos*6*4 + 0*4 + 2] = uLeft;
    globStatusLineBufferData[pos*6*4 + 0*4 + 3] = vBottom;

    globStatusLineBufferData[pos*6*4 + 1*4 + 0] = x2;
    globStatusLineBufferData[pos*6*4 + 1*4 + 1] = 0.0f;
    globStatusLineBufferData[pos*6*4 + 1*4 + 2] = uRight;
    globStatusLineBufferData[pos*6*4 + 1*4 + 3] = vBottom;

    globStatusLineBufferData[pos*6*4 + 2*4 + 0] = x2;
    globStatusLineBufferData[pos*6*4 + 2*4 + 1] = FONT_RENDER_SIZE;
    globStatusLineBufferData[pos*6*4 + 2*4 + 2] = uRight;
    globStatusLineBufferData[pos*6*4 + 2*4 + 3] = vTop;

    // Triangle 2: 3 * X, Y, U, V

    globStatusLineBufferData[pos*6*4 + 3*4 + 0] = x2;
    globStatusLineBufferData[pos*6*4 + 3*4 + 1] = FONT_RENDER_SIZE;
    globStatusLineBufferData[pos*6*4 + 3*4 + 2] = uRight;
    globStatusLineBufferData[pos*6*4 + 3*4 + 3] = vTop;

    globStatusLineBufferData[pos*6*4 + 4*4 + 0] = x1;
    globStatusLineBufferData[pos*6*4 + 4*4 + 1] = FONT_RENDER_SIZE;
    globStatusLineBufferData[pos*6*4 + 4*4 + 2] = uLeft;
    globStatusLineBufferData[pos*6*4 + 4*4 + 3] = vTop;

    globStatusLineBufferData[pos*6*4 + 5*4 + 0] = x1;
    globStatusLineBufferData[pos*6*4 + 5*4 + 1] = 0.0f;
    globStatusLineBufferData[pos*6*4 + 5*4 + 2] = uLeft;
    globStatusLineBufferData[pos*6*4 + 5*4 + 3] = vBottom;

    pos++;
  }

  globStatusLineVerticesNumber = pos*6;

  glBindBuffer(GL_ARRAY_BUFFER, fontVBO);
  glBufferData(GL_ARRAY_BUFFER, sizeof(GLfloat)*6*4*pos,
    globStatusLineBufferData, GL_DYNAMIC_DRAW);
}

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

А вот интересно, только меня смущает объем копипасты в приведенном фрагменте?

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

static void
calculateStatusLineBufferData(const char* text, GLuint fontVBO)
{
  unsigned int pos = 0;
  for(;;)
  {
    const char c = text[pos];
    if(c == '\0')
      break;

    const float uLeft = fontTextureCoordULeft(c);
    const float uRight = fontTextureCoordURight(c);
    const float vTop = fontTextureCoordVTop(c);
    const float vBottom = fontTextureCoordVBottom(c);
    const float x1 = (FONT_RENDER_SIZE/2)*pos;
    const float x2 = (FONT_RENDER_SIZE/2)*pos + (FONT_RENDER_SIZE/2);

    const auto fill = [&](auto index, auto a, auto b, auto c, auto d) {
      const auto start_offset = pos*6*4 + index*4;
      globStatusLineBufferData[start_offset + 0] = a;
      globStatusLineBufferData[start_offset + 1] = b;
      globStatusLineBufferData[start_offset + 2] = c;
      globStatusLineBufferData[start_offset + 3] = d;
    };

    // Triangle 1: 3 * X, Y, U, V

    fill(0, x1, 0.0f, uLeft, vBottom);

    fill(1, x2, 0.0f, uRight, vBottom);

    fill(2, x2, FONT_RENDER_SIZE, uRight, vTop);

    // Triangle 2: 3 * X, Y, U, V

    fill(3, x2, FONT_RENDER_SIZE, uRight, vTop);

    fill(4, x1, FONT_RENDER_SIZE, uLeft, vTop);

    fill(5, x1, 0.0f, uLeft, vBottom);

    pos++;
  }

  globStatusLineVerticesNumber = pos*6;

  glBindBuffer(GL_ARRAY_BUFFER, fontVBO);
  glBufferData(GL_ARRAY_BUFFER, sizeof(GLfloat)*6*4*pos,
    globStatusLineBufferData, GL_DYNAMIC_DRAW);
}

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

Следующая цитата, имхо, так же связана со сложностью языка:

Пример со стримами хорошо иллюстрирует, что C++ — словно вирус. Все начинается с использования какой-то одной маленькой его возможности, и через какое-то время весь проект кишит лямбдами, шаблонами и вот этим всем, и в этом уже никто не может нормально разобраться. Судите сами. Допустим, вы решили использовать классы. Но вот проблема — все private поля объявляются в .hpp файле и при изменении приводят к перекомпиляции половины проекта (в C такой проблемы с инкапсуляцией нет совсем). И вот в дополнение к простому и понятному классу вам уже приходится использовать не такую уж понятную и простую в реализации идиому pImpl (или фабричный метод, что еще менее производительно). А затем еще правильно перегрузить оператор присваивания, реализовать конструктор перемещения, и чтобы при этом все это добро правильно работало с STL… Мы всего лишь хотели классов, помните? Аналогично вы не можете использовать исключения, не обернув все в умные указатели, которые, напомню, являются классами и используют шаблоны, а чтобы кода было поменьше, придется еще использовать и auto. Аналогично вы не можете использовать классы и конструкторы, не используя исключения, так как нормально вернуть ошибку из конструктора можно только бросив исключение. Таким образом, не платить за то, что не используешь, вот как-то не получается — приходится использовать сразу все.

Признаться, по поводу этого абзаца я в серьезном замешательстве. Поскольку он производит впечатление "...смешались в кучу кони, люди...". В целом это выглядит как попытка обвинить сложный и мощный язык в том, что он сложный и мощный. C++ плохой, поскольку у тебя много языковых средств. Так C++ же специально таким создавался, все эти фичи в него запихивались именно для того, чтобы этими фичами пользовались. И, что не удивительно, всеми этими фичами пользуются. Где-то меньше, где-то больше, где-то используется почти все из C++, где-то только ограниченное подмножество. Что же плохого в том, что для борьбы со сложностью задачи разработчики начинают использовать то одну фичу, то другую, то третью? Выше был показан пример, как auto и полиморфные лямбды (читай шаблоны) задействуются для упрощения кода, причем без какого-либо ущерба для производительности. Неужели лучше вместо этого плодить мегатонны одинаковых строк, полученных путем копипасты? Или бороться с копипастой посредством C-шных макросов?

Странный посыл у всего абзаца. Причем он еще более странен из-за того, что в C++ входит весьма серьезное подмножество C. Ну если от продвинутых фич C++ так тошнит, ну так ограничь себя только сильно урезанным подмножеством, в которое войдут только структуры и ссылки вместо указателей. Все равно будет надежнее и удобнее, чем на чистом C. Хотя бы за счет того, что компилятор не дает просто так кастовать void* к чему попало.

Но и по отдельным пунктам абзаца можно пройтись:

  • видимость приватных полей класса в заголовочных файлах. А что, в C с этим что-то иначе? Если описание структуры появляется в C-шном заголовочном файле, то после любой правки этой структуры проект нужно пересобирать (частично или полностью). Модификаторы видимости (public, private, protected) в C++ управляют вовсе не "видимостью" содержимого класса, а правами доступа к этому содержимому. В C и этого нет. Там если структуру сделали видимой (например, чтобы можно было использовать экземпляры этой структуры на стеке, в качестве членов других структур или агрегатов (массивов)), то вообще все в ней доступно для шаловливых и не очень ручек пользователей. А если структуру спрятали за opaque pointer, то смотрим следующий пункт...
  • если в C содержимое структуры прячут от посторонних глаз посредством opaque pointer, то в C++ у разработчика есть похожие по выразительности, трудозатратам и эффективности возможности. Например, "фабрика" или "фабричный метод". Комментарий "что еще менее производительно" вызывает отдельные вопросы. Ибо сложно понять, чем вот это:

    // mystruct.h
    ...
    typedef struct mystruct mystruct_t;
    mystruct_t * mystruct_create();
    void mystruct_destroy(mystruct_t *);
    int mystruct_get_x(mystruct_t *);
    void mystruct_set_x(mystruct_t *, int);
    ...

    // mystruct.c
    ...
    struct mystruct {
       int x;
       ...
    };
    mystruct_t * mystruct_create() {
       mystruct_t * s = malloc(sizeof(mystruct_t));
       if(s) {
          s->x = ...;
          ...
       }
       return s;
    }
    void mystruct_destroy(mystruct_t * s) {
       free(s);
    }
    int mystruct_get_x(mystruct_t * s) {
       return s->x;
    }
    void mystruct_set_x(mystruct_t * s, int x) {
       s->x = v;
    }

    эффективнее, чем вот это:

    // mystruct.hpp
    ...
    class mystruct_t {
    public :
       virtual ~mystruct_t();
       virtual int get_x() const = 0;
       virtual int set_x(int v) = 0;

       static std::unique_ptr<mystruct_t> create();
    };
    ...
    // mystruct.cpp
    ...
    class actual_mystruct_t : public mystruct_t {
       int x_;
       ...
    public :
       actual_mystruct_t() : x_(...), ... {}
       virtual int get_x() const { return x_; }
       virtual void set_x(int v) { x_ = v; }
       ...
    };

    mystruct_t::~mystruct_t() {}
    std::unique_ptr<mystruct_t> mystruct_t::create() {
       return std::make_unique<actual_mystruct_t>();
    }

    Разницу в надежности и удобстве использования вижу. Только отнюдь не в пользу чистого C. А вот с эффективностью...

    Может быть ув.тов.asfikon говорит о стоимости виртуальных вызовов? Если так, то:

    1. Стоимость виртуальных вызовов может оказаться сущими копейками по сравнению с затратами на создание экземпляров "непрозрачных типов" в динамической памяти. Особенно, если такие объекты создаются и уничтожаются очень часто.
    2. О негативном влиянии виртуальных вызовов на производительностью нужно судить по результатам профайлинга. Тем более, что оптимизирующие компиляторы постоянно становятся все мощнее и умнее, и сделать девиртуализацию вызовов вполне способны (тыц).
    3. Если же профайлер показывает, что имеет смысл избавиться от виртуальных вызовов, то есть PImpl, о которой речь пойдет дальше.
  • так вот о PImpl. Что там говорит ув.тов.asfikon? "И вот в дополнение к простому и понятному классу вам уже приходится использовать не такую уж понятную и простую в реализации идиому pImpl". Очень странно, что PImpl оказывается не такой простой и понятной, как opaque pointer. Суть-то ведь одна. Или здесь выборочное зрение и двойные стандарты: сложность в C++ видим, а в C -- не видим?

    В чем вообще сложность PImpl? Вот в этом: "А затем еще правильно перегрузить оператор присваивания, реализовать конструктор перемещения, и чтобы при этом все это добро правильно работало с STL…"?

    Мне, как C++нику, подобный бред комментировать сложно, тем более, что сейчас все-таки времена C++11/14, а не C++98 с его кривым std::auto_ptr. Но, т.к. статью могут читать и те, кто с C++ почти не знаком, попробую пояснить в чем дело.

    Дело в том, что в C++ для всех структур и классов компилятор автоматически генерирует конструкторы и операторы копирования/перемещения. Т.е. если мы в C++ написали что-то вида struct A { std::string a_, b_; }, то мы можем просто копировать экземпляры этих структур. Т.е. можем писать a1 = a2, а не использовать memcpy. Что очень удобно, поскольку большинство типов в C++ имеют т.н. value-семантику. Т.е. в большинстве случаев объекты пользовательских типов (вроде нашей struct A) и встроенных типов (вроде int) ведут себя одинаково.

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

    class void_ptr_stack {
       using void_ptr = void *;
       // Это указатель на блок в динамической памяти.
       // Должен быть явным образом уничтожен в деструкторе.
       void_ptr * data_;
       ...
    public :
       void_ptr_stack(size_t capacity)
          : data_(new void_ptr[capacity]) // Память выделили.
          ...
       {}
       ~void_ptr_stack() {
          delete[] data_; // Память освободили.
       }
       ... // Остальной интерфейс без определения собственных
           // операторов копирования/перемещения.
    };

    На первый взгляд здесь все нормально: в конструкторе память выделили, в деструкторе -- освободили. Но это только на первый взгляд. Проблема в том, что компилятор сгенерирует для void_ptr_stack дефолтные конструкторы и операторы копирования/перемещения. Которые будут работать, в принципе, как memcpy, т.е. сделают побитовое копирование тех полей, для которых нет собственных операторов/конструкторов копирования. Т.е. для void_ptr_stack::data_ будет выполнено побитовое копирование. Это приведет к тому, что после выполнения stack_one = stack_two; и в stack_one, и в stack_two окажется одинаковое значение void_ptr_stack::data_. А это плохо, т.к. деструкторы stack_one/stack_two будут освобождать память по этому указателю. Первому деструктору это удастся сделать, а вот второй деструктор спровоцируют повторное освобождение уже свободного участка. Кому приходилось вручную управлять памятью, тот знает, насколько это плохо.

    Для того, чтобы не порождать проблему повторного освобождения ресурса классам, подобным void_ptr_stack, приходится что-то делать со своими операторами/конструкторами копирования/перемещения. Как правило, их тупо запрещают. В C++98 это делалось так (ну или через использование noncopyable из Boost-а или его аналога):

    class void_ptr_stack {
       // Объявляем конструктор и оператор копирования приватными и не
       // делаем их реализации.
       void_ptr_stack(const void_ptr_stack&);
       void_ptr_stack& operator=(const void_ptr_stack&);
       ...
    };

    В C++11 для этих же целей используется специальный синтаксис:

    class void_ptr_stack {
       ...
    public :
       void_ptr_stack(size_t capacity)
          : data_(new void_ptr[capacity]) // Память выделили.
          ...
       {}
       // Запрещаем копирование и перемещение объекта.
       void_ptr_stack(const void_ptr_stack&) = delete;
       void_ptr_stack(void_ptr_stack&&) = delete;
       ...
    };

    Можно было бы согласиться, что дополнительные телодвижения для того, чтобы запретить копирование/перемещения класса с PImpl -- это усложнение работы программиста, если бы не одно "но". В C++11 для PImpl достаточно использовать std::unique_ptr и забыть про копирование/перемещение:

    // demo.hpp
    ...
    class demo {
       struct impl; // Будет определено в .cpp файле.
       std::unique_ptr<impl> impl_;
    public :
       demo();
       ~demo();
       ...
    };
    ...
    // demo.cpp
    ...
    struct demo::impl {
       ...
    };

    demo::demo() : impl_(std::make_unique<impl>(...)) {}
    demo::~demo() {} // Это важно.

    Вот и все, что нужно для PImpl в современном C++. Главный фокус здесь в том, чтобы деструктор demo определялся там же, где и описание структуры demo::impl.

    Внимательный читатель может заметить, что показанный выше класс demo, на самом деле, не имеет value-семантики. Т.е. экземпляры класса demo нельзя копировать. Это так. Ибо, в большинстве случаев классы, которые владеют какими-то внешними ресурсами (не важно, будь то память, mutex-ы, подключения к БД и пр.), и не должны иметь value-семантики.

    И только если вы оказались в ситуации, когда вам нужны и PImpl, и value-семантика, только тогда вам придется самостоятельно определять такие вещи, как: конструктор копирования, оператор копирования, конструктор перемещения, оператор перемещения, ну и, до кучи, реализацию функции swap (ибо через нее и выражаются операторы копирования/перемещения, да и для пользователей вашего класса она пригодится).

    Звучит сложно? Ну тогда покажите мне, как вы будете делать value-семантику для opaque types в чистом C.

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

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

    • вообще-то идиому RAII нужно использовать не только для памяти и не только в случае исключений. Обычный преждевременный return или break в цикле может оставить тот или иной ресурс неосвобожденным. А это утечка со всеми вытекающими;
    • проблема освобождения ресурсов при выходе из скоупа, сюрпрайз-сюрпрайз, существует и в C. Только там нет ни умных указателей, ни классов с шаблонами дабы использовать RAII, ни auto для еще большего упрощения себе жизни. Поэтому в C мы имеем goto cleanup и кучу тупого и однообразного кода. Который, впрочем, у ув.тов.asfikon-а, как мы уже выяснили, не вызывает напряжения. Чему можно только позавидовать.
  • еще про исключения: "Аналогично вы не можете использовать классы и конструкторы, не используя исключения, так как нормально вернуть ошибку из конструктора можно только бросив исключение." Таки да, если исключения в проекте запрещены (очень надеюсь, что не из-за придури, а по веским причинам), то возвращать ошибки из конструктора нельзя. В ряде проектов вместо бросания исключений просто абортят все приложение. Что, не смотря на кажущуюся жестокость, отнюдь не лишено смысла в некоторых прикладных нишах. Например, в тех же системах реального времени, где все ресурсы и взаимосвязи между объектами устанавливаются на старте. Если что-то пошло не так (ошибка в конструкторе), то лучше грохнуться и рестартовать. Там, где вызывать abort по ошибке в конструкторе нельзя, используется техника под названием two phase init. Ее смысл в том, что конструктор всего лишь назначает дефолтные значения атрибутам класса. А реальная инициализация выполняется в дополнительном методе init. Который уже вполне может возвращать коды возврата. Подход не очень удобный, но вполне себе работающий. С давних лет и до сих пор. Более того, за счет возможностей современного C++ этот подход можно сделать несколько более удобным, нежели в C++98/03:

    // Класс, который нуждается в двухфазной инициализации.
    // По умолчанию является и копируемым, и перемещаемым.
    class twi_demo {
    public :
       twi_demo();
       ...
       // Метод для инициализации объекта.
       error_type init(... /*какие-то аргументы*/);
       ...
    };

    // Вспомогательная функция для создания и иницииализаци.
    // result<T,E> -- это шаблонный класс, реализующий Either-монаду.
    result<twi_demo,error_type> make_twi_demo(... /*какие-то аргументы*/ ) {
       twi_demo obj;
       auto err == obj.init(...);
       if(err)
          return result<twi_demo, error_type>(err);
       else
          return result<twi_demo, error_type>(std::move(obj));
    }

    Что позволить работать с объектами классов с двойной инициализаций вот в таком стиле:

    auto obj = make_twi_demo(...);
    if(!obj)
       return obj.error();
    // Дальше возможна нормальная работа с obj.
    obj->some_method();

    Добавлю при этом, что вспомогательную функцию make_twi_demo в современном C++ можно сделать шаблоном и использовать для разных типов, не только для twi_demo. Будет что-то вроде:

    // Вспомогательная функция для создания и иницииализаци.
    // result<T,E> -- это шаблонный класс, реализующий Either-монаду.
    templatetypename T, typename... Args >
    auto make_object(Args && ...args ) {
       T obj;
       auto err == obj.init(std::forward<Args>(args)...);
       if(err)
          return result<T, decltype(err)>(err);
       else
          return result<T, decltype(err)>(std::move(obj));
    }

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

    Более важно то, что "классы без исключений использовать не получится" -- это, мягко говоря, дезинформирование читателей. А так же еще один "аргументик", который можно смело отправлять в /dev/null.

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


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

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

Отправить комментарий