标准 Header File
C++ 并没有规定 header file 的后缀名为何。约定俗成的习惯,是将 .h 或 .hpp 当作 header file 的后缀名。而 C++ 标准库的 header file,则是根本没有后缀名:
#include <iostream>
#include <vector>
#include <string>
由于 C++ 是由 C 演化而来,C++ 也可以透过 #include C 的 header file 来使用 C 的函数。原本 C 的 header file 都是以 .h 为后缀,但由于 C++ 标准库提供的 header file 应该是没有后缀名的,故除了保留这些 .h 以兼容 C,C++ 又 针对他们,定义了一系列以 c 开头、但没有后缀名的标准 header file,例如 C 的 stdio.h,在 C++ 中就是 cstdio:
#include <cstdio> // 在 C 是 <stdio.h>
#include <cstdlib> // 在 C 是 <stdlib.h>
#include <cmath> // 在 C 是 <math.h>
#include <cstring> // 在 C 是 <string.h>
#include <string> // 注意:这是 C++ 独有的,在 C 没有对应
值得注意的是,<cstring> 与 <string> 在 C++ 是两个功能不同的 header file,<cstring> 包含 C 相关的字符串函数,如 strlen()、strcpy()、strcmp() 等;<string> 则是定义了 C++ 的 string class。
虽然如 <stdio.h> 与 <cstdio> 看似兼容,在标准中,还是提到了他们的差异:
. 若 #include <stdio.h> 这种 C 式 header file,使用其中的函数或类型定义的名称时,要认为他们在 global namespace 中,也就是说,透过 printf() 或 ::printf() 方式使用这些名称。
. 若 #include <cstdio> 这种 C++ 标准 header file,使用其中的函数或类型定义的名称时,要认为他们在 std namespace 中,亦即,透过 std::printf() 这种方式使用这些名称。C++ 标准不保证他们会放入 global namespace。
#include 搜索路径
#include 有两种常见的语法,分别以角括号 <> 或双引号 “” 引入文件:
#include <cstdlib>
#include "Utils.h"
两种语法差异如下:
. #include <filename> 会根据目前平台设定的 C++ 搜索路径来寻找文件,通常用以搜索标准库文件。但我们可通过 gcc、clang 的 compile option、或 Makefile 增加搜索的路径
. #include “filename” 也是根据目前平台设定的 C++ 搜索路径来寻找文件,但与前者稍有不同。常见的实现方法,是若文件 A 使用 #include “B” 来引入文件 B,则系统首先会在 A 所处的目录下搜索 B,若找不到才搜索标注库的路径。此语法常用于引入程序自定的 header file。
下面指令可以显示 g++ 目前的搜索路径。将 g++ 改成 clang++ 就可以检查 clang 的搜索路径了。
sora@sora-VirtualBox:~/cpp/c3$ echo | g++ -Wp,-v -x c++ - -fsyntax-only #include "..." search starts here: #include <...> search starts here: /usr/include/c++/8 /usr/include/x86_64-linux-gnu/c++/8 /usr/include/c++/8/backward /usr/lib/gcc/x86_64-linux-gnu/8/include /usr/local/include /usr/lib/gcc/x86_64-linux-gnu/8/include-fixed /usr/include/x86_64-linux-gnu /usr/include End of search list.
何时需要在 .h 引入 .cpp 文件?
一般常见的情况,是在 .cpp 文件中以 #include 引入 .h 文件,或是在 .h 文件中又引入了其他的 .h 文件。但事实上,C++ 并未规范 #include 能引入哪中文件,反正只要 preprocessor 能处理就行。在某些情况,也能看到 .h 引入 .cpp 文件、甚至 .cpp 引入其他 .cpp 文件。
一般常见以 .h 引入 .cpp 的例子,是 class template 与 template function 的写作。我们先看下面这个会导致 compile error 的例子,本例中,stack.h 里宣告了 Stack 这个 class template,以及 Find 这个 template function,然后将实现放在 stack.cpp 里:
// stack.h
#ifndef STACK_H
#define STACK_H
template <class T>
class Stack
{
public:
void Push(const T& t);
T& Pop();
// ...
};
template <class T>
T* Find(const Stack<T>& s, const T& t);
#endif
// stack.cpp
#include "stack.h"
template <class T>
void Stack<T>::Push(const T& t)
{
// ...
}
template <class T>
T& Stack<T>::Pop()
{
// ...
}
template <class T>
T* Find(const Stack<T>& s, const T& t)
{
// ...
}
//template_test.cpp
#include "stack.h"
int main()
{
Stack<int> s;
s.Push(100);
int* t = Find(s, 100);
// ...
}
上述程序 compile 后,会得到下面的错误信息,意思是找不到 Stack::Push() 与 Find() 函数:
sora@sora-VirtualBox:~/cpp/c3$ clang++ -std=c++17 -stdlib=libc++ --pedantic-errors -o template_test template_test.cpp stack.cpp /tmp/template_test-5f8a14.o: In function `main': template_test.cpp:(.text+0x18): undefined reference to `Stack::Push(int const&)' template_test.cpp:(.text+0x2c): undefined reference to `int* Find(Stack const&, int const&)' clang-6.0: error: linker command failed with exit code 1 (use -v to see invocation)
为什么找不到? 原因就在于,不管是 class template 或 template function,他们并非是 class 或 function 的宣告/定义,更好理解的方式,是将 template 视为 “代码产生器”,由于 template 里的类型是没有指定的,compiler 必须等到真正使用时,才知道要如何产生相对于的代码。本例中,template_test.cpp 使用了 Stack<int> 与调用 Find(s, 100),直到看到这段代码,compiler 才知道是要将 T 换成 int。然而,template_test.cpp 仅含入了 stack.h,并没有看到 Stack<T> 与 Find<T> 两个 template 定义的完整内容,无法产生对应的代码,因此最后就报错了。
此问题最常见的解法,就是在 stack.h 里也引入 stack.cpp,然后在 stack.cpp 加入 include guard,避免重复定义。我们先看 stack.h 加入了 17 ~ 19 行的修改,COMPILER_SUPPORT_SEPARATE_TEMPLATE_IMPL 是我们自己加的 macro,如果目前使用的 compiler,还不提供能将 template 宣告与定义分离的功能 (事实上,目前也没听说哪个 compiler 做的到),就将 stack.cpp 引入:
#ifndef STACK_H
#define STACK_H
template <class T>
class Stack
{
public:
void Push(const T& t);
T& Pop();
// ...
};
template <class T>
T* Find(const Stack<T>& s, const T& t);
#ifndef COMPILER_SUPPORT_SEPARATE_TEMPLATE_IMPL
#include "stack.cpp"
#endif
#endif
另外,stack.cpp 在开头与结尾,都加上 include guard:
#ifndef STACK_CPP
#define STACK_CPP
#include "stack.h"
template <class T>
void Stack<T>::Push(const T& t)
{
// ...
}
template <class T>
T& Stack<T>::Pop()
{
// ...
}
template <class T>
T* Find(const Stack<T>& s, const T& t)
{
// ...
}
#endif
如果不这么写,下面的编译指令就会报错,原因在与编译时加入了 stack.cpp,stack.cpp 中,首先引入了 stack.h,里面又引入了 stack.cpp,导致 class template 与 template function 的定义都出现了两次:
sora@sora-VirtualBox:~/cpp/c3$ clang++ -std=c++17 -stdlib=libc++ --pedantic-errors -o template_test template_test.cpp stack.cpp ./stack.cpp:4:16: error: redefinition of 'Push' void Stack<T>:::Push(const T& t) ^ ./stack.cpp:4:16: note: previous definition is here void Stack<T>:::Push(const T& t) ^ ./stack.cpp:10:14: error: redefinition of 'Pop' T& Stack<T>:::Pop() ^ ./stack.cpp:10:14: note: previous definition is here T& Stack<T>::Pop() ^ ./stack.cpp:16:4: error: redefinition of 'Find' T* Find(const Stack<T>:& s, const T& t) ^ ./stack.cpp:16:4: note: previous definition is here T* Find(const Stack<T>:& s, const T& t) ^ 3 errors generated.
何时需要在 .cpp 引入其他 .cpp 文件?
以 cpp 引入其他 cpp 文件的做法较为罕见,但也不是没有。就笔者经验,曾看过的做法,是在文件 A 根据某些条件先定义一些 macro,然后文件 A 再 #include 文件 B、且 B 中原本就有写如果某些 macro 定义了就做什么等,来改变文件 B 的行为。这种情况,通常文件 B 是第三方所写,基于维护或授权等原因,不打算在 B 中修改。
我们用个改变内存分配错误处理的完整范例,来展示上述行为。有鉴于本文专讲 #include,有可能太枯燥了,所以最后给个彩蛋,给能坚持看到结尾的读者一点福利。
假设我们在开发某个新系统,需要撰写内存分配器好了,原先的实现方式,是调用 mmap() 函数分配内存,如果分配失败,就调用 abort() 结束程序。下面是分配器的实现代码:
// MemAllocator.h
#ifndef MEM_ALLOCATOR_H
#define MEM_ALLOCATOR_H
#include <cstddef>
void* MemAllocate(std::size_t size);
void MemFree(void* ptr, std::size_t size);
#endif
// MemAllocator.cpp
#include <sys/mman.h>
#include <cstdlib>
#ifndef HANDLE_ERROR
#define HANDLE_ERROR() std::abort()
#endif
void* MemAllocate(std::size_t size)
{
void* ptr = mmap(0, size, PROT_READ | PROT_WRITE,
MAP_ANONYMOUS, -1, 0);
if (ptr == MAP_FAILED)
{
HANDLE_ERROR();
}
return ptr;
}
void MemFree(void* ptr, std::size_t size)
{
munmap(ptr, size);
}
而下面是个测试程序,借由传入引数 -1,要求分配大小为 -1 的内存,触发分配失败:
// MemAllocatorTest.cpp
#include "MemAllocator.h"
void Function2()
{
void* ptr = MemAllocate(-1);
}
void Function1(int a, double b)
{
Function2();
}
int main()
{
Function1(3, 4.5);
}
好了,接下来是大显身手的时刻。由于默认的行为是触发 abort(),调试可能不是很方便。我们想写一个错误处理的代码,取代上面的 HANDLE_ERROR () macro,让它能打印出发生错误时的调用栈。于是我们多写了一个 MemAllocatorDebug.cpp,里面 PrintStackTrace() 函数,实现了打印调用栈的功能。接着,定义 HANDLE_ERROR() macro,让他调用 PrintStackTrace,最后,我们再以 #include 引入 MemAllocator.cpp,就完成了取代 HANDLE_ERROR():
// MemAllocatorDebug.cpp
#ifdef MEM_ALLOC_DEBUG
#include <execinfo.h> // for backtrace(), backtrace_symbols()
#include <cxxabi.h> // for abi::__cxa_demangle
#include <iostream>
#include <cstring>
namespace
{
const int MAX_STACK_DEPTH = 100;
const int MAX_FUNC_NAME_LEN = 256;
void PrintStackTrace()
{
void* addressList[MAX_STACK_DEPTH];
int numAddress = backtrace(addressList, MAX_STACK_DEPTH);
if (numAddress == 0)
return;
char** mangledNames = backtrace_symbols(addressList, numAddress);
if (mangledNames == nullptr)
return;
char demangledName[MAX_FUNC_NAME_LEN];
for (int i = 0 ; i < numAddress ; ++i)
{
char* nameStart = nullptr;
char* offsetStart = nullptr;
char* offsetEnd = nullptr;
for (char* p = mangledNames[i] ; *p ; ++p)
{
if (*p == '(')
nameStart = p;
else if (*p == '+')
offsetStart = p;
else if (*p == ')')
offsetEnd = p;
}
if (nameStart == nullptr || offsetStart == nullptr
|| offsetEnd == nullptr || offsetStart >= offsetEnd)
{
std::cout << mangledNames[i] << std::endl;
continue;
}
*nameStart = 0;
++nameStart;
*offsetStart = 0;
++offsetStart;
*offsetEnd = 0;
int status;
size_t length = MAX_FUNC_NAME_LEN;
char* result = abi::__cxa_demangle(nameStart, demangledName, &length, &status);
if (status == 0)
std::cout << mangledNames[i] << " : "
<< demangledName << '+' << offsetStart << std::endl;
else
std::cout << mangledNames[i] << " : "
<< nameStart << "()+" << offsetStart << std::endl;
}
std::free(mangledNames);
}
} // unnamed namespace
#define HANDLE_ERROR() { \
PrintStackTrace(); \
std::exit(-1); \
}
#include "MemAllocator.cpp"
#endif
由于 MemAllocator.cpp 里,需要定义 MEM_ALLOC_DEBUG macro 才能开启此功能,我们在 compile option 里加上了 -D MEM_ALLOC_DEBUG。此外,为了能成功打印出函数名称与参数,compile option 还需加上 -rdynamic,才能让 linker 包含所有 symbol。
Compile 程序时,记得只加入 MemAllocatorDebug.cpp,因为它里面已经引入 MemAllocator.cpp 了,定义了 MemAllocate() 与 MemFree() 两个函数。如果我们 compile 时,又加入了 MemAllocator.cpp,compiler 就会报错,提示重复定义:
sora@sora-VirtualBox:~/cpp/c3$ clang++ -std=c++17 -stdlib=libc++ --pedantic-errors -rdynamic -D MEM_ALLOC_DEBUG -o MemAllocatorTest MemAllocatorTest.cpp MemAllocatorDebug.cpp MemAllocator.cpp /tmp/MemAllocator-912612.o: In function `MemAllocate(unsigned long)': MemAllocator.cpp:(.text+0x0): multiple definition of `MemAllocate(unsigned long)' /tmp/MemAllocatorDebug-70ef19.o:MemAllocatorDebug.cpp:(.text+0x0): first defined here /tmp/MemAllocator-912612.o: In function `MemFree(void*, unsigned long)': MemAllocator.cpp:(.text+0x60): multiple definition of `MemFree(void*, unsigned long)' /tmp/MemAllocatorDebug-70ef19.o:MemAllocatorDebug.cpp:(.text+0x410): first defined here clang-6.0: error: linker command failed with exit code 1 (use -v to see invocation)
正确的 compile 指令、以及程序执行输出结果如下,只要将 clang++ 换成 g++、去掉 -stdlib=libc++,一样能用 g++ compile:
sora@sora-VirtualBox:~/cpp/c3$ clang++ -std=c++17 -stdlib=libc++ --pedantic-errors -rdynamic -D MEM_ALLOC_DEBUG -o MemAllocatorTest MemAllocatorTest.cpp MemAllocatorDebug.cpp sora@sora-VirtualBox:~/cpp/c3$ ./MemAllocatorTest ./MemAllocatorTest() [0x40146c] ./MemAllocatorTest : MemAllocate(unsigned long)+0x50 ./MemAllocatorTest : Function2()+0x14 ./MemAllocatorTest : Function1(int, double)+0x15 ./MemAllocatorTest : main()+0x16 /lib/x86_64-linux-gnu/libc.so.6 : __libc_start_main()+0xe7 ./MemAllocatorTest : _start()+0x2a
这个招数是不是很有趣?笔者第一次见到类似的招数,是在 Android 系统上,看它如何置换,开启内存泄露的侦测功能。透过类似的方式,将每次内存分配的调用栈都记录下来,而释放内存时、则是同步释放调用栈,就能在任意时刻,触发打印现在有多少内存未释放、以及各自的调用栈,来分析 native heap 泄露问题。