вторник, 22 ноября 2016 г.

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

Итак, поскольку на удивление большое количество желающих захотело послушать разбор тезисов статьи ув.тов.asfikon-а "Почему не лишено смысла писать код на C, а не на C++", то начнем выполнять обещание. Для того, чтобы не было завышенных ожиданий, сразу обозначу рамки, в которых будет вестись повествование. Посты с разбором "C vs C++" будут представлять из себя цитаты из оной статьи с моими комментариями о том, что в данной цитате представляет ценность, а что можно смело отправлять в /dev/null. Такой формат предполагался мной изначально. Приношу свои извинения, если кто-то ждал чего-то сильно другого. Впрочем, в комментариях можно оставлять свои соображения о том, какие темы вы бы хотели увидеть более раскрытыми. Может быть со временем получится дойти и до них.

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

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

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

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

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

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

Более того, после того, как выбор сделан, потребуется прилагать некоторые усилия, чтобы выбранное решение со временем не превратилось в кошмар. Например, со временем иерархия классов может оказаться слишком сложной, и вовремя это не удалось заметить. Или чрезмерное увлечение шаблонами довело время компиляции проекта до совсем уж неприличных величин, а компиляторы стали падать с internal compiler error. Или же вы не смогли наладить обучение своих людей и когда опытные разработчики переходят на другие проекты, другие должности или уходят в другие компании, вам просто некого поставить на проект...

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

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

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

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

const unsigned N = ...;
matrix a(N,N), b(N,N), c(N,N), d(N,N);
...
matrix x = a*b + c*d;
forunsigned i = 0; i < N; ++i )
   forunsigned j = 0; j < N; ++j )
      if( x(i,j) < EPSILON )
         x(i,j) = -1;

Тогда как тоже самое на C пришлось бы записывать как:

matrix a, b, c, d;
matrix ab, cd, x;
matrix_init(a, N, N);
matrix_init(b, N, N);
matrix_init(c, N, N);
matrix_init(d, N, N);
...
matrix_init(ab, N, N);
matrix_init(cd, N, N);
matrix_init(x);
matrix_mul(a, b, ab);
matrix_mul(c, d, cd);
matrix_add(ab, cd, x);
matrix_destroy(ab);
matrix_destroy(cd);
forunsigned i = 0; i < N; ++i )
   forunsigned j = 0; j < N; ++j )
      if( matrix_get(x, i, j) < EPSILON )
         matrix_set(i, j) = -1;
...
matrix_destroy(x);
matrix_destroy(d);
matrix_destroy(c);
matrix_destroy(b);
matrix_destroy(a);

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

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

const unsigned N = ...;
const unsigned L = ...;
matrix a(N,N), d(N,N);
stripped_matrix b(N,L), c(N,L);
...
matrix x = a*b + c*d;
forunsigned i = 0; i < N; ++i )
   forunsigned j = 0; j < N; ++j )
      if( x(i,j) < EPSILON )
         x(i,j) = -1;

Почему принципиально ничего не изменилось? Потому, что мы можем делать перегрузку операторов по типам операндов. Нужно перемножить обычные матрицы? Будет задействован один operator*. Нужно перемножить обычную и лентчную? Будет задействован другой operator*. Нужно ленточную и обычную? Опять нет проблем. Точнее, все эти варианты затрудняют работу того, кто реализует классы matrix, stripped_matrix и операции для них. Но облегчают работу тех, кто эти средства использует.

Ну а что будет в чистом C?

matrix a, d;
stripped_matrix b, c;
matrix ab, cd, x;
matrix_init(a, N, N);
stripped_matrix_init(b, N, N);
stripped_matrix_init(c, N, N);
matrix_init(d, N, N);
...
matrix_init(ab, N, N);
matrix_init(cd, N, N);
matrix_init(x);
matrix_mul_stripped(a, b, ab);
stripped_matrix_mul(c, d, cd);
matrix_add(ab, cd, x);
matrix_destroy(ab);
matrix_destroy(cd);
forunsigned i = 0; i < N; ++i )
   forunsigned j = 0; j < N; ++j )
      if( matrix_get(x, i, j) < EPSILON )
         matrix_set(i, j) = -1;
...
matrix_destroy(x);
matrix_destroy(d);
stripped_matrix_destroy(c);
stripped_matrix_destroy(b);
matrix_destroy(a);

Хотя нет, ошибся. Забыл, что для ленточных матриц другая размерностью нужна:

stripped_matrix_init(b, N, L);
stripped_matrix_init(c, N, L);

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

В принципе, пример и так уже получился показательным. Но это мы пока еще ограничивали себя возможностями C++ 25-летней давности. Давайте возьмем C++ чуть-чуть поновее и разберемся с еще одной составляющей этого примера: а значения какого типа хранятся у нас в матрицах? Этот вопрос мог бы стать камнем преткновения в начале 1990-х. Но затем во всех выживших компиляторах C++ появилась поддержка шаблонов и у нас появилась возможность создавать классы matrix и stripped_matrix как шаблонные. Допустим, что в обычных матрицах мы хотим хранить float-ы, а в ленточных double. Что поменяется в нашем примере? Смотрим:

const unsigned N = ...;
const unsigned L = ...;
matrix<float> a(N,N), d(N,N);
stripped_matrix<double> b(N,L), c(N,L);
...
matrix<float> x = a*b + c*d;
forunsigned i = 0; i < N; ++i )
   forunsigned j = 0; j < N; ++j )
      if( x(i,j) < EPSILON )
         x(i,j) = -1;

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

templateclass A, class B >
matrix<A> operator*(const matrix<A> & a, const stripped_matrix<B> & b) {...}

templateclass A, class B >
matrix<A> operator*(const stripped_matrix<A> & a, const matrix<B> & b) {...}

templateclass A, class B >
matrix<A> operator+(const matrix<A> & a, const matrix<B> & b) {...}

Что бы мы имели, если бы пытались тоже самое представить в чистом C? До C11 все было бы очень грустно. В C11, вроде бы ситуация уже получше, но, к сожалению, с C11 никогда дела не имел. Поэтому подходящий пример с _Generic из C11 показать не смогу. Подозреваю, однако, что принципиально ничего бы не изменилось.

Давайте теперь поговорим про эффективность кода в нашем примере.

Компактный код на C++ x=a*b+c*d, если бы он имел _очень тривиальную_ реализацию на старом C++, то он бы эквивалентен вот этому коду на C:

matrix ab, cd, s;
matrix_init(ab, N, N);
matrix_init(cd, N, N);
matrix_mul_stripped(a, b, ab);
stripped_matrix_mul(c, d, cd);
matrix_init(s, N, N);
matrix_add(ab, cd, s);
matrix_copy_init(s, x);
matrix_destroy(s);
matrix_destroy(ab);
matrix_destroy(cd);

Т.е. для проведения операции нам бы потребовалось иметь три промежуточные матрицы для хранения результатов двух умножений и одного сложения. А потом бы пришлось содержимое одной из них копировать в итоговую матрицу x. Что, очевидно, не очень эффективно. Но это если говорить про _очень тривиальные_ реализации _для старого C++_. А в C++11 появились такие штуки, как rvalue references и move semantic. И, если ту же самую тривиальную реализацию на старом C++ адаптировать на C++11, то за x=a*b+c*d скрывался бы уже вот такой эквивалентный C-шный код:

matrix ab, cd, s;
matrix_init(ab, N, N);
matrix_init(cd, N, N);
matrix_mul_stripped(a, b, ab);
stripped_matrix_mul(c, d, cd);
matrix_init(s, N, N);
matrix_add(ab, cd, s);
matrix_move_init(s, x);
matrix_destroy(s);
matrix_destroy(ab);
matrix_destroy(cd);

Что уже очень близко к вручную написанному аналогу на C. Единственные дополнительные накладные расходы в этом варианте -- это вызов matrix_destroy(s), однако s к этому моменту уже пуста и данный вызов стоит ничтожно мало по сравнению с остальными операциями.

Итак, один маленький пример, но наглядно демонстрирующий разницу в подходах. И в производительности.

А теперь давайте подумаем, а много ли возможностей C++ нам потребовалось для получения такого результата? Нам нужны самые простые классы, перегрузка операторов, немного шаблонов, понимание разницы между lvalue references и rvalue references. По нынешним меркам не так уж и много. Могу ошибаться, но для 2016-го года базовый комплект знаний у C++ разработчика должен быть даже посолиднее. Т.е. основываясь на минимально необходимом базисе мы получили существенный выигрыш в выразительности. Стоило ли оно того?

И как раз отталкиваясь от примера, в котором не требуется быть гуру в C++, рассмотрим таки первую цитату из статьи, которую собирались обсуждать. Вот она:

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

Эта же цитата входит в другой тезис, касающийся скорости, но к теме скорости мы еще вернемся. Сейчас хочется остановиться на утверждении, которое часто делают критики C++. Утверждение это можно сформулировать так: "Если вы не используете _все_ возможности языка C++, значит вы пишете не на C++, а на C with classes". Ну или, если уж передергивать, так передергивать: "Если вы не используете _все_ возможности языка C++, значит вы пишете на диалекте С". Что, собственно и демонстрируется в данной цитате.

Охарактеризовать подобное я могу только одним словом: бред.

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

Это бред так же и с позиций здравого смысла. Язык C++ настолько сложен, что в мире найдется вряд ли пару сотен человек, которые знают его весь. Поэтому большинству обычных C++ разработчиков, даже если у них не связаны руки жесткими гайдлайнами, достаточно определенных подмножеств языка. Что нужно человеку для решения его прикладных задач, то он и использует. Нужны только ссылки и перегруженные операторы, значит этого достаточно. Ну а если работа идет в рамках жестких гайдлайнов (например, MISRA-C++), то тут вообще требовать использования _всего_ C++ нереально. Что вовсе не означает, что разрабатываемые в соответствии с рекомендациями MISRA-C++ системы, разрабатываются на C.

Так что первый из "аргументов" можно смело отправить в /dev/null. Ну а по поводу того, какие жуткие вещи нас поджидают, если мы используем счетчики ссылок, классы, исключения, шаблоны, лямбды, STL, Boost и т.д., а так же о влиянии их на скорость, да и о скорости вообще и местах ее (не)применения, поговорим в следующий раз.

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


Большая просьба по поводу комментариев. Пожалуйста, будьте конструктивны. Комментарии "Я не согласен" или "Это бред" не приветствуются и будут вымарываться. Если не согласны, то укажите с чем и почему. Если заметили неточность, покажите, как поправить или дайте ссылку на пояснение/уточнение. Если какая-то тема, по вашему мнению, не раскрыта, то обозначьте и саму тему, и то, куда бы имело смысл двигаться. Если у вас есть интересный пример, поделитесь, плз, это поможет. И да, прежде чем написать свой комментарий, попробуйте прочитать уже имеющиеся, возможно, там уже был дан ответ на ваш вопрос.

Попытки устроить срач будут пресекаться.

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