c++的一个优点是它支持只使用头文件库的开发。然而,c++ 17之前,头文件中不需要或不提供全局变量或对象时才有可能成为一个库。c++ 17可以在头文件中定义一个内联的变量/对象,如果这个定义被多个编译单元使用,它们都指向同一个惟一的对象:
//hpp
class MyClass
{
static inline std::string name = ""; // OK since C++17
//...
};
inline MyClass myGlobalObj; // OK even if included/defined by multiple CPP files
1. Inline变量的动机
在c++中,类结构中不允许初始化非const静态成员:
class MyClass
{
static std::string name = ""; // Compile-Time ERROR
//...
};
包含在多个CPP文件中的头文件中定义类结构之外的变量也是一个错误,:
//hpp
class MyClass
{
static std::string name; // OK
//...
};
MyClass::name = ""; // Link ERROR if included by multiple CPP files
问题在于头文件可能被包含多次,多个包含该头文件的CPP文件中都定义了一份MyClass.name。
同样的原因,如果你在头文件中定义类的对象,你会得到一个链接错误:
//hpp
class MyClass
{
//...
};
MyClass myGlobalObject; // Link ERROR if included by multiple CPP files
为了解决是上述问题,有一些变通办法:
a. 可以在类/结构中初始化静态const整数数据成员:
class MyClass
{
static const bool trace = false;
//...
};
b. 可以定义一个内联函数返回一个静态局部变量:
inline std::string getName()
{
static std::string name = "initial value";
return name;
}
c. 可以定义一个静态成员函数返回值:
std::string getMyGlobalObject()
{
static std::string myGlobalObject = "initial value";
return myGlobalObject;
}
d. 可以使用变量模板(因为c++ 14):
template<typename T = std::string>
T myGlobalObject = "initial value";
e. 可以从静态成员的基类模板派生:
template<typename Dummy>
class MyClassStatics
{
static std::string name;
};
template<typename Dummy>
std::string MyClassStatics<Dummy>::name = "initial value";
class MyClass : public MyClassStatics<void>
{
...
};
但是,所有这些方法都会导致显著的开销、较低的可读性和/或使用全局变量的不同方式。此外,全局变量的初始化可能被推迟到第一次使用时,这将禁用我们希望在程序启动时初始化对象的应用程序(例如在使用对象监视进程时)。
2. 使用Inline变量
现在,使用内联,可以通过只在头文件中定义一个全局可用的对象,它可能被多个CPP文件包含:
class MyClass
{
static inline std::string name = ""; // OK since C++17
...
};
inline MyClass myGlobalObj; // OK even if included/defined by multiple CPP files
执行包含头文件或包含这个定义的第一个编译单元时,将执行初始化。
这里使用line与内联函数具有相同的语义,如果不使用inline则[具体请看下面PS那部分],
a. 可以在多个翻译单元中定义,前提是所有定义都是相同的。
b. 必须在使用它的每个翻译单元中定义。
两者都是通过包含来自相同头文件的定义来给出的。程序的结果行为就好像只有一个变量一样。
PS:[内联函数应该在头文件中定义,这一点不同于其他函数。编译器在调用点内联展开函数的代码时,必须能够找到 inline 函数的定义才能将调用函数替换为函数代码,而对于在头文件中仅有函数声明是不够的。当然内联函数定义也可以放在源文件中,但此时只有定义的那个源文件可以用它,而且必须为每个源文件拷贝一份定义(即每个源文件里的定义必须是完全相同的),当然即使是放在头文件中,也是对每个定义做一份拷贝,只不过是编译器替你完成这种拷贝罢了。但相比于放在源文件中,放在头文件中既能够确保调用函数是定义是相同的,又能够保证在调用点能够找到函数定义从而完成内联(替换)]。
你甚至可以应用这个定义原子类型在头文件中:
inline std::atomic<bool> ready{false};
注意,对于std::atomic,通常在定义值时必须初始化它们。
注意,在初始化类型之前,仍然必须确保类型已经完成。例如,如果结构体或类有自己类型的静态成员,则只能在类型声明之后内联定义该成员:
struct MyValue
{
int value;
MyValue(int i) : value{i}
{
}
// one static object to hold the maximum value of this type:
static const MyValue max; // can only be declared here
...
};
inline const MyValue MyValue::max = 1000;
C++程序通常都有多个C++源文件组成(其以 .cpp 或 .cc 结尾)。这些文件会单独编
译成模块/二进制文件(通常以 .o 结尾)。链接所有模块/二进制文件形成一个单独的
可执行文件,或是动态库/静态库则是编译的最后一步。
当链接器发现一个特定的符号,被定义了多次时就会报错。举个栗子,现在我们有
一个函数声明 int foo(); ,当我们在两个模块中定义了同一个函数,那么哪一个才
是正确的呢?链接器自己不能做主。这样没错,但是这也可能不是开发者想看到的。
为了能提供全局可以使用的方法,通常会在头文件中定义函数,这可以让C++的所
有模块都调用头文件中函数的实现(C++中,头文件中实现的函数,编译器会隐式的
使用inline来进行修饰,从而避免符号重复定义的问题)。这样就可以将函数的定义
单独的放入模块中。之后,就可以安全的将这些模块文件链接在一起了。这种方式
也被称为定义与单一定义规则(ODR,One Definition Rule)。看了下图或许能更好
的理解这个规则:
如果这是唯一的方法,就不需要只有头文件的库了。只有头文件的库非常方便,因为只需要使用 #include 语句将对应的头文件包含入C++源文件/头文件中后,就可以使用这个库了。当提供普库时,开发者需要编写相应的编译脚本,以便连接器将库模块链接在一起,形成对应的可执行文件。这种方式对于很小的库来说是不必要的。
对于这样例子, inline 关键字就能解决不同的模块中使用同一符号采用不同实现的方式。当连接器找到多个具有相同签名的符号时,这些函数定义使用 inline 进行声明,链接器就会选择首先找到的那个实现,然后认为其他符号使用的是相同的定义。所有使用 inline 定义的符号都是完全相同的,对于开发者来说这应该是常识。我们的例子中,连接器将会在每个模块中找到 process_monitor::standard_string 符号,因为这些模块包含了 foo_lib.hpp 。如果没有了 inline 关键字,连接器将不知道选择哪个实现,所以其会将编译过程中断并报错。同样的原理也适用于 global_process_monitor 符号。使用 inline 声明所有符号之后,连接器只会接受其找到的第一个符号,而将后续该符号的不同实现丢弃。
C++17之前,解决的方法是通过额外的C++模块文件提供相应的符号,这将迫使我
们的库用户强制在链接阶段包含该文件。
传统的 inline 关键字还有另外一种功能。其会告诉编译器,可以通过实现直接放在
调用它的地方来消除函数调用的过程。这样的话,代码中的函数调用会减少,这样
我们会认为程序会运行的更快。如果函数非常短,那么生成的程序段也会很短(假
设函数调用也需要若干个指令,保护现场等操作,其耗时会高于实际工作的代
码)。当内联函数非常长,那么二进制文件的大小就会变得很大,有时并无法让代
码运行的更快。因此,编译器会将 inline 关键字作为一个提示,可能会对内联函数
消除函数调用。当然,编译器也会将一些函数进行内联,尽管开发者没有使
用 inline 进行提示。
3. constexpr意味着inline
对于静态数据成员,自从C++17起constexpr意味着内联,因此下面的静态成员变量n为定义了静态数据成员n:
struct D
{
static constexpr int n = 5; // C++11/C++14: //声明但未定义
// since C++17: 定义
};
也就是说,它等于:
struct D
{
inline static constexpr int n = 5;
};
注意,在c++ 17之前,在没有相应定义的情况下声明静态数据成员时加上const就可以在类内初始化:
struct D
{
static constexpr int n = 5;
};
但是,只有在不需要获取static成员变量D::n地址的情况下才可以不用定义D::n,
例如,当D::n通过值传递时:
std::cout << D::n; // OK (ostream::operator<<(int) gets D::n by value)
如果D::n是通过引用或者指针传递给一个函数,编译会报错。
例如:
int inc(const int& i);
std::cout << inc(D::n); // ld: error: undefined symbol: D::n
因此,在c++ 17之前,您必须在一个翻译单元中定义D::n:
constexpr int D:: n; //在C++17之前表达式是定义,C++17中是冗余声明,已被弃用。
4. inline变量和thread_local
通过使用thread_local,可以为每个线程创建一个惟一的内联变量:
struct ThreadData
{
inline static thread_local std::string name; // unique name per thread
...
};
inline thread_local std::vector<std::string> cache; // one cache per thread
作为一个完整的例子,考虑以下头文件:
inlinethreadlocal.hpp
#include <string>
#include <iostream>
struct MyData
{
inline static std::string gName = "global"; // unique in program
inline static thread_local std::string tName = "tls"; // unique per thread
std::string lName = "local"; // for each object
void print(const std::string& msg) const {
std::cout << msg << '\n';
std::cout << "- gName: " << gName << '\n';
std::cout << "- tName: " << tName << '\n';
std::cout << "- lName: " << lName << '\n';
}
};
inline thread_local MyData myThreadData; // one object per thread
在有main()的编译单元中使用:
inlinethreadlocal1.cpp
#include "inlinethreadlocal.hpp"
#include <thread>
void foo();
int main()
{
myThreadData.print("main() begin:");
myThreadData.gName = "thread1 name";
myThreadData.tName = "thread1 name";
myThreadData.lName = "thread1 name";
myThreadData.print("main() later:");
std::thread t(foo);
t.join();
myThreadData.print("main() end:");
}
在另一个定义foo()的编译单元中使用inlinethreadlocal.hpp头文件,foo在主线程中被调用:
inlinethreadlocal2.cpp
#include "inlinethreadlocal.hpp"
void foo()
{
myThreadData.print("foo() begin:");
myThreadData.gName = "thread2 name";
myThreadData.tName = "thread2 name";
myThreadData.lName = "thread2 name";
myThreadData.print("foo() end:");
}
程序输出如下:
5. 使用内联变量来跟踪::new
下面的程序演示了通过只包含该头文件如何使用内联变量跟踪调用::new:
tracknew.hpp
#ifndef TRACKNEW_HPP
#define TRACKNEW_HPP
#include <new>
#include <cstdlib> // for malloc()
#include <iostream>
class TrackNew
{
private:
static inline int numMalloc = 0; // num malloc calls
static inline long sumSize = 0; // bytes allocated so far
static inline bool doTrace = false; // tracing enabled
static inline bool inNew = false; // don’t track output inside new overloads
public:
// reset new/memory counters
static void reset()
{
numMalloc = 0;
sumSize = 0;
}
// enable print output for each new:
static void trace(bool b)
{
doTrace = b;
}
// print current state:
static void status()
{
std::cerr << numMalloc << " mallocs for " << sumSize << " Bytes" << '\n';
}
// implementation of tracked allocation:
static void* allocate(std::size_t size, const char* call)
{
// trace output might again allocate memory, so handle this the usual way:
if (inNew)
{
return std::malloc(size);
}
inNew = true;
// track and trace the allocation:
++numMalloc;
sumSize += size;
void* p = std::malloc(size);
if (doTrace)
{
std::cerr << "#" << numMalloc << " "
<< call << " (" << size << " Bytes) => "
<< p << " (total: " << sumSize << " Bytes)" << '\n';
}
inNew = false;
return p;
}
};
inline void* operator new (std::size_t size)
{
return TrackNew::allocate(size, "::new");
}
inline void* operator new[] (std::size_t size)
{
return TrackNew::allocate(size, "::new[]");
}
#endif // TRACKNEW_HPP
考虑在下面的头文件中使用这个头文件:
racknewtest.hpp
include "tracknew.hpp"
#include <string>
class MyClass
{
static inline std::string name = "initial name with 26 chars";
};
MyClass myGlobalObj; // OK since C++17 even if included by multiple CPP files#
cpp文件tracknewtest.cpp如下:
#include "tracknew.hpp"
#include "tracknewtest.hpp"
#include <iostream>
#include <string>
int main()
{
TrackNew::status();
TrackNew::trace(true);
std::string s = "an string value with 29 chars";
TrackNew::status();
}
输出取决于何时初始化跟踪以及初始化执行了多少分配。但结尾应该是这样的:
.......
#33 ::new (27 Bytes) => 0x89dda0 (total: 2977 Bytes)
33 mallocs for 2977 Bytes
#34 ::new (30 Bytes) => 0x89db00 (total: 3007 Bytes)
34 mallocs for 3007 Bytes
初始化MyClass::name需要27个字节,初始化main()中的s需要30个字节。(注意,字符串是由大于15个字符的值初始化的,以避免使用实现小/短字符串优化的库在堆上不分配内存(SSO),它在数据成员中存储最多15个字符的字符串,而不是分配堆内存。)