方法链——为什么它是一个好的实践方法,或者不是?

190

方法链是一种对象方法返回自身的实践,以便结果可以被用于调用另一个方法。像这样:

participant.addSchedule(events[1]).addSchedule(events[2]).setStatus('attending').save()

这似乎被认为是一种良好的实践,因为它产生了易读的代码,或者称之为"流畅的接口"。然而,对我来说,它似乎打破了面向对象本身暗示的对象调用符号 - 生成的代码不代表对先前方法的结果执行操作,这通常是面向对象代码的预期行为:

participant.getSchedule('monday').saveTo('monnday.file')

这个差异成功地为 "调用结果对象" 的点符号创造了两种不同的含义:在链接上下文中,上面的例子会被解释为保存participant对象,尽管实际上该示例旨在保存由getSchedule收到的schedule对象。

我理解这里的差别在于被调用的方法是否应该返回某些内容(在这种情况下,它将返回用于链接的调用对象本身)。但是,这两种情况无法从符号本身区分出来,只能从所调用方法的语义中区分出来。当不使用方法链时,我总是可以知道方法调用操作与先前调用的结果相关的内容,而使用链接时,这种假设会破裂,我必须从语义上处理整个链以了解正在调用的实际对象。例如:

participant.attend(event).setNotifications('silent').getSocialStream('twitter').postStatus('Joining '+event.name).follow(event.getSocialId('twitter'))

在这里,最后两个方法调用是指 getSocialStream 的结果,而前面的方法调用则是指参与者。也许在实际编写代码时使用上下文更改的链式结构是一个不好的习惯(是吗?),但即使如此,您仍需要不断检查看起来相似的点链是否实际保持在同一上下文中,或者仅适用于结果。

对我来说,方法链表面上确实产生了可读的代码,但是过度使用点号表示法只会导致更多的混淆。由于我并不认为自己是编程大师,因此我假设错误出在我身上。所以:我错过了什么?我是否错误地理解了方法链接?有些情况下链式方法特别好吗?还有一些情况它特别糟糕吗?

附注:我明白这个问题可能被视为掩盖在问题之下的意见声明。然而,这并不是 - 我真诚地想了解为什么链接被认为是良好的实践,并且在哪些方面我的想法有误。


5
在另一个SO讨论中,有人说流畅接口是一个更大的概念,与代码可读性有关,而方法链接只是实现该目标的一种方式。虽然它们密切相关,但我已经添加了标签,并在文本中引用了流畅接口——我认为这就足够了。 - Ilari Kajaste
21
我来为您翻译这段内容:我的理解是,方法链实际上是一种通过语言语法来获取缺失特性的替代方法。如果语言中有一种内置的替代符号,可以忽略任何方法返回值,并始终使用相同对象调用任何链接的方法,则不需要使用方法链。 - Ilari Kajaste
这是一个很棒的编程习惯,但像所有伟大的工具一样,它也会被滥用。 - Martin Spamer
我不同意这个前提:“生成的代码不代表对先前方法的结果执行操作”。当然它是这样的。participant.AddSchedule(events[1]) 的结果是一个参与者,他刚刚将 events[1] 添加到他们的日程表中。 - Adrian McCarthy
1
你可能会发现我这篇博客文章相关:《Demeter法则并不意味着只使用一个点》(https://www.yegor256.com/2016/07/18/law-of-demeter.html) - yegor256
显示剩余6条评论
20个回答

4

方法链可能对大多数情况来说只是一种新奇的东西,但我认为它有它的用处。一个例子可以在CodeIgniter活动记录的使用中找到:

$this->db->select('something')->from('table')->where('id', $id);

我认为这比下面的代码更加清晰易懂:

$this->db->select('something');
$this->db->from('table');
$this->db->where('id', $id);

这是很主观的;每个人都有自己的看法。


这是一个具有方法链接的流畅接口示例,因此UseCase在这里略有不同。您不仅可以链接,还可以创建一个内部领域特定语言,易于阅读。顺便说一下,CI的ActiveRecord不是一个ActiveRecord。 - Gordon

4
我通常不喜欢使用方法链,因为我认为它会降低代码的可读性。紧凑性经常被误解为可读性,但它们并不是相同的概念。如果你把所有的操作都写在一条语句里,那么虽然紧凑,但往往比分成多条语句来得难以阅读(难以跟进)。正如你所注意到的,除非你能保证所使用的方法的返回值相同,否则方法链将会带来困惑。
participant
    .addSchedule(events[1])
    .addSchedule(events[2])
    .setStatus('attending')
    .save();

vs

participant.addSchedule(events[1]);
participant.addSchedule(events[2]);
participant.setStatus('attending');
participant.save()

2.)

participant
    .getSchedule('monday')
        .saveTo('monnday.file');

vs

mondaySchedule = participant.getSchedule('monday');
mondaySchedule.saveTo('monday.file');

3.)

participant
    .attend(event)
    .setNotifications('silent')
    .getSocialStream('twitter')
        .postStatus('Joining '+event.name)
        .follow(event.getSocialId('twitter'));

对比

participant.attend(event);
participant.setNotifications('silent')
twitter = participant.getSocialStream('twitter')
twitter.postStatus('Joining '+event.name)
twitter.follow(event.getSocialId('twitter'));

如您所见,您几乎没有获得任何东西,因为您必须添加换行符以使单个语句更易读,并且必须添加缩进以明确您正在讨论不同的对象。如果我想使用基于缩进的语言,那么我会学习Python而不是这样做,更不用说大多数IDE将通过自动格式化代码来删除缩进了。
我认为这种链接可以有用的唯一场所就是在CLI中管道传输流或在SQL中连接多个查询。但是对于多个语句,它们都需要付出代价。但是,如果您想解决复杂的问题,则最终将使用这些方法并编写多个语句的代码,使用变量编写bash脚本和存储过程或视图。
关于DRY的解释:“避免重复知识(而不是重复文本)。”和“输入较少,甚至不要重复文本。”第一个解释是原则真正的含义,但第二个解释是常见的误解,因为许多人无法理解“系统内每个知识片段必须具有单一、明确、权威的表述”。第二个解释是为了紧凑而牺牲可读性,这在这种情况下会更糟。第一个解释在DDD中会被打破,当您在限界上下文之间复制代码时,因为在这种情况下解耦更加重要。

4
我认为主要的谬误是普遍认为这是一种面向对象的方法,而实际上它更像是一种函数式编程方法。
我使用它的主要原因是为了可读性和防止我的代码被变量淹没。
当别人说它会损害可读性时,我并不真正理解。它是我使用过的最简洁和连贯的编程形式之一。
此外,这样使用:
convertTextToVoice.LoadText("source.txt").ConvertToVoice("destination.wav");
是我通常使用它的方式。我不会使用它来链接x个参数。如果我想在方法调用中放入x个参数,我会使用params语法: public void foo(params object[] items)
并根据类型进行对象转换,或者根据您的用例使用数据类型数组或集合。

2
“主要的谬论是普遍地认为这是一种面向对象的方法,而实际上它更多的是一种函数式编程方法”。其主要应用场景是针对无状态对象的处理(在这种情况下,不改变原始对象的状态,而是返回一个新的对象以继续操作)。原文提出者的问题和其他回答显示出有状态操作似乎与链式调用不协调。 - OmerB
是的,你说得对,这是一个无状态的操作,除非我通常不创建一个新对象,而是使用依赖注入来使其成为可用服务。而且,有状态的用例并不是我认为方法链接的初衷。我唯一能想到的例外情况是,如果你使用某些设置初始化 DI 服务,并且有某种看门狗来监视状态,就像某种 COM 服务一样。仅代表个人意见。 - Shane Thorndike

2

我同意,因此我改变了我的库中流畅接口的实现方式。

之前:

collection.orderBy("column").limit(10);

之后:

collection = collection.orderBy("column").limit(10);

在“before”实现中,函数修改了对象并以return this结尾。 我将实现更改为返回相同类型的新对象我做出此更改的原因是:
  1. 返回值与函数无关,仅用于支持链接部分,根据OOP原则,它应该是一个void函数。

  2. 系统库中的方法链接也是这样实现的(如linq或string):

    myText = myText.trim().toUpperCase();
    
  3. 原始对象保持不变,允许API用户决定如何处理它。它允许:

    page1 = collection.limit(10);
    page2 = collection.offset(10).limit(10);
    
  4. 在构建对象时也可以使用复制实现

    painting = canvas.withBackground('white').withPenSize(10);
    

    其中setBackground(color)函数更改实例并不返回任何值(正如它应该做的那样)

  5. 函数的行为更加可预测(见点1和2)。

  6. 使用短变量名还可以减少代码混乱,而不会强制执行模型上的API。

    var p = participant; // 创建引用
    p.addSchedule(events[1]);p.addSchedule(events[2]);p.setStatus('attending');p.save()
    

结论:
我认为,使用return this实现的流畅接口是错误的。


1
但是,每次调用都返回一个新实例会创建相当大的开销,特别是如果您正在使用较大的项目,至少对于非托管语言来说是如此。 - Apeiron
就性能而言, return this 管理或其他方式肯定更快。我认为它通过“不自然”的方式获得了流畅的 API。(添加的原因6:展示一个不流畅的替代方案,它没有额外的开销/功能) - Bob Fanger
我同意你的看法。通常最好不要改变DSL底层状态,而是在每个方法调用时返回新对象... 我很好奇:你提到的那个库是什么? - Lukas Eder

2

有见解的回答

链式编程最大的缺点是读者很难理解每个方法如何影响原始对象(如果它确实影响),以及每个方法返回的类型是什么。

一些问题:

  • 链中的方法是返回一个新对象还是改变同一个对象?
  • 链中的所有方法是否都返回相同的类型?
  • 如果不是,如何指示链中的类型何时更改?
  • 最后一个方法返回的值是否可以安全地丢弃?

在大多数语言中,调试链式编程确实更难。即使链中的每个步骤都在自己的行上(这有点违背了链式编程的初衷),检查每个步骤返回的值也可能很困难,特别是对于非变异方法。

编译时间可能会更慢,具体取决于语言和编译器,因为表达式可能更加复杂。

我认为,像任何东西一样,链式编程是一个好的解决方案,可以在某些情况下非常方便。它应该谨慎使用,理解其影响,并将链元素的数量限制在几个。


1
完全同意。同时,让方法返回原始对象似乎非常牵强,只是为了使语法更加简洁 - 在我看来,返回原始对象与方法的实际功能和责任无关。这是为了语法而硬塞返回值,以单一职责/关注点为代价。 - ahnbizcad

1

优点:

  1. 简洁,但可以优雅地在一行中放入更多内容。
  2. 有时可以避免使用变量,这可能偶尔会很有用。
  3. 性能可能更好。

缺点:

  1. 您正在实现返回值,实际上是向对象上的方法添加功能,而这些方法并不真正属于它们所要执行的操作的一部分。它返回了您已经拥有的内容,纯粹是为了节省几个字节。
  2. 当一个链条导致另一个链条时,它会隐藏上下文切换。使用getter可以获得此功能,但很明显上下文切换的情况比较清晰。
  3. 跨多行链接看起来很丑,无法很好地处理缩进,并且可能会引起一些运算符处理混淆(特别是在具有ASI的语言中)。
  4. 如果要开始返回其他有用的链接方法,请修复它可能会更加困难或遇到更多问题。
  5. 您正在将控制权转移到一个通常不会转移控制权的实体,仅出于方便考虑,即使在严格类型的语言中,由此引起的错误也不一定总是可以检测到。
  6. 性能可能更差。

总的来说:

一个好的方法是一般情况下不使用链接,直到出现特定模块或特定情况可以特别适用它。

链接在某些情况下会严重影响可读性,尤其是考虑到第1点和第2点的时候。
有时会被误用,例如代替其他方法(比如传递数组)或以奇怪的方式混合使用方法(parent.setSomething().getChild().setSomething().getParent().setSomething())。

0

Linq查询是方法链接的一个很好的例子,其中典型的查询看起来像下面这样:

lstObjects
  .Where(...)
  .Select(...)
  .OrderBy(...)
  .ThenBy(...)
  .ToList();

在我看来,这种方法非常直观,避免了不必要的临时变量来存储部分结果,而这些结果可能并不是我们真正感兴趣的。
还有一个更微妙的问题需要注意,那就是使用“ThenBy”扩展方法,该方法只能在调用“OrderBy”或“OrderByDescending”方法之后调用。这意味着在此处也维护了一个内部状态,该状态确定是否可以调用ThenBy。就像在这个查询中一样,有些情况下客户端应用程序可能不想将内部状态存储在临时变量中,而只对最终结果感兴趣。
因此,在编写库时,如果我们想提供一个API,以便按照特定顺序执行某个操作集,则允许方法链接会使库的使用更加直观。

0

嗯,我认为方法链的最重要优势是您不必重复调用同一对象来执行连续操作。您必须使用它。


0
这里完全错过了重点,方法链允许DRY。它是“with”(在某些语言中实现不佳)的有效替代品。
A.method1().method2().method3(); // one A

A.method1();
A.method2();
A.method3(); // repeating A 3 times

这很重要的原因与DRY(Don't Repeat Yourself)的原则相同;如果A被证明是一个错误,而这些操作需要在B上执行,你只需要在一个地方更新,而不是三个地方。

实际上,在这种情况下,这种优势很小。但仍然有一点少打字,更加健壮(DRY),我会接受它。


16
在源代码中重复变量名与DRY原则无关。DRY原则指出,“在系统内每个知识点必须有一个明确的、权威的表示”,或者换句话说,避免重复表达“知识”(而不是文本)。请注意,我已经尽力使翻译尽可能简洁易懂,但并未改变原文意思。 - pedromanoel
4
重复定义变量名(不必要地)会违反DRY原则,与其他形式的DRY一样会造成更多的依赖和工作。在上面的例子中,如果我们重命名A,则不符合DRY原则的版本需要进行3次更改,如果有任何一个被忽略,则会导致出错调试。 - Anfurny
1
我在这个例子中看不到你指出的问题,因为所有的方法调用都很接近。而且,如果你忘记在一行中更改变量的名称,编译器会返回一个错误,并在运行程序之前进行纠正。此外,变量的名称仅限于其声明的范围。除非这个变量是全局的,这已经是一种不好的编程实践了。在我看来,DRY 不是关于少打字,而是关于保持事物隔离。 - pedromanoel
“编译器”可能会返回一个错误,或者你正在使用PHP或JS,当你遇到这种情况时,它们的解释器可能只会在运行时发出错误。 - Anfurny

0
在静态类型语言中(缺少auto或等效功能),这可以避免实现者必须声明中间结果的类型。
import Participant
import Schedule

Participant participant = new Participant()
... snip...
Schedule s = participant.getSchedule(blah)
s.saveTo(filename)

对于更长的链,您可能需要处理几种不同的中间类型,您需要声明每个类型。

我相信这种方法确实是在Java中发展起来的,其中a)所有函数调用都是成员函数调用,b)需要显式类型。当然,在某些情况下,这里存在一个折衷,即丧失一些明确性,但在某些情况下,有些人认为这是值得的。


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