Шаблоны С++: Убедительная самооценка кода

Я слышал о разворотах кода в контексте шаблонов С++. Я знаю, что это не так с современными компиляторами С++. Но я хочу построить пример и убедить себя.

Допустим, что у нас есть класс

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: Что касается раздувания, я беспокоюсь даже о малейшем раздувании, поскольку я исхожу из встроенного контекста.


Дополнение: меняется ли ситуация, если классы шаблонов явно создаются?

Ответы

Ответ 1

Вы задаете неправильный вопрос - любая "навороченность" в вашем примере не имеет ничего общего с шаблонами. (ответ на ваш вопрос, кстати, должен взять адрес функции-члена в обоих модулях, и вы увидите, что они одинаковы)

Что вы действительно хотите спросить, для каждого экземпляра шаблона результирующий исполняемый файл растет линейно? Ответ отрицательный, компоновщик/оптимизатор будет делать магию.

Скомпилируйте exe, который создает один тип:

Array< int, 100 > MyArray;

Обратите внимание на полученный размер exe. Теперь сделайте это снова:

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

И так далее, для 30 или около того разных версий, намечая полученные размеры exe. Если шаблоны были такими же ужасными, как люди думают, размер exe вырастет на фиксированную сумму для каждого уникального экземпляра шаблона.

Ответ 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<int, 100u>::data() встречается только один раз в конечном исполняемом файле, даже если объектный файл для каждой из двух единиц перевода содержит свою собственную копию. (Инструмент nm также работает с объектными файлами. Вы можете использовать его для проверки того, что x.o и y.o имеют копию Array<int, 100u>::data().)

Если nm не предоставляет достаточно подробных сведений, вы также можете посмотреть инструмент objdump. Это очень похоже на nm, но с включенными отладочными символами он может даже показать вам такие вещи, как разбор выходного исполняемого файла с промежуточными исходными строками.

Ответ 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();

Строго говоря, компилятор должен создать 4 разных экземпляра функции Data, по одному для каждого набора параметров шаблона. На практике некоторые (но не все) компиляторы пытаются объединить их вместе, если сгенерированный код идентичен. (В этом случае сборка, сгенерированная для Array< int, 100 > и Array< long, 100 >, будет одинаковой на многих платформах, и функция также не зависит от размера массива, поэтому варианты 99 и 100 также должны создавать идентичный код, поэтому умный компилятор объединит экземпляры вместе.

В шаблонах нет волшебства. Они не загадочно "раздувают" ваш код. Они просто дают вам инструмент, который позволяет вам легко создавать различные типы баджона из одного и того же шаблона. Если вы действительно используете все эти типы, он должен генерировать код для всех из них. Как всегда с С++, вы платите за то, что используете. Если вы используете как Array<long, 100>, так и Array<int, 100>, Array<unsigned long, 100> и Array<unsigned int, 100>, вы получаете четыре разных класса, потому что четыре разных класса были вами. Если вы не просите четырех разных классов, они ничего не стоят вам.

Ответ 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;
}

Страх также может быть расширен, когда шаблон (трафарет) создается в разных единицах перевода.

Современные компиляторы и линкеры умны. Интеллектуальный компилятор распознает вызов функции шаблона и преобразует его в какое-то уникальное искаженное имя. Тогда компилятор будет использовать только один экземпляр для каждого вызова. Подобно перегрузке функций.

Даже если компилятор был неаккуратным и генерировал несколько экземпляров функции (для одного и того же типа), компоновщик распознавал дубликаты и только помещал один экземпляр в исполняемый файл.

При использовании wrecklessly, шаблон функции или метода может добавить дополнительный код. Примерами являются большие функции, которые отличаются только по типу в нескольких областях. Они имеют высокое отношение непечатаемого кода для ввода зависимого кода.

Реализация приведенного выше примера с меньшим раздуванием:

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.

Полный код (с паролями настроек и исправлений) находится ниже:

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

Ответ 6

Вот небольшая утилита script, которую я использовал, чтобы получить представление об этих проблемах. Он показывает вам не только, если символ определен несколько раз, но также и размер кода, который принимает каждый символ. Я нашел это чрезвычайно ценным для проверки размеров кода.

Например, вот пример вызова:

$ ~/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 или в исполняемом файле. Здесь я вижу, что конструкторы и деструкторы выбрасываются два или три раза, что является результатом этой ошибки.

Вот script:

#!/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

Сгенерированный код будет точно таким же, поскольку код в обоих файлах точно такой же. Вы можете разобрать код, чтобы проверить его, если хотите.