Очень сложно отделить стиль проектирования от стиля кодирования. Мы попытались поместить в очередном разделе те вопросы, которые обычно проявляются, когда вы начинаете писать реальный код.
В этом разделе внимание уделяется принципам и практическим вопросам, область применения которых больше, чем просто отдельный класс или функция. Классические примеры — баланс между простотой и ясностью (рекомендация 6), избежание преждевременной оптимизации (рекомендация 8) и пессимизации (рекомендация 9). Эти рекомендации применимы не только на уровне кодирования отдельной функции, но и к большим областям — проектированию классов и модулей или к решениям с далеко идущими последствиями по поводу архитектуры приложений. (Они применимы также для всех программистов. Если вы считаете иначе — еще раз прочтите приведенную выше цитату Кнута и даже заучите ее на память.)
Многие из прочих рекомендаций этого и следующего разделов имеют дело с управлением зависимостями — краеугольным камнем проектирования программного обеспечения и часто повторяющейся в данной книге темой. Остановитесь и задумайтесь над произвольной методикой проектирования программного обеспечения — любой хорошей методикой. Какую бы методику вы ни выбрали, так или иначе вы столкнетесь с ослаблением зависимостей. Наследование? Разрабатываемый код должен делать производный класс как можно менее зависимым от базового класса. Минимизация использования глобальных переменных? Снижает дальнодействующие зависимости, осуществляемые посредством широко видимых разным частям программы данных. Абстракция? Устраняет зависимости между кодом, который управляет концепциями, и кодом, который их реализует. Сокрытие информации? Делает код клиента менее зависимым от деталей реализации. Забота об управлении зависимостями отражается в устранении совместного использования состояния (рекомендация 10), применении сокрытия информации (рекомендация 11) и прочем.
В этом разделе самой важной нам кажется рекомендация 6: "Главное — корректность, простота и ясность".
5. Один объект — одна задача
Резюме
Концентрируйтесь одновременно только на одной проблеме. Каждый объект (переменная, класс, функция, пространство имен, модуль, библиотека) должны решать одну точно поставленную задачу. С ростом объектов, естественно, увеличивается область их ответственности, но они не должны отклоняться от своего предназначения.
Обсуждение
Хорошая идея, будучи высказанной вслух, должна быть пояснена одним предложением. Аналогично, каждая сущность в программе должна иметь одно ясное предназначение.
Объект с разнородными предназначениями обычно несоразмерно трудно использовать, поскольку он представляет собой нечто большее, чем просто сумму решений, сложностей и ошибок составляющих его частей. Такой объект больше по размеру (зачастую без особых на то причин) и сложнее в применении и повторном использовании. Зачастую такие объекты имеют весьма убогий интерфейс для каждого из своих отдельных предназначений, поскольку частичное перекрытие разных областей функциональности приводит к невозможности четкой реализации в каждой из них.
Объекты с разнородными функциями обычно трудны для проектирования и реализации. "Множественная ответственность" зачастую приводит к тому, что количество возможных вариантов поведения и состояния объектов разрастается в соответствии с законами комбинаторики. Предпочтительнее использовать короткие функции с четко указанным единственным предназначением (см. также рекомендацию 39), маленькие классы, предназначенные для решения одной конкретной задачи, и компактные модули с четко очерченными границами.
Абстракции высокого уровня предпочтительно строить из меньших низкоуровневых абстракций. Избегайте объединения нескольких низкоуровневых абстракций в большой низкоуровневый конгломерат. Реализация сложного поведения из набора простых существенно легче решения обратной задачи.
Примеры
Пример 1. realloc . В стандарте С функция realloc пользуется дурной славой плохо спроектированной функции. Она делает одновременно слишком много дел: выделяет память, если ей передано значение NULL, освобождает ее, если передан нулевой размер, перераспределяет ее на том же месте, если это возможно, или перемещает ее, если такое перераспределение невозможно. Это уже не просто расширение функциональности. Данная функция обычно рассматривается всеми как пример недальновидного, ошибочного проектирования.
Пример 2. basic_string . В стандарте С++ std::basic_string служит таким же пользующимся дурной славой примером проектирования монолитного класса. В этот раздутый до неимоверных размеров класс добавлены все функции, о которых только можно было подумать. Этот класс пытается быть контейнером, что не совсем ему удается; в нем так и остается неразрешенным вопрос между применением итераторов и индексированием. Кроме того, в нем совершенно необоснованно дублируется множество стандартных алгоритмов. В то же время этот класс оставляет очень мало возможностей для расширения. (См. пример к рекомендации 44).
Ссылки
[Henney02a] • [Henney02b] • [McConnell93] §10.5 • [Stroustrup00] §3.8, §4.9.4, §23.4.3.1 • [Sutter00] §10, §12, §19, §23 • [Sutter02] §1 • [Sutter04] §37-40
6. Главное — корректность, простота и ясность
Резюме
Корректность лучше быстроты. Простота лучше сложности. Ясность лучше хитроумия. Безопасность лучше ненадежности (см. рекомендации 83 и 99).
Обсуждение
Сложно преувеличить значение простоты проектирования и ясности кода. Люди, которые будут сопровождать ваш код, скажут вам только спасибо за то, что вы сделали ваш код понятным. Кстати, зачастую это будете вы сами — когда будете вспоминать, о чем это вы думали полгода назад и как же работает этот код, который вы тогда написали... Прислушайтесь к следующим словам.
Программа должна быть написана для человека, который будет ее читать, и только попутно — для машины, которая будет ее выполнять.— Гарольд Абельсон (Harold Abelson) и Джеральд Сассман (Gerald Jay Sussman)
Пишите программы в первую очередь для людей, и только потом для машин.— Стив Мак-Коннелл (Steve McConnell)
Самые дешевые, быстрые и надежные компоненты вычислительной системы — те, которых в ней нет.— Гордон Белл (Gordon Bell)
Эти отсутствующие компоненты также наиболее точны (они никогда не ошибаются), наиболее надежны (никогда не ломаются) и наиболее просты в разработке, документировании, тестировании и сопровождении. Важность простоты дизайна невозможно переоценить.— Ион Бентли (Jon Bentley)
Многие из рекомендаций этой книги естественным образом приводят к легко изменяемому дизайну и коду, а ясность является наиболее желанным качеством для программы, которую легко сопровождать. То, что вы не в состоянии понять, вы не сможете уверенно и надежно переделать.
Вероятно, наиболее распространенным соперничеством в данной области является соперничество между ясностью кода и его оптимизацией (см. рекомендации 7, 8 и 9). Когда — не если — вы находитесь перед соблазном преждевременной оптимизации для повышения производительности и, таким образом, пессимизации для повышения ясности, — вспомните, что говорит рекомендация 8: гораздо проще сделать корректную программу быстрой, чем быструю — корректной. Избегайте "темных закутков" языка. Используйте простейшие из эффективных методов.
Примеры
Пример 1. Избегайте неуместной и/или чересчур хитроумной перегрузки операторов. В одной библиотеке пользовательского графического интерфейса пользователей без нужды заставляют писать w+c; для того, чтобы добавить в окно w дочерний управляющий элемент с (см. рекомендацию 26).
Пример 2. В качестве параметров конструктора лучше использовать именованные, а не временные переменные. Это позволит избежать возможных неоднозначностей объявлений, а также зачастую проясняет назначение вашего кода и тем самым упрощает его сопровождение. Кроме того, часто это просто безопаснее (см. рекомендации 13 и 31).
Ссылки
[Abelson96] • [Bentley00] §4 • [Cargill92] pp. 91-93 • [Cline99] §3.05-06 • [Constantine95] §29 • [Keffer95] p. 17 • [Lakos96] §9.1, §10.2.4 • [McConnell93] • [Meyers01] §47 • [Stroustrup00] §1.7, §2.1, §6.2.3, §23.4.2, §23.4.3.2 • [Sutter00] §40-41, §46 • [Sutter04] §29
7. Кодирование с учетом масштабируемости
Резюме
Всегда помните о возможном росте данных. Подумайте об асимптотической сложности без преждевременной оптимизации. Алгоритмы, которые работают с пользовательскими данными, должны иметь предсказуемое и, желательно, не хуже, чем линейно зависящее от количества обрабатываемых данных время работы. Когда становится важной и необходимой оптимизация, в особенности из-за роста объемов данных, в первую очередь следует улучшать O-сложность алгоритма, а не заниматься микрооптимизациями типа экономии на одном сложении.
Обсуждение
Эта рекомендация иллюстрирует важную точку равновесия между рекомендациями 8 и 9 — не оптимизируйте преждевременно и не пессимизируйте преждевременно. Это делает данный материал трудным в написании, поскольку он может быть неверно истолкован как совет о "преждевременной оптимизации". Это не так.
Вот предпосылки для данной рекомендации. Память и дисковая емкость растут экспоненциально; например, с 1988 по 2004 год емкость дисков росла примерно на 112% в год (почти в 1900 раз за десятилетие). Очевидным следствием этого факта является то, что любой ваш сегодняшний код завтра может иметь дело с большими объемами данных — намного большими! Плохое (хуже линейного) асимптотическое поведение алгоритма рано или поздно поставит на колени даже самую мощную систему, просто завалив ее достаточным количеством данных.
Защита против такого будущего означает, что мы должны избежать встраивания в наши программы того, что станет западней при работе с большими файлами, большими базами данных, с большим количеством пикселей, большим количеством окон, процессов, битов, пересылаемых по каналам связи. Одним из важных факторов успеха такой защиты является то, что стандартная библиотека С++ обеспечивает гарантированную сложность операций и алгоритмов над контейнерами STL.
Здесь и надо искать точку равновесия. Очевидно, что неверно прибегать к преждевременной оптимизации путем использования менее понятных алгоритмов в ожидании больших объемов данных, которые могут никогда не материализоваться. Не менее очевидно и то, что неверно прибегать и к преждевременной пессимизации, закрывая глаза на сложность алгоритмов (О-сложность), а именно — стоимость вычислений как функцию от количества элементов данных, с которыми работает алгоритм.
Данный совет состоит из двух частей. Во-первых, даже до того, как станет известно, будут ли объемы данных достаточно велики, чтобы для конкретных вычислений возникла проблема, по умолчанию следует избегать использования алгоритмов, которые работают с пользовательскими данными (которые могут расти), но не способны к масштабированию, если только использование менее масштабируемого алгоритма не приводит к существенному повышению понятности и удобочитаемости кода (см. рекомендацию 6). Но все мы часто сталкиваемся с сюрпризами. Мы пишем десять фрагментов кода, думая, что они никогда не будут иметь дела с большими наборами данных. И это действительно оказывается так — в девяти случаях из десяти. В десятом случае мы сталкиваемся с проблемами производительности. Это не раз случалось с нами, и мы знаем, что это случалось (или случится) и с вами. Конечно, мы вносили исправления и передавали их потребителям, но лучше было бы избежать таких затруднений и выполнения лишней работы. Так что при прочих равных условиях (включая понятность и удобочитаемость) воспользуйтесь следующими советами.
• Используйте гибкие динамически распределяемые данные вместо массивов фиксированного размера. Массив "больший, чем наибольший массив, который мне когда-либо потребуется" приводит к ошибкам и нарушению безопасности (см. рекомендацию 77). Массивы можно использовать только тогда, когда размеры данных фиксированы и известны во время компиляции.
• Следует точно знать сложность используемого алгоритма. Не забывайте о такой ловушке, как линейный алгоритм, который вызывает другую линейную операцию, что в результате делает алгоритм квадратичным (см., например, рекомендацию 81).
• По возможности используйте линейные или более быстрые алгоритмы. Идеальны алгоритмы с константной сложностью, такие как push_back или поиск в хэш-таблице (см. рекомендации 76 и 80). Неплохи алгоритмы со сложностью O(log N), такие как операции с контейнерами set/map и lower_bound или upper_bound с итераторами произвольного доступа (см. рекомендации 76, 85 и 86). Допустима линейная сложность O(N), как, например, у vector::insert или for_each (см. рекомендации 76, 81 и 84).
• Пытайтесь избежать применения алгоритмов с более чем линейной сложностью, где это возможно. Например, по умолчанию следует затратить определенные усилия на поиск замены имеющегося алгоритма со сложностью O(N log N) или O(N) (если таковая возможна), чтобы избежать непропорционального падения производительности при существенном увеличении объема данных. Так, именно в этом заключается основная причина, по которой в рекомендации 81 советуется предпочитать операции с диапазонами (которые обычно линейны) их копиям для работы с отдельными элементами (которые обычно квадратичны, так как одна линейная операция вызывает другую линейную операцию; см. пример 1 в рекомендации 81).
• Никогда не используйте экспоненциальный алгоритм, если только вы не "приперты к стене" и не имеете другого выхода. Ищите, не жалея сил, альтернативу, прежде чем прибегнуть к экспоненциальному алгоритму, где даже небольшое увеличение данных приводит к существенному падению производительности.
Во-вторых, после того как замеры покажут, что оптимизация действительно нужна и важна, в особенности при росте данных, сконцентрируйте усилия на снижении O-сложности, а не на микрооптимизациях наподобие экономии одного сложения.
Итак, предпочтительно использовать линейные (или лучшие) алгоритмы там, где только это возможно. Избегайте, где можете, алгоритмов с более чем линейной сложностью, и уж тем более — экспоненциальных.
Ссылки
[Bentley00] §6, §8, Appendix 4 • [Cormen01] • [Kernighan99] §7 • [Knuth97a] • [Knuth97b] • [Knuth98] • [McConnell93) §5.1-4, §10.6 • [Murray93] §9.11 • [Sedgewick98] • [Stroustrup00] §17.1.2
8. Не оптимизируйте преждевременно
Резюме
Как гласит пословица, не подгоняйте скачущую лошадь. Преждевременная оптимизация непродуктивна и быстро входит в привычку. Первое правило оптимизации: не оптимизируйте. Второе правило оптимизации (только для экспертов): не оптимизируйте ни в коем случае. Семь раз отмерь, один раз оптимизируй.
Обсуждение
В [Stroustrup00] §6 имеется замечательная цитата:
Преждевременная оптимизация — корень всех бед.— Дональд Кнут (Donald Knuth) [цитирует Хоара (Hoare)]
С другой стороны, мы не можем игнорировать эффективность.— Ион Бентли (Jon Bentley)
Хоар и Кнут совершенно правы (см. рекомендацию 6 и эту). Но прав и Бентли (рекомендация 9).
Мы определяем преждевременную оптимизацию как усложнение дизайна или кода (что делает его менее удобочитаемым) во имя повышения производительности, когда усилия не оправдываются доказанной необходимостью повышения производительности (например, реальными измерениями и сравнением с поставленной целью). Зачастую такие усилия вообще не приводят к повышению производительности программы.
Всегда помните:
Гораздо, гораздо проще сделать корректную программу быстрой, чем быструю — корректной.
Поэтому по умолчанию не концентрируйтесь на том, чтобы сделать код быстрым, в первую очередь его надо сделать максимально понятным и удобочитаемым (рекомендация 6). Ясный код проще написать корректно, проще понять, проще переделать — и проще оптимизировать. Усложнения, включая оптимизацию, всегда можно внести позже — и только при необходимости.
Имеются две основные причины, почему преждевременная оптимизация зачастую не делает программу быстрее. Во-первых, общеизвестно, что программисты обычно плохо представляют, какой код будет быстрее или меньше по размеру, и где будет самое узкое место в разрабатываемом коде. В число таких программистов входят и авторы этой книги, и вы. Подумайте сами — современные компьютеры представляют собой исключительно сложные вычислительные модели, зачастую с несколькими работающими параллельно процессорами, глубокой иерархией кэширования, предсказанием ветвления, конвейеризацией и многим- многим другим. Компилятор, находящийся над всем этим аппаратным обеспечением, преобразует ваш исходный код в машинный, основываясь на собственном знании аппаратного обеспечения и его особенностей, с тем чтобы этот код в максимальной степени использовал все возможности аппаратного обеспечения. Над компилятором находитесь вы, с вашими представлениями о том, как должен работать тот или иной код. У вас практически нет шансов внести такую микрооптимизацию, которая в состоянии существенно повысить производительность генерируемого интеллектуальным компилятором кода. Итак, оптимизации должны предшествовать измерения, а измерениям должна предшествовать выработка целей оптимизации. Пока необходимость оптимизации не доказана — вашим приоритетом №1 должно быть написание кода для человека. (Если кто-то потребует от вас оптимизации кода — потребуйте доказательств необходимости этого.)
Во-вторых, в современных программах все больше и больше операций, скорость работы которых ограничена не процессором, а, например, работой с памятью, диском или сетью, ожиданием ответа от Web-сервиса или базы данных. В лучшем случае оптимизация такого кода приведет к тому, что ваша программа будет быстрее ожидать. Это также означает, что программист зря тратит драгоценное время на улучшение того, что не требует улучшений, вместо того, чтобы заняться тем, что действительно требует его вмешательства.
Само собой разумеется, настанет день, когда вам действительно придется заняться оптимизацией вашего кода. Когда вы займетесь этим — начните с оптимизации алгоритмов (рекомендация 7) и попытайтесь инкапсулировать оптимизацию (например, в пределах функции или класса, см. рекомендации 5 и 11), четко указав в комментариях причину проводимой оптимизации и ссылку на использованный алгоритм.
Обычная ошибка новичка состоит в том, что когда он пишет новый код, то — с гордостью! — старается сделать его оптимальным ценой понятности. Чаще всего это приводит к милям "спагетти" (говоря проще — к "соплям" в программе), и даже корректно работающий код становится очень трудно читать и модифицировать (см. рекомендацию 6).
Передача параметров по ссылке (рекомендация 25), использование префиксной формы операторов ++ и -- (рекомендация 28) или подобных идиом, которые при работе должны естественным образом "стекать с кончиков ваших пальцев", преждевременной оптимизацией не являются. Это всего лишь устранение преждевременной пессимизации (рекомендация 9).
Примеры
Пример. Ирония использования inline . Это простейшая демонстрация скрытой стоимости преждевременной микрооптимизации. Профайлеры легко могут сказать вам (исследовав количество вызовов функций), какие функции должны быть встраиваемыми, но не являются таковыми. Но те же профайлеры не в состоянии подсказать, какие встраиваемые функции не должны быть таковыми. Очень многие программисты используют "встраивание по умолчанию" во имя оптимизации, почти всегда за счет большей связности программы достигая в лучшем случае весьма сомнительных результатов. (Сказанное означает, что делать функцию встраиваемой должен компилятор, а не программист. См. [Sutter00], [Sutter02] и [Sutter04].)
Исключения
Когда вы пишете библиотеки, трудно предсказать, какие операции будут использоваться в критичном по отношению к производительности коде. Но даже автор библиотеки должен испытать свой код на производительность в разнообразных пользовательских приложениях перед тем, как усложнять свой код оптимизацией.
Ссылки
[Bentley00] §6 • [Cline99] §13.01-09 • [Kernighan99] §7 • [Lakos96] §9.1.14 • [Meyers97] §33 • [Murray93] §9.9-10, §9.13 • [Stroustrup00] §6 introduction • [Sutter00] §30, §46 • [Sutter02] §12 • [Sutter04] §25
9. Не пессимизируйте преждевременно
Резюме
То, что просто для вас, — просто и для кода. При прочих равных условиях, в особенности — сложности и удобочитаемости кода, ряд эффективных шаблонов проектирования и идиом кодирования должны естественным образом "стекать с кончиков ваших пальцев" и быть не сложнее в написании, чем их пессимизированные альтернативы. Это не преждевременная оптимизация, а избежание излишней пессимизации.
Обсуждение
Избежание преждевременной оптимизации не влечет за собой снижения эффективности. Под преждевременной пессимизацией мы подразумеваем написание таких неоправданных потенциально неэффективных вещей, как перечисленные ниже.
• Передача параметров по значению там, где применима передача параметров по ссылке (рекомендация 25).
• Использование постфиксной версии ++ там, где с тем же успехом можно воспользоваться префиксной версией (рекомендация 28).
• Использование присваивания в конструкторах вместо списка инициализации (рекомендация 48).
Не является преждевременной оптимизацией снижение количества фиктивных временных копий объектов, в особенности во внутренних циклах, если это не приводит к усложнению кода. В рекомендации 18 поощряется максимально локальное объявление переменных, но там же приведено и описание исключения — возможный вынос переменных из цикла. В большинстве случаев такое действие не усложняет понимание предназначения кода, более того, может помочь пояснить, что именно делается в цикле и какие вычисления являются его инвариантами. Конечно же, предпочтительно использовать готовые алгоритмы вместо написания явных циклов (рекомендация 84).
Два важных способа усовершенствования программы, которые делают ее одновременно и яснее, и эффективнее — это использование абстракций (см. рекомендации 11 и 36) и библиотек (рекомендация 84). Например, использование vector, list, map, find, sort и других возможностей стандартной библиотеки, стандартизированных и реализованных экспертами мирового класса, не только делают ваш код яснее и легче понимаемым, но зачастую и более быстрым.
Избежание преждевременной пессимизации становится особенно важным, когда вы пишете библиотеку. При этом вы обычно не знаете контекста использования вашей библиотеки, а поэтому должны суметь сбалансировать эффективность и возможность повторного использования. Не забывайте уроков рекомендации 7 — следует куда больше заботиться о масштабируемости, чем о выигрыше пары тактов процессора.
Ссылки
[Keffer95] pp. 12-13 • [Stroustrup00] §6 introduction • [Sutter00] §6
10. Минимизируйте глобальные и совместно используемые данные
Резюме
Совместное использование вызывает споры и раздоры. Избегайте совместного использования данных, в особенности глобальных данных. Совместно используемые данные усиливают связность, что приводит к снижению сопровождаемости, а зачастую и производительности.
Обсуждение
Это утверждение носит более общий характер, чем более узкое требование рекомендации 18.
Следует избегать данных со внешним связыванием в области видимости пространства имен или представляющих собой статические члены классов. Их применение усложняет логику программы и приводит к тесной связи между различными (и, что еще хуже, отдаленными) частями программы. Совместно используемые данные делают менее эффективным тестирование модулей, поскольку корректность фрагмента кода, использующего общие данные, обусловлена историей изменения этих данных, а кроме того, обуславливает функционирование некоторого, пока неизвестного, кода, который будет использовать эти данные позже.
Имена объектов в глобальном пространстве имен приводят к его дополнительному засорению.
Если вам никак не обойтись без глобальных объектов, объектов в области видимости пространства имен или статических членов классов, убедитесь в их корректной инициализации. Порядок инициализации таких объектов из разных единиц компиляции не определен, поэтому для корректной работы в таком случае требуются специальные методики (см. прилагаемые ссылки). Правила порядка инициализации достаточно сложны, поэтому лучше их избегать; но если вы все же вынуждены иметь с ними дело, то должны хорошо их изучить и использовать с величайшей осторожностью.
Объекты, находящиеся в области видимости пространств имен, статические члены или совместно используемые разными потоками или процессами, снижают уровень распараллеливания в многопоточных и многопроцессорных средах, и часто являются узким местом с точки зрения производительности и масштабируемости (см. рекомендацию 7). Старайтесь избавиться от совместного использования данных; используйте вместо него средства коммуникации (например, очередь сообщений).
Предпочтительно обеспечить низкую связность и минимизировать взаимодействие классов (см. [Cargill92]).
Исключения
Такие средства уровня программы, как cin, cout и cerr, являются специализированными и реализуются специальным образом. Фабрика должна поддерживать реестр функций, которые должны вызываться для создания данного типа, и такой реестр обычно один на всю программу (тем не менее предпочтительно, чтобы он был внутренним объектом по отношению к фабрике, а не глобальным совместно используемым объектом; см. рекомендацию 11).
Код, в котором объект совместно используется разными потоками, должен обеспечить сериализацию обращений к такому объекту (см. рекомендацию 12 и [Sutter04c]).
Ссылки
[Cargill92] pp. 126, 136, 169-173 • [Dewhurst03] §3 • [Lakos96] §2.3.1 • [McConnell93] §5.1-4 • [Stroustrup00] §C.10.1 • [Sutter00] §47 • [Sutter02] §16, Appendix A • [Sutter04c] • [SuttHysl03]
11. Сокрытие информации
Резюме
Не выпускайте внутреннюю информацию за пределы объекта, обеспечивающего абстракцию.
Обсуждение
Для минимизации зависимостей между вызывающим кодом, который работает с абстракцией, и реализацией абстракции, внутренние данные такой реализации должны быть скрыты. В противном случае вызывающий код может обратиться к этой информации (или, что еще хуже, изменить ее), и такая утечка предназначенной сугубо для внутреннего использования информация делает вызывающий код зависимым от внутреннего представления абстракции. Открывать следует абстракции (предпочтительно из соответствующей предметной области, но, как минимум, абстракцию get/set), а не данные.
Сокрытие информации уменьшает стоимость проекта, сроки разработки и/или риск двумя основными путями.
• Оно локализует изменения. Сокрытие информации снижает область "эффекта волны" вносимого изменения и, соответственно, его стоимость.
• Оно усиливает инварианты. Сокрытие информации ограничивает код, ответственный за сохранение (и, в случае ошибки, за нарушение) инвариантов программы (см. рекомендацию 41).
Не позволяйте "засветиться" данным из любого объекта, который обеспечивает абстракцию (см. также рекомендацию 10). Данные — всего лишь одно из воплощений абстракции, концептуальное состояние. Если вы сконцентрируетесь на концепции, а не на ее представлениях, вы сможете предложить вдумчивый интерфейс, а "вылизать" реализацию можно и позже — например, путем применения кэширования вместо вычисления "на лету", или использования различных оптимизирующих представлений (например, полярных координат вместо декартовых).
Распространенный пример состоит в том, что члены-данные классов никогда не делаются доступными извне при помощи спецификатора public (см. рекомендацию 41) или посредством указателей или дескрипторов (см. рекомендацию 42). Этот принцип в той же степени применим и к большим сущностям — например, таким, как библиотеки, которые также не должны разрешать доступ к внутренней информации извне. Модули и библиотеки должны предоставлять интерфейсы, которые определяют абстракции и работу с ними, и таким образом обеспечивают большую безопасность для вызывающего кода и меньшую связность, чем при применении совместно используемых данных.
Исключения
Тестирование кода зачастую требует возможности обращения ко "внутренностям" тестируемого класса или модуля.
Совокупности значений (struct в стиле С), которые представляют собой просто набор данных без предоставления каких-либо абстракций, не требуют сокрытия своих данных, которые в этом случае являются интерфейсом (см. рекомендацию 41).
Ссылки
[Brooks95] §19 • [McConnell93] §6.2 • [Parnas02] • [Stroustrup00] §24.4 • [SuttHysl04a]
12. Кодирование параллельных вычислений
Резюме
Если ваше приложение использует несколько потоков или процессов, следует минимизировать количество совместно используемых объектов, где это только можно (см. рекомендацию 10), и аккуратно работать с оставшимися.
Обсуждение
Работа с потоками — отдельная большая тема. Данная рекомендация оказалась в книге, потому что эта тема очень важна и требует рассмотрения. К сожалению, одна рекомендация не в силах сделать это полно и корректно, поэтому мы только резюмируем несколько наиболее важных положений и посоветуем обратиться к указанным ниже ссылкам за дополнительной информацией. Среди наиболее важных вопросов, касающихся параллельных вычислений, такие как избежание взаимоблокировок (deadlock), неустойчивых взаимоблокировок (livelock) и условий гонки (race conditions).
Стандарт С++ ничего не говорит о потоках. Тем не менее, С++ постоянно и широко применяется для написания кода с интенсивным использованием многопоточности. Если в вашем приложении потоки совместно используют данные, на это следует обратить особое внимание.
• Ознакомьтесь с документацией по целевой платформе на предмет локальных примитивов синхронизации. Обычно они охватывают диапазон от простых атомарных операций с целыми числами до межпроцессных взаимоисключений и семафоров.
• Предпочтительно "обернуть" примитивы платформы в собственные абстракции. Это хорошая мысль, в особенности если вам требуется межплатформенная переносимость. Вы можете также воспользоваться библиотекой (например, pthreads [Butenhof97]), которая сделает это за вас.
• Убедитесь, что используемые вами типы можно безопасно применять в многопоточных программах. В частности, каждый тип должен как минимум
• гарантировать независимость объектов, которые не используются совместно. Два потока могут свободно использовать различные объекты без каких-либо специальных действий со стороны вызывающего кода;
• документировать необходимые действия со стороны вызывающего кода при использовании одного объекта в разных потоках. Многие типы требуют сериализации доступа к таким совместно используемым объектам, но есть типы, для которых это условие не является обязательным. Обычно такие типы либо спроектированы таким образом, что избегают требований блокировки, либо выполняют блокировку самостоятельно. В любом случае, вы должны быть ознакомлены с ограничениями, накладываемыми используемыми вами типами.
Заметим, что сказанное выше применимо независимо от того, является ли тип некоторым строковым типом, контейнером STL наподобие vector или некоторым иным типом. (Мы заметили, что ряд авторов дают советы, из которых вытекает, что стандартные контейнеры представляют собой нечто отдельное. Это не так — контейнер представляет собой просто объект другого типа.) В частности, если вы хотите использовать компоненты стандартной библиотеки (например, string или контейнеры) в многопоточной программе, проконсультируйтесь сначала с документацией разработчика используемой вами стандартной библиотеки, чтобы узнать, как именно следует пользоваться ею в многопоточном приложении.
При разработке собственного типа, который предназначен для использования в многопоточной программе, вы должны сделать те же две вещи. Во-первых, вы должны гарантировать, что различные потоки могут использовать различные объекты вашего типа без использования блокировок (заметим, что обычно тип с изменяемыми статическими данными не в состоянии обеспечить такую гарантию). Во-вторых, вы должны документировать, что именно должны сделать пользователи для того, чтобы безопасно использовать один и тот же объект в разных потоках. Фундаментальный вопрос проектирования заключается в том, как распределить ответственность за корректное выполнение программы (без условий гонки и взаимоблокировок) между классом и его клиентом. Вот основные возможности.
• Внешняя блокировка. За блокировку отвечает вызывающий код. При таком выборе код, который использует объект, должен сам выяснять, используется ли этот объект другими потоками и, если это так, отвечает за сериализацию его использования. Например, строковые типы обычно используют внешнюю блокировку (или неизменяемость — см. третью возможность).
• Внутренняя блокировка. Каждый объект сериализует все обращения к себе, обычно путем соответствующего блокирования всех открытых функций-членов, так что пользователю не надо предпринимать никаких дополнительных действий по сериализации использования объекта. Например, очереди производитель/потребитель обычно используют внутреннюю блокировку, поскольку сам смысл их существования состоит в совместном использовании разными потоками, и их интерфейсы спроектированы с использованием блокировок соответствующего уровня для отдельных вызовов функций-членов (Push, Pop). В общем случае заметим, что этот вариант применим, только если вы знаете две вещи.
Во-первых, вы должны заранее знать о том, что объекты данного типа практически всегда будут совместно использоваться разными потоками; в противном случае вы просто разработаете бесполезную блокировку. Заметим, что большинство типов не удовлетворяют этому условию; подавляющее большинство объектов даже в программах с интенсивным использованием многопоточности не разделяются разными потоками (и это хорошо — см. рекомендацию 10).
Во-вторых, вы должны заранее быть уверены, что блокировка на уровне функций обеспечивает корректный уровень модульности, которого достаточно для большинства вызывающий функций. В частности, интерфейс типа должен быть спроектирован в пользу самодостаточных операций с невысокой степенью детализации. Если вызывающий код должен блокировать несколько операций, а не одну, то такой способ неприменим. В этом случае отдельные функции могут быть собраны в блокируемый модуль большего масштаба, работа с которым выполняется при помощи дополнительной (внешней) блокировки. Например, рассмотрим тип, который возвращает итератор, который может стать недействительным перед тем, как вы используете его, или предоставляет алгоритм наподобие find, возвращающий верный ответ, который становится неверным до того, как вы им воспользуетесь, или пользователь напишет код if (с.empty()) с.push_back(x); (другие примеры можно найти в [Sutter02]). В таких случаях вызывающая функция должна выполнить внешнюю блокировку на время выполнения всех отдельных вызовов функций-членов, так что отдельные блокировки для каждой функции-члена оказываются ненужной расточительностью.
Итак, внутренняя блокировка связана с открытым интерфейсом типа. Она становится применима только тогда, когда отдельные операции типа являются сами по себе завершенными; другими словами, когда уровень абстракции типа растет и выражается и инкапсулируется более точно (например, как у очереди производитель/потребитель по отношению к обычному контейнеру vector). Объединение примитивных операций для образования более крупных общих операций — этот подход требуется для того, чтобы обеспечить возможность простого вызова функции с большим внутренним содержанием. В ситуациях, когда комбинирование примитивов может быть произвольным и вы не можете определить разумный набор сценариев использования в виде одной именованной операции, имеются две альтернативы. Можно воспользоваться моделью функций обратного вызова (т.е. вызывающая функция должна вызвать одну функцию-член, передавая ей задание, которое следует выполнить, в виде команды или объекта-функции; см. рекомендации с 87 по 89). Второй метод состоит в некотором способе предоставления вызывающему коду возможности блокировки в открытом интерфейсе.
• Проектирование, не требующее блокировок, включая неизменяемость (объекты, предназначенные только для чтения). Можно разработать типы, для которых блокировка окажется полностью ненужной (см. ссылки). Одним из распространенных примеров являются неизменяемые объекты, которые не требуют блокировки, поскольку они никогда не изменяются. Например, будучи создан, объект неизменяемого строкового типа больше не модифицируется, а все строковые операции приводят к созданию новых строк.
Заметим, что вызывающий код ничего не должен знать о деталях реализации ваших типов (см. рекомендацию 11). Если ваш тип внутренне использует какие-то методики разделения данных (например, копирование при записи), вы не должны нести ответственность за все возможные вопросы безопасности потоков, но обязаны обеспечить корректность работы вызывающего кода при обычной работе — т.е. тип должен быть безопасен в плане многопоточности в той же мере, как если бы он не использовал методики совместного использования данных (см. [Sutter04c]). Как упоминалось, все корректно написанные типы должны позволять работу с различными объектами в разных потоках без синхронизации.
В частности, если вы разрабатываете библиотеку, предназначенную для широкого использования, вы должны предусмотреть безопасность ваших объектов в многопоточных программах, как описано выше, но при этом без дополнительных накладных расходов при работе в однопоточной программе. Например, если вы пишете библиотеку, содержащую тип, использующий копирование при записи, и вы должны обеспечить, как минимум, некоторую внутреннюю блокировку, то предпочтительно разработать ее так, чтобы в однопоточном варианте вашей библиотеки ее не было (обычно для этого используются директивы препроцессора #ifdef).
Если используется несколько блокировок, то избежать взаимоблокировки можно путем их запроса в одинаковом порядке (освобождение блокировок может выполняться в любом порядке). Одно из решений состоит в запросе блокировок в порядке возрастания адресов в памяти, что обеспечивает удобное, однозначное упорядочение в пределах приложения.
Ссылки
[Alexandrescu02a] • [Alexandrescu04] • [Butenhof97] • [Henney00] • [Henney01] • [Meyers04] • [Schmidt01] • [Stroustrup00] §14.9 • [Sutter02] §16 • [Sutter04c]
13. Ресурсы должны быть во владении объектов
Резюме
Не работайте вручную, если у вас есть мощные инструменты. Идиома С++ "выделение ресурса есть инициализация" (resource acquisition is initialization — RAII) представляет собой мощный инструмент для корректной работы с ресурсами. RAII позволяет компилятору автоматически обеспечить строгую гарантию того, что в других языках надо делать вручную. При выделении ресурса передайте его объекту-владельцу. Никогда не выделяйте несколько ресурсов в одной инструкции.
Обсуждение
Симметрия конструктор/деструктор, обеспечиваемая языком С++, воспроизводит симметрию, присущую парам функций захвата/освобождения ресурса, таким как fopen/fclose, lock/unlock и new/delete. Это делает стековые объекты (или объекты со счетчиком ссылок), в конструкторе которых происходит захват ресурса (а в деструкторе его освобождение), превосходным инструментом для автоматизации управления ресурсами.
Автоматизация легко реализуема, элегантна, недорога и по сути безопасна в плане ошибок. Если вы не будете ею пользоваться, то обречете себя на нетривиальную и кропотливую ручную работу по "спариванию" вызовов захвата и освобождения ресурсов, включающую отслеживание всех ветвлений и исключений. Это совершенно неприемлемый путь для С++, который предоставляет возможность автоматизации этой работы при помощи простой в использовании идиомы RAII.
Когда вы имеете дело с ресурсом, который требует спаривания вызовов функций захвата/освобождения, инкапсулируйте этот ресурс в объект, который выполнит эту работу за вас и освободит ресурс в своем деструкторе. Например, вместо непосредственного вызова пары функций (не членов) OpenPort/ClosePort можно поступить иначе:
class Port {
public:
Port(const string& destination); // Вызов OpenPort
~Port(); // вызов ClosePort
// Порты обычно не клонируются, так что запрещаем
// копирование и присваивание
};
void DoSomething() {
Port port1("server1:80");
// ...
} // Забыть закрыть порт нельзя - он будет закрыт
// автоматически при выходе из области видимости
shared_ptr
// автоматически, когда будет уничтожен последний
// ссылающийся на него объект shared_ptr
Вы можете также использовать библиотеки, которые реализуют соответствующий шаблон проектирования (см. [Alexandrescu00c]).
При реализации идиомы RAII следует особо тщательно подходить к вопросу о копирующем конструкторе и присваивании (см. рекомендацию 49): обычно генерируемые компилятором версии этих функций не подходят. Если копирование лишено смысла, копирующий конструктор и оператор присваивания можно явным образом запретить, делая их закрытыми членами и не определяя (см. рекомендацию 53). В противном случае копирующий конструктор дублирует ресурс или использует счетчик ссылок на него, и то же делает и оператор присваивания, при необходимости освободив ресурс, которым объект владел до присваивания. Классической ошибкой является освобождение старого ресурса до того, как успешно дублирован новый (см. рекомендацию 71).
Обеспечьте, чтобы все ресурсы принадлежали объектам. Предпочтительно хранить все динамически выделенные ресурсы посредством интеллектуальных, а не обычных, указателей. Кроме того, следует выполнять каждое явное выделение ресурса (например, new) в отдельной инструкции, которая тут же передает ресурс управляющему объекту (например, shared_ptr). В противном случае может возникнуть утечка ресурсов, связанная с тем, что порядок вычисления параметров функции не определен (см. рекомендацию 31). Например:
void Fun(shared_ptr
// ...
Fun(shared_ptr
shared_ptr
Такой код небезопасен. Стандарт C++ предоставляет компилятору большую свободу действий по переупорядочению выражений, которые создают два аргумента функции. В частности, компилятор может чередовать выполнение этих двух выражений: сначала для обоих объектов может быть выполнено выделение памяти (при помощи оператора new), а уже затем будут вызваны два конструктора Widget. Такая последовательность действий может привести к утечке: если один из конструкторов сгенерирует исключение, то память для другого объекта никогда не будет освобождена (более детальную информацию по этому вопросу вы найдете в [Sutter02]).
Эта тонкая проблема имеет простое решение: следуйте приведенному выше совету и никогда не выделяйте в одной инструкции больше одного ресурса. Следует выполнять каждое явное выделение ресурса (например, new) в отдельной инструкции, которая тут же передает ресурс управляющему объекту (например, shared_ptr), например:
shared_ptr
Fun(sp1, sp2);
См. также описание дополнительных преимуществ такого стиля в рекомендации 31.
Исключения
Можно чересчур увлечься интеллектуальными указателями. Обычные указатели вполне подходят для кода, в котором указываемый объект виден только в ограниченном объеме (например, внутри класса — типа указателя на узел дерева в классе Tree, использующийся для навигации по дереву).
Ссылки
[Alexandrescu00c] • [Cline99] §31.03-05 • [Dewhurst03] §24, §67 • [Meyers96] §9-10 • [Mitewski01] • [Stroustrup00] §14.3-4, §25.7, §E.3, §E.6 • [Sutter00] §16 • [Sutter02] §20-21 • [Vandevoorde03] §20.1.4