C ++处理大型模板实现的首选方法


10

通常,在声明C ++类时,最佳实践是仅将声明放入头文件中,并将实现放入源文件中。但是,这种设计模型似乎不适用于模板类。

在网上查找时,对于管理模板类的最佳方法似乎有两种意见:

1.标头中的整个声明和实现。

这相当简单,但是我认为,当模板变大时,很难维护和编辑代码文件。

2.将实现写入末尾包含的模板包含文件(.tpp)中。

对我来说,这似乎是一个更好的解决方案,但似乎并未得到广泛应用。是否存在这种方法不及格的原因?

我知道很多时候,代码风格是由个人喜好或传统风格决定的。我正在开始一个新项目(将旧的C项目移植到C ++),并且我对OO设计比较陌生,并且希望从一开始就遵循最佳实践。


1
请参阅codeproject.com上已有9年历史的文章。方法3是您描述的。似乎并不像您所相信的那么特别。
布朗

..或此处,同样的方法,2014年的文章:codeofhonour.blogspot.com/2014/11/…–
布朗

2
密切相关:stackoverflow.com/q/1208028/179910。Gnu通常使用“ .tcc”扩展名而不是“ .tpp”,但在其他方面几乎相同。
杰里·科芬

我一直使用“ ipp”作为扩展名,但是我在编写的代码中做了很多相同的事情。
塞巴斯蒂安·雷德尔

Answers:


6

在编写模板化的C ++类时,通常有三个选择:

(1)将声明和定义放在标题中。

// foo.h
#pragma once

template <typename T>
struct Foo
{
    void f()
    {
        ...
    }
};

要么

// foo.h
#pragma once

template <typename T>
struct Foo
{
    void f();
};

template <typename T>
inline void Foo::f()
{
    ...
}

优点:

  • 使用非常方便(只需包含标题)。

缺点:

  • 接口和方法实现混合在一起。这只是“可读性”问题。有些人认为这是无法维持的,因为它与通常的.h / .cpp方法不同。但是,请注意,在其他语言(例如C#和Java)中这不是问题。
  • 重建影响很大:如果您Foo以成员的身份声明新类,则需要添加foo.h。这意味着更改Foo::f头文件和源文件传播的实现。

让我们仔细研究一下重建影响:对于非模板C ++类,将声明放在.h中,将方法定义放在.cpp中。这样,当更改方法的实现时,只需重新编译一个.cpp。如果.h包含您所有的代码,则这对于模板类是不同的。看下面的例子:

// bar.h
#pragma once
#include "foo.h"
struct Bar
{
    void b();
    Foo<int> foo;
};

// bar.cpp
#include "bar.h"
void Bar::b()
{
    foo.f();
}

// qux.h
#pragma once
#include "bar.h"
struct Qux
{
    void q();
    Bar bar;
}

// qux.cpp
#include "qux.h"
void Qux::q()
{
    bar.b();
}

在这里,的唯一用法Foo::f是inside bar.cpp。但是,如果你改变的实施Foo::f,既bar.cppqux.cpp需要重新编译。Foo::f即使两个文件中的任何部分都没有Qux直接使用中的任何内容,这两个文件中都存在生命的实现Foo::f。对于大型项目,这很快就会成为问题。

(2)将声明放在.h中,并将定义放在.tpp中,并将其包括在.h中。

// foo.h
#pragma once
template <typename T>
struct Foo
{
    void f();
};
#include "foo.tpp"    

// foo.tpp
#pragma once // not necessary if foo.h is the only one that includes this file
template <typename T>
inline void Foo::f()
{
    ...
}

优点:

  • 使用非常方便(只需包含标题)。
  • 接口和方法定义是分开的。

缺点:

  • 重建影响大(与(1)相同)。

此解决方案将声明和方法定义分为两个单独的文件,就像.h / .cpp一样。但是,此方法具有与(1)相同的重建问题,因为标头直接包含方法定义。

(3)将声明放在.h中,将定义放在.tpp中,但不要在.h中包括.tpp。

// foo.h
#pragma once
template <typename T>
struct Foo
{
    void f();
};

// foo.tpp
#pragma once
template <typename T>
void Foo::f()
{
    ...
}

优点:

  • 减少重建影响,就像.h / .cpp分隔一样。
  • 接口和方法定义是分开的。

缺点:

  • 使用不便:将Foo成员添加到类中时Bar,您需要将其包括foo.h在标题中。如果调用Foo::f.cpp,则必须在其中添加foo.tpp

这种方法减少了重建影响,因为仅Foo::f需要重新编译真正使用的.cpp文件。但是,这是有代价的:所有这些文件都需要包含foo.tpp。从上面的示例开始,并使用新方法:

// bar.h
#pragma once
#include "foo.h"
struct Bar
{
    void b();
    Foo<int> foo;
};

// bar.cpp
#include "bar.h"
#include "foo.tpp"
void Bar::b()
{
    foo.f();
}

// qux.h
#pragma once
#include "bar.h"
struct Qux
{
    void q();
    Bar bar;
}

// qux.cpp
#include "qux.h"
void Qux::q()
{
    bar.b();
}

如您所见,唯一的不同是foo.tppin中的附加include bar.cpp。这很不方便,并且根据您是否在类上调用方法看起来很丑陋,因此为该类添加第二个include。但是,您可以减少重建的影响:仅bar.cpp当更改的实现时才需要重新编译Foo::f。该文件qux.cpp无需重新编译。

摘要:

如果实现一个库,通常不需要关心重建影响。库的用户抓住并使用它,并且库的实现在用户的日常工作中不会改变。在这种情况下,库可以使用方法(1)(2),而选择哪种只是一个口味问题。

但是,如果您正在处理应用程序,或者正在处理公司的内部库,则代码会经常更改。因此,您必须关心重建影响。如果让开发人员接受其他包含,则选择方法(3)是一个不错的选择。


2

与这个.tpp想法类似(我从未见过使用过),我们将大多数内联功能放入了-inl.hpp文件中,该文件包含在普通.hpp文件的末尾。

就像其他人指出的那样,这可以通过在另一个文件中移动内联实现(如模板)的混乱情况来保持界面可读性。我们允许一些接口内联,但尝试将它们限制为较小的,通常为单行的函数。


1

第二个变种的一个优点是,标题看起来更整洁。

缺点可能是您可能进行了内联IDE错误检查,并且调试器绑定搞砸了。


2nd还需要大量模板参数声明冗余,尤其是在使用sfinae时,这可能变得非常冗长。与OP相反,我发现第二难阅读的代码更多,特别是由于冗余样板。
Sopel

0

我非常喜欢将实现放在一个单独的文件中,并且仅在头文件中包含文档和声明的方法。

也许您没有看到这种方法在实践中使用太多的原因是,您没有在正确的位置查看;-)

或者-也许是因为在开发软件时需要花费一些额外的精力。但是,对于类库而言,恕我直言,这是值得的,并且可以通过更易于使用/阅读的库来回报自己。

以这个库为例:https : //github.com/SophistSolutions/Stroika/

整个库都是用这种方法编写的,如果您仔细阅读代码,就会发现它的工作原理。

头文件与实现文件一样长,但是头文件除了声明和文档外什么都没有。

将Stroika的可读性与您最喜欢的std c ++实现(gcc或libc ++或msvc)的可读性进行比较。这些都使用内联头内实现方法,尽管它们写得非常好,但恕我直言,不是可读的实现。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.