Как я могу предотвратить ад заголовок?

45

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

Что мы можем сделать, чтобы предотвратить «адский заголовок», ака «заголовки спагетти»?

  • Один заголовок на исходный файл?
  • Плюс один на подсистему?
  • Отделить typdefs, stucts & enums от прототипов функций?
  • Отделить внутреннюю подсистему от внешней подсистемы?
  • Настаивать на том, что каждый отдельный файл, будь то заголовок или источник, должен быть автономно компилируемым?

Я не спрашиваю о «лучшем» способе, просто указываю на то, на что следует обращать внимание и что может вызвать горе, чтобы мы могли попытаться избежать этого.

Это будет проект C ++, но информация C поможет будущим читателям.

Mawg
источник
16
Получите копию Large-Scale C ++ Software Design , она научит не только избегать проблем с заголовками, но и многих других проблем, касающихся физических зависимостей между исходными файлами и файлами объектов в проекте C ++.
Док Браун
6
Все ответы здесь великолепны. Я хотел добавить, что документация по использованию объектов, методов, функций должна быть в заголовочных файлах. Я все еще вижу документацию в исходных файлах. Не заставляй меня читать источник. В этом смысл заголовочного файла. Мне не нужно читать источник, если я не разработчик.
Билл Дверь
1
Я уверен, что я работал с вами раньше. Часто :-(
Mawg
5
То, что вы описываете, не большой проект. Хороший дизайн всегда приветствуется, но вы никогда не столкнетесь с проблемами «крупномасштабных систем».
Сэм
2
Boost на самом деле имеет подход все включено. Каждая отдельная функция имеет свой собственный файл заголовка, но каждый больший модуль также имеет заголовок, который включает в себя все. Это оказывается действительно мощным средством минимизации заголовка, не заставляя вас каждый раз #include включать несколько сотен файлов.
Cort Ammon

Ответы:

39

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

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

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

gnasher729
источник
Тут все сложно, это вызовы кросс-подсистемных функций с параметрами типов, объявленными в другой подсистеме.
Mawg
6
Хитрый или нет, "#include <subystem1.h>" должен скомпилироваться. Как вы этого достигнете, зависит от вас. @FrankPuffer: почему?
gnasher729
13
@Mawg Это либо указывает на то, что вам нужна отдельная общая подсистема, которая включает в себя общие черты отдельных подсистем, либо вам нужны упрощенные заголовки «интерфейса» для каждой подсистемы (которая затем используется заголовками реализации, как внутренними, так и межсистемными) , Если вы не можете написать заголовки интерфейса без перекрестных включений, дизайн вашей подсистемы испортился, и вам нужно изменить дизайн, чтобы ваши подсистемы стали более независимыми. (Это может включать вытаскивание общей подсистемы в качестве третьего модуля.)
RM
8
Хорошим методом обеспечения независимости заголовка является наличие правила, согласно которому исходный файл всегда сначала включает свой собственный заголовок . Это будет ловить случаи, когда вам нужно переместить зависимости включения из файла реализации в заголовочный файл.
doug65536
4
@FrankPuffer: Пожалуйста, не удаляйте свои комментарии, особенно если другие отвечают на них, так как это делает ответы без контекста. Вы всегда можете исправить свое утверждение в новом комментарии. Спасибо! Мне интересно узнать, что вы на самом деле сказали, но теперь этого нет :(
MPW
18

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

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

Чтобы ответить на ваши конкретные вопросы:

  • Один заголовок на исходный файл? → Да, это хорошо работает в большинстве случаев и облегчает поиск вещей. Но не делайте это религией.
  • Плюс один на подсистему? → Нет, почему вы хотите это сделать?
  • Отделить typdefs, stucts & enums от прототипов функций? → Нет, функции и связанные типы принадлежат друг другу.
  • Отделить внутреннюю подсистему от внешней подсистемы? → Да, конечно. Это уменьшит зависимости.
  • Настаиваете, что каждый отдельный файл, будь то заголовок или источник автономно совместим? → Да, никогда не требуйте, чтобы какой-либо заголовок был включен перед другим заголовком.
Фрэнк Пуффер
источник
12

В дополнение к другим рекомендациям, в соответствии с сокращением зависимостей (в основном применимо к C ++):

  1. Включайте только то, что вам действительно нужно, там, где это нужно (минимально возможный уровень). Например не включайте в заголовок, если вам нужны звонки только в источнике.
  2. По возможности используйте предварительные объявления в заголовках (заголовок содержит только указатели или ссылки на другие классы).
  3. Очистите включения после каждого рефакторинга (закомментируйте их, посмотрите, где происходит сбой компиляции, переместите их туда, удалите все еще прокомментированные строки включения).
  4. Не упаковывайте слишком много общих средств в один файл; разделить их по функциональности (например, Logger - это один класс, то есть один заголовок и один исходный файл; SystemHelper dito. и т. д.).
  5. Придерживайтесь принципов ОО, даже если все, что вы получаете, это класс, состоящий исключительно из статических методов (вместо автономных функций), или вместо этого используйте пространство имен .
  6. Для некоторых общих возможностей шаблон синглтона довольно полезен, так как вам не нужно запрашивать экземпляр у какого-либо другого, не связанного с этим объекта.
Мерфи
источник
5
На # 3 может помочь инструмент, который включает в себя то, что вы используете , избегая метода ручной перекомпиляции «угадай и проверь».
RM
1
Не могли бы вы объяснить, каково преимущество синглетонов в этом контексте? Я действительно не понимаю.
Фрэнк
@FrankPuffer Мое обоснование таково: без единого экземпляра некоторый экземпляр класса обычно владеет экземпляром вспомогательного класса, например, Logger. Если третий класс хочет использовать его, он должен запросить ссылку на вспомогательный класс у владельца, то есть вы используете два класса и, конечно, включите их заголовки - даже если пользователь не имеет никакого отношения к владельцу в противном случае. В случае синглтона вам нужно только включить заголовок вспомогательного класса и запросить экземпляр непосредственно у него. Видите ли вы недостаток в этой логике?
Мерфи
1
# 2 (предварительные объявления) может иметь огромное значение во время компиляции и проверке зависимостей. Как показывает этот ответ ( stackoverflow.com/a/9999752/509928 ), он применим как к C ++, так и к C
Дейв Комптон
3
Вы также можете использовать предварительные объявления при передаче по значению, если функция не определена как встроенная. Объявление функции не является контекстом, где требуется полное определение типа (определение контекста или вызов функции является таким контекстом).
StoryTeller - Unslander Monica
6

Один заголовок на исходный файл, который определяет, что его исходный файл реализует / экспортирует.

Столько заголовочных файлов, сколько необходимо, включенных в каждый исходный файл (начиная с собственного заголовка).

Избегайте включения (минимизируйте включение) заголовочных файлов в другие заголовочные файлы (чтобы избежать циклических зависимостей). Подробнее см. Ответ на вопрос «могут ли два класса видеть друг друга с помощью C ++?»

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

ChrisW
источник
4

Я бы сказал, что ваш вопрос принципиально не отвечает, поскольку существует два вида адских заголовков:

  • Та, где вам нужно включить миллион различных заголовков, и кто в аду может даже вспомнить их все? И поддерживать эти списки заголовков? Тьфу.
  • То, где вы включаете одну вещь, и обнаруживаете, что вы включили всю Вавилонскую башню (или я должен сказать Башню Буста? ...)

Дело в том, что если вы попытаетесь избежать первого, то в какой-то степени вы получите второе, и наоборот.

Существует также третий вид ада, это круговые зависимости. Они могут появиться, если вы не будете осторожны ... избегать их не сложно, но вам нужно время, чтобы подумать, как это сделать. См Джон LAKOS говорить о Levelization в CppCon 2016 году (или только слайды ).

einpoklum - восстановить Монику
источник
1
Вы не можете всегда избегать круговых зависимостей. Примером является модель, в которой сущности ссылаются друг на друга. По крайней мере, вы можете попытаться ограничить округлость в подсистеме, это означает, что если вы включаете заголовок подсистемы, вы абстрагируете от округлости.
17
2
@nalply: я имел в виду избегать циклической зависимости заголовков, а не кода ... если вы не избежите циклической зависимости заголовка, вы, вероятно, не сможете построить. Но да, точка взята +1.
einpoklum - восстановить Монику
1

Развязка

В конечном счете, речь идет о развязке для меня на самом фундаментальном уровне проектирования, лишенном нюансов характеристик наших компиляторов и компоновщиков. Я имею в виду, что вы можете делать такие вещи, как сделать так, чтобы каждый заголовок определял только один класс, использовать pimpls, пересылать объявления типам, которые нужно только объявить, но не определить, возможно, даже использовать заголовки, которые просто содержат прямые объявления (например:) <iosfwd>, один заголовок на исходный файл организовать систему последовательно, основываясь на типе декларируемой / определенной вещи и т. д.

Методы уменьшения "зависимостей времени компиляции"

И некоторые из методов могут немного помочь, но вы можете исчерпать эти методы и все же найти свой средний исходный файл в вашей системе, нуждающийся в двухстраничной преамбуле #includeуказание делать что-то немного значимое со стремительно растущим временем сборки, если вы слишком много внимания уделяете сокращению зависимостей во время компиляции на уровне заголовка без уменьшения логических зависимостей в ваших интерфейсах, и хотя это, строго говоря, не может считаться «заголовками спагетти», я Я бы все еще сказал, что это приводит к подобным вредным проблемам производительности на практике. В конце концов, если ваши модули компиляции по-прежнему требуют, чтобы для выполнения чего-либо было достаточно информации, то это приведет к увеличению времени сборки и умножению причин, по которым вам придется вернуться назад и что-то менять, в то время как разработчики делают чувствую, что они бьют головой систему, просто пытаясь закончить свое ежедневное кодирование. Это'

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

Пересылка объявлений внешним типам

Из всех методов, которые я исчерпал, чтобы попытаться получить прежнюю кодовую базу, на создание которой ушло два часа, в то время как разработчики иногда ждали своей очереди в CI на наших серверах сборки (2 дня) (вы можете почти представить, что эти машины сборки истощены чудовищными усилиями, отчаянно пытаясь чтобы не отставать и терпеть неудачу, пока разработчики продвигают свои изменения), самым сомнительным для меня было прямое объявление типов, определенных в других заголовках. И мне удалось довести эту кодовую базу до 40 минут или около того после целого ряда этапов, которые делали это небольшими пошаговыми шагами, пытаясь уменьшить «заголовок спагетти», наиболее сомнительную практику в ретроспективе (например, заставляя меня упускать из виду фундаментальную природу проектирование, в то время как туннель рассматривал взаимозависимости заголовков), было прямым объявлением типов, определенных в других заголовках.

Если вы представляете Foo.hppзаголовок, который имеет что-то вроде:

#include "Bar.hpp"

И он использует только Barв заголовке способ, который требует объявления, а не определения. тогда это может показаться легким делом, чтобы объявить, class Bar;чтобы избежать определения Barвидимого в заголовке. За исключением случаев, когда на практике часто вы либо обнаружите, что большинство используемых модулей компиляции Foo.hppвсе равно в конечном итоге нуждаются Barв определении с дополнительным бременем необходимости включать Bar.hppсебя поверх Foo.hpp, либо вы столкнетесь с другим сценарием, где это действительно помогает и % ваших модулей компиляции могут работать без включения Bar.hpp, за исключением того, что это поднимает более фундаментальный вопрос проектирования (или, по крайней мере, я думаю, что это должно происходить в настоящее время), почему они должны даже видеть объявление Barи почемуFoo даже нужно беспокоиться, чтобы узнать об этом, если это не имеет отношения к большинству случаев использования (зачем обременять дизайн зависимостями с другим, который едва когда-либо использовался?).

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

Встроенные сценарии

Это действительно для крупномасштабных кодовых баз, но другой метод, который я считаю чрезвычайно полезным, - это использование встроенного языка сценариев, по крайней мере, для самых высокоуровневых частей вашей системы. Я обнаружил, что смог встраивать Lua за один день и иметь возможность одинаково вызывать все команды в нашей системе (к счастью, команды были абстрактными). К сожалению, я наткнулся на контрольно-пропускной пункт, где разработчики не доверяли внедрению другого языка и, возможно, наиболее странно, с производительностью как их наибольшим подозрением. Тем не менее, хотя я мог бы понять и другие проблемы, производительность не должна быть проблемой, если мы используем сценарий только для вызова команд, когда пользователи нажимают кнопки, например, которые не выполняют своих собственных значительных циклов (что мы пытаемся сделать, беспокоиться о разнице наносекунд во времени отклика на нажатие кнопки?).

пример

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

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

введите описание изображения здесь

Дизайн, Дизайн, Дизайн

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

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

Энергия Дракона
источник
1
Отличный ответ! Хотя мне пришлось немного покопаться, чтобы узнать, что такое прыщи :-)
Mawg
0

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

В отличие от других ответов, я рекомендую один (довольно большой) публичный заголовок для каждой подсистемы (который может включать «частные» заголовки, возможно, имеющие отдельные файлы для реализации многих встроенных функций). Вы могли бы даже рассмотреть заголовок, имеющий только несколько #include директив.

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

(Если у вас много маленьких файлов, вам нужно будет включить их в каждую маленькую единицу перевода , и общее время сборки может пострадать)

Что вы действительно хотите, так это определить для каждой подсистемы и файла главного разработчика, ответственного за это.

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

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

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

Обратите внимание, что в C ++ стандартные заголовочные файлы требуют много кода . Например #include <vector>, тянет более десяти тысяч строк на моем GCC 6 в Linux (18100 строк). И #include <map> расширяется почти до 40KLOC. Следовательно, если у вас есть много маленьких заголовочных файлов, включая стандартные заголовки, вы в конечном итоге анализируете много тысяч строк во время сборки, и ваше время компиляции страдает. Вот почему мне не нравится иметь много маленьких строк исходного кода C ++ (максимум несколько сотен строк), но я предпочитаю иметь меньше, но больше файлов C ++ (из нескольких тысяч строк).

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

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

Также обратите внимание на предварительную практику в существующих проектах свободного программного обеспечения (например, на github ).

Обратите внимание, что с зависимостями можно справиться с хорошей системой автоматизации сборки . Изучите документацию по GNU make . Помните о различных -Mфлагах препроцессора для GCC (полезно для автоматической генерации зависимостей).

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

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

Василий Старынкевич
источник
Упомянутые вами технические характеристики могут быть правильными. Однако, насколько я понял, OP спрашивал, как улучшить удобство сопровождения кода и его организацию, а не время компиляции. И я вижу прямой конфликт между этими двумя целями.
Мерфи
Но это все еще вопрос мнения. И ОП, видимо, начинает не такой большой проект.
Старынкевич,