显式模板实例化-何时使用?

休息了几个星期后,我试图通过 David Vandevoorde 和 Nicolai M。 Josuttis 的书 完整指南来扩展和延伸我对模板的知识,此刻我试图理解的是模板的显式实例化。

我实际上对这种机制并没有什么问题,但是我无法想象在什么情况下我想要或者想要使用这个特性。如果有人能解释给我听,我会非常感激。

108458 次浏览

直接复制自 https://learn.microsoft.com/en-us/cpp/cpp/explicit-instantiation:

可以使用显式实例化来创建模板化类或函数的实例化,而不必在代码中实际使用它。因为 这在创建使用模板的库(. lib)文件时非常有用用于分发,所以未实例化的模板定义不会放入 object (。Obj)档案。

(例如,libstdc + + 包含 std::basic_string<char,char_traits<char>,allocator<char> >(即 std::string)的显式实例化,因此每次使用 std::string的函数时,不需要将相同的函数代码复制到对象。编译器只需要将它们引用(链接)到 libstdc + + 。)

如果您定义了一个只希望用于两个显式类型的模板类。

像普通类一样将模板声明放在头文件中。

像普通类一样,将模板定义放在源文件中。

然后,在源文件的末尾,显式地只实例化您希望可用的版本。

愚蠢的例子:

// StringAdapter.h
template<typename T>
class StringAdapter
{
public:
StringAdapter(T* data);
void doAdapterStuff();
private:
std::basic_string<T> m_data;
};
typedef StringAdapter<char>    StrAdapter;
typedef StringAdapter<wchar_t> WStrAdapter;

来源:

// StringAdapter.cpp
#include "StringAdapter.h"


template<typename T>
StringAdapter<T>::StringAdapter(T* data)
:m_data(data)
{}


template<typename T>
void StringAdapter<T>::doAdapterStuff()
{
/* Manipulate a string */
}


// Explicitly instantiate only the classes you want to be defined.
// In this case I only want the template to work with characters but
// I want to support both char and wchar_t with the same code.
template class StringAdapter<char>;
template class StringAdapter<wchar_t>;

总部

#include "StringAdapter.h"


// Note: Main can not see the definition of the template from here (just the declaration)
//       So it relies on the explicit instantiation to make sure it links.
int main()
{
StrAdapter  x("hi There");
x.doAdapterStuff();
}

这取决于编译器模型——显然有 Borland 模型和 CFront 模型。然后它还取决于您的意图-如果您正在编写一个库,您可以(如上所述)显式地实例化您想要的专门化。

GNUc + + 页面讨论了这里的模型 https://gcc.gnu.org/onlinedocs/gcc-4.5.2/gcc/Template-Instantiation.html

显式实例化允许减少编译时间和输出大小

这些是它能够提供的主要收益。它们来自以下各节详细描述的以下两个影响:

  • 删除标题中的定义,以防止智能构建系统在对这些模板的每次更改中重新构建包含器(节省时间)
  • 防止对象重新定义(节省时间和大小)

从标题中删除定义

显式实例化允许在. cpp 文件中保留定义。

当定义位于头部并进行修改时,智能构建系统将重新编译所有包含器,这可能是几十个文件,可能使得在单个文件更改之后的增量重新编译速度慢得令人难以忍受。

输入定义。Cpp 文件的缺点是外部库不能将模板与它们自己的新类一起重用,但是下面的“从包含的头中删除定义,但是也公开一个外部 API 模板”显示了一个解决方案。

请看下面的具体例子。

检测生成系统的示例包括并重新生成:

对象重新定义获益: 理解问题

如果您只是在头文件上完全定义了一个模板,那么包含该头文件的每个编译单元最终都会为每个不同的模板参数用法编译自己的模板隐式副本。

这意味着大量无用的磁盘使用和编译时间。

下面是一个具体的例子,其中 main.cppnotmain.cpp都隐式地定义了 MyTemplate<int>,因为它们在这些文件中都有使用。

Main.cpp

#include <iostream>


#include "mytemplate.hpp"
#include "notmain.hpp"


int main() {
std::cout << notmain() + MyTemplate<int>().f(1) << std::endl;
}

不是 main.cpp

#include "mytemplate.hpp"
#include "notmain.hpp"


int notmain() { return MyTemplate<int>().f(1); }

Mytemplate.hpp

#ifndef MYTEMPLATE_HPP
#define MYTEMPLATE_HPP


template<class T>
struct MyTemplate {
T f(T t) { return t + 1; }
};


#endif

不是 Main.hpp

#ifndef NOTMAIN_HPP
#define NOTMAIN_HPP


int notmain();


#endif

GitHub 上游。

nm编译和查看符号:

g++ -c -Wall -Wextra -std=c++11 -pedantic-errors -o notmain.o notmain.cpp
g++ -c -Wall -Wextra -std=c++11 -pedantic-errors -o main.o main.cpp
g++    -Wall -Wextra -std=c++11 -pedantic-errors -o main.out notmain.o main.o
echo notmain.o
nm -C -S notmain.o | grep MyTemplate
echo main.o
nm -C -S main.o | grep MyTemplate

产出:

notmain.o
0000000000000000 0000000000000017 W MyTemplate<int>::f(int)
main.o
0000000000000000 0000000000000017 W MyTemplate<int>::f(int)

因此,我们看到为每个单独的方法实例化生成了一个单独的部分,它们中的每一个当然都占用目标文件中的空间。

man nm中,我们看到 W表示弱符号,GCC 选择这个符号是因为它是一个模板函数。

它之所以没有在多个定义的链接时崩溃,是因为 连接器接受多个弱定义,只是选择其中一个放入最终的可执行文件,在我们的例子中,它们都是相同的,所以一切都很好。

输出中的数字意味着:

  • 0000000000000000: 区域内的地址。这个零是因为模板会自动放到它们自己的区域内
  • 0000000000000017: 为它们生成的代码的大小

我们可以更清楚地看到这一点:

objdump -S main.o | c++filt

结尾是:

Disassembly of section .text._ZN10MyTemplateIiE1fEi:


0000000000000000 <MyTemplate<int>::f(int)>:
0:   f3 0f 1e fa             endbr64
4:   55                      push   %rbp
5:   48 89 e5                mov    %rsp,%rbp
8:   48 89 7d f8             mov    %rdi,-0x8(%rbp)
c:   89 75 f4                mov    %esi,-0xc(%rbp)
f:   8b 45 f4                mov    -0xc(%rbp),%eax
12:   83 c0 01                add    $0x1,%eax
15:   5d                      pop    %rbp
16:   c3                      retq

_ZN10MyTemplateIiE1fEiMyTemplate<int>::f(int)>的混乱名字,c++filt决定不解决这个问题。

对象重定义问题的解决方案

这个问题可以通过使用显式的实例化或者:

  1. 保持 hpp 上的定义,并在 hpp 上为将要显式实例化的类型添加 extern template

    如上所述: 使用外部模板(C + + 11) extern template防止完全定义的模板被编译单元实例化,除了我们的显式实例化。这样,只有我们的显式实例化将在最终对象中定义:

    Mytemplate.hpp

    #ifndef MYTEMPLATE_HPP
    #define MYTEMPLATE_HPP
    
    
    template<class T>
    struct MyTemplate {
    T f(T t) { return t + 1; }
    };
    
    
    extern template class MyTemplate<int>;
    
    
    #endif
    

    Mytemplate.cpp

    #include "mytemplate.hpp"
    
    
    // Explicit instantiation required just for int.
    template class MyTemplate<int>;
    

    Main.cpp

    #include <iostream>
    
    
    #include "mytemplate.hpp"
    #include "notmain.hpp"
    
    
    int main() {
    std::cout << notmain() + MyTemplate<int>().f(1) << std::endl;
    }
    

    不是 main.cpp

    #include "mytemplate.hpp"
    #include "notmain.hpp"
    
    
    int notmain() { return MyTemplate<int>().f(1); }
    

    缺点:

    • 定义保留在标头中,使单个文件更改重新编译到该标头的速度可能较慢
    • 如果您只是标题库,则强制外部项目执行它们自己的显式实例化。如果您不是只有标题的库,那么这个解决方案可能是最好的。
    • 如果模板类型是在您自己的项目中定义的,而不是像 int那样的内置类型,那么似乎您必须在头部添加 include,仅仅添加一个前向声明是不够的:。
  2. 移动 cpp 文件中的定义,只在 hpp 上保留声明,即将原始示例修改为:

    Mytemplate.hpp

    #ifndef MYTEMPLATE_HPP
    #define MYTEMPLATE_HPP
    
    
    template<class T>
    struct MyTemplate {
    T f(T t);
    };
    
    
    #endif
    

    Mytemplate.cpp

    #include "mytemplate.hpp"
    
    
    template<class T>
    T MyTemplate<T>::f(T t) { return t + 1; }
    
    
    // Explicit instantiation.
    template class MyTemplate<int>;
    

    缺点: 外部项目不能将您的模板用于它们自己的类型。此外,还必须显式实例化所有类型。但也许这是一个好的方面,因为程序员不会忘记。

  3. 保持 hpp 上的定义,并在每个包含器上添加 extern template:

    Mytemplate.cpp

    #include "mytemplate.hpp"
    
    
    // Explicit instantiation.
    template class MyTemplate<int>;
    

    Main.cpp

    #include <iostream>
    
    
    #include "mytemplate.hpp"
    #include "notmain.hpp"
    
    
    // extern template declaration
    extern template class MyTemplate<int>;
    
    
    int main() {
    std::cout << notmain() + MyTemplate<int>().f(1) << std::endl;
    }
    

    不是 main.cpp

    #include "mytemplate.hpp"
    #include "notmain.hpp"
    
    
    // extern template declaration
    extern template class MyTemplate<int>;
    
    
    int notmain() { return MyTemplate<int>().f(1); }
    

    缺点: 所有的包含者都必须将 extern添加到他们的 CPP 文件中,程序员很可能会忘记这样做。

有了这些解决方案,nm现在包含:

notmain.o
U MyTemplate<int>::f(int)
main.o
U MyTemplate<int>::f(int)
mytemplate.o
0000000000000000 W MyTemplate<int>::f(int)

所以我们看到只有 mytemplate.o有一个所需的 MyTemplate<int>的编译,而 notmain.omain.o没有,因为 U的意思是未定义的。

从包含的标头中删除定义,但也在只包含标头的库中公开外部 API 的模板

如果您的库不仅仅是头文件,那么 extern template方法将会工作,因为使用项目只会链接到您的目标文件,这个目标文件将包含显式模板实例化的对象。

但是,如果希望同时使用这两种方法,则仅对于标头库:

  • 加快项目的编译
  • 将标头作为外部库 API 公开以供其他人使用

你可尝试以下其中一种方法:

    • mytemplate.hpp: 模板定义
    • 模板声明只匹配 mytemplate_interface.hpp的定义,没有定义
    • mytemplate.cpp: 包含 mytemplate.hpp并进行显式的实例化
    • 代码库中的 main.cpp和其他任何地方: 包括 mytemplate_interface.hpp,而不是 mytemplate.hpp
    • mytemplate.hpp: 模板定义
    • mytemplate_implementation.hpp: 包含 mytemplate.hpp并将 extern添加到每个将被实例化的类中
    • mytemplate.cpp: 包含 mytemplate.hpp并进行显式的实例化
    • 代码库中的 main.cpp和其他任何地方: 包括 mytemplate_implementation.hpp,而不是 mytemplate.hpp

或者对于多个标题更好: 在 includes/文件夹中创建一个 intf/impl文件夹,并始终使用 mytemplate.hpp作为名称。

mytemplate_interface.hpp方法是这样的:

Mytemplate.hpp

#ifndef MYTEMPLATE_HPP
#define MYTEMPLATE_HPP


#include "mytemplate_interface.hpp"


template<class T>
T MyTemplate<T>::f(T t) { return t + 1; }


#endif

Mytemplate _ interface. hpp

#ifndef MYTEMPLATE_INTERFACE_HPP
#define MYTEMPLATE_INTERFACE_HPP


template<class T>
struct MyTemplate {
T f(T t);
};


#endif

Mytemplate.cpp

#include "mytemplate.hpp"


// Explicit instantiation.
template class MyTemplate<int>;

Main.cpp

#include <iostream>


#include "mytemplate_interface.hpp"


int main() {
std::cout << MyTemplate<int>().f(1) << std::endl;
}

编译并运行:

g++ -c -Wall -Wextra -std=c++11 -pedantic-errors -o mytemplate.o mytemplate.cpp
g++ -c -Wall -Wextra -std=c++11 -pedantic-errors -o main.o main.cpp
g++    -Wall -Wextra -std=c++11 -pedantic-errors -o main.out main.o mytemplate.o

产出:

2

在 Ubuntu 18.04中测试。

C + + 20个模块

Https://en.cppreference.com/w/cpp/language/modules

我认为这个特性将提供最好的设置,因为它变得可用,但我还没有检查它,因为它还没有在我的 GCC 9.2。

您仍然需要进行显式的实例化来获得加速/磁盘保存,但至少我们将有一个明智的解决方案“从包含的头中删除定义,但也将模板暴露为外部 API”,这不需要复制大约100次。

预期的用法(没有明确的实例化,不确定确切的语法是什么样的,参见: 如何在 C + + 20模块中使用模板显式实例化?) :

Helloworld.cpp

export module helloworld;  // module declaration
import <iostream>;         // import declaration
 

template<class T>
export void hello(T t) {      // export declaration
std::cout << t << std::end;
}

Main.cpp

import helloworld;  // import declaration
 

int main() {
hello(1);
hello("world");
}

然后是 https://quuxplusone.github.io/blog/2019/11/07/modular-hello-world/中提到的编译

clang++ -std=c++2a -c helloworld.cpp -Xclang -emit-module-interface -o helloworld.pcm
clang++ -std=c++2a -c -o helloworld.o helloworld.cpp
clang++ -std=c++2a -fprebuilt-module-path=. -o main.out main.cpp helloworld.o

所以从这里我们可以看到 clang 可以将模板接口 + 实现提取到神奇的 helloworld.pcm中,它必须包含源代码的一些 LLVM 中间表示: 在 C + + 模块系统中如何处理模板?,它仍然允许模板规范的发生。

如何快速分析您的构建,看看它是否会从模板实例化中获得很多

所以,你有一个复杂的项目,你想决定是否模板实例化会带来显著的收益,而不实际做完整的重构?

下面的分析可能会帮助您决定,或者至少选择最有希望的对象,以便在您进行实验时首先进行重构,方法是借鉴 我的 C + + 对象文件太大了的一些想法

# List all weak symbols with size only, no address.
find . -name '*.o' | xargs -I{} nm -C --size-sort --radix d '{}' |
grep ' W ' > nm.log


# Sort by symbol size.
sort -k1 -n nm.log -o nm.sort.log


# Get a repetition count.
uniq -c nm.sort.log > nm.uniq.log


# Find the most repeated/largest objects.
sort -k1,2 -n nm.uniq.log -o nm.uniq.sort.log


# Find the objects that would give you the most gain after refactor.
# This gain is calculated as "(n_occurences - 1) * size" which is
# the size you would gain for keeping just a single instance.
# If you are going to refactor anything, you should start with the ones
# at the bottom of this list.
awk '{gain = ($1 - 1) * $2; print gain, $0}' nm.uniq.sort.log |
sort -k1 -n > nm.gains.log


# Total gain if you refactored everything.
awk 'START{sum=0}{sum += $1}END{print sum}' nm.gains.log


# Total size. The closer total gain above is to total size, the more
# you would gain from the refactor.
awk 'START{sum=0}{sum += $1}END{print sum}' nm.log

梦想: 一个模板编译器缓存

我认为最终的解决方案是,如果我们可以建立:

g++ --template-cache myfile.o file1.cpp
g++ --template-cache myfile.o file2.cpp

然后 myfile.o将自动跨文件重用以前编译的模板。

这意味着除了将额外的 CLI 选项传递给构建系统之外,程序员不需要付出额外的努力。

显式模板实例化的第二个好处是: 帮助 IDE 列出模板实例化

我发现有些 IDE (如 Eclipse)无法解析“所使用的所有模板实例的列表”。

因此,例如,如果您在一个模板代码中,并且您想要找到模板的可能值,那么您必须一个一个地找到构造函数的用法,并且一个一个地推断出可能的类型。

但是在 Eclipse 2020-03上,我可以通过对类名进行一个 Find all use (Ctrl + Alt + G)搜索,轻松地列出显式实例化的模板,这会给我指出一些例子,比如:

template <class T>
struct AnimalTemplate {
T animal;
AnimalTemplate(T animal) : animal(animal) {}
std::string noise() {
return animal.noise();
}
};

致:

template class AnimalTemplate<Dog>;

这里有一个演示: https://github.com/cirosantilli/ide-test-projects/blob/e1c7c6634f2d5cdeafd2bdc79bcfbb2057cb04c4/cpp/animal_template.hpp#L15

然而,您可以在 IDE 之外使用的另一种游击技术是在最终可执行文件上运行 nm -C,并对模板名进行 grep 处理:

nm -C main.out | grep AnimalTemplate

这直接指出了 Dog是实例之一的事实:

0000000000004dac W AnimalTemplate<Dog>::noise[abi:cxx11]()
0000000000004d82 W AnimalTemplate<Dog>::AnimalTemplate(Dog)
0000000000004d82 W AnimalTemplate<Dog>::AnimalTemplate(Dog)