C++-将具有引用的长参数列表重构为结构



我喜欢让类在调用其构造函数后具有有效状态,即所有必需的依赖项都传递到构造函数中。

我还喜欢将必需的依赖项作为引用传递,因为在编译时,nullptr只是被禁止作为这些参数的值。

示例:

class B;
class A
{
public:
A(B& b) : b(b) {}
private:
B& b;
}

在实例化A之后,您(几乎)可以保证该实例处于有效状态。我发现这种代码风格非常安全,不会出现编程错误。

我的问题涉及重构这样的类,当它们有很多依赖关系时。

示例:

// Includes for B, C, D, E, F...
class A
{
public:
A(B b, C c, D d, E e, F f) : b(b), c(c), d(d), e(e), f(f) {}
private:
B b;
C c;
D d;
E e;
F f;
}

通常,我会在结构中列出一长串参数,比如:

struct Deps
{
B b;
C c;
D d;
E e;
F f;
}
class A
{
public:
A(Deps deps) : b(deps.b), c(deps.c), d(deps.d), e(deps.e), f(deps.f) {}
private:
B b;
C c;
D d;
E e;
F f;
}

这样,它使调用站点更加明确,也不容易出错:由于所有参数都必须命名,因此您不会因为顺序错误而错误地切换其中两个参数。

遗憾的是,这种技术在参考文献中效果不佳。在Deps结构中有引用会将问题转移到该结构:然后,Deps结构需要有一个初始化引用的构造函数,然后该构造函数将有一个长的参数列表,基本上什么都不解决。

现在的问题是:有没有一种方法可以重构包含引用的构造函数中的长参数列表,这样就不会有函数导致长参数列表、所有参数始终有效,也不会有类的实例处于无效状态(即,某些依赖项未初始化或为空)?

你不能既吃蛋糕又吃蛋糕。除非你会使用魔法(也被称为更强大的类型)。

让构造函数获取所有必要的依赖关系的关键思想是确保它们都是因为构造发生而提供的,并静态地强制执行。如果将此负担转移到结构中,则只有在所有字段都已填充的情况下,才应将此结构传递给构造函数。如果您有展开的引用,显然不可能只部分填充这个结构,而且您也无法真正向编译器证明您稍后会提供所需的参数。

当然,您可以进行运行时检查,但这不是我们想要的。理想情况下,我们能够对类型本身中初始化的参数进行编码。这很难以通用的方式实现,如果你做出一些让步并为特定类型手工编写,只会稍微容易一些。

考虑一个简化的例子,其中类型在签名中不重复(例如构造函数的签名是ctor(int, bool, string))。然后,我们可以使用std::tuple来表示部分填充的参数列表,如下所示:

auto start = tuple<>();
auto withIntArg = push(42, start);
auto withStringArg = push("xyz"s, withIntArg);
auto withBoolArg = push(true, withStringArg);

我已经使用了auto,但如果您考虑这些变量的类型,您会意识到只有在执行完所有这些变量之后(尽管是按随机顺序),它才会达到所需的tuple<int, string, bool>。然后,您可以将类构造函数编写为只接受具有所有必需类型的元组的模板,编写push函数,瞧!

当然,这是一个很大的样板,可能会出现非常严重的错误,除非你在写上面的文章时非常小心。你想做的任何其他解决方案都需要有效地做同样的事情修改部分填充的参数列表的类型,直到它符合所需的集合。

值得吗?好吧,你自己决定。

实际上,使用std::tuple:有一个非常优雅/简单的解决方案

#include <tuple>
struct A{};
struct B{};
struct C{};
struct D{};
struct E{};
struct F{};
class Bar
{
public:
template<class TTuple>
Bar(TTuple refs)
: a(std::get<A&>(refs))
, b(std::get<B&>(refs))
, c(std::get<C&>(refs))
, d(std::get<D&>(refs))
, e(std::get<E&>(refs))
, f(std::get<F&>(refs))
{
}
private:
A& a;
B& b;
C& c;
D& d;
E& e;
F& f;
};

void test()
{
A a; B b; C c; D d; E e; F f;
// Different ways to incrementally build the reference holder:
auto tac = std::tie(a, c); // This is a std::tuple<A&, C&>.
auto tabc = std::tuple_cat(tac, std::tie(b));
auto tabcdef = std::tuple_cat(tabc, std::tie(d, f), std::tie(e));
// We have everything, let's build the object:
Bar bar(tabcdef);
}

https://godbolt.org/z/pG1R7U

std::tie的存在正是为了创建一个引用元组。我们可以使用std::tuple_cat组合引用元组。std::get<T>允许从给定的元组中准确地检索我们需要的引用。

这有:

  • 最小样板:您只需要在每个引用类型的成员初始值设定项列表中写入std::get<X&>。无需提供/重复任何其他内容即可将其用于更多引用或包含引用的类型。

  • 完全编译时安全:如果您忘记提供引用或提供两次,编译器会抱怨。打字系统对所有必要的信息进行编码。

  • 对添加引用的顺序没有限制。

  • 没有手写的模板机械。使用标准设施而不是手工编写的模板机制意味着你不会引入错误/忘记角落案例。这也意味着这种方法的用户/读者没有什么需要经历的(可能会尖叫着逃跑)。

我认为这是一个非常简单的解决方案,哪怕只是因为std::tuple和朋友们已经实现了这里所需的所有元编程。它仍然比"一个长列表中的所有内容"稍微复杂一些,但我确信这是值得权衡的。

(我以前的手写模板版本存在于编辑历史中。但我意识到std::tuple已经完成了我们需要的一切。)

如果您可以在运行时检查完整性,我建议将指针存储在Deps中,并在A的构造函数中检查所有指针是否为非空。这允许您增量地构建Deps,并且与以前一样安全。要在取消引用指针之前执行非null检查,您可能需要一些丑陋的东西(比如逗号运算符)。您还可以为A成员存储指针而不是引用,因为(如果构造函数检查null)它同样安全,但允许例如赋值运算符。并使空检查更容易:

struct Deps
{
B* b;
C* c;
D* d;
E* e;
F* f;
};
template<class ... Ts>
bool allNonNull(Ts* ... ts)
{
return ((ts != nullptr) && ...);
}
class A
{
public:
A(Deps deps) : b(deps.b), c(deps.c), d(deps.d), e(deps.e), f(deps.f)
{
assert(allNonNull(b, c, d, e, f));
if (!allNonNull(b, c, d, e, f))
/*whatever error handling you want*/;
}
private:
B* b;
C* c;
D* d;
E* e;
F* f;
};

当然,缺点是没有更多的编译时检查,并且有很多代码重复。还可能忘记更新null检查函数的参数。

最新更新