C++中的长委托链

23

这个问题的答案肯定是主观的,但我希望尽量避免争论。如果人们适当对待它,我认为这可能是一个有趣的问题。

在我的几个最近的项目中,我经常使用实现长委派链的架构。

双重委派链经常会遇到:

bool Exists = Env->FileSystem->FileExists( "foo.txt" );

而三重委托并不罕见:

Env->Renderer->GetCanvas()->TextStr( ... );

高阶委托链存在,但非常少见。

在上述例子中,由于使用的对象始终存在且对程序运行至关重要,在执行开始时已明确构造,因此不执行任何空值运行时检查。基本上,在这些情况下我会将委托链拆分为:

1) 通过委托链重用对象:

{ // make C invisible to the parent scope
   clCanvas* C = Env->Renderer->GetCanvas();
   C->TextStr( ... );
   C->TextStr( ... );
   C->TextStr( ... );
}

2)在委托链的中间某个位置应该在使用之前检查中间对象是否为NULL。例如:

clCanvas* C = Env->Renderer->GetCanvas();

if ( C ) C->TextStr( ... );

我曾经通过提供代理对象来处理情况(2),以便在非NULL对象上调用方法,导致结果为空。

我的问题是:

  1. 情况(1)或(2)中的任何一个是模式还是反模式?
  2. 有没有更好的方法来处理C++中的长委托链?

以下是我考虑做出选择时考虑的一些利弊:

优点:

  • 它非常描述性:从一行代码清楚地知道对象来自哪里
  • 长的委托链看起来很好看

缺点:

  • 交互式调试很麻烦,因为很难检查委托链中不止一个临时对象

我想知道长委托链的其他利弊。请根据您的推理进行投票,并基于论点的说服力而不是您同意的程度。


1
虽然是个难选择,但我个人倾向于简单调试。 - Almo
@Almo:请畅所欲言! - Sergey K.
3
我不认为“长的委托链看起来很好”。当我看到 Env->Renderer->GetCanvas()->TextStr() 这样的代码时,我需要一次性考虑许多事情:环境对象是什么,渲染器对象是什么,画布是什么,TextStr 方法又是做什么的。此外,这意味着代码与所有这些类紧密耦合在一起,而它可能可以更好地封装(阅读文本从画布中真正需要访问整个环境吗?)。请参阅《迪米特定律》(http://en.wikipedia.org/wiki/Law_of_Demeter) 了解更多信息。 - Luc Touraille
4
关于您使用代理对象的方式,它看起来非常类似于“空对象模式”。 - Luc Touraille
8个回答

14

我不会去称呼它们为反模式。然而,第一种方式的缺点是你的变量 C 在逻辑上已经没有意义时仍然可见(作用域过于慷慨)。

你可以通过使用以下语法来解决这个问题:

if (clCanvas* C = Env->Renderer->GetCanvas()) {
  C->TextStr( ... );
  /* some more things with C */
}

这在C++中是允许的(而在C中不允许),可以使作用域正确(C的作用域就像它在条件块内一样),并检查NULL。
断言某个对象不为NULL比遇到段错误崩溃要好得多。因此,我不建议简单地跳过这些检查,除非你百分之百确定该指针永远不可能为NULL。
此外,如果你感觉特别时髦,你可以将这些检查封装在一个额外的自由函数中:
template <typename T>
T notNULL(T value) {
  assert(value);
  return value;
}

// e.g.
notNULL(notNULL(Env)->Renderer->GetCanvas())->TextStr();

对于父级作用域的不可见性是一个好的特点。我已经相应地更新了相关的代码示例。 - Sergey K.
如果notNULL的参数为NULL,它应该怎么做? - Sergey K.
@SergeyK.:嗯,你可以考虑几种替代方案。在这里,我只是assert它不是NULL。你也可以抛出异常(如果你喜欢那种东西),或者返回一个虚拟对象(我不建议这样做)。如果你使用断言,这是一种很好的方式来显式地检查你隐含地假设EnvEnv->Renderer等都不是NULL。 - bitmask
1
我已经为回答的第一部分(使用if语句块)点赞了;我建议不要使用notNULL函数,因为它会导致无用的断言消息,比段错误更难追踪。如果您知道如何使用调试器和核心转储,追踪空指针解引用非常容易。找出哪个调用导致notNULL出错也不难,但并不比调试段错误更容易。 - wolfgang
@wolfgang:是的,你说得部分正确——assert有一些问题,正如你所说的那样。但是想想对GetCanvas的调用。即使Renderer为空,它仍然可能有效,因为它可能不需要this指针。这可能会导致一个未发现的错误,在生产中突然出现并打你的脸。这就是为什么我总是更喜欢断言失败而不是段错误。 - bitmask

6

根据我的经验,这样的链条通常包含一些不太简单的getter,导致效率低下。我认为使用方法(1)是一个合理的做法。使用代理对象似乎有点过度设计。我宁愿看到空指针引发崩溃,而不是使用代理对象。


2
在关键任务代码的中间可能会出现潜在的崩溃。 - Sergey K.
1
@SergeyK.:那么你的测试覆盖率不足以完成手头的任务。 - wilx
抱歉,这个样本太小了。但并不是所有的东西都能被压缩到足以放入一个问题中。 - Sergey K.
你的想法很清晰,如果你能提供更多的理由就更好了。请继续说出你的想法。 - Sergey K.

6

如果您遵循迪米特法则,就不应该发生如此长的委派链。我经常和一些支持者争论他们过于严格地遵守这个法则,但是如果你开始考虑如何处理长委托链, 那么最好还是要更加遵从其建议。


这将是我最大的关注点。问题归结为当前类应该知道哪些接口。在流畅接口的情况下,您可以轻松地拥有长委托链,而不违反 Demeter 法则。 - Martin Spamer

4
有趣的问题,我认为这是可以解释的:
我的意见:
设计模式只是常见问题的可重用解决方案,它们具有普适性,可以广泛应用于面向对象(通常是)编程。许多常见的模式都会带来接口、继承链和/或包含关系,这将导致您在某种程度上使用链接调用事物。但是,这些模式并不试图解决这样的编程问题——链式编程只是它们解决手头功能问题的副作用。所以,我不认为这是一种设计模式。
同样地,反模式是与(在我看来)设计模式目的相悖的方法。例如,设计模式的全部内容都涉及结构和代码的适应性。人们认为单例是反模式,因为它(通常而言)会导致类似于蜘蛛网的代码,因为它本质上创建了全局变量,如果有很多个,你的设计将迅速恶化。
所以,您的链式编程问题并不一定表示好或坏的设计——它与模式的功能目标或反模式的缺点无关。即使设计良好,有些设计仍然有很多嵌套对象。
处理该问题:
长委派链在一段时间后肯定会让人感到烦恼,只要您的设计规定这些链中的指针不会被重新分配,我认为将临时指针保存到您感兴趣的链中点是完全可以的(函数范围或不太好的选择)。
但是,个人来说,我反对将永久指针保存为类成员以指向链的某一部分,因为我见过人们永久存储30个子对象的指针,这样您就失去了有关对象在您正在使用的模式或架构中的布局的所有概念。
还有一个想法——我不确定是否喜欢它,但我见过一些人创建一个私有函数(为了您的健康)来导航链,这样您可以召回它而不必担心您的指针在幕后发生了什么变化,或者您是否具有空值。将所有的逻辑封装在一起可能很好,放一个漂亮的注释在函数顶部说明从哪里获取指针,然后直接在代码中使用函数结果而不是每次都使用委派链。
性能方面:
我的最后一个注释是,这种包装函数方法以及委派链方法都会遇到性能问题。如果您在循环中使用这些对象,则保存临时指针可使您避免额外的两个解除引用操作。同样,存储来自函数调用的指针将避免每次循环周期的额外函数调用开销。

顺便提一下,在C++11中,“constexpr”可以帮助编译器优化纯委托调用。 - Sergey K.
很酷,我得去了解一下,之前没听说过。我在过去的一年里一直在阅读有关C#的内容,所以还没有太多时间去学习C++11的东西。 - John Humphreys

3
长代理链对我来说有点设计上的问题。
代理链告诉我一段代码可以深度访问与其无关的另一段代码,这让我想到高耦合,这与SOLID设计原则相违背。
我对此的主要问题是可维护性。如果你需要到达两个层级,那么这就是两个独立的代码片段,可能会在你的代码中独立演化并被打破。当你在链中使用函数时,这个问题会快速扩大,因为它们可能包含自己的链 - 例如,Renderer->GetCanvas()可以根据其他对象层次结构的信息选择画布,很难强制执行不在代码基础生命周期内深入对象的代码路径。
更好的方式是创建一个遵循SOLID原则并使用依赖注入控制反转等技术来保证对象始终可以访问执行其职责所需的内容的架构。这种方法也非常适合自动化和单元测试。
只是我的个人看法。

在我们的情况下,“Renderer”是一个服务定位器,而Canvas是通过“依赖注入”实例化的。 - Sergey K.

3

对于bool Exists = Env->FileSystem->FileExists( "foo.txt" );,我更希望您提供更详细的代码拆分,因此在我理想的世界中,应有以下代码行:

Environment* env = GetEnv();
FileSystem* fs = env->FileSystem;
bool exists = fs->FileExists( "foo.txt" );

为什么要这样做呢?以下是一些原因:

  1. 易读性:对于bool Exists = Env->FileSystem->FileExists( "foo.txt" );这样的代码,我需要阅读到整行末尾才能理解其含义。这对我来说太长了。
  2. 可靠性:即使您提到了这些对象,如果您的公司明天雇用了一个新程序员并开始编写代码,那么这些对象可能就不存在了。这些过长的行对新手来说非常不友好,他们可能会感到惊吓,并尝试对它们进行优化......这将需要更有经验的程序员额外的时间来修复。
  3. 调试能力:如果应用程序在冗长的链中抛出分段错误,很难找出是哪个对象导致的。对于详细的代码,可以更容易地找到错误的位置。
  4. 速度:如果需要多次调用相同的链元素,则从链中“取出”一个局部变量可能比调用“适当”的getter函数更快。我不知道您的代码是否已投入生产使用,但它似乎缺少“适当”的getter函数,而只使用属性。

2
如果可能的话,我会使用引用而不是指针。这样委托就保证返回有效对象或抛出异常。
clCanvas & C = Env.Renderer().GetCanvas();

对于无法存在的对象,我将提供额外的方法,例如 has、is 等。

if ( Env.HasRenderer() ) clCanvas* C = Env.Renderer().GetCanvas();

所有对象都是动态分配的。异常被禁用(代码应在移动平台上运行)。 - Sergey K.

1

如果您能保证所有对象都存在,我认为您所做的事情并没有问题。正如其他人所提到的,即使您认为NULL永远不会发生,它也可能会发生。

话虽如此,我看到您在任何地方都使用裸指针。我建议您开始使用智能指针。当您使用->运算符时,智能指针通常会在指针为NULL时抛出异常,从而避免了SegFault。不仅如此,如果您使用智能指针,您可以保留副本,对象不会突然消失。在指针变为NULL之前,您必须显式重置每个智能指针。

话虽如此,这并不能防止->运算符偶尔抛出异常。

否则,我更愿意使用AProgrammer提出的方法。如果对象A需要指向对象B指向的对象C的指针,则对象A正在执行的工作可能是对象B实际应该执行的工作。因此,A可以保证始终拥有指向B的指针(因为它持有指向B的共享指针,因此它不会为NULL),因此它始终可以调用B上的函数,在对象C上执行操作Z。在函数Z中,B知道它是否始终拥有指向C的指针。这是B的实现的一部分。

请注意,使用C++11后您可以使用std::smart_ptr<>,所以请使用它!

是的,我们在某些地方使用侵入式智能指针,其中共享对象没有特定所有者和/或我们需要在线程之间传递所有权。但是,Renderer和Canvas是持久的(就像Env中的所有内容一样),并且是显式创建和销毁的。 - Sergey K.
侵入式指针也很好。实际上,smart_ptr<> 有一些缺点...现在我遇到了渲染器的问题,其中一个指针留在消息中,在删除渲染器对象后执行该消息。智能指针可以轻松解决这些问题。 - Alexis Wilke

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