深入浅出 C++:#include Directive PART 2 - 使用上的各种注意事项、经验谈

标准 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 泄露问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值