个性化阅读
专注于IT技术分析

C++的工作原理:了解编译原理

本文概述

Bjarne Stroustrup的C ++编程语言有一章名为” C ++之旅:基础知识” —标准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选项与-o选项一起传递给g ++编译器, 以指定所需的预处理源文件名。

在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目录中的文件。

如何处理功能。

有一个名为sum.c的简单C(不是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不支持。

这可以在名为print.cpp的C ++源文件中看到:

#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版本)。除非声明为extern” C”, 否则C ++中的每个函数名称都会被修饰。在print.cpp中用C链接声明了两个函数:printSumInt和printSumFloat。

因此, 它们不能重载, 否则它们的导出名称将是相同的, 因为它们没有被篡改。我必须通过将Int或Float附加到其名称的末尾来区分它们。

因为它们不会被破坏, 所以我们可以很快从C代码中调用它们。

要像在C ++源代码中看到的那样查看混乱的名称, 可以在nm命令中使用-C(demangle)选项。同样, 我将仅复制输出的相同相关部分:

$ 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

使用此选项, 我们看到的不是print_sum(float, float)而不是_Z8printSumff, 我们看到的是std :: cout而不是_ZSt4cout, 它们是更人性化的名称。

我们还看到我们的C ++代码正在调用C代码:print.cpp正在调用sumI和sumF, 它们是在sum.h中声明为具有C链接的C函数。可以在上面的print.o的nm输出中看到, 该输出通知一些未定义的(U)符号:sumF, sumI和std :: cout。那些未定义的符号应该在一个目标文件(或库)中提供, 这些目标文件将在链接阶段与此目标文件输出链接在一起。

到目前为止, 我们仅将源代码编译为目标代码, 但尚未链接。如果我们不将包含这些导入符号定义的目标文件与此对象文件链接在一起, 则链接器将停止并显示”缺少符号”错误。

还要注意, 由于print.cpp是C ++源文件, 是使用C ++编译器(g ++)编译的, 因此其中的所有代码都将编译为C ++代码。具有C链接的函数(例如printSumInt和printSumFloat)也是可以使用C ++功能的C ++函数。只有符号名称与C兼容, 但是代码是C ++, 这可以从两个函数都调用一个重载函数(printSum)的事实看出, 如果在C中编译了printSumInt或printSumFloat, 则不会发生。

现在让我们看一下print.hpp, 它是可以从C或C ++源文件中包含的头文件, 这将允许从C和C ++中都调用printSumInt和printSumFloat, 并从C ++中调用printSum:

#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);

由于C的名称已变形, 因此无法在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包装一样。

如果我们包含C ++源文件中的print.hpp, 则将定义__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

现在, 让我们尝试一个名为cpp-main.cpp的C ++主文件:

#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版本。

你可能想知道为什么为什么主符号没有从此C ++源导出为像main(int, char **)这样的错误符号, 因为它是C ++源文件, 并且没有定义为外部” 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 {
       ^

为简洁起见, 由于大多数示例都是简短示例, 因此在不必要的情况下, 我将不使用受保护的标头。但是请始终保护头文件。不是你的源文件, 该文件不会随处包含。只是头文件。

按值传递和参数的恒定性

查看cpp-article / symbols / pass-by中的by-value.cpp文件:

#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名称空间带入每个源文件的全局范围。

在这些文件中, 即使是我后面的标头也将在范围内包含这些符号。由于他们不希望发生这种情况, 因此可能会产生名称冲突。因此, 请勿在标题中使用此指令。如果需要, 仅在包含所有标头之后才在源文件中使用它。

注意一些参数是常量。这意味着, 如果我们尝试更改它们, 则无法在函数主体中进行更改。它会产生编译错误。另外, 请注意, 此源文件中的所有参数都是通过值传递的, 而不是通过引用(&)或通过指针(*)传递的。这意味着调用者将复制它们并传递给函数。因此, 调用者是否为const无关紧要, 因为如果我们在函数主体中对其进行修改, 则只会修改副本, 而不会修改调用者传递给函数的原始值。

由于通过值(复制)传递的参数的常数对于调用者而言无关紧要, 因此它不会在函数签名中被破坏, 因为在编译和检查目标代码(仅相关输出)后可以看到:

$ 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> >)

签名不表示函数主体中复制的参数是否为const。没关系仅对于函数定义很重要, 以便一眼向函数主体的读者显示这些值是否会改变。在该示例中, 仅将一半参数声明为const, 因此可以看到对比, 但是如果我们想进行const正确操作, 则应该全部声明它们, 因为在函数体中都没有对它们进行任何修改(并且它们不应该)。

由于调用者看到的函数声明无关紧要, 因此我们可以创建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限定词(你甚至可以将其定义为在定义中不是const的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 *

如果你还希望指针本身是const, 即不能将指针更改为指向其他对象, 则在星号后添加一个const:

int const * const
const int * const

如果你希望指针本身为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 ++中的Argument Passing)中进行研究。

这里不会涉及的另一个相关主题是如何调用所有这些函数。如果所有这些标头都包含在源文件中但未被调用, 则编译和链接将成功。但是, 如果你要调用所有函数, 则会出现一些错误, 因为某些调用将是模棱两可的。编译器将能够为某些参数选择sum的一个以上版本, 尤其是在选择通过复制还是通过引用(或const引用)传递时。这种分析超出了本文的范围。

用不同的标志编译

现在, 让我们来看看与此主题相关的现实情况, 其中可能会发现难以发现的错误。

转到目录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

输出结果与预期不符。 crement1函数看到了Counters类的发行版本, 其中只有两个int成员字段。因此, 它增加了第一个字段, 认为它是m_counter1, 并且没有增加其他任何内容, 因为它对m_debugAllCounters字段一无所知。我说递增1会使计数器递增, 因为Counter中的inc1方法是内联的, 因此它是在increment1函数体中内联的, 而不是从中调用的。编译器可能决定内联它, 因为使用了-O2优化级别标志。

因此, m_counter1永远不会增加, 而m_debugAllCounters会被递增而不是被错误地递增1。因此, m_counter1看到0, 而m_debugAllCounters仍然看到7。

在一个项目中, 我们有大量的源文件, 这些文件被分组在许多静态库中, 碰巧其中一些库在编译时没有调试std :: vector的选项, 而另一些库则使用这些选项进行编译。

可能在某个时候, 所有库都使用相同的标志, 但是随着时间的流逝, 添加新库时并未考虑这些标志(它们不是默认标志, 而是手动添加的)。我们使用IDE进行编译, 因此要查看每个库的标志, 你必须深入研究选项卡和窗口, 并针对不同的编译模式(发布, 调试, 配置文件…)使用不同的(和多个)标志, 因此更加困难请注意, 这些标志不一致。

这导致在极少数情况下, 当使用一组标志编译的目标文件将std :: vector传递给使用另一组标志编译的目标文件时, 该目标文件对该标志进行了某些操作, 导致应用程序崩溃。想象一下, 调试起来并不容易, 因为据报道崩溃发生在发行版本中, 而崩溃没有发生在调试版本中(至少在报告的情况下并非如此)。

调试器还做了疯狂的事情, 因为它正在调试非常优化的代码。崩溃发生在正确而琐碎的代码中。

编译器的功能超出你的想象

在本文中, 你已经了解了C ++的一些基本语言结构, 以及从处理阶段到链接阶段, 编译器如何使用它们。知道它是如何工作的, 可以帮助你以不同的方式看待整个过程, 并使你对我们在C ++开发中理所当然的这些过程有更多的了解。

从三步编译过程到处理函数名并在不同情况下产生不同的函数签名, 编译器做了很多工作来提供C ++作为已编译编程语言的功能。

我希望你将从本文中获得的知识对你的C ++项目有用。

相关:如何学习C和C ++语言:最终列表

赞(0)
未经允许不得转载:srcmini » C++的工作原理:了解编译原理

评论 抢沙发

评论前必须登录!