Почему я не должен включать файлы cpp и вместо этого использовать заголовок?

147

Итак, я закончил свое первое задание по программированию на C ++ и получил оценку. Но по оценке я потерял оценки за including cpp files instead of compiling and linking them. Мне не очень понятно, что это значит.

Оглядываясь назад на мой код, я решил не создавать файлы заголовков для своих классов, но сделал все в файлах cpp (казалось, что все работает нормально без файлов заголовков ...). Я предполагаю, что грейдер имел в виду, что я написал '#include "mycppfile.cpp";' в некоторых из моих файлов.

Мои аргументы для #includeиспользования файлов cpp были: - Все, что должно было попасть в заголовочный файл, было в моем файле cpp, поэтому я притворился, что это похоже на заголовочный файл - В monkey-see-monkey do fashion я увидел, что другие заголовочные файлы были #includeв файлах, так что я сделал то же самое для моего файла cpp.

Так что именно я сделал не так и почему это плохо?

ialm
источник
36
Это действительно хороший вопрос. Я ожидаю, что это поможет многим новичкам на С ++.
Миа Кларк

Ответы:

175

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

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

«О, это не имеет большого значения. Если это работает, все в порядке», я слышу, как ты плачешь. И в каком-то смысле вы были бы правы. Но сейчас вы имеете дело с крошечной крошечной программой и хорошим и относительно свободным процессором, чтобы скомпилировать ее для вас. Тебе не всегда повезет.

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

«О, нет! Это звучит ужасно! Однако я могу предотвратить эту ужасную судьбу ?!» К сожалению, вы ничего не можете с этим поделать. Если для компиляции требуются часы, для компиляции требуются часы. Но это действительно имеет значение только в первый раз - после того, как вы его скомпилировали один раз, нет причин для его повторной компиляции.

Если вы не измените что-то.

Теперь, если у вас есть два миллиона строк кода, объединенных в одного гигантского бегемота, и вам нужно сделать простое исправление ошибки, например, скажем x = y + 1, это означает, что вам нужно снова скомпилировать все два миллиона строк, чтобы проверить это. И если вы обнаружите, что x = y - 1вместо этого хотите сделать a , то опять два миллиона строк компиляции ждут вас. Это много потраченного времени, которое можно потратить на что-то еще.

«Но я ненавижу быть непродуктивным! Если бы был какой-то способ скомпилировать отдельные части моей кодовой базы по отдельности и как-то связать их вместе потом»! Отличная идея, в теории. Но что, если вашей программе нужно знать, что происходит в другом файле? Невозможно полностью отделить вашу кодовую базу, если вы не хотите вместо этого запустить кучу крошечных крошечных файлов .exe.

«Но, конечно, это должно быть возможно! В противном случае программирование звучит как настоящая пытка! Что если я найду способ отделить интерфейс от реализации ? Скажем, беря достаточно информации из этих отдельных сегментов кода, чтобы идентифицировать их для остальной части программы, и помещая вместо этого в каком-то заголовочном файле? И таким образом, я могу использовать #include директиву препроцессора, чтобы вводить только информацию, необходимую для компиляции! "

Хм. Вы можете быть там к чему-то. Дайте мне знать, как это работает для вас.

goldPseudo
источник
13
Хороший ответ, сэр. Это было весело читать и легко понять. Я бы хотел, чтобы мой учебник был написан так.
ialm
@veol Search for Head Первая серия книг - я не знаю, есть ли у них версия C ++. headfirstlabs.com
Amarghosh
2
Это (определенно) лучшая на сегодняшний день формулировка, которую я слышал или обдумывал. Джастин Кейс, опытный новичок, достиг проекта, набирающего один миллион нажатий клавиш, еще не доставлен, и похвальный «первый проект», который видит свет приложения в реальной пользовательской базе, распознал проблему, решаемую замыканиями. Звучит удивительно похоже на расширенные состояния первоначального определения проблемы OP, за исключением того, что «закодировано это почти сто раз, и он не может понять, что делать с нулем (как без объекта) против нуля (как с племянником) без использования программирования с помощью исключений».
Николас Джордан
Конечно, все это разбивается на шаблоны, потому что большинство компиляторов не поддерживают / не реализуют ключевое слово «export».
KitsuneYMG
1
Другое дело, что у вас есть много современных библиотек (если подумать о BOOST), которые используют только классы заголовков ... Хо, подожди? Почему опытный программист не отделяет интерфейс от реализации? Часть ответа может заключаться в том, что сказал Blindly, другая часть может заключаться в том, что один файл лучше, чем два, когда это возможно, а другая часть в том, что связывание имеет стоимость, которая может быть довольно высокой. Я видел, как программы работают в десять раз быстрее с прямым включением оптимизации исходного кода и компилятора. Потому что связывание в основном блокирует оптимизацию.
Крис
45

Это, вероятно, более подробный ответ, чем вы хотели, но я думаю, что достойное объяснение оправдано.

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

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

/* Function declaration, usually found in headers. */
/* Implicitly 'extern', i.e the symbol is visible everywhere, not just locally.*/
int add(int, int);

/* function body, or function definition. */
int add(int a, int b) 
{
   return a + b;
}

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

Так как же все это в конце концов смешается? Это работа линкера. Компоновщик читает все объектные файлы, которые генерируются на этапе ассемблера, и разрешает символы. Как я уже говорил ранее, символ - это просто имя. Например, имя переменной или функции. Когда блоки перевода, которые вызывают функции или объявляют типы, не знают реализацию этих функций или типов, эти символы называются неразрешенными. Компоновщик разрешает неразрешенный символ, соединяя модуль перевода, который содержит неопределенный символ, с тем, который содержит реализацию. Уф. Это верно для всех видимых извне символов, независимо от того, реализованы они в вашем коде или предоставлены дополнительной библиотекой. Библиотека - это просто архив с многоразовым кодом.

Есть два заметных исключения. Во-первых, если у вас есть небольшая функция, вы можете сделать ее встроенной. Это означает, что сгенерированный машинный код не генерирует вызов функции extern, а буквально объединяется на месте. Поскольку они обычно небольшие, размер накладных расходов не имеет значения. Вы можете представить их статичными в том, как они работают. Так что безопасно реализовывать встроенные функции в заголовках. Реализации функций внутри определения класса или структуры также часто автоматически вставляются компилятором.

Другое исключение - шаблоны. Поскольку при создании экземпляра компилятору необходимо видеть все определение типа шаблона, невозможно отделить реализацию от определения, как в случае автономных функций или обычных классов. Что ж, возможно, это возможно сейчас, но получение широкой поддержки компилятором для ключевого слова "export" заняло много времени. Таким образом, без поддержки «экспорта» единицы перевода получают свои собственные локальные копии экземпляров шаблонизированных типов и функций, аналогично тому, как работают встроенные функции. С поддержкой «экспорта» это не так.

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

Краткое описание всего процесса от кода C ++ (несколько файлов) до конечного исполняемого файла:

  • Препроцессор запускается, который анализирует все директивы , которая начинается с «#». Например, директива #include объединяет включенный файл с подчиненным. Он также выполняет макро-замену и вставку токена.
  • Фактический компилятор запускается в промежуточном текстовом файле после этапа препроцессора и испускает код ассемблера.
  • В ассемблере работает на файл сборки и высылает машинный код, обычно это называется объектный файл и следует двоичный исполняемый формат оперативной системы в вопросе. Например, Windows использует PE (переносимый исполняемый формат), в то время как Linux использует формат Unix System V ELF с расширениями GNU. На этом этапе символы все еще помечены как неопределенные.
  • Наконец, компоновщик запускается. Все предыдущие этапы выполнялись на каждом блоке перевода по порядку. Однако этап компоновщика работает со всеми сгенерированными объектными файлами, которые были сгенерированы ассемблером. Компоновщик разрешает символы и выполняет много волшебства, например, создает разделы и сегменты, что зависит от целевой платформы и двоичного формата. Программисты не обязаны знать это в целом, но это, безусловно, помогает в некоторых случаях.

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

Мельпомена
источник
2
Спасибо за ваше подробное объяснение. Признаюсь, это еще не все имеет для меня смысл, и я думаю, что мне нужно будет перечитать ваш ответ снова (и снова).
ialm
1
+1 за отличное объяснение. Жаль, что это, вероятно, отпугнет всех новичков в C ++. :)
goldPseudo
1
Хех, не чувствую себя плохо, Вул. В переполнении стека самый длинный ответ редко бывает лучшим ответом.
int add(int, int);это объявление функции . Часть прототипа это просто int, int. Тем не менее, все функции в C ++ имеют прототип, поэтому этот термин действительно имеет смысл только в C. Я отредактировал ваш ответ на этот вопрос.
Мельпомена
exportШаблоны были удалены из языка в 2011 году. Он никогда не был поддержан компиляторами.
Мельпомена
10

Типичным решением является использование .hфайлов только для объявлений и .cppфайлов для реализации. Если вам нужно повторно использовать реализацию, вы включаете соответствующий .hфайл в .cppфайл, где используется необходимый класс / функция / что бы то ни было, и ссылаетесь на уже скомпилированный .cppфайл ( .objфайл, обычно используемый в одном проекте, или файл .lib, обычно используемый для повторного использования из нескольких проектов). Таким образом, вам не нужно перекомпилировать все, если меняется только реализация.

Sharptooth
источник
6

Думайте о файлах cpp как о черном ящике, а о файлах .h как о том, как использовать эти черные ящики.

Файлы cpp могут быть скомпилированы заранее. Это не работает в вас #include их, так как необходимо «включать» код в вашу программу каждый раз, когда он компилирует его. Если вы просто включите заголовок, он может просто использовать файл заголовка, чтобы определить, как использовать предварительно скомпилированный файл cpp.

Хотя это не будет иметь большого значения для вашего первого проекта, если вы начнете писать большие программы на cpp, люди будут вас ненавидеть, потому что время компиляции будет стремительно расти.

Также прочитайте это: Заголовочный файл включает шаблоны

Дэн МакГрат
источник
Спасибо за более конкретный пример. Я пытался прочитать вашу ссылку, но теперь я запутался ... в чем разница между явным включением заголовка и предварительной декларацией?
ialm
Это замечательная статья. Veol, здесь они включают заголовки, где компилятору нужна информация о размере класса. Форвардное объявление используется, когда вы используете только указатели.
Панкайт
объявление вперед: int someFunction (int requiredValue); обратите внимание на использование информации о типе и (обычно) никаких фигурных скобок. Это, как дано, говорит компилятору, что в какой-то момент вам понадобится функция, которая принимает int и возвращает int, компилятор может зарезервировать для него вызов, используя эту информацию. Это будет называться предварительным заявлением. Предполагается, что более классные компиляторы смогут найти функцию, не нуждаясь в этом, в том числе заголовок может быть удобным способом объявления нескольких предварительных объявлений.
Николас Джордан
6

Заголовочные файлы обычно содержат объявления функций / классов, в то время как файлы .cpp содержат фактические реализации. Во время компиляции каждый файл .cpp компилируется в объектный файл (обычно расширение .o), и компоновщик объединяет различные объектные файлы в конечный исполняемый файл. Процесс компоновки обычно намного быстрее, чем компиляция.

Преимущества такого разделения: если вы перекомпилируете один из файлов .cpp в своем проекте, вам не нужно перекомпилировать все остальные. Вы просто создаете новый объектный файл для этого конкретного файла .cpp. Компилятору не нужно смотреть на другие файлы .cpp. Однако, если вы хотите вызывать функции в вашем текущем файле .cpp, которые были реализованы в других файлах .cpp, вы должны сообщить компилятору, какие аргументы они принимают; это цель включения заголовочных файлов.

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

int3
источник
5

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

Лукаш Лалинский
источник
3

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

Компиляция займет больше времени (что становится проблемой для больших проектов), если вы вносите изменения в файлы #included cpp, которые затем вызывают принудительную перекомпиляцию любых файлов, включая их.

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

NeilDurant
источник
Таким образом, помимо увеличения времени компиляции, у меня начнутся проблемы, когда я # включу свой файл cpp во множество различных файлов, которые используют функции из включенных файлов cpp?
ialm
Да, это называется столкновением пространства имен. Здесь интересен вопрос о том, не создает ли связь с библиотеками проблемы с пространством имен. В общем, я считаю, что компиляторы производят лучшее время компиляции для области блока перевода (все в одном файле), что вызывает проблемы с пространством имен - что приводит к разделению снова .... вы можете включить файл включения в каждую единицу перевода (предполагается) есть даже прагма (однажды #pragma), которая должна обеспечивать это, но это предположение суппозитория. Будьте осторожны, чтобы не слепо полагаться на библиотеки (файлы .O), откуда бы 32-битные ссылки не применялись.
Николас Джордан
2

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

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

Avi
источник
2

повторное использование, архитектура и инкапсуляция данных

вот пример:

скажем, вы создаете файл cpp, который содержит простую форму строковых подпрограмм, все в классе mystring, вы помещаете класс decl для этого в mystring.h, компилируя mystring.cpp в файл .obj.

теперь в вашей основной программе (например, main.cpp) вы включаете заголовок и ссылку на mystring.obj. чтобы использовать mystring в вашей программе, вам не нужны подробности о том, как реализована mystring, поскольку заголовок говорит о том, что он может делать

теперь, если друг хочет использовать ваш класс mystring, вы даете ему mystring.h и mystring.obj, ему также не обязательно знать, как он работает, пока он работает.

позже, если у вас будет больше таких файлов .obj, вы можете объединить их в файл .lib и вместо этого ссылаться на него.

Вы также можете изменить файл mystring.cpp и реализовать его более эффективно, это не повлияет на ваш main.cpp или вашу программу друзей.

AndersK
источник
2

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

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

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

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

Единственное, что действительно «нетрадиционно» в том, что вы сделали, - это присвоение имен включенным файлам «.cpp» вместо «.h» или «.hpp».

Брент Брэдберн
источник
1

Когда вы компилируете и связываете программу, компилятор сначала компилирует отдельные файлы cpp, а затем они связывают (связывают) их. Заголовки никогда не будут скомпилированы, если они не включены в файл cpp.

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

Jonas
источник
извините, если вы пропустите реализацию из заголовочного файла, но это звучит как интерфейс Java, верно?
gansub
1

Я предлагаю вам пройти через дизайн программного обеспечения Large Scale C ++ Джона Лакоса . В колледже мы обычно пишем небольшие проекты, в которых нам не приходится сталкиваться с такими проблемами. В книге подчеркивается важность разделения интерфейсов и реализаций.

Заголовочные файлы обычно имеют интерфейсы, которые не должны меняться так часто. Аналогичным образом, изучение таких шаблонов, как идиома Virtual Constructor, поможет вам лучше понять концепцию.

Я все еще учусь как ты :)

pankajt
источник
Спасибо за предложение книги. Я не знаю, доберусь ли я когда-нибудь до стадии создания крупномасштабных программ на C ++ ...
Ialm
это весело для написания крупномасштабных программ и для многих проблем. Мне это начинает нравиться :)
панкайт
1

Это как писать книгу, вы хотите распечатать законченные главы только один раз

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

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

Вернемся к программному обеспечению: у меня есть Linux и Ruby src. Грубая мера строк кода ...

     Linux       Ruby
   100,000    100,000   core functionality (just kernel/*, ruby top level dir)
10,000,000    200,000   everything 

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

DigitalRoss
источник