Здравствуйте, уважаемые читатели.

Предполагаем, что некоторые из вас уже в курсе грядущего переиздания фундаментальной работы Джона Лакоса "Large-Scale C++".



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


Одна из самых интересных книг о программировании на С++, которую мне довелось прочитать – «Large-Scale C++ Software Design» Джона Лакоса. Она вышла в 1996 году и, к сожалению, так и остается единственной книгой о физическом дизайне на C++ и о масштабировании таких проектов для обслуживания больших систем.
Как изложенные в ней принципы могли не устареть более чем через 10 лет? Ниже – мои краткие комментарии о наиболее важных советах автора – все эти советы я проверил на реальных проектах:

Разберемся с терминологией

  • Физический дизайн – это работа с физическими сущностями программной системы (файлами, каталогами, библиотеками).
  • Объявление – это первое упоминание имени в программе; определение дает уникальное описание сущности (напр. типа, экземпляра, функции) в программе
  • Имя обладает внутренним связыванием, если используется локально в рамках своей единицы трансляции – то есть, если во время компоновки исключен конфликт между ним и идентичным именем, определенным в рамках другой единицы трансляции.
  • Имя обладает внешним связыванием, если в многофайловой программе во время компоновки это имя может взаимодействовать с другими единицами трансляции.
  • Компонент – это мельчайший элемент физического дизайна. Как правило, компонент – это заголовочный файл + файл исходников.
  • Пакет – это группа компонентов
  • Подсистема – это группа пакетов


Рекомендации
  • Информация в классах должна быть приватной


Это одна из ключевых рекомендаций, касающаяся как объектно-ориентированного проектирования, так и физического дизайна. Идея хорошая, поскольку таким образом частично скрывается сложность компонента.
Если просто объявлять переменные экземпляра как приватные, это никоим образом не отразится на физическом дизайне, но если сделать еще один шаг, воспользовавшись брандмауэром компиляции (PIMPL/Cheshire cat), то удается уменьшить количество зависимостей времени компиляции и, соответственно, ускорить саму компиляцию.

Вердикт: рулит

  • Избегайте данных со внешним связыванием в области видимости файла


Очень просто делается (всего лишь добавляем «static»), при этом помогает избежать ошибок при связывании и багов компоновщика. Например: у меня возникла проблема с двумя функциями, обладавшими внешним связыванием, при этом одноименными и имевшими такие параметры, которые могли преобразовываться друг в друга. Во время выполнения вызывалась не та функция, но никаких предупреждений во время компиляции при этом не возникало.
Единственная загвоздка здесь заключается в том, что большинство компиляторов C++ не позволяют делать символы внутренними, включая их в анонимное пространство имен, даже хотя этот метод и рекомендуется в качестве стандартного, а статический метод официально признан нежелательным

Вердикт: рулит

  • Избегайте свободных функций (не считая операторных функций) в области видимости .h-файлов; избегайте свободных функций с внешним связыванием (даже если это операторные функции) в файлах .cpp.


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

Вердикт: рулит

  • Избегайте перечислений, определений типов и констант в области видимости .h-файлов.


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

Вердикт: рулит

  • Старайтесь не использовать макросы препроцессора в заголовочных файлах, разве что в качестве защиты подключения.


Макросы в заголовочных файлах могут провоцировать такие баги, которые очень сложно отследить. Классический пример – макрос с неудачно подобранным именем, который может случайно изменить тот код, что его включает, а то и сработать даже менее очевидно: однажды у моего бывшего коллеги возникла проблема с повреждением информации в памяти, когда программа валилась в отладчике при удалении определенного объекта. Но самое интересное – что это происходило не при всех операциях удаления, а только при некоторых. Казалось, что при удалении из пакета А программа всегда отказывает, а при удалении из B – продолжает работать!

Чтобы вас не томить, рассказываю: он #ifdef-ал некоторые переменные экземпляров этого объекта. Пакет A валился при удалении, поскольку получал объекты размера X из пакета B и пытался удалить их при помощи size X — sizeof(#ifdef-нутые переменные экземпляра).

Вердикт: рулит

  • Только классы, структуры, объединения и свободные операторные функции следует объявлять в области видимости файла в файле .h; только классы, структуры, объединения и встраиваемые функции (функции экземпляров или свободные операторные функции) должны определяться в области видимости файла в файле .h.


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

Вердикт: рулит

  • Применяйте уникальную и предсказуемую (внутреннюю) защиту подключения, которая должна охватывать содержимое каждого заголовочного файла.


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

Мне нравится использовать варианты, производные от имени файла, например, INC_FILENAME_H. Для этого я написал у себя в IDE небольшой макрос, который генерирует защиту подключения для выделенного текста.

Вердикт: рулит

  • Логические сущности, объявленные внутри компонента, не должны определяться за пределами этого компонента.
  • Никогда не сталкивался с ситуацией, в которой бы нарушалось это правило, но, вероятно, такое бывает на практике, иначе зачем бы автор о ней упоминал? Пожалуй, C++ — один из немногих языков, где такая практика может сойти вам с рук. Тем не менее, не представляю, зачем бы я стал это делать…


Вердикт: самоочевидно

  • .c-файл каждого компонента должен включать собственный .h-файл в первой же значимой строке кода.


Смысл этого правила – не допустить успешной компиляции неполных заголовочных файлов (в которых отсутствуют включения). Если такой файл включается первым, то он определенно не скомпилируется, а если он включается после других заголовочных файлов, то в них могут содержаться файлы, которых недостает в нашем заголовке, то компиляция пройдет успешно.

Это правило идет вразрез с использованием прекомпилированных заголовков, которые обычно должны включаться в качестве первого файла (напр. MSVC stdafx.h). Я делаю так: сначала включаю прекомпилированный заголовок, затем заголовок компонента, далее заголовки проекта, заголовки внешних библиотек (e.g: boost, wxWidgets, etc.) и, наконец, заголовки STL/CRT. Кроме того, я явно включаю файлы, входящие в состав прекомпилированного заголовка, поскольку у компиляторов хватает ума их пропустить, и в случае необходимости я могу выполнить компиляцию без прекомпилированных заголовков.

Вердикт: рулит

  • Избегайте определений с внешним связыванием в .c-файле, если они явно не объявлены в соответствующем .h-файле.
  • Избегайте обращений к компоненту с внешним связыванием в другом компоненте через локальное объявление; в таком случае включайте .h-файл для этого компонента.


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

Вердикт: рулит

  • Каждый глобальный идентификатор должен сопровождаться префиксом своего пакета
  • Имя каждого исходного файла должно сопровождаться префиксом соответствующего пакета


Чрезмерная осторожность, вам не кажется? Однако, не забывайте, книга об ОЧЕНЬ БОЛЬШИХ программах. Если в вашем проекте тысячи файлов, то вы, вероятно, не сможете держать в голове названия всех файлов, и любые ориентиры вам очень пригодятся.

Мне такая практика особенно пригодилась в следующих случаях:

  • При анализе кода на распечатках (или в очень плохой IDE/редакторе)
  • При фильтрации пакетов, когда пытаешься найти путь к файлу
  • При фильтрации пакетов, когда пытаешься найти путь к идентификатору


Вердикт: рулит

  • Избегайте циклических зависимостей между пакетами


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

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

  • Вы не сможете тестировать пакеты по отдельности, что, в свою очередь, мешает автоматизированному модульному тестированию
  • Увеличивается время компиляции
  • Изменения просачиваются по всему исходному коду программы


Вердикт: неимоверно рулит

  • Обеспечивайте механизм для высвобождения всей динамической памяти, выделенной под статические конструкции внутри компонента.


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

Более того, если память используется объектом, то деструктор не будет вызван до тех самых пор, пока программист явно не высвободит память, пусть даже ОС может высвободить память при выходе из программы.
Проще всего такая проблема решается при помощи умного указателя, например, auto_ptr или shared_ptr.

Вердикт: рулит

Выводы

Как видите, большинство рекомендаций по-прежнему актуальны. Книга Лакоса во многом определила мое представление о крупномасштабном программировании на С++, эти рекомендации пригодились мне на нескольких реальных проектах.
Мое впечатление

Проголосовал 61 человек. Воздержалось 24 человека.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Поделиться с друзьями
-->

Комментарии (5)


  1. Torvald3d
    27.05.2016 17:17
    +2

    Вердикт: очевидно


  1. abikineev
    27.05.2016 21:36
    +1

    Не могу не похвастаться: однажды сам Лэйкос подарил мне первое издание как студенту на CppNow…


  1. sashagil
    29.05.2016 17:07
    +1

    Упоминание auto_ptr («в грядущем переиздании») не рулит. Если вы можете повлиять на редактуру, сделайте, пожалуйста, сноску о том, что этот смарпойнтер is getting deprecated (replaced by unique_ptr) — или, ещё лучше, просто молча замените на unique_ptr. Автор одобрит, я уверен.


  1. DFooz
    29.05.2016 18:15

    «Проще всего такая проблема решается при помощи умного указателя, например, auto_ptr или shared_ptr.»

    если будете выпускать, неплохо было б сноски с учётом C++11 делать. Типа не используйте auto_ptr.


    1. ph_piter
      30.05.2016 09:11

      Как вы понимаете, речь идет об издании книги 2016, а не 1996 года. Не сомневаемся, что все нововведения там будут учтены