将模板化的C++类分割成.hpp和.cpp文件,这是否可行?

111

我尝试编译一个C++模板类,该类分为.hpp.cpp两个文件,但是出现了错误:

$ g++ -c -o main.o main.cpp  
$ g++ -c -o stack.o stack.cpp   
$ g++ -o main main.o stack.o  
main.o: In function `main':  
main.cpp:(.text+0xe): undefined reference to 'stack<int>::stack()'  
main.cpp:(.text+0x1c): undefined reference to 'stack<int>::~stack()'  
collect2: ld returned 1 exit status  
make: *** [program] Error 1  

以下是我的代码:

stack.hpp:

#ifndef _STACK_HPP
#define _STACK_HPP

template <typename Type>
class stack {
    public:
            stack();
            ~stack();
};
#endif

stack.cpp:

=>

stack.cpp:

#include <iostream>
#include "stack.hpp"

template <typename Type> stack<Type>::stack() {
        std::cerr << "Hello, stack " << this << "!" << std::endl;
}

template <typename Type> stack<Type>::~stack() {
        std::cerr << "Goodbye, stack " << this << "." << std::endl;
}

main.cpp:

:主要的 C++ 源代码文件。
#include "stack.hpp"

int main() {
    stack<int> s;

    return 0;
}

ld 当然是正确的:符号不在 stack.o 中。

这个问题 的答案没有帮助,因为我已经按照它说的做了。
这个或许有点帮助,但我不想把每个方法都移动到 .hpp 文件中,我不应该这样做,对吗?

唯一合理的解决方案是将 .cpp 文件中的所有内容都移到 .hpp 文件中,并简单地包含所有内容,而不是链接到一个独立的目标文件中吗?那看起来太丑陋了!如果那样的话,我可能还不如回到之前的状态,将 stack.cpp 改名为 stack.hpp 就行了。


有两种很好的解决方案,可以让你真正地隐藏代码(在二进制文件中)或保持代码的清晰。虽然在第一种情况下需要减少通用性。这里解释了原因:https://dev59.com/O3RB5IYBdhLWcg3w1Kr0 - Sheric
1
显式模板实例化是减少模板编译时间的一种方法:https://dev59.com/2HE95IYBdhLWcg3wWMdQ - Ciro Santilli OurBigBook.com
16个回答

171

无法将模板类的实现写在单独的cpp文件中并进行编译。如果有人声称可以这样做,那么所有方式都是用来模拟使用单独cpp文件的变通方法,但实际上,如果你想编写一个模板类库并通过头文件和lib文件分发它来隐藏实现,那就根本不可能。

要知道原因,让我们看一下编译过程。头文件永远不会被编译,只会被预处理。预处理后的代码然后与实际编译的cpp文件组合在一起。现在,如果编译器必须为对象生成适当的内存布局,则需要知道模板类的数据类型。

实际上,必须了解模板类实际上根本不是类,而是类的模板,其声明和定义是由编译器在编译时根据参数从所获得的信息生成的。只要不能创建内存布局,就无法生成方法定义的指令。请记住,类方法的第一个参数是“this”运算符。所有类方法都转换为具有名称重整和第一个参数为所操作对象的单个方法。 “this”参数实际上告诉了对象的大小,而在模板类中,编译器无法获取该信息,除非用户使用有效的类型参数实例化对象。在这种情况下,如果将方法定义放在单独的cpp文件中并尝试对其进行编译,则对象文件本身将不会生成包含类信息的代码。编译不会失败,它会生成对象文件,但不会在对象文件中为模板类生成任何代码。这就是链接器无法在对象文件中找到符号,并且构建失败的原因。

那么,隐藏重要的实现细节有什么替代方案呢?正如我们所知,将接口与实现分离的主要目标是以二进制形式隐藏实现细节。这就是你必须分离数据结构和算法的地方。你的模板类必须仅表示数据结构而不是算法。这使你能够在单独的非模板类库中隐藏更有价值的实现细节,其中的类可以使用模板类来处理数据或仅用它们来保存数据。模板类实际上只包含少量用于分配、获取和设置数据的代码,其余工作将由算法类完成。

希望这个讨论对你有所帮助。


3
必须理解,模板类根本不是一个类。-难道不是相反吗?类模板是一个模板。 "模板类"有时用于代替"模板的实例化",并将成为一个实际的类。 - Xupicor
仅供参考,说没有解决方法是不正确的!将数据结构与方法分离也是一个坏主意,因为这与封装相反。在某些情况下(我相信大多数情况),有一个很好的解决方法可以使用,链接如下:https://dev59.com/O3RB5IYBdhLWcg3w1Kr0 - Sheric
@Xupicor,你是正确的。从技术上讲,“类模板”是你编写的内容,以便你可以实例化“模板类”及其相应的对象。然而,我认为在通用术语中,两个术语可以互换使用并不完全错误,定义“类模板”的语法本身以单词“template”而不是“class”开头。 - Sharjith N.
@Sheric,我没有说没有解决方法。事实上,在模板类的接口和实现中,所有可用的都只是模仿分离的解决方法。这些解决方法都无法在不实例化特定类型的模板类的情况下运行。这反而化解了使用类模板的通用性的全部要点。将数据结构与算法分离并不等同于将数据结构与方法分离。数据结构类可以很好地拥有像构造函数、获取器和设置器之类的方法。 - Sharjith N.
我刚刚发现让这个工作的最接近方法是使用一对.h/.hpp文件,并在定义模板类的.h文件的结尾处 #include "filename.hpp"(在类定义的右括号和分号之间)。这至少在文件上结构上将它们分开,并且是允许的,因为最终编译器会将你的.hpp代码复制/粘贴到你的#include "filename.hpp"中。 - Artorias2718
你能提供一个简短的Foo-Bar示例吗?因为算法总是以某种方式操作数据,所以为了完全理解,一个示例可能会有帮助。 - Vassilis

102

只要你知道需要实例化哪些内容,就可以做到这一点。

将以下代码添加到stack.cpp的末尾,它就可以工作:

template class stack<int>;

所有非模板方法都将被实例化,连接步骤将正常工作。


9
实际上,大多数人会使用一个单独的cpp文件来处理这个问题,比如stackinstantiations.cpp。 - Nemanja Trifunovic
3
实际上还有其他解决方案:http://www.codeproject.com/Articles/48575/How-to-define-a-template-class-in-a-h-file-and-imp - sleepsort
@Benoît 我收到了一个错误 error: expected unqualified-id before ‘;’ token template stack<int>; 你知道为什么吗?谢谢! - camino
@camino 你能具体说明一下你正在尝试编译的环境(编译器)和代码吗? - Benoît
3
实际上,正确的语法是 template class stack<int>; - Paul Baltescu
显示剩余6条评论

9

你可以用以下方式实现

// xyz.h
#ifndef _XYZ_
#define _XYZ_

template <typename XYZTYPE>
class XYZ {
  //Class members declaration
};

#include "xyz.cpp"
#endif

//xyz.cpp
#ifdef _XYZ_
//Class definition goes here

#endif

这个问题已经在DaniwebFAQ中讨论过,但是使用了C++的export关键字。

5
包含(include)一个 .cpp 文件通常是一个糟糕的想法。即使你有充分的理由,这个文件 - 实际上只是一个经过包装的头文件 - 应该被赋予一个 .hpp 或其他不同的扩展名(例如 .tpp),以清楚地表明正在发生什么,消除对 makefile 目标实际上是 .cpp 文件所带来的混淆等。 - underscore_d
@underscore_d,你能解释一下为什么包含一个.cpp文件是个糟糕的主意吗? - Abbas
2
@Abbas 因为扩展名 cpp(或者 ccc 或其他)表示该文件是一个实现部分,它的结果翻译单元(预处理器输出)可以分别编译,并且该文件的内容只编译一次。它并不表示该文件是接口的可重用部分,可以在任何地方随意包含。直接 #include 一个 实际的 cpp 文件会迅速填满你的屏幕,导致多重定义错误。在这种情况下,由于有理由需要 #include 它,使用 cpp 扩展名只是错误的选择。 - underscore_d
所以基本上仅仅使用 .cpp 扩展名是错误的,但是使用另一个比如 .tpp 完全可以,它有相同的作用,但使用不同的扩展名更易于/更快地理解。 - Abbas
1
@Abbas,是的,必须避免使用cpp/cc等扩展名,但最好使用除了hpp以外的其他名称,例如tpptcc等。这样可以重用文件名的其他部分,并指示tpp文件,尽管它的功能与头文件类似,但它保存了相应hpp中模板声明的非内联实现。因此,本文开始的想法很好——将声明和定义分别放在两个不同的文件中,这样更容易理解/搜索,有时由于循环依赖性也是必需的-但随后提出第二个文件的扩展名有误的建议,这一点需要注意。 - underscore_d
我建议使用 .inl 扩展名代替 .cpp。大多数 IDE 已经配置为将 .inl 用作 C/C++ 头文件。这样你就可以将声明放在 .h 文件中,将实现放在 .inl 文件中。 - rxantos

4

不,这是不可能的。如果没有export关键字,就无法实现。但是实际上,这个关键字并不存在。

你最好的办法是将函数实现放在“.tcc”或“.tpp”文件中,并在你的“.hpp”文件末尾#include .tcc文件。然而,这只是表面上的改变;它仍然与在头文件中实现一切相同。这就是使用模板所付出的代价。


4
你的答案不正确。如果你知道要使用哪些模板参数,你可以从模板类中生成代码,只需在 cpp 文件中进行。有关更多信息,请查看我的答案。 - Benoît
2
真的,但这带来了一个严重的限制,即每次引入使用模板的新类型时都需要更新.cpp文件并重新编译,这可能不是OP所想要的。 - Charles Salvia

3
问题在于模板并不会生成一个实际的类,它只是一个指示编译器如何生成类的模板。你需要生成一个具体的类。
最简单和自然的方法是将方法放在头文件中。但是还有另一种方法。
在您的 .cpp 文件中,如果您引用了所需的每个模板实例化和方法,编译器会在那里生成它们以供整个项目使用。
新建 stack.cpp:
#include <iostream>
#include "stack.hpp"
template <typename Type> stack<Type>::stack() {
        std::cerr << "Hello, stack " << this << "!" << std::endl;
}
template <typename Type> stack<Type>::~stack() {
        std::cerr << "Goodbye, stack " << this << "." << std::endl;
}
static void DummyFunc() {
    static stack<int> stack_int;  // generates the constructor and destructor code
    // ... any other method invocations need to go here to produce the method code
}

8
你不需要使用虚拟函数:可以使用 'template stack<int>;'。这将强制在当前编译单元中实例化模板。如果你定义了一个模板但只想在共享库中实现几个特定的实例,那么这非常有用。 - Martin York
@Martin:包括所有成员函数吗?太棒了。你应该将这个建议添加到“隐藏的C++特性”主题中。 - Mark Ransom
@LokiAstari,我找到了一篇相关文章,如果有人想要了解更多,请看这里:http://www.cplusplus.com/forum/articles/14272/。 - Andrew Larsson

3

只有在stack.hpp的末尾添加#include "stack.cpp"才能实现。如果实现比较大,并且将.cpp文件重命名为另一个扩展名以区分常规代码,则建议采用此方法。


4
如果你正在做这件事情,你需要在你的stack.cpp文件中添加#ifndef STACK_CPP(和相关内容)。 - Stephen Newell
别人抢先提出了我的建议。我也不喜欢这种方式,因为它不符合风格要求。 - luke
2
是的,在这种情况下,第二个文件绝对不应该被赋予扩展名cpp(或cc或其他),因为这与它的真实角色形成了鲜明的对比。相反,它应该被赋予一个不同的扩展名,表明它是(A)一个头文件和(B)要包含在另一个头文件的底部的头文件。我使用tpp来表示这一点,它还可以代表template implementation(带有线外定义)。我在这里详细阐述了这个问题:https://dev59.com/f3I-5IYBdhLWcg3wsKr9#iK6gEYcBWogLw_1b4jZ1 - underscore_d

3

我认为将模板代码拆分为头文件和cpp文件的主要原因有两个:

第一个原因是为了简洁美观。我们都喜欢编写易于阅读、管理和重复使用的代码。

另一个原因是减少编译时间。

目前我在与OpenCL一起编写仿真软件,我们希望保持代码可以使用float(cl_float)或double(cl_double)类型运行,具体取决于硬件能力。目前这是通过在代码开头使用#define REAL来实现的,但这不太优雅。更改所需精度需要重新编译应用程序。由于没有实际的运行时类型,我们暂时只能接受这种情况。幸运的是,OpenCL内核在运行时编译,并且简单的sizeof(REAL)允许我们相应地在内核代码中更改。

更大的问题是,在开发辅助类(如预计算仿真常量的类)时,即使应用程序是模块化的,这些类也必须是模板化的。这些类至少会出现在类依赖树的顶部一次,因为最终的模板类Simulation将具有其中一个工厂类的实例,这意味着每当我对工厂类进行微小更改时,整个软件都必须重新构建。这非常烦人,但我似乎找不到更好的解决方案。


2
有时候,如果你可以将所有的模板参数中公共的功能“foo”提取出来放到非模板类中(可能是类型不安全的),那么就可以将大部分实现隐藏在cpp文件中。然后头文件会包含对该类的重定向调用。在解决“模板膨胀”问题时也会使用类似的方法。

+1 - 即使大多数情况下结果并不太好(至少不如我希望的那样),但仍然值得一试。 - peterchen

2
如果您知道您的堆栈将与哪些类型一起使用,那么您可以在cpp文件中明确地实例化它们,并将所有相关代码保留在那里。
这也可能导出到DLL(!),但是要正确使用语法相当棘手(MS特定的__declspec(dllexport)和export关键字的组合)。
我们在一个数学/几何库中使用了这个模板双倍/浮点数,但有相当多的代码。(我在当时搜索了它,但今天没有那个代码。)

2
你可能想要这样做的地方是在创建库和头文件组合并对用户隐藏实现时。因此,建议使用显式实例化,因为你知道你的软件应该提供什么,并且可以隐藏实现。
以下是一些有用的信息: https://learn.microsoft.com/en-us/cpp/cpp/explicit-instantiation?view=vs-2019 对于你的同样的例子: Stack.hpp
template <class T>
class Stack {

public:
    Stack();
    ~Stack();
    void Push(T val);
    T Pop();
private:
    T val;
};


template class Stack<int>;

stack.cpp

#include <iostream>
#include "Stack.hpp"
using namespace std;

template<class T>
void Stack<T>::Push(T val) {
    cout << "Pushing Value " << endl;
    this->val = val;
}

template<class T>
T Stack<T>::Pop() {
    cout << "Popping Value " << endl;
    return this->val;
}

template <class T> Stack<T>::Stack() {
    cout << "Construct Stack " << this << endl;
}

template <class T> Stack<T>::~Stack() {
    cout << "Destruct Stack " << this << endl;
}

main.cpp

#include <iostream>
using namespace std;

#include "Stack.hpp"

int main() {
    Stack<int> s;
    s.Push(10);
    cout << s.Pop() << endl;
    return 0;
}

输出:

> Construct Stack 000000AAC012F8B4
> Pushing Value
> Popping Value
> 10
> Destruct Stack 000000AAC012F8B4

然而,我并不完全喜欢这种方法,因为它允许应用程序通过将不正确的数据类型传递给模板类来自我毁灭。例如,在主函数中,您可以传递其他可以隐式转换为int的类型,如s.Push(1.2); 我认为这样做很糟糕。

显式模板实例化的具体问题:https://dev59.com/2HE95IYBdhLWcg3wWMdQ - Ciro Santilli OurBigBook.com

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接