42

当我使用 gcov 测量 C++ 代码的测试覆盖率时,它会报告析构函数中的分支。

struct Foo
{
    virtual ~Foo()
    {
    }
};

int main (int argc, char* argv[])
{
    Foo f;
}

当我在启用分支概率 (-b) 的情况下运行 gcov 时,我得到以下输出。

$ gcov /home/epronk/src/lcov-1.9/example/example.gcda -o /home/epronk/src/lcov-1.9/example -b
File 'example.cpp'
Lines executed:100.00% of 6
Branches executed:100.00% of 2
Taken at least once:50.00% of 2
Calls executed:40.00% of 5
example.cpp:creating 'example.cpp.gcov'

困扰我的部分是“至少服用一次:2 的 50.00%”。

生成的 .gcov 文件提供了更多详细信息。

$ cat example.cpp.gcov | c++filt
        -:    0:Source:example.cpp
        -:    0:Graph:/home/epronk/src/lcov-1.9/example/example.gcno
        -:    0:Data:/home/epronk/src/lcov-1.9/example/example.gcda
        -:    0:Runs:1
        -:    0:Programs:1
        -:    1:struct Foo
function Foo::Foo() called 1 returned 100% blocks executed 100%
        1:    2:{
function Foo::~Foo() called 1 returned 100% blocks executed 75%
function Foo::~Foo() called 0 returned 0% blocks executed 0%
        1:    3:    virtual ~Foo()
        1:    4:    {
        1:    5:    }
branch  0 taken 0% (fallthrough)
branch  1 taken 100%
call    2 never executed
call    3 never executed
call    4 never executed
        -:    6:};
        -:    7:
function main called 1 returned 100% blocks executed 100%
        1:    8:int main (int argc, char* argv[])
        -:    9:{
        1:   10:    Foo f;
call    0 returned 100%
call    1 returned 100%
        -:   11:}

请注意“分支 0 采用 0% (fallthrough)”这一行。

是什么导致了这个分支,我需要在代码中做什么才能在此处获得 100%?

  • g++ (Ubuntu/Linaro 4.5.2-8ubuntu4) 4.5.2
  • gcov (Ubuntu/Linaro 4.5.2-8ubuntu4) 4.5.2
4

3 回答 3

61

在典型的实现中,析构函数通常有两个分支:一个用于非动态对象销毁,另一个用于动态对象销毁。特定分支的选择是通过调用者传递给析构函数的隐藏布尔参数来执行的。它通常以 0 或 1 的形式通过寄存器传递。

我猜想,因为在您的情况下,破坏是针对非动态对象的,所以不会采用动态分支。尝试添加一个new-ed 然后delete-ed 类Foo的对象,第二个分支也应该被采用。

之所以需要这种分支,是源于 C++ 语言的规范。当某个类定义了自己的operator delete时,选择要调用的特定operator delete对象就像从类析构函数内部查找一样。这样做的最终结果是,对于具有虚拟析构函数的类,operator delete它的行为就好像它是一个虚拟函数(尽管形式上是该类的静态成员)。

许多编译器从字面上operator delete实现了这种行为:直接从析构函数实现内部调用正确的。当然,operator delete只应在销毁动态分配的对象时调用(不适用于本地或静态对象)。为了实现这一点,调用operator delete被放置到由上述隐藏参数控制的分支中。

在您的示例中,事情看起来很简单。我希望优化器删除所有不必要的分支。但是,它似乎以某种方式设法在优化中幸存下来。


这里有一点额外的研究。考虑这段代码

#include <stdio.h>

struct A {
  void operator delete(void *) { scanf("11"); }
  virtual ~A() { printf("22"); }
};

struct B : A {
  void operator delete(void *) { scanf("33"); }
  virtual ~B() { printf("44"); }
};

int main() {
  A *a = new B;
  delete a;
} 

这是A在默认优化设置下使用 GCC 4.3.4 编译时的析构函数代码的样子

__ZN1AD2Ev:                      ; destructor A::~A  
LFB8:
        pushl   %ebp
LCFI8:
        movl    %esp, %ebp
LCFI9:
        subl    $8, %esp
LCFI10:
        movl    8(%ebp), %eax
        movl    $__ZTV1A+8, (%eax)
        movl    $LC1, (%esp)     ; LC1 is "22"
        call    _printf
        movl    $0, %eax         ; <------ Note this
        testb   %al, %al         ; <------ 
        je      L10              ; <------ 
        movl    8(%ebp), %eax    ; <------ 
        movl    %eax, (%esp)     ; <------ 
        call    __ZN1AdlEPv      ; <------ calling `A::operator delete`
L10:
        leave
        ret

(析构函数B有点复杂,这就是我A在这里作为例子的原因。但就所讨论的分支而言,析构函数也是B如此)。

然而,在这个析构函数之后,生成的代码包含同一个类的另一个版本的析构函数A,看起来完全一样,除了movl $0, %eax指令被指令替换movl $1, %eax

__ZN1AD0Ev:                      ; another destructor A::~A       
LFB10:
        pushl   %ebp
LCFI13:
        movl    %esp, %ebp
LCFI14:
        subl    $8, %esp
LCFI15:
        movl    8(%ebp), %eax
        movl    $__ZTV1A+8, (%eax)
        movl    $LC1, (%esp)     ; LC1 is "22"
        call    _printf
        movl    $1, %eax         ; <------ See the difference?
        testb   %al, %al         ; <------
        je      L14              ; <------
        movl    8(%ebp), %eax    ; <------
        movl    %eax, (%esp)     ; <------
        call    __ZN1AdlEPv      ; <------ calling `A::operator delete`
L14:
        leave
        ret

请注意我用箭头标记的代码块。这正是我所说的。寄存器al用作隐藏参数。这个“伪分支”应该operator delete根据 的值调用或跳过调用al。然而,在第一个版本的析构函数中,这个参数像往常一样被硬编码到主体中0,而在第二个版本中,它像往常一样被硬编码1

B也有为其生成的析构函数的两个版本。所以我们最终在编译的程序中有 4 个不同的析构函数:每个类有两个析构函数。

我可以猜想,一开始编译器在内部考虑的是单个“参数化”析构函数(它的工作方式与我在上面的中断中描述的完全一样)。然后它决定将参数化析构函数拆分为两个独立的非参数化版本:一个用于0(非动态析构函数)的硬编码参数值,另一个用于1(动态析构函数)的硬编码参数值。在非优化模式下,它确实做到了这一点,通过在函数体内分配实际参数值并保持所有分支完全不变。我猜这在非优化代码中是可以接受的。这正是您正在处理的问题。

换句话说,您的问题的答案是:在这种情况下,不可能使编译器获取所有分支。没有办法达到 100% 的覆盖率。其中一些分支是“死的”。只是在这个版本的 GCC 中生成非优化代码的方法相当“懒惰”和“松散”。

我认为可能有一种方法可以防止在非优化模式下分裂。我只是还没有找到它。或者,很可能,它无法完成。旧版本的 GCC 使用真正的参数化析构函数。也许在这个版本的 GCC 中,他们决定切换到两个析构函数方法,并且在这样做的同时,他们以一种快速而肮脏的方式“重用”现有的代码生成器,期望优化器清除无用的分支。

当您在启用优化的情况下进行编译时,GCC 将不允许自己在最终代码中进行无用分支等奢侈品。您可能应该尝试分析优化的代码。非优化的 GCC 生成的代码有很多像这样的无意义的不可访问的分支。

于 2011-08-26T03:24:11.513 回答
7

在析构函数中,GCC 为一个永远不会为真的条件生成了一个条件跳转(%al 不为零,因为它只是被分配了一个 1):

[...]
  29:   b8 01 00 00 00          mov    $0x1,%eax
  2e:   84 c0                   test   %al,%al
  30:   74 30                   je     62 <_ZN3FooD0Ev+0x62>
[...]
于 2011-08-26T03:15:01.797 回答
0

gcc 5.4.0 版的析构函数问题仍然存在,但 Clang 似乎不存在。

经测试:

clang version 3.8.0-2ubuntu4 (tags/RELEASE_380/final)
Target: x86_64-pc-linux-gnu
Thread model: posix
InstalledDir: /usr/bin

然后使用“llvm-cov gcov ...”生成覆盖,如此所述。

于 2018-01-10T17:20:23.313 回答