代码之家  ›  专栏  ›  技术社区  ›  Arun

C++模板:说服自己抵抗代码膨胀

  •  14
  • Arun  · 技术社区  · 14 年前

    我在C++模板的上下文中听说过代码膨胀。我知道现代C++编译器的情况并非如此。但是,我想树立一个榜样并说服自己。

    假设我们有一个班

    template< typename T, size_t N >
    class Array {
      public:
        T * data();
      private:
        T elems_[ N ];
    };
    
    template< typename T, size_t N >
    T * Array<T>::data() {
        return elems_;
    }
    

    更进一步,我们假设 types.h 包含

    typedef Array< int, 100 > MyArray;
    

    x.cpp 包含

    MyArray ArrayX;
    

    y.cpp 包含

    MyArray ArrayY;
    

    现在,我如何验证 MyArray::data() 两者都一样 ArrayX ArrayY ?

    从这个(或其他类似的简单)例子中,我还应该知道和验证什么?如果有任何G++特定的提示,我也对此感兴趣。

    附言:关于膨胀,我很关心哪怕是一点点的膨胀,因为我来自于嵌入式环境。


    另外:如果模板类是显式实例化的,情况会有任何变化吗?

    7 回复  |  直到 14 年前
        1
  •  12
  •   Terry Mahaffey    14 年前

    您提出的问题是错误的——您示例中的任何“膨胀”都与模板无关。(你的问题的答案是,顺便说一句,取两个模块中成员函数的地址,你会发现它们是相同的)

    您真正想问的是,对于每个模板实例化,生成的可执行文件是否线性增长?答案是否定的,链接器/优化器会变魔术。

    编译创建一个类型的exe:

    Array< int, 100 > MyArray;
    

    注意生成的exe大小。现在再做一次:

    Array< int, 100 > MyArray;
    Array< int, 99 > MyArray;
    

    等等,对于大约30个不同的版本,用图表标出生成的exe大小。如果模板像人们想象的那样糟糕,那么每个唯一的模板实例化的exe大小将增加一个固定的量。

        2
  •  10
  •   Boojum    14 年前

    在这种特定的情况下,如果您对访问器进行了任何优化,您会发现g++将倾向于内联访问器。尽管调用的开销是否会减少还是有争议的。

    但是,验证编译内容的一个简单方法是使用 nm 工具。如果我用一个简单的 main() 锻炼 ArrayX::data() ArrayY::data() 然后用编译它 -O0 要关闭内嵌,我可以运行 nm -C 要查看可执行文件中的符号:

    % nm -C test
    0804a040 B ArrayX
    0804a1e0 B ArrayY
    08049f08 d _DYNAMIC
    08049ff4 d _GLOBAL_OFFSET_TABLE_
    0804858c R _IO_stdin_used
             w _Jv_RegisterClasses
    080484c4 W Array<int, 100u>::data()
    08049ef8 d __CTOR_END__
    08049ef4 d __CTOR_LIST__
    08049f00 D __DTOR_END__
    ...
    

    你会看到 Array<int, 100u>::data() 符号在最终可执行文件中只出现一次,即使两个翻译单元的对象文件都包含它自己的副本。(The 纳米级 工具也可以处理对象文件。你可以用它来检查 x.o y.o 每个都有一份 数组<int,100u>::data()) )

    如果 纳米级 没有提供足够的细节,您还可以查看 objdump 工具。这很像 纳米级 但是,在启用调试符号的情况下,它甚至可以向您展示一些东西,比如使用混合源代码行对输出可执行文件进行反汇编。

        3
  •  7
  •   Stack Overflow is garbage    14 年前

    模板与此无关。

    考虑一下这个小程序:

    A.H:

    class a {
        int foo() { return 42; }
    };
    

    B.CPP:

    #include "a.h"
    
    void b() {
      a my_a;
      my_a.foo();
    }
    

    CCPP:

    #include "a.h"
    
    void c() {
      a my_a;
      my_a.foo();
    }
    

    没有模板,但有完全相同的问题。相同的函数在多个翻译单元中定义。规则是相同的:在最终程序中只允许存在一个定义,否则编译器将无法确定要调用哪个定义,否则指向同一函数的两个函数指针可能指向不同的地址。

    模板代码膨胀的“问题”是不同的:如果你为同一个模板创建了很多不同的实例化。例如,使用您的类,这个程序将冒代码膨胀的风险:

    Array< int, 100 > i100;
    Array< int, 99 > i99;
    Array< long, 100 > l100;
    Array< long, 99> l99;
    
    i100.Data();
    i99.Data();
    l100.Data();
    l99.Data();
    

    严格来说,编译器需要创建 Data 函数,每组模板参数一个。在实践中,只要生成的代码相同,一些(但不是全部)编译器就会尝试将它们合并回一起。(在本例中,为生成的程序集 Array< int, 100 > Array< long, 100 > 在许多平台上都是相同的,函数也不依赖数组大小,因此99和100个变量也应该产生相同的代码,因此一个聪明的编译器将把实例化合并在一起。

    模板没有魔力。他们不会神秘地“膨胀”你的代码。他们只是给你一个工具,让你很容易创建一个巴吉利昂不同类型从同一个模板。如果您实际使用所有这些类型,它必须为所有类型生成代码。和C++一样,你为你所用的东西付费。如果您同时使用 Array<long, 100> ,一个 Array<int, 100> ,一个 Array<unsigned long, 100> 和一个 Array<unsigned int, 100> 然后你得到四个不同的班级,因为四个不同的班级是你要求的。如果你不要求四个不同的班级,他们不会给你带来任何损失。

        4
  •  4
  •   Thomas Matthews    14 年前

    更好地说明 代码膨胀 使用模板是使用模板来生成代码,而不是变量。典型的恐慌是由于编译器为模板(模具)的每个实例生成代码。这类似于由于内联函数和方法而导致的代码膨胀。然而,现代编译器和链接器可以执行magick来减小代码大小,这取决于优化设置。

    例如:

    template <typename Any_Type>
    void Print_Hello(const Any_Type& v)
    {
        std::cout << "Hello, your value is:\n"
                  << v
                  << "\n";
        return;
    }
    

    上面的代码最好被认为是一个模板。编译器将根据传递给的类型生成代码 Print_Hello . 这里的膨胀是很少的代码实际上依赖于变量。 (这可以通过分解常量代码和数据来减少。)

    担心的是编译器将使用相同的变量类型为每个实例化生成代码,从而构建重复的代码:

    int main(void)
    {
      int a = 5;
      int b = 6;
      Print_Hello(a); // Instantiation #1
      Print_Hello(b); // Instantiation #2
      return 0;
    }
    

    当模板(模具)在不同的翻译单元中实例化时,恐惧也可以扩展。

    现代的编译器和链接器很聪明。智能编译器可以识别模板函数调用,并将其转换为一些唯一的损坏名称。编译器将只对每个调用使用一个实例化。类似于函数重载。

    即使编译器是草率的,并且生成了函数的多个实例(对于同一类型),链接器也会识别重复的实例,并且只将一个实例放入可执行文件中。

    当使用失败时,函数或方法模板可以添加额外的代码。示例是大型函数,它们仅在一些区域中因类型而异。它们的非类型代码与依赖类型的代码比率很高。

    上面例子的一个实现,具有较少的膨胀:

    void Print_Prompt(void)
    {
      std::cout << "Hello, your value is:\n";
      return;
    }
    
    template <typename Any_Type>
    void Better_Print_Hello(const Any_Type& v)
    {
      Print_Prompt();
      std::cout << v << "\n";
      return;
    }
    

    主要的区别在于,不依赖变量类型的代码已经分解为一个新函数。对于这个小例子来说,这似乎不值得,但它说明了这个概念。这个概念是将函数重构成依赖于变量类型和不依赖于变量类型的片段。依赖项被转换为模板化函数。

        5
  •  3
  •   JohnMcG    14 年前

    一个测试是在data()中放置一个静态变量,并在每次调用时增加它,并报告它。

    如果myarray::data()占用了相同的代码空间,那么应该看到它先报告1,然后报告2。

    如果没有,您应该只看到1。

    我运行它,得到了1,然后是2,表明它是从同一组代码运行的。为了验证这是真的,我创建了另一个大小参数为50的数组,它将1踢出。

    完整代码(有一些调整和修复)如下:

    Array.hpp:

    #ifndef ARRAY_HPP
    #define ARRAY_HPP
    #include <cstdlib>
    #include <iostream>
    
    using std::size_t;
    
    template< typename T, size_t N >
    class Array {
      public:
        T * data();
      private:
        T elems_[ N ];
    };
    
    template< typename T, size_t N >
    T * Array<T,N>::data() {
        static int i = 0;
        std::cout << ++i << std::endl;
        return elems_;
    }
    
    #endif
    

    类型:

    #ifndef TYPES_HPP
    #define TYPES_HPP
    
    #include "Array.hpp"
    
    typedef Array< int, 100 > MyArray;
    typedef Array< int, 50 > MyArray2;
    
    #endif
    

    X.CPP:

    #include "types.hpp"
    
    void x()
    {
        MyArray arrayX;
        arrayX.data();
    }
    

    Y.CPP:

    #include "types.hpp"
    
    void y()
    {
        MyArray arrayY;
        arrayY.data();
        MyArray2 arrayY2;
        arrayY2.data();
    }
    

    MCP.CPP:

    void x();
    void y();
    
    int main()
    {
        x();
        y();
        return 0;
    }
    
        6
  •  3
  •   Josh Haberman    14 年前

    下面是一个小实用程序脚本,我一直使用它来深入了解这些问题。它不仅显示了一个符号被定义了多次,而且还显示了每个符号占用的代码大小。我发现这对于审计代码大小问题非常有价值。

    例如,下面是一个示例调用:

    $ ~/nmsize src/upb_table.o 
     39.5%     488 upb::TableBase::DoInsert(upb::TableBase::Entry const&)
     57.9%     228 upb::TableBase::InsertBase(upb::TableBase::Entry const&)
     70.8%     159 upb::MurmurHash2(void const*, unsigned long, unsigned int)
     78.0%      89 upb::TableBase::GetEmptyBucket() const
     83.8%      72 vtable for upb::TableBase
     89.1%      65 upb::TableBase::TableBase(unsigned int)
     94.3%      65 upb::TableBase::TableBase(unsigned int)
     95.7%      17 typeinfo name for upb::TableBase
     97.0%      16 typeinfo for upb::TableBase
     98.0%      12 upb::TableBase::~TableBase()
     98.7%       9 upb::TableBase::Swap(upb::TableBase*)
     99.4%       8 upb::TableBase::~TableBase()
    100.0%       8 upb::TableBase::~TableBase()
    100.0%       0 
    100.0%       0 __cxxabiv1::__class_type_info
    100.0%       0 
    100.0%    1236 TOTAL
    

    在本例中,我在单个.o文件上运行它,但您也可以在.a文件或可执行文件上运行它。在这里,我可以看到构造函数和析构函数被发出了两到三次,这是 this bug .

    下面是脚本:

    #!/usr/bin/env ruby
    
    syms = []
    total = 0
    IO.popen("nm --demangle -S #{ARGV.join(' ')}").each_line { |line|
      addr, size, scope, name = line.split(' ', 4)
      next unless addr and size and scope and name
      name.chomp!
      addr = addr.to_i(16)
      size = size.to_i(16)
      total += size
      syms << [size, name]
    }
    
    syms.sort! { |a,b| b[0] <=> a[0] }
    
    cumulative = 0.0
    syms.each { |sym|
      size = sym[0]
      cumulative += size
      printf "%5.1f%%  %6s %s\n", cumulative / total * 100, size.to_s, sym[1]
    }
    
    printf "%5.1f%%  %6s %s\n", 100, total, "TOTAL"
    

    如果你自己运行这个.a文件或可执行文件,你应该能够让自己确信你确切地知道你的代码大小发生了什么。我相信最新版本的gcc可能会在链接时删除多余或无用的模板实例化,所以我建议您分析实际的可执行文件。

        7
  •  -1
  •   CMircea    14 年前

    生成的代码将完全相同,因为两个文件中的代码完全相同。如果需要,可以反汇编代码来检查它。