Найти тему

Как работает C++:

Оглавление


1. Понимание компиляции


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

Daniel Trejo
Daniel Trejo

В книге Бьярне Страуструпа “Язык программирования C++” есть глава, озаглавленная "Экскурсия по C++: основы" — Стандарт C++. В этой главе, в разделе 2.2, на половине страницы упоминается процесс компиляции и компоновки в C++. Компиляция и компоновка - это два очень базовых процесса, которые постоянно происходят при разработке программного обеспечения на C++, но, как ни странно, они не очень хорошо поняты многими разработчиками на C++.


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


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


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

Примеры были скомпилированы на компьютере CentOS Linux:

$ uname -sr
Linux 3.10.0-327.36.3.el7.x86_64

Использование версии g++:

$ g++ --version
g++ (GCC) 4.8.5 20150623 (Red Hat 4.8.5-11)

Предоставленные исходные файлы должны быть переносимыми для других операционных систем, хотя сопровождающие их Makefiles для автоматизированного процесса сборки должны быть переносимыми только для Unix-like систем.

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

Исходный файл C++ может включать в себя другие файлы, известные как файлы заголовков, с помощью директивы #include. Файлы заголовков имеют расширения, такие как .h, .hpp или .hxx, или вообще не имеют расширения, как в стандартной библиотеке C++ и файлах заголовков других библиотек (например, Qt). Расширение не имеет значения для препроцессора C++, который буквально заменит строку, содержащую директиву #include, всем содержимым включаемого файла.

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

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

Для каждого исходного файла C++ препроцессор создаст блок перевода, вставив в него содержимое, когда найдет директиву #include, в то же время он будет удалять код из исходного файла и заголовков, когда найдет блоки условной компиляции, директива которых оценивается как false. Он также будет выполнять некоторые другие задачи, такие как замена макросов.

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

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

В cpp-article/hello-world есть “hello-world.cpp” пример файла:

-2

Создайте предварительно обработанный файл с помощью:

$ g++ -E hello-world.cpp -o hello-world.ii

И посмотрите количество строк:
$ wc -l hello-world.ii
17558 hello-world.ii

В моём коде 17 588 строк. Вы также можете просто запустить make в этом каталоге, и он выполнит эти шаги за вас.

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

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

2. Как исходные файлы импортируют и экспортируют символы:

Давайте теперь посмотрим файлы в каталоге cpp-article/symbols/c-vs-cpp-names.

-3

Существует простой исходный файл C (не C++) с именем sum.c который экспортирует две функции, одну для добавления двух целых чисел и одну для добавления двух чисел с плавающей точкой:

-4

Скомпилируйте его (или запустите make и все шаги по созданию двух примеров приложений, которые будут выполнены), чтобы создать объектный файл sum.o:

$ gcc -c sum.c

Теперь посмотрите на символы, экспортируемые и импортируемые этим объектным файлом:

-5

Никакие символы не импортируются, а экспортируются два символа: SumF и sumI. Эти символы экспортируются как часть сегмента .text (T), поэтому они являются именами функций, исполняемым кодом.

Если другие исходные файлы (как C, так и C++) хотят вызывать эти функции, им необходимо объявить их перед вызовом.

Стандартный способ сделать это - создать заголовочный файл, который объявляет их и включает в любой исходный файл, который мы хотим вызвать. Заголовок может иметь любое имя и расширение. Я выбрал sum.h:

-6

Что это за блоки условной компиляции ifdef/endif? Если я включу этот заголовок из исходного файла C, я хочу, чтобы он стал:

int sumI(int a, int b);
float sumF(float a, float b);

Но если я включу их из исходного файла C++, я хочу, чтобы он стал:

-7

Язык C ничего не знает о внешней директиве "C", но C++ знает, и ему нужно, чтобы эта директива применялась к объявлениям функций C. Это связано с тем, что C++ искажает имена функций (и методов), поскольку он поддерживает перегрузку функций / методов, в то время как C этого не делает.

Это можно увидеть в исходном файле C++ с именем print.cpp:

-8

Есть две функции с одинаковым именем (printSum)’ которые отличаются только типом своих параметров: int или float. Перегрузка функций - это функция C++, которой нет в C. Чтобы реализовать эту функцию и дифференцировать эти функции, C ++ искажает имя функции, как мы можем видеть в их экспортированном имени символа (я выберу только то, что относится к выходным данным nm):

-9

Эти функции экспортируются (в моей системе) как _Z8printSumff для версии float и _Z8printSumii для версии int. Каждое имя функции в C++ искажено, если оно не объявлено как extern "C". Есть две функции, которые были объявлены со связью C в print.cpp : printSumInt и printSumFloat.

Следовательно, они не могут быть перегружены, иначе их экспортированные имена были бы одинаковыми, поскольку они не искажены. Мне пришлось отличать их друг от друга, добавляя Int или Float в конец их имен.

Поскольку они не искажены, их можно вызвать из кода C, как мы скоро увидим.

Чтобы увидеть искаженные имена так, как мы видели бы их в исходном коде C++, мы можем использовать опцию -C (demangle) в команде nm. Опять же, я скопирую только ту же самую соответствующую часть выходных данных:

-10

С помощью этого параметра вместо _Z8printSumff мы видим printSum(float, поплавок), а вместо _ZSt4cout мы видим std::cout, которые являются более удобными для человека именами.

Мы также видим, что наш C++-код вызывает C-код: print.cpp вызывает sumI и SumF, которые являются функциями C, объявленными как имеющие связь C в sum.h. Это можно увидеть в выводе nm из print.o выше, который сообщает о некоторых неопределенных символах (U): SumF, сумИ и ЗППП::cout. Предполагается, что эти неопределенные символы должны быть предоставлены в одном из объектных файлов (или библиотек), которые будут связаны вместе с выводом этого объектного файла на этапе ссылки.

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

Отметим также, что поскольку print.cpp это исходный файл C++, скомпилированный с помощью компилятора C++ (g++), весь код в нем скомпилирован как код C++. Функции с привязкой к C, такие как printSumInt и printSumFloat, также являются функциями C++, которые могут использовать функции C++. Только имена символов совместимы с C, но код - C++, что видно по тому факту, что обе функции вызывают перегруженную функцию (printSum), чего не могло бы произойти, если бы printSumInt или printSumFloat были скомпилированы на C.

Давайте посмотрим теперь print.hpp, файл заголовка, который может быть включен как из исходных файлов C, так и C++, что позволит вызывать printSumInt и printSumFloat как из C, так и из C++, а printSum - из C++:

-11

Если мы включаем его из исходного файла C, мы просто хотим увидеть:
void printSumInt(int a, int b);
void printSumFloat(float a, float b);

printSum не может быть виден из кода C, так как его имя искажено, поэтому у нас нет (стандартного и переносимого) способа объявить его для кода C. Да, я могу объявить их как:

void _Z8printSumii(int a, int b);
void _Z8printSumff(float a, float b);

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

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

Существуют стандартные способы смешивания кода C и C++, и стандартный способ вызова перегруженных функций C++ из C - это обернуть их в функции с привязкой C, как мы сделали, обернув printSum с помощью printSumInt и printSumFloat.

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

-12

Это позволит коду C++ вызывать перегруженную функцию printSum или ее оболочки print Sum Int и printSumFloat.

Теперь давайте создадим исходный файл C, содержащий основную функцию, которая является точкой входа для программы. Эта основная функция C вызовет printSumInt и printSumFloat, то есть вызовет обе функции C++ с привязкой C. Помните, что это функции C++ (их тела функций выполняют код C ++), которые только не имеют искаженных имен C++. Файл называется c-main.c:

-13

Скомпилируйте его для создания объектного файла:

$ gcc -c c-main.c

И увидеть импортированные/экспортированные символы:

-14

Он экспортирует main и импортирует print Sum Float и printSumInt, как и ожидалось.

Чтобы связать все это вместе в исполняемый файл, нам нужно использовать компоновщик C++ (g++), поскольку по крайней мере один файл, который мы свяжем, print.o, был скомпилирован на C++:

$ g++ -o c-app sum.o print.o c-main.o

Выполнение приводит к ожидаемому результату:

$ ./c-app
1 + 2 = 3
1.5 + 2.5 = 4

Теперь давайте попробуем с основным файлом C++, названным cpp-main.cpp:

-15

Скомпилируйте и просмотрите импортированные/экспортированные символы объектного файла cpp-main.o:

-16

Скомпилируйте и просмотрите импортированные/экспортированные символы объектного файла cpp-main.o: он экспортирует main и импортирует ссылки C printSumFloat и printSumInt, а также обе искаженные версии printSum.

Возможно, вам интересно, почему основной символ не экспортируется в виде искаженного символа, такого как main(int, char **) из этого исходного кода C++, поскольку это исходный файл C++, и он не определен как extern "C". Ну, main - это специальная функция, определенная реализацией, и моя реализация, похоже, решила использовать для нее связь C, независимо от того, определена ли она в исходном файле C или C++.

Связывание и запуск программы дают ожидаемый результат:

-17

Продолжение данной статьи будет чуть позже.

Спасибо за уделённое время, всего наилучшего!


Наука
7 млн интересуются