Как работает C++: понимание компиляции
Опубликовано: 2022-03-11В книге Бьерна Страуструпа « Язык программирования C++ » есть глава под названием «Путешествие по C++: основы» — Standard C++. В этой главе, в 2.2, на половине страницы упоминается процесс компиляции и компоновки в C++. Компиляция и компоновка — это два основных процесса, которые постоянно происходят при разработке программного обеспечения на C++, но, как ни странно, многие разработчики C++ не очень хорошо понимают их.
Почему исходный код C++ разделен на заголовочные и исходные файлы? Как каждая часть видится компилятору? Как это влияет на компиляцию и компоновку? Есть еще много подобных вопросов, о которых вы, возможно, думали, но приняли как условность.
Независимо от того, разрабатываете ли вы приложение на C++, реализуете для него новые функции, пытаетесь устранить ошибки (особенно некоторые странные ошибки) или пытаетесь заставить код C и C++ работать вместе, знание того, как работает компиляция и компоновка, сэкономит вам много времени и сделать эти задачи намного приятнее. В этой статье вы узнаете именно об этом.
В статье будет объяснено, как компилятор C++ работает с некоторыми базовыми языковыми конструкциями, даны ответы на некоторые распространенные вопросы, связанные с их процессами, и поможет вам обойти некоторые связанные ошибки, которые разработчики часто допускают при разработке C++.
Примечание. В этой статье есть пример исходного кода, который можно загрузить с https://bitbucket.org/danielmunoz/cpp-article.
Примеры были скомпилированы на машине 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)Предоставленные исходные файлы должны быть переносимы на другие операционные системы, хотя файлы Makefile, сопровождающие их для автоматизированного процесса сборки, должны быть переносимы только на Unix-подобные системы.
Конвейер сборки: предварительная обработка, компиляция и связывание
Каждый исходный файл C++ должен быть скомпилирован в объектный файл. Объектные файлы, полученные в результате компиляции нескольких исходных файлов, затем связываются с исполняемым файлом, разделяемой библиотекой или статической библиотекой (последняя из них представляет собой просто архив объектных файлов). Исходные файлы C++ обычно имеют суффиксы расширения .cpp, .cxx или .cc.
Исходный файл C++ может включать в себя другие файлы, известные как файлы заголовков, с помощью директивы #include . Заголовочные файлы имеют такие расширения, как .h, .hpp или .hxx, или вообще не имеют расширения, как в стандартной библиотеке C++ и заголовочных файлах других библиотек (например, Qt). Расширение не имеет значения для препроцессора C++, который буквально заменит строку, содержащую директиву #include , всем содержимым подключаемого файла.
Первый шаг, который компилятор сделает с исходным файлом, — запустить на нем препроцессор. Компилятору передаются только исходные файлы (для предварительной обработки и компиляции). Заголовочные файлы не передаются компилятору. Вместо этого они включаются из исходных файлов.
Каждый заголовочный файл может быть открыт несколько раз на этапе предварительной обработки всех исходных файлов, в зависимости от того, сколько исходных файлов включает их или сколько других заголовочных файлов, включенных из исходных файлов, также включают их (может быть много уровней косвенности). . С другой стороны, исходные файлы открываются компилятором (и препроцессором) только один раз, когда они передаются ему.
Для каждого исходного файла C++ препроцессор создаст единицу перевода, вставив в нее содержимое, когда найдет директиву #include, и в то же время удалит код из исходного файла и заголовков, когда найдет условную компиляцию. блоки, чья директива оценивается как false . Он также будет выполнять некоторые другие задачи, такие как замена макросов.
Как только препроцессор заканчивает создание этой (иногда огромной) единицы трансляции, компилятор начинает фазу компиляции и создает объектный файл.
Чтобы получить эту единицу перевода (предварительно обработанный исходный код), параметр -E можно передать компилятору g++ вместе с параметром -o , чтобы указать желаемое имя предварительно обработанного исходного файла.
В каталоге cpp-article/hello-world находится пример файла «hello-world.cpp»:
#include <iostream> int main(int argc, char* argv[]) { std::cout << "Hello world" << std::endl; return 0; }Создайте предварительно обработанный файл:
$ g++ -E hello-world.cpp -o hello-world.iiИ посмотрите количество строк:
$ wc -l hello-world.ii 17558 hello-world.ii На моей машине 17 588 строк. Вы также можете просто запустить make в этом каталоге, и он сделает эти шаги за вас.
Мы видим, что компилятор должен скомпилировать файл гораздо большего размера, чем простой исходный файл, который мы видим. Это из-за включенных заголовков. И в нашем примере мы включили только один заголовок. Единица перевода становится все больше и больше по мере того, как мы продолжаем включать заголовки.
Этот процесс предварительной обработки и компиляции аналогичен для языка C. Он следует правилам компиляции C, и способ включения заголовочных файлов и создания объектного кода почти такой же.
Как исходные файлы импортируют и экспортируют символы
Теперь посмотрим на файлы в каталоге cpp-article/symbols/c-vs-cpp-names .
Существует простой исходный файл C (не C++) с именем sum.c, который экспортирует две функции: одну для сложения двух целых чисел и одну для сложения двух чисел с плавающей запятой:
int sumI(int a, int b) { return a + b; } float sumF(float a, float b) { return a + b; } Скомпилируйте его (или запустите make и все шаги по созданию двух приложений-примеров для выполнения), чтобы создать объектный файл sum.o:
$ gcc -c sum.cТеперь посмотрите на символы, экспортированные и импортированные этим объектным файлом:
$ nm sum.o 0000000000000014 T sumF 0000000000000000 T sumI Никакие символы не импортируются, а экспортируются два символа: sumF и sumI . Эти символы экспортируются как часть сегмента .text (T), поэтому они являются именами функций, исполняемым кодом.
Если другие исходные файлы (как C, так и C++) хотят вызвать эти функции, они должны объявить их перед вызовом.
Стандартный способ сделать это — создать заголовочный файл, в котором они объявляются и включаются в любой исходный файл, который мы хотим назвать. Заголовок может иметь любое имя и расширение. Я выбрал sum.h :
#ifdef __cplusplus extern "C" { #endif int sumI(int a, int b); float sumF(float a, float b); #ifdef __cplusplus } // end extern "C" #endif Что это за блоки условной компиляции ifdef / endif ? Если я включу этот заголовок из исходного файла C, я хочу, чтобы он стал таким:
int sumI(int a, int b); float sumF(float a, float b);Но если я включу их из исходного файла C++, я хочу, чтобы это стало:
extern "C" { int sumI(int a, int b); float sumF(float a, float b); } // end extern "C" Язык C ничего не знает о директиве extern "C" , но C++ знает, и ему нужно, чтобы эта директива применялась к объявлениям функций C. Это связано с тем, что C++ искажает имена функций (и методов), потому что он поддерживает перегрузку функций/методов, а C — нет.
Это можно увидеть в исходном файле C++ с именем print.cpp:
#include <iostream> // std::cout, std::endl #include "sum.h" // sumI, sumF void printSum(int a, int b) { std::cout << a << " + " << b << " = " << sumI(a, b) << std::endl; } void printSum(float a, float b) { std::cout << a << " + " << b << " = " << sumF(a, b) << std::endl; } extern "C" void printSumInt(int a, int b) { printSum(a, b); } extern "C" void printSumFloat(float a, float b) { printSum(a, b); } Есть две функции с одинаковым именем ( printSum ), которые отличаются только типом параметров: int или float . Перегрузка функций — это функция C++, которой нет в C. Чтобы реализовать эту функцию и различать эти функции, C++ искажает имя функции, как мы можем видеть в имени экспортируемого символа (я выберу только то, что актуально из вывода nm) :
$ g++ -c print.cpp $ nm print.o 0000000000000132 T printSumFloat 0000000000000113 T printSumInt U sumF U sumI 0000000000000074 T _Z8printSumff 0000000000000000 T _Z8printSumii U _ZSt4cout Эти функции экспортируются (в моей системе) как _Z8printSumff для версии с плавающей точкой и _Z8printSumii для версии с типом int. Каждое имя функции в C++ искажено, если оно не объявлено как extern "C" . Есть две функции, которые были объявлены со связью C в print.cpp : printSumInt и printSumFloat .
Следовательно, их нельзя перегрузить, иначе их экспортируемые имена будут такими же, поскольку они не искажены. Мне пришлось отличать их друг от друга, добавляя Int или Float в конце их имен.
Поскольку они не искажены, их можно вызывать из кода C, как мы скоро увидим.
Чтобы увидеть искаженные имена, как если бы мы видели их в исходном коде C++, мы можем использовать параметр -C (demangle) в команде nm . Опять же, я скопирую только одну и ту же соответствующую часть вывода:
$ nm -C print.o 0000000000000132 T printSumFloat 0000000000000113 T printSumInt U sumF U sumI 0000000000000074 T printSum(float, float) 0000000000000000 T printSum(int, int) U std::cout С этой опцией вместо _Z8printSumff мы видим printSum(float, float) , а вместо _ZSt4cout мы видим std::cout, которые являются более понятными именами.
Мы также видим, что наш код C++ вызывает код C: print.cpp вызывает sumI и sumF , которые являются функциями C, объявленными как имеющие связь C в sum.h . Это можно увидеть в выводе nm команды print.o выше, которая сообщает о некоторых неопределенных (U) символах: sumF , sumI и std::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++:
#ifdef __cplusplus void printSum(int a, int b); void printSum(float a, float b); extern "C" { #endif void printSumInt(int a, int b); void printSumFloat(float a, float b); #ifdef __cplusplus } // end extern "C" #endifЕсли мы включаем его из исходного файла 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 , и файл будет выглядеть так:
void printSum(int a, int b); void printSum(float a, float b); extern "C" { void printSumInt(int a, int b); void printSumFloat(float a, float b); } // end extern "C" Это позволит коду C++ вызывать перегруженную функцию printSum или ее оболочки printSumInt и printSumFloat .
Теперь давайте создадим исходный файл C, содержащий основную функцию, которая является точкой входа для программы. Эта основная функция C будет вызывать printSumInt и printSumFloat , то есть будет вызывать обе функции C++ со связью C. Помните, что это функции C++ (тела их функций выполняют код C++), которые не имеют искаженных имен C++. Файл называется c-main.c :
#include "print.hpp" int main(int argc, char* argv[]) { printSumInt(1, 2); printSumFloat(1.5f, 2.5f); return 0; }Скомпилируйте его, чтобы сгенерировать объектный файл:
$ gcc -c c-main.cИ посмотрите импортированные/экспортированные символы:
$ nm c-main.o 0000000000000000 T main U printSumFloat U printSumInt Он экспортирует main и импортирует printSumFloat и 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 :
#include "print.hpp" int main(int argc, char* argv[]) { printSum(1, 2); printSum(1.5f, 2.5f); printSumInt(3, 4); printSumFloat(3.5f, 4.5f); return 0; } Скомпилируйте и просмотрите импортированные/экспортированные символы объектного файла cpp-main.o :
$ g++ -c cpp-main.cpp $ nm -C cpp-main.o 0000000000000000 T main U printSumFloat U printSumInt U printSum(float, float) U printSum(int, int) Он экспортирует main и импортирует связь C printSumFloat и printSumInt , а также обе искаженные версии printSum .
Вам может быть интересно, почему основной символ не экспортируется как искаженный символ, такой как main(int, char**) из этого исходного кода C++, поскольку это исходный файл C++ и он не определен как extern "C" . Что ж, main — это функция, определяемая специальной реализацией, и моя реализация, похоже, решила использовать для нее связь C независимо от того, определена ли она в исходном файле C или C++.
Связывание и запуск программы дает ожидаемый результат:
$ g++ -o cpp-app sum.o print.o cpp-main.o $ ./cpp-app 1 + 2 = 3 1.5 + 2.5 = 4 3 + 4 = 7 3.5 + 4.5 = 8Как работают охранники заголовков
До сих пор я старался не включать свои заголовки дважды, прямо или косвенно, из одного и того же исходного файла. Но поскольку один заголовок может включать в себя другие заголовки, один и тот же заголовок может косвенно включаться несколько раз. А поскольку содержимое заголовка просто вставляется в то место, откуда оно было включено, легко закончить с дублированными объявлениями.
См. примеры файлов в cpp-article/header-guards .
// unguarded.hpp class A { public: A(int a) : m_a(a) {} void setA(int a) { m_a = a; } int getA() const { return m_a; } private: int m_a; }; // guarded.hpp: #ifndef __GUARDED_HPP #define __GUARDED_HPP class A { public: A(int a) : m_a(a) {} void setA(int a) { m_a = a; } int getA() const { return m_a; } private: int m_a; }; #endif // __GUARDED_HPP Разница в том, что в guarded.hpp мы заключаем весь заголовок в условие, которое будет включено только в том случае, если макрос препроцессора __GUARDED_HPP не определен. В первый раз, когда препроцессор включает этот файл, он не будет определен. Но, поскольку макрос определен внутри этого защищенного кода, в следующий раз, когда он будет включен (из того же исходного файла, прямо или косвенно), препроцессор увидит строки между #ifndef и #endif и отбросит весь код между ними. их.
Обратите внимание, что этот процесс происходит для каждого исходного файла, который мы компилируем. Это означает, что этот заголовочный файл может быть включен один раз и только один раз для каждого исходного файла. Тот факт, что он был включен из одного исходного файла, не помешает его включению из другого исходного файла при компиляции этого исходного файла. Это просто предотвратит его включение более одного раза из одного и того же исходного файла.
В файле примера main-guarded.cpp guarded.hpp дважды:
#include "guarded.hpp" #include "guarded.hpp" int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); } Но предварительно обработанный вывод показывает только одно определение класса A :
$ g++ -E main-guarded.cpp # 1 "main-guarded.cpp" # 1 "<built-in>" # 1 "<command-line>" # 1 "/usr/include/stdc-predef.h" 1 3 4 # 1 "<command-line>" 2 # 1 "main-guarded.cpp" # 1 "guarded.hpp" 1 class A { public: A(int a) : m_a(a) {} void setA(int a) { m_a = a; } int getA() const { return m_a; } private: int m_a; }; # 2 "main-guarded.cpp" 2 int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }Поэтому его можно без проблем скомпилировать:
$ g++ -o guarded main-guarded.cpp Но файл main-unguarded.cpp включает unguarded.hpp дважды:
#include "unguarded.hpp" #include "unguarded.hpp" int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }И предварительно обработанный вывод показывает два определения класса A:
$ g++ -E main-unguarded.cpp # 1 "main-unguarded.cpp" # 1 "<built-in>" # 1 "<command-line>" # 1 "/usr/include/stdc-predef.h" 1 3 4 # 1 "<command-line>" 2 # 1 "main-unguarded.cpp" # 1 "unguarded.hpp" 1 class A { public: A(int a) : m_a(a) {} void setA(int a) { m_a = a; } int getA() const { return m_a; } private: int m_a; }; # 2 "main-unguarded.cpp" 2 # 1 "unguarded.hpp" 1 class A { public: A(int a) : m_a(a) {} void setA(int a) { m_a = a; } int getA() const { return m_a; } private: int m_a; }; # 3 "main-unguarded.cpp" 2 int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }Это вызовет проблемы при компиляции:

$ g++ -o unguarded main-unguarded.cpp В файле, включенном из main-unguarded.cpp:2:0 :
unguarded.hpp:1:7: error: redefinition of 'class A' class A { ^ In file included from main-unguarded.cpp:1:0: unguarded.hpp:1:7: error: previous definition of 'class A' class A { ^Для краткости я не буду использовать защищенные заголовки в этой статье, если в этом нет необходимости, поскольку большинство из них являются короткими примерами. Но всегда охраняйте свои заголовочные файлы. Не ваши исходные файлы, которые не будут включены откуда угодно. Просто заголовочные файлы.
Передача по значению и постоянству параметров
Посмотрите файл by-value.cpp в cpp-article/symbols/pass-by :
#include <vector> #include <numeric> #include <iostream> // std::vector, std::accumulate, std::cout, std::endl using namespace std; int sum(int a, const int b) { cout << "sum(int, const int)" << endl; const int c = a + b; ++a; // Possible, not const // ++b; // Not possible, this would result in a compilation error return c; } float sum(const float a, float b) { cout << "sum(const float, float)" << endl; return a + b; } int sum(vector<int> v) { cout << "sum(vector<int>)" << endl; return accumulate(v.begin(), v.end(), 0); } float sum(const vector<float> v) { cout << "sum(const vector<float>)" << endl; return accumulate(v.begin(), v.end(), 0.0f); } Поскольку я использую директиву using namespace std , мне не нужно уточнять имена символов (функций или классов) внутри пространства имен std в остальной части единицы перевода, которой в моем случае является остальная часть исходного файла. Если бы это был файл заголовка, я не должен был вставлять эту директиву, потому что файл заголовка должен быть включен из нескольких исходных файлов; эта директива перенесла бы в глобальную область действия каждого исходного файла все пространство имен std с точки, в которой они включают мой заголовок.
Даже заголовки, включенные после моего в эти файлы, будут иметь эти символы в области действия. Это может привести к конфликтам имен, поскольку они не ожидали, что это произойдет. Поэтому не используйте эту директиву в заголовках. Используйте его только в исходных файлах, если хотите, и только после включения всех заголовков.
Обратите внимание, что некоторые параметры являются константами. Это означает, что они не могут быть изменены в теле функции, если мы попытаемся это сделать. Это дало бы ошибку компиляции. Также обратите внимание, что все параметры в этом исходном файле передаются по значению, а не по ссылке (&) или указателю (*). Это означает, что вызывающая сторона сделает их копию и передаст функции. Таким образом, для вызывающей стороны не имеет значения, константны они или нет, потому что, если мы изменим их в теле функции, мы изменим только копию, а не исходное значение, которое вызывающая сторона передала функции.
Поскольку константность параметра, передаваемого по значению (копии), не имеет значения для вызывающей стороны, он не искажается в сигнатуре функции, как это видно после компиляции и проверки объектного кода (только соответствующий вывод):
$ g++ -c by-value.cpp $ nm -C by-value.o 000000000000001e T sum(float, float) 0000000000000000 T sum(int, int) 0000000000000087 T sum(std::vector<float, std::allocator<float> >) 0000000000000048 T sum(std::vector<int, std::allocator<int> >)Сигнатуры не указывают, являются ли скопированные параметры константными или нет в теле функции. Это не имеет значения. Это имело значение только для определения функции, чтобы с первого взгляда показать читателю тела функции, будут ли эти значения когда-либо меняться. В этом примере только половина параметров объявлена как константа, поэтому мы можем видеть контраст, но если мы хотим быть корректными с константой, все они должны быть объявлены так, поскольку ни один из них не изменяется в теле функции (и они не должен).
Поскольку для объявления функции не имеет значения, что видит вызывающий объект, мы можем создать заголовок by-value.hpp следующим образом:
#include <vector> int sum(int a, int b); float sum(float a, float b); int sum(std::vector<int> v); int sum(std::vector<float> v);Добавление квалификаторов const здесь разрешено (вы даже можете квалифицировать как константные переменные, которые не являются константами в определении, и это будет работать), но это не обязательно, и это только сделает объявления излишне подробными.
Пройти по ссылке
Посмотрим by-reference.cpp :
#include <vector> #include <iostream> #include <numeric> using namespace std; int sum(const int& a, int& b) { cout << "sum(const int&, int&)" << endl; const int c = a + b; ++b; // Will modify caller variable // ++a; // Not allowed, but would also modify caller variable return c; } float sum(float& a, const float& b) { cout << "sum(float&, const float&)" << endl; return a + b; } int sum(const std::vector<int>& v) { cout << "sum(const std::vector<int>&)" << endl; return accumulate(v.begin(), v.end(), 0); } float sum(const std::vector<float>& v) { cout << "sum(const std::vector<float>&)" << endl; return accumulate(v.begin(), v.end(), 0.0f); }Постоянство при передаче по ссылке имеет значение для вызывающего объекта, потому что он сообщает вызывающему объекту, будет ли его аргумент изменен вызываемым или нет. Поэтому символы экспортируются с их константностью:
$ g++ -c by-reference.cpp $ nm -C by-reference.o 0000000000000051 T sum(float&, float const&) 0000000000000000 T sum(int const&, int&) 00000000000000fe T sum(std::vector<float, std::allocator<float> > const&) 00000000000000a3 T sum(std::vector<int, std::allocator<int> > const&)Это также должно быть отражено в заголовке, который будут использовать вызывающие абоненты:
#include <vector> int sum(const int&, int&); float sum(float&, const float&); int sum(const std::vector<int>&); float sum(const std::vector<float>&);Обратите внимание, что я не писал имена переменных в объявлениях (в заголовке), как делал это до сих пор. Это также допустимо для этого примера и для предыдущих. Имена переменных не требуются в объявлении, поскольку вызывающей стороне не нужно знать, как вы хотите назвать свою переменную. Но имена параметров, как правило, желательны в объявлениях, чтобы пользователь мог с первого взгляда понять, что означает каждый параметр и, следовательно, что отправлять в вызове.
Удивительно, но имена переменных также не нужны в определении функции. Они нужны только в том случае, если вы действительно используете параметр в функции. Но если вы никогда не используете его, вы можете оставить параметр с типом, но без имени. Зачем функции объявлять параметр, который она никогда не использует? Иногда функции (или методы) являются просто частью интерфейса, например, интерфейс обратного вызова, который определяет определенные параметры, которые передаются наблюдателю. Наблюдатель должен создать обратный вызов со всеми параметрами, указанными интерфейсом, поскольку все они будут отправлены вызывающей стороной. Но наблюдателя могут интересовать не все из них, поэтому вместо предупреждения компилятора о «неиспользуемом параметре» определение функции может просто оставить его без имени.
Пройти мимо указателя
// by-pointer.cpp: #include <iostream> #include <vector> #include <numeric> using namespace std; int sum(int const * a, int const * const b) { cout << "sum(int const *, int const * const)" << endl; const int c = *a+ *b; // *a = 4; // Can't change. The value pointed to is const. // *b = 4; // Can't change. The value pointed to is const. a = b; // I can make a point to another const int // b = a; // Can't change where b points because the pointer itself is const. return c; } float sum(float * const a, float * b) { cout << "sum(int const * const, float const *)" << endl; return *a + *b; } int sum(const std::vector<int>* v) { cout << "sum(std::vector<int> const *)" << endl; // v->clear(); // I can't modify the const object pointed by v const int c = accumulate(v->begin(), v->end(), 0); v = NULL; // I can make v point to somewhere else return c; } float sum(const std::vector<float> * const v) { cout << "sum(std::vector<float> const * const)" << endl; // v->clear(); // I can't modify the const object pointed by v // v = NULL; // I can't modify where the pointer points to return accumulate(v->begin(), v->end(), 0.0f); }Чтобы объявить указатель на элемент const (int в примере), вы можете объявить тип как любой из:
int const * const int *Если вы также хотите, чтобы сам указатель был константным, то есть чтобы указатель нельзя было изменить, чтобы он указывал на что-то еще, вы добавляете константу после звездочки:
int const * const const int * constЕсли вы хотите, чтобы сам указатель был константным, но не элемент, на который он указывает:
int * constСравните сигнатуры функций с разобранной проверкой объектного файла:
$ g++ -c by-pointer.cpp $ nm -C by-pointer.o 000000000000004a T sum(float*, float*) 0000000000000000 T sum(int const*, int const*) 0000000000000105 T sum(std::vector<float, std::allocator<float> > const*) 000000000000009c T sum(std::vector<int, std::allocator<int> > const*) Как видите, инструмент nm использует первое обозначение (const после типа). Кроме того, обратите внимание, что единственная константа, которая экспортируется и имеет значение для вызывающей стороны, заключается в том, будет ли функция изменять элемент, на который указывает указатель, или нет. Постоянство самого указателя не имеет значения для вызывающей стороны, поскольку сам указатель всегда передается как копия. Функция может сделать только свою собственную копию указателя, чтобы указать на что-то еще, что не имеет значения для вызывающей стороны.
Таким образом, заголовочный файл может быть создан как:
#include <vector> int sum(int const* a, int const* b); float sum(float* a, float* b); int sum(std::vector<int>* const); float sum(std::vector<float>* const);Передача по указателю аналогична передаче по ссылке. Одно отличие состоит в том, что при передаче по ссылке предполагается, что вызывающий объект передал действительную ссылку на элемент, а не указывает на NULL или другой недопустимый адрес, в то время как указатель может указывать, например, на NULL. Указатели можно использовать вместо ссылок, когда передача NULL имеет особое значение.
Поскольку значения C++11 также можно передавать с помощью семантики перемещения. Эта тема не будет рассматриваться в этой статье, но ее можно изучить в других статьях, таких как «Передача аргументов в C++».
Еще одна связанная с этим тема, которая здесь не рассматривается, — как вызывать все эти функции. Если все эти заголовки включены из исходного файла, но не вызываются, компиляция и компоновка завершатся успешно. Но если вы хотите вызвать все функции, будут некоторые ошибки, потому что некоторые вызовы будут неоднозначными. Компилятор сможет выбрать более одной версии суммы для определенных аргументов, особенно при выборе передачи по копии или по ссылке (или константной ссылке). Этот анализ выходит за рамки данной статьи.
Компиляция с разными флагами
Давайте теперь посмотрим на реальную ситуацию, связанную с этой темой, где могут обнаружиться трудно обнаруживаемые ошибки.
Перейдите в каталог cpp-article/diff-flags и посмотрите на Counters.hpp :
class Counters { public: Counters() : #ifndef NDEBUG // Enabled in debug builds m_debugAllCounters(0), #endif m_counter1(0), m_counter2(0) { } #ifndef NDEBUG // Enabled in debug build #endif void inc1() { #ifndef NDEBUG // Enabled in debug build ++m_debugAllCounters; #endif ++m_counter1; } void inc2() { #ifndef NDEBUG // Enabled in debug build ++m_debugAllCounters; #endif ++m_counter2; } #ifndef NDEBUG // Enabled in debug build int getDebugAllCounters() { return m_debugAllCounters; } #endif int get1() const { return m_counter1; } int get2() const { return m_counter2; } private: #ifndef NDEBUG // Enabled in debug builds int m_debugAllCounters; #endif int m_counter1; int m_counter2; }; Этот класс имеет два счетчика, которые начинаются с нуля и могут увеличиваться или считываться. Для отладочных сборок, которые я буду называть сборками, где макрос NDEBUG не определен, я также добавляю третий счетчик, который будет увеличиваться каждый раз, когда увеличивается любой из двух других счетчиков. Это будет своего рода помощником по отладке для этого класса. Многие классы сторонних библиотек или даже встроенные заголовочные файлы C++ (в зависимости от компилятора) используют подобные приемы, чтобы обеспечить разные уровни отладки. Это позволяет сборкам отладки обнаруживать выход итераторов за пределы допустимого диапазона и другие интересные вещи, о которых может подумать создатель библиотеки. Я буду называть выпускные сборки «сборками, в которых определен макрос NDEBUG ».
Для релизных сборок предварительно скомпилированный заголовок выглядит так (я использую grep для удаления пустых строк):
$ g++ -E -DNDEBUG Counters.hpp | grep -v -e '^$' # 1 "Counters.hpp" # 1 "<built-in>" # 1 "<command-line>" # 1 "/usr/include/stdc-predef.h" 1 3 4 # 1 "<command-line>" 2 # 1 "Counters.hpp" class Counters { public: Counters() : m_counter1(0), m_counter2(0) { } void inc1() { ++m_counter1; } void inc2() { ++m_counter2; } int get1() const { return m_counter1; } int get2() const { return m_counter2; } private: int m_counter1; int m_counter2; };В то время как для отладочных сборок это будет выглядеть так:
$ g++ -E Counters.hpp | grep -v -e '^$' # 1 "Counters.hpp" # 1 "<built-in>" # 1 "<command-line>" # 1 "/usr/include/stdc-predef.h" 1 3 4 # 1 "<command-line>" 2 # 1 "Counters.hpp" class Counters { public: Counters() : m_debugAllCounters(0), m_counter1(0), m_counter2(0) { } void inc1() { ++m_debugAllCounters; ++m_counter1; } void inc2() { ++m_debugAllCounters; ++m_counter2; } int getDebugAllCounters() { return m_debugAllCounters; } int get1() const { return m_counter1; } int get2() const { return m_counter2; } private: int m_debugAllCounters; int m_counter1; int m_counter2; };Как я объяснял ранее, в отладочных сборках есть еще один счетчик.
Я также создал несколько вспомогательных файлов.
// increment1.hpp: // Forward declaration so I don't have to include the entire header here class Counters; int increment1(Counters&); // increment1.cpp: #include "Counters.hpp" void increment1(Counters& c) { c.inc1(); } // increment2.hpp: // Forward declaration so I don't have to include the entire header here class Counters; int increment2(Counters&); // increment2.cpp: #include "Counters.hpp" void increment2(Counters& c) { c.inc2(); } // main.cpp: #include <iostream> #include "Counters.hpp" #include "increment1.hpp" #include "increment2.hpp" using namespace std; int main(int argc, char* argv[]) { Counters c; increment1(c); // 3 times increment1(c); increment1(c); increment2(c); // 4 times increment2(c); increment2(c); increment2(c); cout << "c.get1(): " << c.get1() << endl; // Should be 3 cout << "c.get2(): " << c.get2() << endl; // Should be 4 #ifndef NDEBUG // For debug builds cout << "c.getDebugAllCounters(): " << c.getDebugAllCounters() << endl; // Should be 3 + 4 = 7 #endif return 0; } И Makefile , который может настраивать флаги компилятора только для increment2.cpp :
all: main.o increment1.o increment2.o g++ -o diff-flags main.o increment1.o increment2.o main.o: main.cpp increment1.hpp increment2.hpp Counters.hpp g++ -c -O2 main.cpp increment1.o: increment1.cpp Counters.hpp g++ -c $(CFLAGS) -O2 increment1.cpp increment2.o: increment2.cpp Counters.hpp g++ -c -O2 increment2.cpp clean: rm -f *.o diff-flags Итак, скомпилируем все это в режиме отладки, не определяя NDEBUG :
$ CFLAGS='' make g++ -c -O2 main.cpp g++ -c -O2 increment1.cpp g++ -c -O2 increment2.cpp g++ -o diff-flags main.o increment1.o increment2.oТеперь запустите:
$ ./diff-flags c.get1(): 3 c.get2(): 4 c.getDebugAllCounters(): 7 Результат такой же, как и ожидалось. Теперь давайте скомпилируем только один из файлов с определенным NDEBUG , который будет режимом выпуска, и посмотрим, что произойдет:
$ make clean rm -f *.o diff-flags $ CFLAGS='-DNDEBUG' make g++ -c -O2 main.cpp g++ -c -DNDEBUG -O2 increment1.cpp g++ -c -O2 increment2.cpp g++ -o diff-flags main.o increment1.o increment2.o $ ./diff-flags c.get1(): 0 c.get2(): 4 c.getDebugAllCounters(): 7 Вывод не такой, как ожидалось. increment1 function saw a release version of the Counters class, in which there are only two int member fields. So, it incremented the first field, thinking that it was m_counter1 , and didn't increment anything else since it knows nothing about the m_debugAllCounters field. I say that increment1 incremented the counter because the inc1 method in Counter is inline, so it was inlined in increment1 function body, not called from it. The compiler probably decided to inline it because the -O2 optimization level flag was used.
So, m_counter1 was never incremented and m_debugAllCounters was incremented instead of it by mistake in increment1 . That's why we see 0 for m_counter1 but we still see 7 for m_debugAllCounters .
Working in a project where we had tons of source files, grouped in many static libraries, it happened that some of those libraries were compiled without debugging options for std::vector , and others were compiled with those options.
Probably at some point, all libraries were using the same flags, but as time passed, new libraries were added without taking those flags into consideration (they weren't default flags, they had been added by hand). We used an IDE to compile, so to see the flags for each library, you had to dig into tabs and windows, having different (and multiple) flags for different compilation modes (release, debug, profile…), so it was even harder to note that the flags weren't consistent.
This caused that in the rare occasions when an object file, compiled with one set of flags, passed a std::vector to an object file compiled with a different set of flags, which did certain operations on that vector, the application crashed. Imagine that it wasn't easy to debug since the crash was reported to happen in the release version, and it didn't happen in the debug version (at least not in the same situations that were reported).
The debugger also did crazy things because it was debugging very optimized code. The crashes were happening in correct and trivial code.
The Compiler Does a Lot More Than You May Think
In this article, you have learned about some of the basic language constructs of C++ and how the compiler works with them, starting from the processing stage to the linking stage. Knowing how it works can help you look at the whole process differently and give you more insight into these processes that we take for granted in C++ development.
From a three-step compilation process to mangling of function names and producing different function signatures in different situations, the compiler does a lot of work to offer the power of C++ as a compiled programming language.
I hope you will find the knowledge from this article useful in your C++ projects.
Дальнейшее чтение в блоге Toptal Engineering:
- Как выучить языки C и C++: полный список
- C# против C++: что лежит в основе?
