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

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个回答

96

我同意这是主观的。大多数时候,我避免方法链式编程,但最近我也发现了一种情况,它恰好是正确的做法——我有一个接受大约10个参数的方法,并且需要更多,但大多数时候只需要指定几个。使用重载很快变得非常麻烦。相反,我选择了链式编程方法:

MyObject.Start()
    .SpecifySomeParameter(asdasd)
    .SpecifySomeOtherParameter(asdasd)
    .Execute();

方法链接的应用是可选的,但它可以让编写代码更加容易(尤其是在 IntelliSense 的帮助下)。不过需要注意的是,这只是个例,并不是我代码中的通常做法。

重点是,在99%的情况下,你可能可以不使用方法链接并且完成同样好甚至更好的效果。但在1%的情况下,这可能是最佳方法。


6
在这种情况下,使用方法链接的最佳方式是创建一个参数对象并将其传递给函数。例如:P = MyObject.GetParamsObj().SomeParameter(asdasd).SomeOtherParameter(asdasd); Obj = MyObject.Start(); MyObject.Execute(P);。这样做的好处是可以在其他调用中重复使用该参数对象,非常方便! - pedromanoel
26
关于设计模式,我想提供一些观点。工厂方法通常只有一个创建点,产品是基于工厂方法的参数做出静态选择。链式创建更像是建造者模式,其中调用不同的方法以获取结果,这些方法可以选择性地链接在一起,就像 方法链 中的示例 PizzaBuilder.AddSauce().AddDough().AddTopping(),更多资料请参考 这里 - Marco Medrano
3
当方法链接(如原问题中的chown)违反了“德米特法则”时,被认为是不好的。参见:http://ifacethoughts.net/2006/03/07/the-law-of-demeter-and-object-oriented-programming/ 实际上,这里给出的答案遵循了该法则,因为它是一个"builder模式"。 - Angel O'Sphere
5
@Marco Medrano,自从我很久以前在JavaWorld上读到PizzaBuilder示例以来,它就一直困扰着我。我觉得应该向我的披萨中添加酱料,而不是向我的厨师中添加酱料。 - Breandán Dalton
1
@Marco Medrano 是的,Marco,就是这样。AddXXX 这个部分让我感到困扰。它已经对我有了不同的语义意义(即将 XXX 添加到点左侧的对象)。我正在考虑如何改进它,我会将 AddSauce 等更改为 UsingSauce 等(或 WithXXX),这样它会更自然地读起来(对我来说),如 PizzaBuilder.UsingDough().UsingSauce().UsingTopping().MakePizza(); - Breandán Dalton
显示剩余8条评论

96

只是我的个人看法:

方法链使得调试很棘手: - 你无法将断点放在一个简洁的位置,以便在想要暂停程序的地方精确地暂停它 - 如果这些方法中的一个抛出异常,并且你获得了行号,你不知道“链”中哪个方法引起了问题。

我认为总体上写非常短和简洁的代码是好的实践。每一行都应该只调用一个方法,比起较长的一行更喜欢使用更多的行。

编辑:评论提到方法链和分行是不同的。这是真的。但是,根据调试器,可能可以或不可以在语句中间设置断点。即使您可以,使用中间变量的分离行提供了更大的灵活性和一堆值,可以在监视窗口中检查,这可以帮助调试过程。


6
使用换行和方法链是否是独立的?根据@Vilx的答案,您可以在每个调用上使用换行符进行链接,并且通常可以在同一行上放置多个单独的语句(例如,在Java中使用分号)。 - brabster
2
这个回复是完全正确的,但它只是显示了我所知道的所有调试器中存在的一个弱点,并不特别与问题相关。 - masterxilo
5
+1,在进行方法链调试时,你永远不知道每个方法返回了什么。方法链模式是一个“hack”。它是维护程序员的噩梦。 - Lee Kowalkowski
3
我并不是很喜欢使用调试器,但为什么不直接在方法定义的内部设置断点呢? - Pankaj
3
这是唯一正确的答案,也应该被采纳为答案。此帖中大多数其他回答仅关注编写代码的过程。在处理软件时,您必须考虑整个产品生命周期。一个公司将会花费更多的时间来维护代码,而非实际编写代码,以确保产品的寿命。 - Shane
显示剩余5条评论

51

就我个人而言,我更喜欢链接那些仅作用于原始对象的方法,例如设置多个属性或调用实用程序类型的方法。

foo.setHeight(100).setWidth(50).setColor('#ffffff');
foo.moveTo(100,100).highlight();

在我的示例中,如果一条或多条链式方法返回的对象不是foo,则我不使用它。尽管在语法上你可以链接任何东西,只要你在链中使用正确的API,但是改变对象会使事情不够可读,并且如果不同对象的API有任何相似之处,这可能会非常令人困惑。如果您在末尾执行某些非常常见的方法调用(.toString().print()等),则您最终执行操作的是哪个对象?浏览代码的人可能无法注意到它将是隐式返回的链中的对象,而不是原始引用。

链接不同的对象还可能导致意外的空指针错误。在我的示例中,假设foo是有效的,所有方法调用都是“安全”的(例如,对于foo有效)。在OP的示例中:

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

没有保证(作为查看代码的外部开发人员),getSchedule实际上会返回一个有效的非空日程表对象。此外,调试这种代码风格通常要困难得多,因为许多IDE在调试时不会将方法调用评估为您可以检查的对象。在我看来,每当您需要检查对象以进行调试时,我更喜欢将其放在显式变量中。


如果有可能一个“参与者”没有有效的“日程安排”,那么“getSchedule”方法被设计为返回一个“Maybe(of Schedule)”类型,而“saveTo”方法被设计为接受一个“Maybe”类型。 - Lightman

29

Martin Fowler在这里进行了很好的讨论:

方法链

何时使用它

方法链可以大大增加内部DSL的可读性, 因此在某些人看来,它已经成为内部DSL的代名词。 然而,当它与其他函数组合一起使用时,最好使用方法链。

方法链特别适用于类似parent :: =(this | that)*的语法。 使用不同的方法提供了一种可读的方式来查看接下来的参数。 同样,使用方法链可以轻松跳过可选参数。 像parent::= first second这样的必填项列表基本形式工作效果不佳, 尽管可以通过使用渐进式界面来很好地支持它。对于这种情况, 我大多数时候会更喜欢嵌套函数。

方法链最大的问题是完成问题。 虽然有解决方法,但通常如果你遇到这个问题,最好使用嵌套函数。 如果您要与上下文变量混乱,则嵌套函数也是更好的选择。


DSL是什么意思?领域特定语言。 - Soren
@Sören:Fowler 指的是领域特定语言。 - Dirk Vollmar

27

在我看来,方法链有点新奇。当然,它看起来很酷,但我没有看到任何实际优势。

这样如何:

someList.addObject("str1").addObject("str2").addObject("str3")

有比这更好的解决方法吗:

someList.addObject("str1")
someList.addObject("str2")
someList.addObject("str3")

当 addObject() 返回一个新对象时,异常情况可能是未链接的代码会变得有些繁琐:

someList = someList.addObject("str1")
someList = someList.addObject("str2")
someList = someList.addObject("str3")

编辑:我对此的看法在过去的10年中已经发生了变化。对于可变对象,我仍然没有看到很多好处,尽管它有助于避免一些重复。但是现在我更加倾向于不可变性,方法链接是我的首选方式进行非破坏性更新,我一直在使用。


11
第一个例子中,你避免了两个“someList”部分,因此更为简洁,只需一行即可完成。现在它是好还是坏取决于不同的因素,或许是个人品味问题。 - Fabian Steeg
33
只有一个'someList'的真正优点是,它更容易给它取一个更长、更具描述性的名称。每当一个名称需要在短时间内多次出现时,人们往往会将其缩短(以减少重复并提高可读性),这使得名称不够描述性,从而降低了可读性。 - Chris Dodd
关于可读性和DRY原则的好处,有一个要注意的地方,那就是方法链会阻止对给定方法返回值的内省,并且暗示每个链中的方法都具有空列表/集合和非空值的假设(这种谬误经常导致我需要调试/修复的系统中出现许多NPE)。 - Darrell Teague
1
@inf3rno:自动完成如何提高可读性?如果有什么影响,那就是相反的——它使得编写冗长、难以理解的混乱代码变得容易,只有计算机才能理解。 - Chris Dodd
1
“人脑非常擅长识别具有相同缩进的文本重复”——这正是重复的问题所在:它会导致大脑专注于重复的模式。因此,为了阅读和理解代码,您必须强迫自己的大脑超越/背后的重复模式,看到真正发生的事情,即重复模式之间的差异,而这些差异往往被您的大脑忽略。这就是为什么重复对于代码可读性如此不利的原因。 - Chris Dodd
显示剩余5条评论

8

方法链可以直接在Java中设计高级DSL。本质上,您可以模拟至少这些类型的DSL规则:

1. SINGLE-WORD
2. PARAMETERISED-WORD parameter
3. WORD1 [ OPTIONAL-WORD]
4. WORD2 { WORD-CHOICE-A | WORD-CHOICE-B }
5. WORD3 [ , WORD3 ... ]

这些规则可以使用这些接口来实现。
// Initial interface, entry point of the DSL
interface Start {
  End singleWord();
  End parameterisedWord(String parameter);
  Intermediate1 word1();
  Intermediate2 word2();
  Intermediate3 word3();
}

// Terminating interface, might also contain methods like execute();
interface End {}

// Intermediate DSL "step" extending the interface that is returned
// by optionalWord(), to make that method "optional"
interface Intermediate1 extends End {
  End optionalWord();
}

// Intermediate DSL "step" providing several choices (similar to Start)
interface Intermediate2 {
  End wordChoiceA();
  End wordChoiceB();
}

// Intermediate interface returning itself on word3(), in order to allow for
// repetitions. Repetitions can be ended any time because this interface
// extends End
interface Intermediate3 extends End {
  Intermediate3 word3();
}

通过这些简单的规则,您可以直接在Java中实现复杂的DSL,例如jOOQ所做的那样。jOOQ是我创建的一个库。在这里查看一个相当复杂的SQL示例,取自我的博客:

create().select(
    r1.ROUTINE_NAME,
    r1.SPECIFIC_NAME,
    decode()
        .when(exists(create()
            .selectOne()
            .from(PARAMETERS)
            .where(PARAMETERS.SPECIFIC_SCHEMA.equal(r1.SPECIFIC_SCHEMA))
            .and(PARAMETERS.SPECIFIC_NAME.equal(r1.SPECIFIC_NAME))
            .and(upper(PARAMETERS.PARAMETER_MODE).notEqual("IN"))),
                val("void"))
        .otherwise(r1.DATA_TYPE).as("data_type"),
    r1.NUMERIC_PRECISION,
    r1.NUMERIC_SCALE,
    r1.TYPE_UDT_NAME,
    decode().when(
    exists(
        create().selectOne()
            .from(r2)
            .where(r2.ROUTINE_SCHEMA.equal(getSchemaName()))
            .and(r2.ROUTINE_NAME.equal(r1.ROUTINE_NAME))
            .and(r2.SPECIFIC_NAME.notEqual(r1.SPECIFIC_NAME))),
        create().select(count())
            .from(r2)
            .where(r2.ROUTINE_SCHEMA.equal(getSchemaName()))
            .and(r2.ROUTINE_NAME.equal(r1.ROUTINE_NAME))
            .and(r2.SPECIFIC_NAME.lessOrEqual(r1.SPECIFIC_NAME)).asField())
    .as("overload"))
.from(r1)
.where(r1.ROUTINE_SCHEMA.equal(getSchemaName()))
.orderBy(r1.ROUTINE_NAME.asc())
.fetch()

另一个不错的例子是 jRTF,它是一个小型的DSL,专门用于在Java中直接创建RTF文档。以下是一个示例:
rtf()
  .header(
    color( 0xff, 0, 0 ).at( 0 ),
    color( 0, 0xff, 0 ).at( 1 ),
    color( 0, 0, 0xff ).at( 2 ),
    font( "Calibri" ).at( 0 ) )
  .section(
        p( font( 1, "Second paragraph" ) ),
        p( color( 1, "green" ) )
  )
).out( out );

@user877329:是的,它可以在几乎任何支持接口和子类型多态性的面向对象编程语言中使用。 - Lukas Eder

8

这是危险的,因为您可能会依赖于比预期更多的对象,比如当您的调用返回另一个类的实例时:

我将举一个例子:

foodStore 是由您拥有的许多食品店组成的对象。 foodstore.getLocalStore() 返回一个保存最接近参数店铺信息的对象。 getPriceforProduct(anything) 是该对象的一个方法。

所以当您调用 foodStore.getLocalStore(parameters).getPriceforProduct(anything) 时,

您不仅依赖于 FoodStore,还依赖于 LocalStore。

如果 getPriceforProduct(anything) 发生变化,您需要更改的不仅是 FoodStore,还有调用链接方法的类。

您应该始终以类之间的松耦合为目标。

话虽如此,但我个人在编写 Ruby 代码时喜欢链式调用它们。


8

很多人使用方法链作为一种方便的形式,而不是考虑可读性问题。如果方法链涉及在同一对象上执行相同的操作,则是可以接受的 - 但仅当它实际上增强可读性,而不只是为了编写更少的代码。

不幸的是,很多人像问题中给出的示例那样使用方法链。尽管它们仍然可读,但它们不幸地导致多个类之间的高耦合,因此并不理想。


7

链接的好处
即,我喜欢使用它的地方

链式编程的一个好处是,在变量初始化时使用它的能力,或者在向方法传递新对象时使用它,不确定这是否是不良实践。

我知道这是一个人为的例子,但假设你有以下类:

Public Class Location
   Private _x As Integer = 15
   Private _y As Integer = 421513

   Public Function X() As Integer
      Return _x
   End Function
   Public Function X(ByVal value As Integer) As Location
      _x = value
      Return Me
   End Function

   Public Function Y() As Integer
      Return _y
   End Function
   Public Function Y(ByVal value As Integer) As Location
      _y = value
      Return Me
   End Function

   Public Overrides Function toString() As String
      Return String.Format("{0},{1}", _x, _y)
   End Function
End Class

Public Class HomeLocation
   Inherits Location

   Public Overrides Function toString() As String
      Return String.Format("Home Is at: {0},{1}", X(), Y())
   End Function
End Class

假设您没有访问基类,或者默认值是基于时间等动态的。是的,您可以实例化它们,然后更改值,但这可能变得很麻烦,特别是如果您只是将值传递给方法:

  Dim loc As New HomeLocation()
  loc.X(1337)
  PrintLocation(loc)

但是这不是更易于阅读吗:

  PrintLocation(New HomeLocation().X(1337))

或者,一个类成员怎么样?
Public Class Dummy
   Private _locA As New Location()
   Public Sub New()
      _locA.X(1337)
   End Sub
End Class

vs

Public Class Dummy
   Private _locC As Location = New Location().X(1337)
End Class

这是我一直在使用链式编程的方式,通常我的方法只用于配置,所以它们只有两行长,设置一个值,然后返回 Me。对我们来说,这已经将非常难以阅读和理解的代码大幅简化成了一行,就像一句话。

New Dealer.CarPicker().Subaru.WRX.SixSpeed.TurboCharged.BlueExterior.GrayInterior.Leather.HeatedSeats

与...类似的Vs

New Dealer.CarPicker(Dealer.CarPicker.Makes.Subaru
                   , Dealer.CarPicker.Models.WRX
                   , Dealer.CarPicker.Transmissions.SixSpeed
                   , Dealer.CarPicker.Engine.Options.TurboCharged
                   , Dealer.CarPicker.Exterior.Color.Blue
                   , Dealer.CarPicker.Interior.Color.Gray
                   , Dealer.CarPicker.Interior.Options.Leather
                   , Dealer.CarPicker.Interior.Seats.Heated)

链式编程的缺点
即我不喜欢使用它的地方

当需要传递大量参数时,我不使用链式编程,主要是因为行会变得非常长,正如OP所提到的,当你调用其他类的例程来传递给链式方法时,它可能会变得混乱。

还有一个问题是例程可能返回无效数据,因此到目前为止,我只在返回调用的同一实例时使用链式编程。正如指出的那样,如果在类之间进行链接,将使调试更加困难(哪个返回了null?),并且可能增加类之间的依赖关系耦合。

结论

像生活和编程中的所有事情一样,链式编程既不好也不坏,如果您可以避免坏的东西,那么链式编程可以带来巨大的好处。

我尝试遵循以下规则:

  1. 尽量不要在类之间进行链接
  2. 专门为链接创建例程
  3. 在链接例程中只做一件事情
  4. 在提高可读性时使用它
  5. 在简化代码时使用它

6

这似乎有点主观。

在我看来,方法链并不是本质上好或坏的东西。

可读性是最重要的。

(另外考虑到如果某些东西发生了变化,拥有大量链接方法将使事情非常脆弱)


这可能确实是主观的,因此有了主观标签。我希望答案能够向我展示在哪些情况下方法链会是一个好主意 - 目前我并没有看到很多,但我认为这只是我没有理解这个概念的好处,而不是方法链本身固有的问题。 - Ilari Kajaste
如果导致高耦合,那不是本质上的坏事吗?将链条分解为单独的语句并不会降低可读性。 - aberrant80
1
取决于你想要多么教条。如果这样做能使结果更可读,那么在许多情况下这可能更可取。我对这种方法的大问题是,对象上的大多数方法将返回对对象本身的引用,但通常该方法将返回对子对象的引用,您可以在其上链接更多方法。一旦你开始这样做,那么另一个编码器很难解开正在发生的事情。此外,任何方法功能的更改都将是在大型复合语句中调试的痛苦。 - John Nicholas

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