Почему бы не конкатенировать исходные файлы C перед компиляцией?

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

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

Вот пример того, что я описываю. Здесь у меня есть три файла:

// includes.c
#include <stdio.h>
// main.c
int main() {
    foo();
    printf("world\n");
    return 0;
}
// foo.c
void foo() {
    printf("Hello ");
}

Выполняя что-то вроде cat *.c > to_compile.c && gcc -o myprogram to_compile.c в моем Makefile, я могу уменьшить количество кода, который я пишу.

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

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

Ответ 1

Некоторые программы построены таким образом.

Типичным примером является SQLite. Он иногда компилируется как amalgamation (выполняется во время сборки из многих исходных файлов).

Но у этого подхода есть плюсы и минусы.

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

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

Мне не нужно писать заголовочный файл для каждой функции

Это неправильный подход (наличия одного файла заголовка для каждой функции). Для проекта с одним человеком (менее ста тысяч строк кода, иначе KLOC = килограмма code), это вполне разумно - по крайней мере, для небольших проектов - иметь один общий заголовочный файл (который вы могли бы предварительно скомпилировать, если используете GCC), который будет содержать декларации всех публичных функций и типов и, возможно, определения функций static inline (достаточно малых и называемых достаточно часто, чтобы получить прибыль от inlining). Например, sash shell организована таким образом (а также lout formatter, с 52 KLOC).

Возможно, у вас также есть несколько файлов заголовков и, возможно, есть какой-то один заголовок "grouping", который #include -s все из них (и который вы можете предварительно скомпилировать). См. Например jansson (на самом деле имеется один общий заголовочный файл) и GTK (у которого много внутренних заголовков, но большинство приложений, использующих его имеют только один #include <gtk/gtk.h>, который в свою очередь, включают все внутренние заголовки). На противоположной стороне POSIX имеет большое количество файлов заголовков, и он документирует, какие из них должны быть включены и в каком порядке.

Некоторые люди предпочитают иметь много файлов заголовков (и некоторые даже предпочитают помещать одно объявление функции в свой собственный заголовок). Я не делаю (для личных проектов или небольших проектов, на которых только два или три человека будут совершать код), но это вопрос вкуса. Кстати, когда проект много растет, часто случается, что набор файлов заголовков (и единиц перевода) значительно меняется. Посмотрите также на REDIS (у него есть 139 .h файлов заголовка и 214 .c файлов, т.е. единицы перевода, суммирующие 126 KLOC).

Наличие одного или нескольких единиц перевода также зависит от вкуса (и удобства, привычек и условностей). Мое предпочтение состоит в том, чтобы иметь исходные файлы (то есть единицы перевода), которые не слишком малы, как правило, несколько тысяч строк каждый, и часто имеют (для небольшого проекта менее 60 KLOC) общий одиночный заголовочный файл. Не забудьте использовать инструмент build automation, например GNU make (часто с parallel строить через make -j, тогда у вас будет несколько процессов компиляции, выполняющихся одновременно). Преимущество такой организации исходного файла заключается в том, что компиляция достаточно быстро. BTW, в некоторых случаях целесообразно использовать metaprogramming: некоторые из ваших (внутренних заголовков или единиц перевода) C "исходных" файлов могут (например, некоторые script в AWK, некоторые специализированные программы на C, такие как bison или ваша собственная вещь).

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

Я настоятельно рекомендую изучить исходный код и построить некоторые существующие проекты бесплатного программного обеспечения(например, на GitHub или SourceForge или ваш любимый дистрибутив Linux). Вы узнаете, что они разные подходы. Помните, что в соглашениях и привычках C имеют большое значение на практике, поэтому существуют различные способы организации вашего проекта в файлах .c и .h. Читайте о = > nofollow noreferrer → .

Это также означает, что мне не нужно включать стандартные библиотеки в каждый созданный мной файл

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

Обратите внимание, что то, что входит в некоторый файл #include -d, является обычным (и не определено спецификацией C). Некоторые программы имеют некоторый код в каком-то таком файле (который тогда не следует называть "заголовком", а всего лишь "включенным файлом" и который не должен иметь суффикса .h, а что-то вроде .inc), Посмотрите пример на XPM файлы. С другой стороны, вы могли бы в принципе не иметь каких-либо ваших собственных файлов заголовков (вам все равно нужны файлы заголовков из реализации, например <stdio.h> или <dlfcn.h> из вашей системы POSIX), и скопируйте и вставьте дублированный код в свой .c файлы -eg имеют строку int foo(void); в каждом файле .c, но это очень плохая практика и неодобрительно. Тем не менее, некоторые программы генерируют файлы C, совместно использующие общий контент.

BTW, C или С++ 14 не имеют модулей (например, OCaml). Другими словами, в C модуль является главным образом соглашением.

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

Если вы начнете работать над проектом с одним человеком в C, я бы предложил сначала иметь один заголовочный файл (и предварительно скомпилировать его) и несколько единиц перевода .c. На практике вы будете изменять файлы .c гораздо чаще, чем .h. Если у вас более 10 KLOC, вы можете реорганизовать это в несколько файлов заголовков. Такой рефакторинг сложный для проектирования, но его легко сделать (просто копирование и вставка кода). У других людей были бы разные предложения и подсказки (и это нормально!). Но не забудьте включить все предупреждения и отладочную информацию при компиляции (поэтому скомпилируйте с gcc -Wall -g, возможно, установите CFLAGS= -Wall -g в Makefile). Используйте отладчик gdbvalgrind...). Попросите оптимизацию (-O2), когда вы сравниваете уже отлаженную программу. Также используйте систему управления версиями, например Git.

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

В комментарии добавьте:

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

Я не понимаю, почему это было бы полезно (за исключением очень странных случаев). Гораздо лучше (и очень обычная и обычная практика) скомпилировать каждую единицу перевода (например, каждый файл .c) в свой object file ( a .o файл ELF в Linux) и link их позже. Это легко с помощью make (на практике, когда вы измените только один файл .c, например, чтобы исправить ошибку, только этот файл компилируется и инкрементная сборка выполняется очень быстро), и вы можете попросить его скомпилировать объект файлы в parallel с помощью make -j (а затем ваша сборка идет очень быстро на вашем многоядерном процессоре).

Ответ 2

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

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

  • Стандартная библиотека C состоит из предварительно скомпилированных компонентов. Вы действительно хотите перекомпилировать все это?

  • Легче сотрудничать с другими программистами, если база кода разделена на разные файлы.

Ответ 3

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

В вашем подходе могут быть некоторые преимущества, но для таких языков, как C, компиляция каждого модуля имеет больше смысла.

Ответ 4

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

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

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

Предположим, вы пишете прошивку для управления автомобилем. Один модуль в программе управляет FM-радиоприемником. Затем вы повторно используете радиокод в другом проекте, чтобы управлять FM-радио на смартфоне. И тогда ваш радиокод не будет компилироваться, потому что он не может найти тормоза, колеса, шестерни и т.д. Вещи, которые не имеют ни малейшего смысла для FM-радио, не говоря уже о смартфоне, о котором нужно знать.

Что еще хуже, если у вас жесткая связь, ошибки растут во всей программе, а не остаются локальными в модуле, где находится ошибка. Это делает последствия ошибок более серьезными. Вы пишете ошибку в коде FM-радио, а затем внезапно тормоз автомобиля перестает работать. Даже если вы не коснулись кода тормоза с обновлением, содержащим ошибку.

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

Ответ 5

Ваш подход к конкатенированию .c файлов полностью нарушен:

  • Несмотря на то, что команда cat *.c > to_compile.c поместит все функции в один файл, порядок: вы должны объявить каждую функцию до ее первого использования.

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

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

  • Когда вы объединяете все в один файл, , вы принудительно завершаете полную перестройку всякий раз, когда изменяется одна строка в вашем проекте.

    При использовании классического подхода компиляции .c/.h, изменение в реализации функции требует перекомпиляции ровно одного файла, в то время как изменение заголовка требует перекомпиляции файлов, которые фактически включают этот заголовок. Это может легко ускорить восстановление после небольшого изменения в 100 или более раз (в зависимости от количества файлов .c).

  • Вы теряете все возможности параллельной компиляции, когда вы объединяете все в один файл.

    У вас есть большой жирный 12-ядерный процессор с поддержкой hyper-threading? Жаль, ваш объединенный исходный файл скомпилирован одним потоком. Вы просто потеряли ускорение с коэффициентом больше 20... Хорошо, это крайний пример, но у меня уже есть программное обеспечение с make -j16, и, говорю вам, это может иметь огромное значение.

  • Время компиляции обычно не линейно.

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

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

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

Также обратите внимание, что многие из этих проблем проявляются только при больших размерах проекта. Если ваш проект составляет менее 5000 строк кода, все равно относительно неважно, как вы его компилируете. Но когда у вас более 50000 строк кода, вам определенно нужна система сборки, которая поддерживает инкрементные и параллельные сборки. В противном случае вы тратите свое рабочее время.

Ответ 6

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

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

Ответ 7

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

Если у вас есть - как в примере выше - 10 000 исходных файлов, а компиляция занимает 10 мс, тогда весь проект строит инкрементно (после смены одного файла) либо в (10 ms + время связывания), если вы просто компилируете этот измененный файл или (10 ms * 10000 + короткое время связывания), если вы скомпилируете все как единый конкатенированный blob.

Ответ 8

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

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

Ответ 9

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

Это цель файлов .h, поэтому вы можете определить, что вам нужно, и включить его повсюду. В некоторых проектах есть заголовок everything.h, который включает в себя каждый отдельный файл .h. Таким образом, ваш pro может быть достигнут с отдельными .c файлами.

Это означает, что мне не нужно писать заголовочный файл для каждой создаваемой мной функции [...]

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

Ответ 10

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

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

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

Однако он может быть использован для разбиения единицы перевода на более мелкие биты, которые совместно используют доступ к функциям таким образом, который напоминает доступность пакета Java.

То, как это достигается, связано с некоторой дисциплиной и помощью препроцессора.

Например, вы можете разбить блок перевода на два файла:

// a.c

static void utility() {
}

static void a_func() {
  utility();
}

// b.c

static void b_func() {
  utility();
}

Теперь вы добавляете файл для своей единицы перевода:

// ab.c

static void utility();

#include "a.c"
#include "b.c"

И ваша система сборки не создает ни a.c, ни b.c, но вместо этого строит только ab.o из ab.c.

Что делает ab.c?

Он включает в себя оба файла для создания единой единицы перевода и предоставляет прототип утилиты. Чтобы код в a.c и b.c мог видеть его, независимо от порядка, в котором они включены, и не требуя, чтобы функция была extern.