Чому я не повинен включати файли cpp і замість цього використовувати заголовок?


147

Тому я закінчив своє перше завдання з програмування на C ++ і отримав свою оцінку. Але згідно з оцінкою я втратив оцінку за including cpp files instead of compiling and linking them. Мені не надто зрозуміло, що це означає.

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

Мої міркування щодо #include«файлів cpp: Файли заголовка були #includeу файлах, тому я зробив те ж саме для свого файлу cpp.

Отже, що саме я зробив неправильно, і чому це погано?


36
Це справді гарне запитання. Я сподіваюсь, що цьому допоможе багато новонароджених с ++.
Міа Кларк

Відповіді:


174

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

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

"О, це не велика справа. Якщо вона біжить, це добре", я чую, як ти плачеш. І в певному сенсі ти будеш прав. Але зараз ви маєте справу з крихітною маленькою програмою та приємним та відносно необрізаним процесором, щоб скласти її для вас. Вам не завжди так пощастить.

Якщо ви коли-небудь занурюєтесь у сфери серйозного комп'ютерного програмування, ви побачите проекти з кількістю ліній, які можуть сягати мільйонів, а не десятків. Це багато рядків. А якщо спробувати скласти одне з них на сучасному настільному комп’ютері, це може зайняти лічені години замість секунд.

"О, ні! Це звучить жахливо! Однак чи можу я запобігти цій жахливій долі ?!" На жаль, з цим не можна багато зробити. Якщо для складання потрібні години, потрібно складати години. Але це насправді важливо лише перший раз - після того, як ви склали його один раз, немає жодної причини складати його знову.

Якщо ти щось не зміниш.

Тепер, якщо у вас було два мільйони рядків коду, об'єднаних в один гігантський бегемот, і вам потрібно зробити просте виправлення помилок, наприклад, скажімо x = y + 1, це означає, що вам доведеться знову скомпілювати всі два мільйони рядків, щоб перевірити це. І якщо ви дізнаєтесь, що ви мали намір зробитиx = y - 1 замість цього, то знову на вас чекають два мільйони рядків компіляції. Це витрачено багато годин часу, які можна краще витратити на будь-що інше.

"Але я ненавиджу бути малопродуктивним! Якби тільки існував якийсь спосіб скласти окремі частини моєї кодової бази окремо і якось зв'язати їх після цього!" Відмінна ідея, теоретично. Але що робити, якщо ваша програма повинна знати, що відбувається в іншому файлі? Повністю розділити кодову базу неможливо, якщо ви не хочете замість цього запустити купу крихітних крихітних файлів .exe.

"Але, безумовно, це має бути можливо! Програмування звучить як чиста тортура в іншому випадку! Що робити, якщо я знайшов якийсь спосіб відокремити інтерфейс від реалізації ? Скажіть, взявши достатньо інформації з цих окремих сегментів коду, щоб ідентифікувати їх до решти програми, і поставити замість них у якомусь файлі заголовка ? І таким чином я можу використовувати #include директиву препроцесора, щоб вносити лише інформацію, необхідну для компіляції! "

Хм. Ви, можливо, будете на чомусь там. Дайте мені знати, як це працює для вас.


13
Гарна відповідь, сер. Це було веселе читання, і легко зрозуміти. Я хочу, щоб мій підручник був написаний так.
ialm

@veol Пошук голови Перша серія книг - я не знаю, чи є у них версія C ++. headfirstlabs.com
Amarghosh

2
Це (певне) найкраще формулювання на сьогоднішній день, яке я чув чи роздумував. Джастін Кейс, досвідчений початківець, домігся завершення проекту на мільйон натискань клавіш, ще не відправлений, і похвальний «перший проект», який бачить світло застосування у реальній базі користувачів, визнав проблему, яку вирішують закриття. Звучить надзвичайно схоже на початкові проблеми початкової проблеми ОП мінус "кодується це майже сто разів і не може зрозуміти, що робити для null (як жодного об'єкта) проти null (як племінник), не використовуючи програмування за винятками".
Ніколас Йордан

Звичайно, це все розпадається на шаблони, оскільки більшість компіляторів не підтримує / не реалізує ключове слово "експортувати".
KitsuneYMG

1
Ще один момент полягає в тому, що у вас є багато сучасних бібліотек (якщо подумати про BOOST), які використовують заголовки лише класи ... Хо, чекайте? Чому досвідчений програміст не відокремлює інтерфейс від реалізації? Частина відповіді може бути тим, що сліпо сказано, інша частина може полягати в тому, що один файл кращий за два, коли це можливо, а інша частина полягає в тому, що посилання має вартість, ніж може бути досить високою. Я бачив, як програми запускаються в десять разів швидше з прямим включенням джерела та оптимізатора компілятора. Тому що зв'язування здебільшого блокує оптимізацію.
kriss

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;
}

Якщо ви хочете, щоб функція була локальною для одиниці перекладу, ви визначаєте її як "статичну". Що це означає? Це означає, що якщо ви включаєте вихідні файли з функціями зовнішньої форми, ви отримаєте помилки переосмислення, оскільки компілятор не раз стикається з тією ж реалізацією. Отже, ви хочете, щоб усі ваші перекладацькі одиниці бачили декларацію функції, але не тіло функції .

То як все це пюре в кінці? Це робота лінкера. Лінкер зчитує всі об'єктні файли, що генеруються на етапі асемблера, і розв'язує символи. Як я вже говорив раніше, символ - це лише назва. Наприклад, ім'я змінної або функції. Коли підрозділи перекладу, які викликають функції або оголошують типи, не знають реалізації для цих функцій чи типів, ці символи вважаються невирішеними. Лінкер розв'язує невирішений символ, з'єднуючи блок перекладу, який містить не визначений символ разом з тим, який містить реалізацію. Phew. Це справедливо для всіх зовнішньо видимих ​​символів, незалежно від того, чи вони реалізовані у вашому коді, чи надані додатковою бібліотекою. Бібліотека - це справді архів із кодом для багаторазового використання.

Є два помітні винятки. По-перше, якщо у вас є невелика функція, ви можете зробити її вбудованою. Це означає, що згенерований машинний код не генерує виклик зовнішньої функції, а є буквально об'єднаним на місці. Оскільки вони зазвичай невеликі, розмір накладних витрат не має значення. Ви можете уявити, що вони статичні в тому, як вони працюють. Тому безпечно реалізовувати вбудовані функції у заголовках. Реалізації функцій усередині визначення класу або структури також часто підкреслюються автоматично компілятором.

Інший виняток - шаблони. Оскільки компілятору потрібно бачити ціле визначення шаблону при імментируванні, неможливо від'єднати реалізацію від визначення, як при автономних функціях або нормальних класах. Ну, можливо, це можливо і зараз, але отримання широкої підтримки компілятора для ключового слова "експорт" зайняло багато часу. Тож без підтримки "експорту" перекладацькі блоки отримують власні локальні копії створених шаблонів типів та функцій, подібно до того, як працюють вбудовані функції. З підтримкою "експорту" це не так.

За двома винятками, деякі люди вважають, що "приємніше" розміщувати реалізовані вбудовані функції, шаблонні функції та шаблонні типи у файлах .cpp, а потім #include .cpp-файл. Будь це заголовок чи вихідний файл, насправді не має значення; препроцесор не дбає і є лише умовою.

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

  • Препроцесор запускається, який аналізує всі директиви , яка починається з "#". Директива #include, наприклад, об'єднує включений файл із нижчим. Це також робить макрозаміни та вставки лексеми.
  • Фактичний компілятор працює на проміжному текстовому файлі після етапу препроцесора та видає код асемблера.
  • В асемблері працює на файл збірки і висилає машинний код, зазвичай це називається об'єктний файл і слід двійковий виконуваний формат оперативної системи в питанні. Наприклад, Windows використовує PE (портативний виконуваний формат), тоді як Linux використовує формат ELF Unix System V з розширеннями GNU. На цьому етапі символи все ще позначаються як невизначені.
  • Нарешті, запускається лінкер . Усі попередні етапи виконувались на кожному блоці перекладу в порядку. Однак етап linker працює на всіх згенерованих файлах об'єктів, які були створені асемблером. Лінкер розв'язує символи і робить багато магії, як створення розділів і сегментів, що залежить від цільової платформи та бінарного формату. Програмістам не потрібно цього знати загалом, але це, безумовно, допомагає в деяких випадках.

Знову ж таки, це було напевно більше, ніж ви просили, але я сподіваюся, що деталі, що містять зернистість, допоможуть вам побачити більшу картину.


2
Дякую за ретельне пояснення. Зізнаюся, це ще не все має сенс для мене, і я думаю, мені потрібно буде ще раз (і знову) прочитати вашу відповідь.
ialm

1
+1 за відмінне пояснення. занадто погано, це, ймовірно, відлякує всіх C ++ новачок. :)
золотоПсевдо

1
Хе, не відчувай себе погано. На переповнення стека найдовша відповідь рідко є найкращою відповіддю.

int add(int, int);є функцією декларації . Прототип частина його просто int, int. Однак усі функції в C ++ мають прототип, тому термін справді має сенс лише у C. Я відредагував вашу відповідь на цей ефект.
Мельпомена

exportдля шаблонів було видалено з мови в 2011 році. Його ніколи не підтримували компілятори.
Мельпомена

10

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


6

Розгляньте файли cpp як чорну скриньку, а файли .h як посібники, як використовувати ці чорні поля.

Файли cpp можна скласти достроково. Це не працює у вас # включайте їх, оскільки потрібно фактично "включати" код у вашу програму кожного разу, коли він його компілює. Якщо ви просто включите заголовок, він може просто використовувати заголовок, щоб визначити, як використовувати попередньо складений файл cpp.

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

Також прочитайте це: Файл заголовка включає в себе шаблони


Дякую за конкретніший приклад. Я спробував прочитати ваше посилання, але зараз я плутаюсь ... в чому різниця між включенням заголовка явно і прямої заяви?
ialm

це чудова стаття. Veol, тут вони включають заголовки, де компілятору потрібна інформація щодо розміру класу. Переадресація оголошень використовується, коли ви використовуєте лише покажчики.
pankajt

вперед декларація: int someFunction (int needValue); зауважте використання інформації про тип і (звичайно) відсутні фігурні дужки. Це, як зазначено, повідомляє компілятору, що в якийсь момент вам знадобиться функція, яка приймає int і повертає int, компілятор може резервувати виклик для неї, використовуючи цю інформацію. Це можна було б назвати форвардною декларацією. Укладачі Fancier, як передбачається, зможуть знайти функцію, не потребуючи цього, в тому числі заголовок може бути зручним способом оголосити купу форвардних оголошень.
Ніколас Йордан

6

Файли заголовків зазвичай містять декларації функцій / класів, тоді як .cpp файли містять фактичні реалізації. Під час компіляції кожен .cpp-файл збирається в об’єктний файл (як правило, розширення .o), і лінкер поєднує різні об’єктні файли в остаточний виконуваний файл. Процес зв’язування, як правило, набагато швидший, ніж компіляція.

Переваги цього розділення: Якщо ви рекомпілюєте один із .cpp-файлів у своєму проекті, вам не доведеться перекомпілювати всі інші. Ви просто створіть новий об’єктний файл саме для цього .cpp-файлу. Компілятору не потрібно дивитись на інші .cpp-файли. Однак, якщо ви хочете викликати функції у вашому поточному .cpp-файлі, які були реалізовані в інших .cpp-файлах, ви повинні сказати компілятору, які аргументи вони беруть; це є метою включення файлів заголовків.

Недоліки. Під час компіляції заданого файлу .cpp компілятор не може «бачити», що знаходиться в інших .cpp-файлах. Тож воно не знає, як реалізовані функції, і в результаті не може оптимізуватися настільки агресивно. Але я думаю, що вам поки що не потрібно займатися цим (:


5

Основна ідея, що заголовки включаються лише у файли, а файли cpp лише компілюються. Це стане кориснішим, коли у вас буде багато файлів cpp, і перекомпіляція всієї програми, коли ви модифікуєте лише один з них, буде надто повільною. Або коли функції у файлах запускатимуться залежно один від одного. Отже, вам слід відокремити декларації класу у файлах заголовків, залишити реалізацію у файлах cpp та написати Makefile (або щось інше, залежно від того, якими інструментами ви користуєтесь) для компіляції файлів cpp та зв’язування отриманих об’єктних файлів у програму.


3

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

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

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


Отже, крім тривалішого часу компіляції, у мене почнуть виникати проблеми, коли я #include мій cpp-файл у безлічі різних файлів, які використовують функції у включених файлах cpp?
ialm

Так, це називається зіткненням простору імен. Цікавим тут є те, чи пов'язування проти ліб вводить проблеми з простором імен. Взагалі, я вважаю, що компілятори виробляють кращі часи компіляції для області перекладу одиниці перекладу (все в одному файлі), що вводить проблеми з простором імен - що призводить до повторного розділення .... Ви можете включити файл включення у кожну одиницю перекладу, (передбачається) є навіть прагма (#прагма один раз), яка повинна виконувати це, але це супозиторій. Будьте обережні, щоб сліпо не покладатися на libs (.O файли) звідки завгодно, оскільки 32-бітні посилання не застосовуються.
Nicholas Jordan

2

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

Згідно з умовами, це допомагає зрозуміти, де все знаходиться, і чітко розрізняє інтерфейс та реалізацію ваших модулів. Це також означає, що вам ніколи не доведеться перевіряти, чи файл .cpp включений в інший, перш ніж додати в нього щось, що може зламатися, якщо він був визначений у кількох різних одиницях.


2

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

ось приклад:

скажімо, ви створюєте файл cpp, який містить просту форму послідовних підпрограм, всі в класі містування, ви кладете клас decl для цього в mystring.h, компілюючи mystring.cpp у файл .obj

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

Тепер, якщо приятель хоче використовувати ваш клас містінг, ви даєте йому mystring.h та mystring.obj, він також не обов'язково повинен знати, як це працює, поки він працює.

пізніше, якщо у вас є більше таких .obj-файлів, ви можете об'єднати їх у .lib-файл та покласти на нього замість цього.

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


2

Якщо це працює для вас, то в цьому немає нічого поганого - окрім того, що воно роздмухне пір’я людей, які думають, що існує лише один спосіб зробити.

Багато наведених тут відповідей стосуються оптимізацій для масштабних програмних проектів. Це хороші речі, про які слід знати, але немає сенсу в оптимізації невеликого проекту, як якщо б це був великий проект - саме так називають "передчасна оптимізація". Залежно від вашого середовища розробки, може виникнути значна додаткова складність у створенні конфігурації збірки для підтримки декількох вихідних файлів на програму.

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

Кілька відповідей обговорюють відокремлення інтерфейсу від реалізації. Однак це не є притаманною особливістю файлів, що включають файли, і це досить звична для файлів #include "header", які безпосередньо включають їх реалізацію (навіть стандартна бібліотека C ++ робить це значною мірою).

Єдине, що справді було "нетрадиційним" щодо того, що ви робили, - це називати ваші файли, що включаються ".cpp", а не ".h" або ".hpp".


1

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

Зазвичай заголовки є деклараціями, а cpp - файлами реалізації. У заголовках ви визначаєте інтерфейс для класу чи функції, але ви не залишаєте, як реально реалізувати деталі. Таким чином, вам не доведеться перекомпілювати кожен cpp-файл, якщо ви внесли зміни в один.


якщо ви залишаєте реалізацію поза файлом заголовка, вибачте, але це здається мені інтерфейсом Java, правда?
gansub

1

Я запропоную вам пройти розробку програмного забезпечення великих масштабів C ++ від Джона Лакоса . У коледжі ми зазвичай пишемо невеликі проекти, де ми не стикаємося з такими проблемами. Книга підкреслює важливість розділення інтерфейсів та реалізацій.

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

Я все ще вчусь, як ти :)


Дякуємо за пропозицію книги. Я не знаю, чи зможу я коли-небудь потрапити на етап створення широкомасштабних програм C ++ ...
ialm

приємно кодувати великомасштабні програми, а для багатьох - виклик. Мені починає подобатися :)
pankajt

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 

Будь-яка з цих чотирьох категорій має багато коду, отже, необхідність модульності. Цей вид кодової бази напрочуд типовий для реальних систем.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.