【问题标题】:C++ Templates: Convincing self against code bloatC++ 模板:说服自己反对代码膨胀
【发布时间】:2010-05-27 04:15:17
【问题描述】:

我听说过 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() 的代码空间对于ArrayXArrayY 是否相同?

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

PS:关于膨胀,我什至担心最轻微的膨胀,因为我来自嵌入式上下文。


补充:如果模板类被显式实例化,情况是否会发生变化?

【问题讨论】:

    标签: c++ templates compiler-construction g++


    【解决方案1】:

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

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

    编译一个创建一种类型的exe:

    Array< int, 100 > MyArray;
    

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

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

    等等,对于大约 30 个不同的版本,绘制生成的 exe 大小图表。如果模板像人们想象的那样可怕,那么每个唯一模板实例化的 exe 大小都会增长固定数量。

    【讨论】:

    • "获取两个模块中成员函数的地址,你会发现它们是相同的" - 这会调用 Schroedingers 编译器。通过观察一件事,你可以改变它。特别是获取某物的地址限制了编译器可以用它做什么。例如。如果您需要一个 int* ,则不能将 int 放入寄存器中。
    • @MSalters:有点毫无意义,因为 C++ 是围绕可观察行为定义的。如果您没有观察 C++ 程序,编译器可能只会发出一个 nop
    • 这个答案不准确。如果您的函数生成相同的代码而不考虑 N(例如您的 data() 函数),那么智能编译器可以防止发出重复的函数。 OTOH,如果您的函数实际上使用模板参数(例如“if(i
    • 顺便说一句,“comdat 折叠”是我所说的智能编译器(实际上是链接器,正如你提到的)可以做的。但是就像我之前说的,如果函数的主体实际上依赖于模板参数,则不能应用comdat折叠,因为函数不同。在这种情况下,增长确实是线性的。
    • @jalf:应用程序的大小在你可以看到的意义上是可观察的,在 ISO 标准意义上是不可观察的。希望的优化(函数折叠)产生相同的 ISO 标准可观察应用程序输出,但会导致不同的应用程序大小。
    【解决方案2】:

    在这种特定情况下,如果您进行了任何类型的优化,您会发现 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&lt;int, 100u&gt;::data() 符号在最终的可执行文件中只出现一次,即使两个翻译单元中的每一个的目标文件都包含它自己的副本。 (nm 工具也适用于目标文件。您可以使用它来检查x.oy.o 是否都有Array&lt;int, 100u&gt;::data() 的副本。)

    如果nm 没有提供足够的详细信息,您还可以查看objdump 工具。它很像nm,但在打开调试符号的情况下,它甚至可以向您显示输出可执行文件的反汇编以及混合的源代码行之类的内容。

    【讨论】:

    • +!:这是看待问题的好方法。我验证了最终的可执行文件有 single 的 Array 副本
    【解决方案3】:

    模板与此无关。

    考虑这个小程序:

    啊哈:

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

    b.cpp:

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

    c.cpp:

    #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 函数创建4 个不同的实例,每个实例用于一组模板参数。在实践中,一些(但不是全部)编译器会尝试将它们重新合并在一起,只要生成的代码相同。 (在这种情况下,为 Array&lt; int, 100 &gt;Array&lt; long, 100 &gt; 生成的程序集在许多平台上都是相同的,并且函数也不依赖于数组大小,因此 99 和 100 变体也应该产生相同的代码,所以聪明的编译器会将实例重新合并在一起。

    模板没有魔法。他们不会神秘地“膨胀”你的代码。他们只是为您提供了一个工具,让您可以轻松地从同一个模板创建大量不同的类型。如果您实际使用所有这些类型,它必须为所有这些类型生成代码。与 C++ 一样,您为使用的内容付费。如果你同时使用Array&lt;long, 100&gt;Array&lt;int, 100&gt;Array&lt;unsigned long, 100&gt;Array&lt;unsigned int, 100&gt;,那么你会得到四个不同的类,因为你需要四个不同的类。如果您不要求四个不同的课程,它们不会花费您任何费用。

    【讨论】:

    • 这个分析不正确。与您的替代示例不同,模板实例化会生成最终在目标文件中的非内联方法。请参阅:gcc.gnu.org/onlinedocs/gcc/Template-Instantiation.html。默认选项是“3。什么都不做。假装 G++ 确实实现了自动实例化管理。为 Borland 模型编写的代码可以正常工作,但每个翻译单元将包含它使用的每个模板的实例。在大型程序中,这可以导致无法接受的代码重复量。”
    • @Josh:翻译单元将包含相同代码的实例(我的非模板示例也是如此),是的,但最终的可执行文件肯定不会。您的链接甚至明确表示:“编译器和链接器必须以某种方式确保每个模板实例在需要时在可执行文件中只出现一次,否则根本不出现”。如果编译器生成了多个实例化(如在“什么都不做”模型中),则链接器会删除除一个之外的所有实例,就像处理非模板符号一样。
    • 你是对的,我把我的模板问题搞混了。例如,您仍然可以在可执行文件中得到有效的冗余定义。 Foo::Bar() 和 Foo::Bar() 生成完全相同的代码。即使发出的代码相同,许多编译器也不会将它们折叠成一个副本。但你说得对,这是你声明的不同实例的函数。
    • 这里的Data() 是什么?它是 OP 自己的函数还是 STL 访问器?无论哪种方式,都没有大写的D
    【解决方案4】:

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

    例如:

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

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

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

    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】:

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

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

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

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

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

      数组.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
      

      types.hpp:

      #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();
      }
      

      main.cpp:

      void x();
      void y();
      
      int main()
      {
          x();
          y();
          return 0;
      }
      

      【讨论】:

      • +1:这是一个出色的实验结构。我使用相同的线路进行了验证并说服了自己。
      【解决方案6】:

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

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

      $ ~/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】:

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

        【讨论】:

        • 但是一两份?
        • @BCS 很可能在生成的代码中只有一个实例,因为它们是相同的
        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2018-06-02
        • 1970-01-01
        • 1970-01-01
        • 2014-06-11
        • 2011-03-03
        相关资源
        最近更新 更多