В этом разделе мы перейдем от вопросов проектирования к вопросам, которые появляются в основном при реальном кодировании.
Правила и рекомендации из этого раздела применимы безотносительно к конкретной области языка программирования (например, функциям, классам или пространствам имен), но приводят к повышению качества вашего кода. Многие из представленных идиом позволяют вашему компилятору активнее помогать вам в работе, а вам — избежать опасных мест (включая неопределенное поведение), которые компилятор не всегда в состоянии выявить. Все это делает ваш код более надежным.
В этом разделе мы считаем наиболее важной рекомендацию 14: "Предпочитайте ошибки компиляции и компоновки ошибкам времени выполнения".
14. Предпочитайте ошибки компиляции и компоновки ошибкам времени выполнения
Резюме
Не стоит откладывать до выполнения программы выявление ошибок, которые можно обнаружить при ее сборке. Предпочтительно писать код, который использует компилятор для проверки инвариантов в процессе компиляции, вместо того, чтобы проверять их во время работы программы. Проверки времени выполнения зависят от выполнимого кода и данных, так что вы только изредка можете полностью полагаться на них. Проверки времени компиляции, напротив, не зависят от данных и предыстории исполнения, что обычно обеспечивает более высокую степень надежности.
Обсуждение
Язык С++ предлагает массу средств для "ускорения" обнаружения ошибок во время компиляции. Использование этих возможностей статических проверок дает массу преимуществ, включая следующие.
• Статические проверки не зависят от данных и логики программы. Статические проверки гарантируют независимость от входных данных программы или потока ее выполнения. В противоположность этому, чтобы убедиться в достаточной строгости тестирования времени выполнения, вы должны проверить его на представительном наборе входных данных. Это достаточно большая и неприятная работа для всех нетривиальных систем.
• Статически выраженные модели более строги. Зачастую то, что программа полагается на проверки времени компиляции, а не времени выполнения, отражает лучший дизайн, поскольку модель, создаваемая программой, корректно выражена с использованием системы типов С++. Таким образом, вы и компилятор оказываетесь партнерами с общим взглядом на инварианты программ. Зачастую проверки времени выполнения приходится использовать там, где теоретически проверку можно было бы провести статически, но сделать это невозможно из-за ограничений языка программирования (см. рекомендацию 68).
• Статические проверки не приводят к накладным расходам времени выполнения. При замене динамических проверок статическими создаваемая выполнимая программа оказывается быстрее, оставаясь столь же корректной.
Один из наиболее мощных инструментов статических проверок в С++ — статическая проверка типов. Споры о том, должны ли типы проверяться статически (С++, Java, ML, Haskell) или динамически (Smalltalk, Ruby, Python, Lisp), все еще активно продолжаются. В общем случае нет явного победителя, и имеются языки и стили разработки, которые дают хорошие результаты как в одном, так и во втором случае. Сторонники статической проверки аргументируют свою позицию тем, что обработка большого класса ошибок времени выполнения может быть просто устранена, что дает более надежную и качественную программу. Поклонники динамических проверок говорят, что компиляторы способны выявить только часть потенциальных ошибок, так что если вы все равно должны писать тесты для ваших модулей, вы можете вообще не волноваться о статических проверках, получив при этом менее ограничивающую среду программирования.
Понятно одно: в контексте статически типизированного языка С++, обеспечивающего строгую проверку типов и минимальную автоматическую проверку времени выполнения, программисты определенно должны использовать систему типов для своей пользы везде, где только это возможно (см. рекомендации с 90 по 100). В то же время тестирование времени выполнения целесообразно для выполнения проверок, зависящих от данных и потока выполнения программы (например, проверка выхода за границы массива или корректности входных данных) (см. рекомендации 70 и 71).
Примеры
Имеется ряд примеров, где вы можете заменить проверки времени выполнения проверками времени компиляции.
Пример 1. Логические условия времени компиляции. Если вы проверяете логическое условие времени компиляции наподобие sizeof(int) >= 8, используйте статические проверки (обратите также внимание на рекомендацию 91).
Пример 2. Полиморфизм времени компиляции. Подумайте о замене полиморфизма времени выполнения (виртуальные функции) полиморфизмом времени компиляции (шаблоны) при определении обобщенных функций или типов. Последний приводит к коду с лучшей статической проверкой (см. также рекомендацию 64).
Пример 3. Перечисления. Подумайте об определении перечислений (или, что еще лучше, полностью законченных типов), когда вам требуется выразить символьные константы или ограниченные целочисленные значения.
Пример 4. Понижающее преобразование типов. Если вы часто используете оператор dynamic_cast (или, что еще хуже, непроверяемый static_cast) для понижающего преобразования типов, возможно, ваш базовый класс предоставляет слишком малую функциональность? Подумайте над перепроектированием ваших интерфейсов таким образом, чтобы ваша программа могла выразить необходимые вычисления посредством базового класса.
Исключения
Некоторые условия не могут быть проверены в процессе компиляции и требуют проверки времени выполнения. В таком случае для обнаружения внутренних программных ошибок следует использовать assert (см. рекомендацию 68) и следовать советам из остальной части раздела, посвященного обработке ошибок, для прочих ошибок времени выполнения, таких как ошибки, зависящие от данных (см. рекомендации с 69 по 75).
Ссылки
[Alexandrescu01] §3 • [Boost] • [Meyers97] §46 • [Stroustrup00] §2.4.2 • [Sutter02] §4 • [Sutter04] §2, §19
15. Активно используйте
const
Резюме
const — ваш друг: неизменяемые значения проще понимать, отслеживать и мотивировать, т.е. там, где это целесообразно, лучше использовать константы вместо переменных. Сделайте const описанием по умолчанию при определении значения — это безопасно, проверяемо во время компиляции (см. рекомендацию 14) и интегрируемо с системой типов С++. Не выполняйте преобразований типов с отбрасыванием const кроме как при вызове некорректной с точки зрения употребления const функции (см. рекомендацию 94).
Обсуждение
Константы упрощают код, поскольку вам достаточно только один раз взглянуть на ее определение, чтобы знать, чему она равна везде. Рассмотрим такой код:
void Fun(vector
// ...
const size_t len = v.size();
// ... и еще 30 строк ...
}
Увидев такое определение len, вы получаете надежную информацию о семантике этой константы в пределах области ее видимости (в предположении, что код не устраняет ее константность, чего он делать не должен, как вы узнаете далее): это информация о длине v в определенной точке программы. Взглянув на одну строку, вы получили всю необходимую информацию для всей области видимости. Если бы переменная len не была определена как const, она могла бы быть позже изменена — непосредственно или косвенно.
Заметим, что описание const не является глубоким. Чтобы понять что имеется в виду, рассмотрим класс С, который имеет член типа X*. В объекте С, который является константой, член X* также является константой, но сам объект X, на который он указывает, константой не является (см. [Saks99]).
Логическую константность следует реализовывать с использованием членов, описанных как mutable. Когда константная функция-член класса оправданно требует модификации переменной-члена (т.е. когда эта переменная не влияет на наблюдаемое состояние объекта, например, если это кэшированные данные), объявите эту переменную-член как mutable. Заметим, что если все закрытые члены скрыты с использованием идиомы Pimpl (см. рекомендацию 43), описание mutable не является необходимым ни для кэшированной информации, ни для неизменного указателя на нее.
Модификатор const напоминает вирусное заболевание — появившись в вашем коде один раз, он приведет к необходимости соответствующего изменения сигнатур функций, которые еще не являются корректными в плане использования const. Это как раз не ошибка, а хорошее свойство, существенно увеличивающее мощь модификатора const, который еще не так давно был достаточно заброшен, а его возможности не вполне поняты и оценены. Переделка существующего кода для его корректности в плане использования const требует усилий, но они стоят того и даже позволяют выявить скрытые ошибки.
Корректное применение const дает отличные результаты и повышает эффективность. Чрезвычайно важно правильно и последовательно использовать модификатор const в ваших программах. Понимание того, как и где изменяется состояние программы, особенно необходимо, а модификатор const по сути документирует непосредственно в коде программы, где именно компилятор может помочь вам в этом. Правильное употребление const поможет вам лучше разобраться с вопросами проектирования и сделать ваш код более надежным и безопасным. Если вы выяснили, что некоторую функцию-член невозможно сделать константной, значит, вы более детально разобрались с тем, как, где и почему эта функция модифицирует состояние объекта. Кроме того, вы сможете понять, какие члены-данные объединяют физическую и логическую константность (см. приведенные ниже примеры).
Никогда не прибегайте к преобразованию константного типа в неконстантный, кроме случаев вызова функции, некорректной в плане использования модификатора const (не модифицирующей параметр, который тем не менее описан как неконстантный), а также такого редкого случая, как способ замены mutable в старом компиляторе, не поддерживающем эту возможность.
Примеры
Пример. Избегайте const в объявлениях функций, принимающих параметры по значению. Два следующих объявления абсолютно эквивалентны:
void Fun(int x);
void Fun(const int x); // Объявление той же самой функции:
// const здесь игнорируется
Во втором объявлении модификатор const избыточен. Мы рекомендуем объявлять функции без таких высокоуровневых модификаторов const, чтобы тот, кто читает ваши заголовочные файлы, не был дезориентирован. Однако использование такого модификатора имеет значение в определении функции и его применение может быть оправдано с точки зрения обнаружения непреднамеренного изменения переданного параметра:
void Fun(const int x) { // определение функции Fun
// ...
++x; // Ошибка: нельзя изменять константное значение
// ...
}
Ссылки
[Allison98] §10 • [Cline99] §14.02-12 • [Dewhurst03] §6, §31-32, §82 • [Keffer95] pp. 5-6 • [Koenig97] §4 • [Lakos96] §9.1.6, §9.1.12 • [Meyers97] §21 • [Murray93] §2.7 • [Stroustrup00] §7.2, §10.2.6, §16.3.1 • [Sutter00] §43
16. Избегайте макросов
Резюме
Макрос — самый неприятный инструмент С и С++, оборотень, скрывающийся под личиной функции, кот, гуляющий сам по себе и не обращающий никакого внимания на границы ваших областей видимости. Берегитесь его!
Обсуждение
Трудно найти язык, достаточно красочный, чтобы выразить все, что хочется сказать о макросах. Но тем не менее приведем несколько цитат.
Макросы по многим причинам — весьма неприятная вещь, которая может стать попросту опасной. В первую очередь это связано с тем, что макросы — средство замены текста, действующее во время обработки исходного текста препроцессором, т.е. еще до того, как начнется какая-либо проверка синтаксиса и семантики.— [Sutter04] §31
Мне не нравится большинство видов препроцессоров и макросов. Одна из целей С++ — сделать препроцессор С излишним (§4.4, §18), поскольку я считаю его большой ошибкой— [Stroustrup94] §3.3.1.
Макросы почти никогда не являются необходимыми в С++. Используйте const (§5.4) или enum (§4.8) для определения явных констант [см. рекомендацию 15] , inline (§7.1.1) для того, чтобы избежать накладных расходов на вызов функции [но см. рекомендацию 8], template (глава 13) для определения семейств функций и типов [см. рекомендации с 64 по 67] , и namespace (§8.2) для того, чтобы избежать конфликтов имен [см. рекомендации с 57 по 59].— [Stroustrup00] §1.6.1
Первое правило по применению макросов гласит: не используйте их до тех пор, пока у вас не будет другого выхода. Практически любой макрос свидетельствует о несовершенстве языка программирования, программы или программиста.— [Stroustrup00] §7.8
Основная проблема с макросами С++ заключается в том, что они выглядят гораздо привлекательнее, чем являются таковыми на самом деле. Макросы игнорируют области видимости, игнорируют прочие возможности и правила языка, и заменяют все символы, которые переопределяют при помощи директивы #define, до самого конца файла. Применение макросов внешне походит на имя или вызов функции, но не имеет с ними ничего общего. Макросы "негигиеничны", в том смысле, что они могут быть раскрыты неожиданно, причем превратиться в зависимости от контекста их использования в самые разные конструкции. Подстановка текста, выполняемая макросами, делает написание хотя бы в небольшой степени "приличного" макроса смесью искусства и черной магии.
Программисты, которые полагают, что тяжелее всего расшифровать ошибки, связанные с шаблонами, вероятно, просто никогда не имели дела с плохо написанными или неверно использованными макросами. Шаблоны являются частью системы типов С++, и тем самым позволяют компилятору куда лучше справляться с ними, чем с макросами, которые имеют мало общего с языком программирования. Хуже того, в отличие от шаблонов неверные макросы могут быть раскрыты в нечто, что в силу чистой случайности скомпилируется, не имея при этом никакого смысла. И наконец, ошибка в макросе обнаруживается только после того, как макрос раскрывается, а не при его определении.
Даже в тех редких случаях, где применение макросов оправданно (см. подраздел, посвященный исключениям), нельзя даже подумать о том, чтобы написать макрос, который является распространенным словом или аббревиатурой. Для всех макросов как можно скорее применяйте директиву #undef, всегда давая им необычные уродливые имена в верхнем регистре, избегая при этом размещения их в заголовочных файлах.
Примеры
Пример. Передача инстанцирования шаблона макросу. Макросы понимают в достаточной мере только круглые и квадратные скобки. В С++, однако, определена новая конструкция с угловыми скобками, используемая в шаблонах. Макросы не могут корректно обработать эту ситуацию, так что вызов
macro(Foo
макрос воспринимает так, будто ему переданы два аргумента, а именно Foo
Исключения
Макросы остаются единственно возможным решением для некоторых важных задач, таких как защита директивы #include (см. рекомендацию 24), использование директив #ifdef и #if defined для условной компиляции и реализация assert (см. рекомендацию 68).
При условной компиляции (например, системно-зависимых частей) избегайте разброса по всему тексту директив #ifdef. Вместо этого лучше организовать код таким образом, чтобы использование макросов обеспечивало возможность альтернативных реализаций одного общего интерфейса, который затем будет использоваться в программе.
Можно (но осторожно) использовать макросы вместо большого количества копирований и вставок близких фрагментов кода.
Заметим, что [C99] и [Boost] включают соответственно умеренные и радикальные расширения препроцессоров.
Ссылки
[Boost] • [С99] • [Dewhurst03] §25-28 • [Lakos96] §2.3.4 • [Meyers96] §1 • [Stroustrup94] §3.3.1 • [Stroustrup00] §1.6.1, §7.8 • [Sutter02] §34-35 • [Sutter04] §31 • [Sutter04a]
17. Избегайте магических чисел
Резюме
Избегайте использования в коде литеральных констант наподобие 42 или 3.1415926. Такие константы не самоочевидны и усложняют сопровождение кода, поскольку вносят в него трудноопределимый вид дублирования. Используйте вместо них символьные имена и выражения наподобие width*aspectRatiо.
Обсуждение
Имена добавляют информацию и вводят единую точку сопровождения; в отличие от них дублированные по всей программе обычные числа анонимны и трудно сопровождаемы. Константы должны быть перечислениями или const-значениями, с соответствующими областями видимости и именами.
Одно число 42 может не быть тем же числом 42, что и другое. Что еще хуже, программист может выполнять какие-то вычисления "в уме" (например: "Вот это 84 — просто удвоенное 42, которое было пятью строками ранее"), что совершенно запутывает код и делает последующую замену 42 другой константой источником огромного количества ошибок.
Лучше заменять такие жестко кодированные величины символьными константами. Строки лучше хранить отдельно от кода (например, в отдельном .срр-файле или файле ресурса), что позволит непрограммистам просмотреть и обновить их, снижая количество дубликатов и помогая в интернационализации вашей программы.
Примеры
Пример 1. Важные константы из предметной области на уровне пространств имен.
const size_t PAGE_SIZE = 8192,
WORDS_PER_PAGE = PAGE_SIZE / sizeof(int),
INFO_BITS_PER_PAGE = 32 * CHAR_BIT;
Пример 2. Константы, специфичные для данного класса. Вы можете определить статические интегральные константы в определении класса; константы других типов требуют отдельного определения или применения коротких функций.
// Файл widget.h
class Widget {
// Значение указано в объявлении
static const int defaultWidth = 400;
// Значение указано в определении
static const double defaultPercent;
static const char* Name() { return "widget"; }
};
// Файл widget.cpp
// Значение указано в определении
const double widget::defaultPercent = 66.67;
// Требуется объявление
const int widget::defaultWidth;
Ссылки
[Dewhurst03] §2 • [Kernighan99] §1.5 • [Stroustrup00] §4.8, §5.4
18. Объявляйте переменные как можно локальнее
Резюме
Избегайте "раздувания" областей видимости. Переменных должно быть как можно меньше, а время их жизни — как можно короче. Эта рекомендация по сути является частным случаем рекомендации 10.
Обсуждение
Переменные, время жизни которых превышает необходимое, имеют ряд недостатков.
• Они делают программу трудно понимаемой и сопровождаемой. Например, должен ли код обновлять строку path на уровне модуля, если изменен только текущий диск?
• Они засоряют контекст своими именами. Непосредственным следствием является то, что переменные на уровне пространства имен, наиболее видимые среди всех остальных, одновременно являются и наихудшими (см. рекомендацию 10).
• Они не всегда могут быть корректно инициализированы. Никогда не объявляйте переменную до того, как вы сможете корректно ее инициализировать. Неинициализированные переменные — источник "расползающихся" ошибок во всех программах С и С++, и требуют особого внимания в связи с тем, что не всегда могут быть обнаружены компилятором (см. рекомендацию 19).
В частности, старые версии языка С до [C99] требовали, чтобы переменные были определены только в начале области видимости; такой стиль в С++ вышел из употребления. Серьезная проблема такого ограничения состоит в том, что зачастую в начале области видимости не имеется достаточной информации для инициализации переменных. В результате у вас остается два выхода — либо инициализировать переменные некоторым значением по умолчанию (например, нулем), что обычно расточительно и может привести к ошибкам (если переменная будет использована до того, как приобретет некоторое осмысленное значение), либо оставить их неинициализированными, что опасно. Неинициализированная переменная пользовательского типа будет самоинициализироваться некоторым пустым значением.
Лечение этой болезни очень простое — определяйте каждую переменную настолько локально, насколько можете, что обычно означает точку непосредственное перед ее первым использованием, когда у вас уже достаточно данных для корректной инициализации.
Исключения
Иногда с точки зрения производительности может оказаться выгодным вынесение переменной за пределы цикла (см. рекомендацию 9).
Поскольку константы не являются частью состояния программы, данная рекомендация на них не распространяется (см. рекомендацию 17).
Ссылки
[Dewhurst03] §3, §48, §66 • [Dewhurst03] §95 [McConnell93] §5.1-4, §10.1 • [Stroustrup00] §4.9.4, §6.3
19. Всегда инициализируйте переменные
Резюме
Неинициализированные переменные — распространенный источник ошибок в программах на С и С++. Избегайте их, выработав привычку очищать память перед ее использованием; инициализируйте переменные при их определении.
Обсуждение
В традициях низкоуровневой эффективности С++ (как и С), от компилятора зачастую не требуется инициализация переменных, пока вы не сделаете это явно (например, локальные переменные, члены, опущенные в списке инициализации конструктора). Такие переменные надо инициализировать явно.
Имеется несколько причин, по которым переменная может остаться неинициализированной. Но ни одна из них не является достаточно серьезной для того, чтобы оправдать опасность неопределенного поведения.
Если вы используете процедурный язык (такой как Pascal, С, Fortran или Cobol), вы можете определить переменные отдельно от кода, их использующего, и присвоить им значения позже, когда эти переменные будут использоваться. Этот подход устарел и не рекомендуется для использования (см. рекомендацию 18).
Распространенное заблуждение по поводу неинициализированных переменных заключается в том, что они приводят к краху программы, так что несколько неинициализированных переменных быстро обнаруживаются простым тестированием. На самом деле программы с неинициализированными переменными могут безукоризненно работать годами, если биты в памяти соответствуют требованиям программы. Позже вызов с другим контекстом, перекомпиляция или какие-то изменения в другой части программы могут привести к последствиям разной степени тяжести — от необъяснимого поведения до периодического аварийного завершения программы.
Примеры
Пример 1. Использование инициализирующего значения по умолчанию или оператора ?: для снижения степени смешивания потока данных и потока управления.
// Не рекомендуется: не инициализирует переменную
int speedupFactor;
if (condition)
speedupFactor = 2;
else
speedupFactor = -1;
// Лучше: инициализирует переменную
int speedupFactor = -1;
if (condition)
speedupFactor = 2;
// лучше: инициализирует переменную
int speedupFactor = condition ? 2 : -1;
Варианты, отмеченные как лучшие, не имеют промежутка между определением и инициализацией.
Пример 2. Замена сложных вычислений вызовом функции. Иногда вычисление значения происходит таким образом, что лучше инкапсулировать его в функции (см. рекомендацию 11).
// Не рекомендуется: не инициализирует переменную
int speedupFactor;
if (condition) {
// ... код ...
speedupFactor = somevalue;
} else {
// ... код ...
speedupFactor = someothervalue;
}
// Лучше: инициализирует переменную
int speedupFactor = ComputeSpeedupFactor();
Пример 3. Инициализация массивов. Для больших составных типов, таких как массивы, корректная инициализация не всегда означает реальное обращение к данным. Пусть, например, вы используете API, который заставляет вас использовать фиксированные массивы char размера МАХ_РАТН (см. рекомендации 77 и 78). Если вы уверены, что массивы всегда будут рассматриваться как строки в стиле С с завершающим нулевым символом, то такого немедленного присваивания будет достаточно:
// Допустимо: Создание пустой строки
char path[MAX_PATH];
path[0] = '\0';
Более безопасная инициализация заполняет все элементы массива нулевыми значениями:
// Лучше: заполняем нулями весь массив
char path[MAX_PATH] = { '\0' };
Рекомендованы оба варианта, но в общем случае вы должны предпочитать безопасность излишней эффективности.
Исключения
Входные буферы и данные, описанные как volatile, которые записываются непосредственно аппаратным обеспечением или другими процессами, не требуют инициализации программой.
Ссылки
[Dewhurst03] §48 • [Stroustrup00] §4.9.5, §6.3
20. Избегайте длинных функций и глубокой вложенности
Резюме
Краткость — сестра таланта. Чересчур длинные функции и чрезмерно вложенные блоки кода зачастую препятствуют реализации принципа "одна функция — одна задача" (см. рекомендацию 5), и обычно эта проблема решается лучшим разделением задачи на отдельные части.
Обсуждение
Каждая функция должна представлять собой связную единицу работы, несущую значимое имя (см. рекомендацию 5 и обсуждение рекомендации 70). Когда функция вместо этого пытается объединить малые концептуальные элементы такого рода в одном большом теле функции, это приводит к тому, что она начинает делать слишком многое.
Чрезмерно большая по размеру функция и чрезмерная вложенность блоков (например, блоков if, for, while и try) делают функции более трудными для понимания и сопровождения, причем зачастую без каких бы то ни было оснований.
Каждый дополнительный уровень вложенности приводит к излишним интеллектуальным нагрузкам при чтении кода, поскольку при этом требуется хранить в памяти "стек" наподобие "вошли в цикл… вошли в блок try… вошли в условный оператор… еще в один цикл…". Вам никогда не приходилось продираться сквозь сложный код и искать, какой же именно из множества конструкций for, whilе и т.п. соответствует вот эта закрывающая фигурная скобка? Более хорошее и продуманное разложение задачи на функции позволит читателю вашей программы одновременно удерживать в голове существенно больший контекст.
Воспользуйтесь здравым смыслом. Ограничивайте длину и глубину ваших функций. Далее приведены некоторые добрые советы, которые помогут вам в этом.
• Предпочитайте связность. Пусть одна функция решает только одну задачу (см. рекомендацию 5).
• Не повторяйтесь. Следует предпочесть именованную функцию повтору схожих фрагментов кода.
• Пользуйтесь оператором && . Избегайте вложенных последовательных конструкций if там, где их можно заменить оператором &&.
• Не нагружайте try . Предпочитайте использовать освобождение ресурсов в деструкторах, а не в try-блоках (см. рекомендацию 13).
• Пользуйтесь алгоритмами. Они короче, чем рукописные циклы, и зачастую лучше и эффективнее (см. рекомендацию 84).
• Не используйте switch для дескрипторов типов. Применяйте вместо этого полиморфные функции (см. рекомендацию 90).
Исключения
Функция может на законных основаниях быть длинной и/или глубокой, если ее функциональность нельзя разумно разделить на отдельные подзадачи, поскольку каждое такое потенциальное разделение требует передачи массы локальных переменных и контекста (что приводит к еще менее удобочитаемому результату). Но если несколько таких потенциальных функций получают аналогичные аргументы, они могут быть кандидатами в члены нового класса.
Ссылки
[Piwowarski82] • [Miller56]
21. Избегайте зависимостей инициализаций между единицами компиляции
Резюме
Объекты уровня пространств имен в разных единицах компиляции не должны зависеть друг от друга при инициализации, поскольку порядок их инициализации не определен. В противном случае вам обеспечена головная боль при попытках разобраться со сбоями в работе программы после внесения небольших изменений в ваш проект и невозможностью его переноса даже на новую версию того же самого компилятора.
Обсуждение
Когда вы определяете два объекта уровня пространства имен в разных единицах компиляции, конструктор какого из объектов будет вызван первым, не определено. Чаще всего (но не всегда) ваш инструментарий будет инициализировать их в том порядке, в котором компонуются скомпилированные объектные файлы, но полагаться на это предположение нельзя, даже если оно и выполняется, — вы же не хотите, чтобы корректность вашей программы зависела от вашего файла проекта или makefilе (дополнительную информацию о неприятностях, связанных с зависимостью от порядка, можно почерпнуть в рекомендации 59).
Таким образом, в коде инициализации любого объекта уровня пространства имен вы не можете полагаться на то, что уже инициализирован некоторый объект, определенный в другой единице компиляции. Это же касается и динамически инициализируемых переменных примитивных типов (пример такого кода на уровне пространства имен: bool reg_success = LibRegister("mylib");).
Заметим, что еще до того, как будет вызван конструктор, объект на уровне пространства имен статически инициализируется нулями (в отличие от, скажем, автоматического объекта, который обычно содержит мусор). Парадоксально, но эта инициализация нулями может затруднить обнаружение ошибки, поскольку вместо аварийного завершения программы такой заполненный нулями (но на самом деле неинициализированный) объект создает видимость корректности. Вам кажется, что строка пуста, указатель имеет нулевое значение, целое число равно нулю, — в то время как на самом деле никакой код еще и не пытался инициализировать ваши объекты.
Чтобы решить эту проблему, избегайте переменных уровня пространства имен везде, где только можно; такие переменные — вообще опасная практика (см. рекомендацию 10). Если вам нужна такая переменная, которая может зависеть от другой, подумайте о применении шаблона проектирования Singleton; при аккуратном его использовании можно избежать неявных зависимостей, обеспечивая инициализацию объекта при его первом использовании. Singleton остается глобальной переменной в шкуре овцы (еще раз см. рекомендацию 10), и не работает при взаимных или циклических зависимостях (и здесь инициализация нулями также способствует общей неразберихе).
Ссылки
[Dewhurst03] §55 • [Gamma95] • [McConnell93] §5.1-4 • [Stroustrup00] §9.4.1, §10.4.9
22. Минимизируйте зависимости определений и избегайте циклических зависимостей
Резюме
Избегайте излишних зависимостей. Не включайте при помощи директивы #include определения там, где достаточно предварительного объявления.
Избегайте взаимозависимостей. Циклические зависимости возникают, когда два модуля непосредственно или опосредованно зависят друг от друга. Модуль представляет собой обособленную единицу; взаимозависимые модули не являются полностью отдельными модулями, будучи по сути частями одного большего модуля. Таким образом, циклические зависимости являются противниками модульности и представляют угрозу большим проектам. Избегайте их.
Обсуждение
Предпочитайте использовать предварительные объявления везде, где не требуется полное определение типа. Полное определение класса С требуется в двух основных случаях.
• Когда вам необходимо знать размер объекта С. Например, при создании объекта С в стеке или при использовании его в качестве непосредственно хранимого (не через указатель) члена другого класса.
• Когда вам требуется имя или вызов члена С. Например, когда вы вызываете функцию-член этого класса.
Не будем рассматривать в этой книге тривиальные случаи циклических зависимостей, которые приводят к ошибкам компиляции — думаем, вы успешно справитесь с ними, воспользовавшись многими хорошими советами, имеющимися в литературе и рекомендации 1. Мы же обратимся к циклическим зависимостям, которые остаются в компилируемом коде, и посмотрим, как они влияют на его качество и какие действия следует предпринять, чтобы избежать их.
В общем случае зависимости и их циклы следует рассматривать на уровне модулей. Модуль представляет собой образующий единое целое набор совместно опубликованных классов и функций (см. рекомендацию 5). Циклическая зависимость в простейшем виде представляет собой два класса, непосредственно зависящих друг от друга
class Child; // Устранение циклической зависимости
class Parent { // ...
Child* myChild_;
};
// возможно, в другом заголовочном файле
class Child { // ...
Parent* myParent_;
};
Классы Parent и Child зависят друг от друга. Приведенный код компилируется, но мы сталкиваемся с фундаментальной проблемой: эти два класса больше не являются независимыми, и более того, становятся взаимозависимы друг от друга. Это не всегда плохо, но должно иметь место лишь в том случае, когда оба класса являются частями одного и того же модуля (разработанного одним и тем же человеком или командой, оттестированного и выпущенного как единое целое).
В противоположность описанной ситуации, рассмотрим, что будет, если класс Child не должен будет хранить обратную связь с объектом Parent? Тогда Child может быть выпущен в собственном отдельном небольшом модуле (и, возможно, под другим именем), полностью независимо от Parent — что, конечно, существенно повышает гибкость проектирования.
Все становится еще хуже, когда зависимости охватывают несколько модулей. Такой мощный "клей", как зависимости, объединяют эти модуля в единую монолитную публикуемую единицу. Вот почему циклы являются самыми страшными врагами модульности.
Чтобы разорвать циклы, примените принцип инверсии зависимостей (Dependency Inversion Principle), описанный в [Martin96a] и [Martin00] (см. также рекомендацию 36): не делайте модули высокого уровня зависящими от модулей низкого уровня; вместо этого делайте их зависимыми от абстракций. Если вы можете определить независимые абстрактные классы для Parent или Child, вы разорвете цикл. В противном случае вы должны сделать их частями одного и того же модуля.
Частный вид зависимости, от которой страдают некоторые проекты, — это транзитивная зависимость от производных классов, которая осуществляется, когда базовый класс зависит от всех своих наследников, прямых и непрямых. Ряд реализаций шаблона проектирования Visitor приводят к такому виду зависимости, которая допустима только в исключительно стабильных иерархиях. В противном случае вам лучше изменить свой проект, например, воспользовавшись шаблоном проектирования Acyclic Visitor [Martin98].
Одним из симптомов чрезмерных зависимостей является перекомпиляция больших частей проекта при внесении небольших локальных изменений (см. рекомендацию 2).
Исключения
Циклические зависимости между классами — не всегда плохо, пока классы рассматриваются как часть одного модуля и совместно тестируются и выпускаются. Простая непосредственная реализация таких шаблонов проектирования, как Command и Visitor приводят к интерфейсам, которые естественным образом оказываются взаимозависимыми. Такие зависимости можно разрушить, но это требует более четкого проектирования.
Ссылки
[Alexandrescu01] §3 • [Boost] • [Gamma95] • [Lakos96] §0.2.1, §4.6-14, §5 • [Martin96a] • [Martin96b] • [Martin98] §7 • [Martin00] • [McConnell93] §5 • [Meyers97] §46 • [Stroustrup00] §24.3.5 • [Sutter00] §26 • [Sutter02] §37 • [Sutter03]
23. Делайте заголовочные файлы самодостаточными
Резюме
Убедитесь, что каждый написанный вами заголовочный файл компилируем самостоятельно, т.е. что он включает все заголовочные файлы, от которых зависит его содержимое.
Обсуждение
Если один заголовочный файл не работает, пока не включен другой заголовочный файл, проект получается очень неуклюжим, а на пользователя возлагается дополнительная задача следить за тем, какие заголовочные файлы надо включить в исходный текст.
Раньше некоторые эксперты советовали, чтобы заголовочные файлы не включали другие заголовочные файлы из-за накладных расходов на многократное открытие и анализ заголовочных файлов, защищенных директивами препроцессоров от повторной обработки. К счастью, сейчас этот совет устарел. Многие современные компиляторы С++ распознают соответствующую защиту заголовочных файлов автоматически (см. рекомендацию 24) и просто не открывают один и тот же заголовочный файл дважды. Некоторые компиляторы используют предкомпиляцию заголовочных файлов, которая позволяет избежать анализа часто используемых заголовочных файлов.
Однако не включайте заголовочные файлы, в которых вы не нуждаетесь, так как это напрасно создает паразитные зависимости.
Для гарантии самодостаточности заголовочных файлов скомпилируйте каждый из них отдельно от других и убедитесь, что это не приводит к ошибкам или предупреждениям.
Примеры
Ряд тонких моментов возникает в связи с использованием шаблонов.
Пример 1. Зависимые имена. Шаблоны компилируются в точке, где они определены, с тем исключением, что все зависимые имена или типы не компилируются до точки инстанцирования. Это означает, что template
Пример 2. Шаблоны функций-членов и функции-члены шаблонов инстанцируются только при использовании. Предположим, что Widget не имеет члена типа std::deque
Ссылки
[Lakos96] §3.2 • [Stroustrup00] §9.2.3 • [Sutter00] §26-30 • [Vandevoorde03] §9-10
24. Используйте только внутреннюю, но не внешнюю защиту директивы
#include
Резюме
Предотвращайте непреднамеренное множественное включение ваших заголовочных файлов директивой #include, используя в них защиту с уникальными именами.
Обсуждение
Каждый заголовочный файл должен использовать внутреннюю защиту директивы #include, чтобы предотвратить переопределения в случае множественного включения данного файла. Например, заголовочный файл fоо.h должен иметь такой общий вид:
#ifndef FOO_H_INCLUDED_
#define FOO_H_INCLUDED_
// ... Содержимое файла …
#endif
Обратите внимание на следующие правила при определении защиты включения.
• Используйте для защиты уникальные имена. Убедитесь, что вы используете имена, уникальные, по крайней мере, в пределах вашего приложения. Выше мы использовали одно распространенное соглашение для используемых в защите имен; имена для защиты могут включать имя приложения, а некоторые инструменты генерируют имена для защиты, содержащие случайные числа.
• Не пытайтесь хитрить. Не размещайте никакого кода или комментариев до и после защищенной части, и следуйте показанной выше стандартной форме защиты. Современные препроцессоры могут обнаружить защиту, но могут оказаться малоинтеллектуальными и ожидать кода защиты строго в начале и в конце заголовочных файлов.
Избегайте использования устаревшей внешней защиты директивы #include, рекомендуемой в некоторых старых книгах:
#ifndef FOO_H_INCLUDED_ // не рекомендуется
#include "foo.h"
#define FOO_H_INCLUDED_
#endif
Внешняя защита утомительна, устарела для современных компиляторов и ненадежна из-за необходимости согласования имен для защиты.
Исключения
В очень редких случаях заголовочный файл может быть предназначен для многократного включения.
Ссылки
[C++03, §2.1] • [Stroustrup00] §9.3.3