C ++模板:说服自己反对代码膨胀

时间:2010-05-27 04:15:17

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

我在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 ++特定提示,我也对此感兴趣。

PS:关于臃肿,我甚至担心最轻微的臃肿,因为我来自嵌入式环境。


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

7 个答案:

答案 0 :(得分:12)

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

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

编译创建一种类型的exe:

Array< int, 100 > MyArray;

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

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

等等,对于30个左右的不同版本,绘制生成的exe大小。如果模板和人们想象的一样糟糕,那么每个唯一模板实例化的exe大小都会增加一个固定的数量。

答案 1 :(得分:10)

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

如果nm没有提供足够的详细信息,您可能还需要查看objdump工具。它与nm非常相似,但启用了调试符号后,它甚至可以显示诸如使用混合源代码行反汇编输出可执行文件之类的内容。

答案 2 :(得分:7)

模板与此无关。

考虑这个小程序:

A.H:

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< int, 100 >Array< long, 100 >生成的程序集在许多平台上都是相同的,并且该函数也不依赖于数组大小,因此99和100变体也应该相同代码,因此一个聪明的编译器会将实例化合并在一起。

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

答案 3 :(得分:4)

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

例如:

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;
}

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

答案 4 :(得分:3)

一个测试是将静态变量放在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

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;
}

答案 5 :(得分:3)

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

例如,这是一个示例调用:

$ ~/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可能会在链接时删除冗余或无用的模板实例化,因此我建议您分析实际的可执行文件。

答案 6 :(得分:-1)

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