Pull to refresh

Эдвард руки — С++

Reading time 10 min
Views 55K
Original author: Bartosz Milewski
Я искал, с чем бы сравнить программирование на С++ и я вспомнил фильм 1990 года режиссера Тима Бертона — «Эдвард руки-ножницы»

Это темная версия Пиноккио, снятая в атмосфере пригорода. В этом фильме жуткий парень (Джонни Депп) пытается аккуратно обнять Ванону Райден, но его неуклюжие руки-ножницы делают это очень опасным для них обоих. Его лицо уже покрыто глубокими шрамами.
Если у вас ножницы вместо рук, то это не так уж и плохо. У Эдварда много талантов: например, он может потрясающе стричь собак!
Меня часто посещают похожие мысли после посещения С++ конференций: в этот раз это было после Going Native 2013. В прошлом году были восторги и волнения по поводу нового стандарта — С++11. В этом году это было проверкой в реальных условиях. Не поймите меня неправильно: там было много потрясающих собачьих причесок (я имею в виду код на С++, который был простым и элегантным), но основная часть конференции была о том, как избежать увечий и оказать первую помощь в случае нечаянной ампутации.

Маленький магазинчик ужасов.

Там было так много разговоров о том, как не использовать С++, что это натолкнуло меня на такую мысль: речь идет не о проблеме некомпетентных программистов, просто использование С++ — это вообще неправильно. Так, если вы только изучаете основы языка и пытаетесь использовать его, то вы обречены.
У С++ есть оправдание: обратная совместимость, в частности, совместимость с С. Вы можете относиться к С как к подмножеству С++, по-настоящему ассемблерному языку, который вам лучше не использовать в повседневном программированию, кроме тех ситуаций, когда это явно необходимо. Если вы слепо погружены в С++, то вы размышляете о чистых указателях, for-циклах — всё это действительно дурацкая затея.
Хорошо известный пример того, как не надо делать — это использование malloc для динамического выделения памяти и free для ее освобождения. malloc принимает количество байт и возвращает указатель на void, который вам надо кастить во что-то более удобное — придумать худшее API для управления памяти тяжело. Вот пример действительно плохого (но почти корректного, если бы не возможность обращения по нулевому указателю) кода:
struct Pod {
int count;
int * counters;
};
int n = 10;
Pod * pod = (Pod *) malloc (sizeof Pod);
pod->count = n
pod->counters = (int *) malloc (n * sizeof(int));
...
free (pod->counters);
free (pod);

Надеюсь, что так на С++ не пишет никто, хотя я уверен, что есть много старых приложений с такими конструкциями, поэтому не надо смеяться.
С++ «решил» проблему чересчур многословного и подверженного ошибкам вычисления размера заменой malloc и free на new и delete. Корректная версия кода выше на С++ должна выглядеть так:
struct Pod {
int count;
int * counters;
};
int n = 10;
Pod * pod = new Pod;
pod->count = n;
pod->counters = new int [n];
...
delete [] pod->counters;
delete pod;

Кстати, проблема обращения по нулевому указателю тоже решилась, потому что new бросит исключение, когда системе не хватает памяти. В коде выше всё еще остается небольшой шанс утечки памяти, если второй вызов new будет неудачным (Но как часто это случается? Подсказка: насколько большим может быть n?). Короче, вот по-настоящему корректная версия кода:
class Snd { // Sophisticated New Data (в противовес POD)
public:
Snd (int n) : _count(n), _counters(new int [n]) {}
~Snd () { delete [] _counters; }
private:
int _count;
int * _counters;
};
Snd * snd = new Snd (10);
...
delete snd;

Всё ли мы сделали? Конечно нет! Код не является безопасным в плане исключений.
С++ рекомендует вам избегать чистых указателей, избегать массивов и избегать delete.
Таким образом, на замену malloc пришел оператор new, который тоже сломан: он возвращает опасный указатель, а указатели — это зло.
Все мы знаем (и шрамы на наших лицах это подтверждают), что крайне желательно использовать STL-контейнеры и умные указатели везде, где это возможно. Да, и нужно использовать value-семантику для передачи объектов. Но подождите! Value-семантика приносит потерю производительности из-за чрезмерного копирования. А что насчет shared_ptr и векторов из shared_ptr? Но они добавляют оверхэд при подсчете ссылок! Нет, вот новая идея: move-семантика и rvalue-ссылки.
Я могу продолжать снова и снова (часто я так и делаю!). Вы видите закономерность? Каждое улучшение требует нового улучшения. Теперь у нас не только С-подмножество, которое следует избегать. Каждая новая фича языка или дополнение в библиотеку порождает новую серию подводных камней. И вы знаете, новые фичи имеют ужасный дизайн, если Скот Мейерс говорил о ней. (Его последнее выступление, как вы догадались, было о подводных камнях семантики перемещения).

Философия С++


Бьерн Страустроуп подчеркивает, насколько важна обратная совместимость для С++.
Это один из столпов философии языка. Учитывая огромное количество старого кода, это имеет смысл. Но совместимость ведет к тяжелым последствиям для эволюции языка. Если бы природа относилась к обратной совместимости так же, как С++, то люди до сих пор имели бы хвосты, жабры, плавники, рога и внешние скелеты — ведь всё это имело смысл в какой-то момент эволюции.
С++ стал чрезвычайно сложным языком. Есть бесконечное множество способов сделать одно и то же — и почти все из них либо просто неправильны, опасны, неподдерживаемы либо всё вышеперечисленное. Проблема в том, что большинство кода компилируется и даже запускается. Ошибки и недочеты выявляются гораздо позже, часто после того, как продукт был выпущен.
Вы можете сказать, что это всего лишь природа программирования.
Если вы так думаете, то вам нужно как следует посмотреть в сторону Хаскеля. Ваша первая реакция будет: я не знаю, как реализовать это (что угодно, кроме факториала и чисел Фибоначчи) в этом чересчур ограниченном языке программирования. Это полностью отличается от опыта на С++, когда вы можете разрабатывать на нем с первого дня.
Вы не представляете, что в лучшем случае вам понадобится 10 лет чтобы найти «правильный путь» программирования на С++ (если он вообще есть). Представьте себе, чем лучше вы программируете на С++, тем больше функционального стиля в ваших программах. Спросите любого гуру С++ и он ответит вам: избегайте mutation, избегайте побочных эффектов, не используйте циклы, избегайте иерархий классов и наследования.
Но вам понадобится строгая дисциплина и тотальный контроль над вашими сотрудниками, чтобы это осуществить — ведь С++ разрешает всё.
Хаскель не такой добрый, он не даст вам (или вашим коллегам) писать небезопасный код.
Да, сначала вы будете чесать затылок, пытаясь реализовать на Хаскеле то, что на С++ пишете за 10 минут. Если вам повезло и вы работаете на Шона Пэрента" или других исключительных программистов, то они будут просматривать ваш код и покажут, как не писать на С++. В противном случае, вы можете быть в неведении в течение десятилетий, причиняя раны самому себе и мечтая о собачьих прическах.

Управление ресурсами


Я начал эту статью с примерами управления ресурсами (строго говоря, управления памятью), потому что это одно из моих личных предпочтений. Я выступал и писал об этом с девяностых годов (см. библиографию в конце). Очевидно, что я не справился, потому что спустя 20 лет техники управления ресурсами всё еще не общеизвестны. Бьерн Страуструп был вынужден половину свой вступительной речи говорить об управлении ресурсами перед толпой передовых С++ программистов. Опять же, можно было обвинять некомпетентных программистов в непринятии управления ресурсами как основы С++.
Однако проблема в том, что в языке нет ничего, что бы сообщило программисту о неладах в коде, который я привел в начале статьи. Фактически, изучение корректных техник принимается как изучение нового языка.
Почему это так сложно? Потому что большая часть управления ресурсами в С++ — это управление памятью. На самом деле, неоднократно подчеркивалось, что сборщик мусора не решит проблему управления ресурсами: всегда будут хэндлы файлов и окон, открытые базы данных и транзакции, итд. Всё это важные ресурсы, но управление ими скрыто тенью утомительного управления памятью. Причина, по которой С++ не имеет сборщика мусора не в том, что его нельзя сделать эффективно, а в том, что С++ сам по себе враждебен к сборке мусора. Компилятор и среда выполнения должны всегда предполагать худшее: не только что любой указатель может указывать на любой другой указатель, но и что адрес памяти может быть сохранен как целое число или его младшие биты могут быть использованы как битовые поля (вот почему для С++ рассматриваются только консервативные сборщики мусора).
Это распространенное, но ошибочное мнение, что подсчет ссылок (в частности, использование shared-указателей) лучше сборки мусора. Современные исследования показывают, что эти два подхода являются всего лишь разными сторонами одной медали. Вы должны понимать, что удаление shared-указателя может привести к сколь угодно длинным паузам в выполнении программы, равно как и работа сборщика мусора. Это происходит не только потому, что каждый серьезный алгоритм подсчета ссылок должен уметь работать с циклами, но еще и потому, что каждый раз, когда счетчик ссылок на какую-то часть данных достигает нуля, целый граф указателей, достижимых из этого объекта, должен быть пройден. Структуры данных, спроектированные с применением shared-указателей, могут требовать много времени для удаления и, за исключением простых случаев, вы никогда не знаете, какой из указателей выйдет за область видимости и вызовет деструктор.
Аккуратное управление ресурсами и использование shared_ptr всё же могут быть обоснованными в однопоточных приложениях, но вы обретаете большие неприятности, когда начинаете работать с многопоточностью. Каждое увеличение и уменьшение счетчика требует блокировки! Эта блокировка обычно реализуется с помощью атомарных переменных, но есть еще и мьютексы! Не позволяйте себя обмануть: доступ к атомарным переменным обходится дорого. Это подводит меня к центральной проблеме в С++.

Многопоточность и параллелизм


Прошло 8 лет с тех пор, как Герб Саттер лихо заявил: «Халява закончилась!»
С тех пор огромный танкер С++ всё медленнее меняет свой курс. Многопоточность не была изобретена в 2005 году. Posix-потоки были созданы в 1995 году. Майкрософт представила потоки в Windows 95, а поддержку многопроцессорных систем — в Windows NT. Однако многопоточность была признана в стандарте С++ только 2011 года.
С++ 11 был вынужден начать с глубоких раздумий. Надо было определить модель памяти: когда и в каком порядке память, записанная из нескольких потоков, становится видимой из других потоков. Исходя из практических соображений, модель памяти в С++ была скопирована с Java (за вычетом некоторых спорных гарантий, который Java дает о поведении в случае рейсов). Короче говоря, программы на С++ являются последовательно согласованными, если нет рейсов. Но С++ приходится конкурировать с языком ассемблера, поэтому полная модель памяти включает так называемую слабую атомарность, которую я предпочитаю описывать как переносимые рейсы и рекомендую держаться подальше от нее.
C++11 также определяет примитивы для создания и управления потоками, а также базовые примитивы для синхронизации, определенные Дейкстрой и Хоаром в шестидесятых, такие как мьютексы и условные переменные. Можно усомниться в том, что они являются по-настоящему верной основой для синхронизации, но это не так уж и важно, ведь их всё равно нельзя использовать в композиции. Компонуемая абстракция для синхронизации — это STM (Software Transactional Memory), которую сложно реализовать корректно и эффективно в императивном языке программирования. В Комитете Стандарта есть группа изучения STM, так что есть шанс, что в один прекрасный день STM станет частью стандарта. Но STM будет очень сложно использовать должным образом, ведь С++ не предлагает никакого контроля последствий своих действий.
Еще была ошибочная и запутанная попытка предоставить поддержку task-ориентированного параллелизма с асинхронными задачами и не компонуемыми future (и то, и другое являются серьезными кандидатами в deprecated-список в С++ 14). Локальные переменные потоков были также стандартизированы исходя из task-ориентированного подхода, который является более сложным. Блокировки и условные переменные тоже связано с потоками, а не с тасками. Поэтому это было вполне себе катастрофой. Комитет Стандарта обеспечил себя работой по удалению всего этого на много лет вперед. Работа включает в себя task-ориентированный компонуемый параллелизм, связь между потоками, чтобы заменить futures (хотелось бы надеяться), отмену task'ов и, возможно в далекой перспективе, работающий с данными параллелизм, включая поддержку GPU.
Производная от Microsoft PPL и Intel TBB должна стать частью Стандарта (надеюсь, что Microsoft AMP там не будет).
Давайте поверим в это и допустим, что все эти вещи будут стандартизированы и реализованы к, скажем, 2015 году. Даже если это вдруг случится, я всё еще не верю, что люди получат возможность использовать С++ для массового параллельного программирования. С++ был спроектирован для однопоточного проектирования, а параллельное программирование требует революционных, а не эволюционных изменений. Два слова: data race. Императивные языки не предоставляют защиты от рейсов — может быть, за исключением D.
В С++ данные по умолчанию расшарены между потоками, являются изменяемыми по умолчанию, и функции имеют побочные эффекты также по умолчанию. Все эти указатели и ссылки создают благодатную почву для рейсов, а подверженность структур данных и функций рейсам никак не отражается в системе типов. В С++, даже если у вас есть константная ссылка на объект, нет никаких гарантий, что другой поток не модифицирует его. Хуже того, любые ссылки внутри конст-объектов по умолчанию являются изменяемыми.
D, по крайней мере, имеет понятие глубокой константности и неизменности (ни один поток не может изменить неизменную структуру данных). Еще один плюс D — это возможность объявлять чистые функции. Еще в D изменяемые объекты не расшарены между потоками по умолчанию. Это шаг в верном направлении, хотя он и добавляет стоимость выполнения при работе с расшаренными объектами. Хотя самое главное: потоки не являются хорошей абстракцией для параллельного программирования, поэтому такой подход не будет работать для легковесных задачи и работой с очередями, когда задачи передаются между потоками.
Но С++ не поддерживает ничего из этого и не похоже, что когда-нибудь начнет.
Конечно, вы можете назвать всё это про-многопоточностью и фичами параллелизма как функционального программирования — в частности, неизменность и чистые функции.
Но рискну показаться навязчивым: Хаскель на голову впереди всех в отношении параллелизма, включая поддержку GPU. Вот почему я так легко перешел на сторону Хаскеля после долгих лет хорошей евангелистской практики на С++. Каждый программист, серьезно относящийся к параллелизму и многопоточности, должен изучить Хаскель, чтобы понять его работу с ними. Есть отличная книга Саймона Марлоу — «Parallel and Concurrent Programming in Haskell». После ее прочтения вы либо начнете использовать техники функционального программирования при работе с С++, либо обнаружите глобальное несоответствие между параллельным программированием и императивным языком, после чего переключитесь на Хаскель.

Заключение


Я считаю, что язык С++ и вся его философия находятся в прямом конфликте с требованиями функционального программирования. Этот конфликт несет ответственность за очень медленное внедрение параллельного программирования в мэйнстримную разработку софта. Мощности многоядерных процессоров, векторные юниты и GPU теряют производительность из-за устаревших парадигм программирования.
Библиография

Здесь я привел несколько из моих публикаций об управлении ресурсами:
  1. Bartosz Milewski, “Resource Management in C++,” Journal of Object Oriented Programming, March/April 1997, Vol. 10, No 1. p. 14-22. Здесь всё еще нет unique_ptr, поэтому я использую auto_ptr, если это необходимо. Я реализовал auto_vector, ведь нет возможности пользоваться вектором auto_ptr.
  2. C++ Report in September 1998 and February 1999 (auto_ptr еще используется).
  3. C++ in Action (still auto_ptr), Addison Wesley 2001.Смотрите часть этой книги, в которой говорится о управлении ресурсами.
  4. Walking Down Memory Lane, with Andrei Alexandrescu, CUJ October 2005 (используется unique_ptr)
  5. unique_ptr–How Unique is it?, WordPress, 2009
Tags:
Hubs:
+85
Comments 217
Comments Comments 217

Articles