美文网首页@IT·互联网
一种基于对象的设计模式——链式调用

一种基于对象的设计模式——链式调用

作者: 骆日海 | 来源:发表于2023-12-24 00:26 被阅读0次
  • 什么是链式调用?

链式调用,也被称为方法链命名参数法。从字面意思上来理解,即以类似于链条的形式连续地、多次地调用某一对象的多个方法(可以是同一个方法)。

从上述概念来理解,它似乎是一种编程风格,但以上提及的过程是发生在更偏向应用的层面。而在更偏向底层设计的层面,原生的语言并不支持我们以这样的方式去编写程序(大多数情况下)。在进行这种风格的程序设计之前,首先需要构造支持以链式调用方法的抽象类,因此,更准确地说,链式调用是一种基于对象的设计模式。

枯燥的概念确实让人容易摸不着头脑,那么接下来将以形象的方式带你领略链式调用的具体含义。

有这样一个场景:
在一个晴朗的早晨,我的同学Bob从床上醒来,他拿起床边的杯子喝水,然后去卫生间刷牙,之后他吃完早饭拿起书包,开开心心地去学校了。
假如将这个过程抽象成Python的表达,常用的设计模式如下:

'''
class Kid:
   def wake(self): ...
   def drink(self): ...
   def brush(self): ...
   def eat(self): ...
   def pick(self): ...
   def go(self): ...
'''
Bob = Kid()
Bob.wake()
Bob.drink()
Bob.brush()
Bob.eat()
Bob.pick()
Bob.go()

显然,这并不符合自然语言的风格,在大多数情况下我们不会去说:“Bob从床上醒来,Bob拿起床边的杯子喝水,然后Bob去卫生间刷牙......”
再回看上述代码,最前面的Bob是不是显得有些累赘了呢?

虽然这样的设计模式符合计算机语言的风格,但其实我们更希望,计算机能够以贴近自然语言的方式与我们交互,换句话说,我们期望以更贴近自然语言的风格编写程序。

而链式调用是能够提供实现这种编程风格的一种设计模式。比如我们可以这样去设计程序:

Bob = Kid()
Bob.wake().drink().brush().eat().pick().go()

在这一部分代码中,可以直观地看到Bob的五种方法通过.依次被调用,从形式上看起来像一条链子一样,这即是链式调用名称的来由。
在当前场景中,原本Bob的动作(方法)由单独依次地使用变为了连续的动作(方法)链。这是不是有点像我们最开始学汉语或者学英语时,老师教我们把“我走进教室,我坐在凳子上。” / "I walk into the classroom. I sit on the bench."改为“我走进教室坐在凳子上。” / "I walk into the classroom and sit on the bench."

为了便于理解,具体如何实现链式调用暂且先不具体说明,这部分内容将再后文再详细阐述。

现在再回过头去看链式调用的概念是不是立刻就清晰了。
同样地,由此也可以很清晰地感觉到这种设计模式在应用层面的简洁性,它省去了许多重复的Bob(我将其看作是类似于指针的东西),并且允许我们在一行之中完成所需的功能。在我看来,这是一种更加Pythonic的设计模式。

我本人并不推荐在一行内写完所有方法链。在实际业务场景中,大量的方法会导致过长的方法链,而当这些内容在一行中呈现时,则会大大降低代码的可读性。

同时,链式调用也有相当的可读性,不过这显然指的不是在一行内写完所有方法链的编程风格。当然,链式调用还有其他诸多优点,如组合性、拓展性等,这些都将在后文被介绍到。

  • 如何实现链式调用?

首先我想聊聊两年前我第一次接触链式调用时的情形,当时我正在jupyter环境中尝试去清洗我刚爬取出来的数据。有一组字符串类型的数据需要删去其中的一些标点符号,其中的每一个数据可能包含!?,.等符号中的一个或多个,或者不包含其中的任何一个,我并不确定具体有哪些符号需要删除。
我习惯于先取出一组同类数据中的一个,在另外一个code cell中先尝试着处理成我需要的格式,再将处理的过程批量执行于这组数据。当时在一个code cell中我先写下了res = s.replace('!', '')s是我从原始数据中取出的一个值,之后当我代回原始数据进行批处理时,我发现有一个数据还有?需要去除,于是我将s改为了需要去除?的那个数据,并且在尝试处理的code cell中我又加了一行res2 = res.replace('?', '')。同样的步骤,我又发现其他的标点,又修改s的值,又增加新的一行res{n+1} = res{n}.replace...

最后处理了七八个标点符号之后的cell大概长下面这样:

res = s.replace('!', '')
res2 = res.replace('?', '')
res3 = res2.replace(',', '')
 ...
res8 = res7.replace('.', '')

就在我已经厌倦了这烦躁的标点符号处理时,我突然从代码工整的对仗中得到了一丝灵感。
我突然间意识到,既然res相当于一个指针一样指向了s.replace('!', ''),那res2 = res.replace('?', '')可不可以直接写成res2 = s.replace('!', '').replace('?', '')呢?
说干就干,当我写下res2 = s.replace('!', '').中最后一个.时,代码提示工具蹦出来的replace几乎让我兴奋得叫出声来。
于是乎,这个cell的代码最终改成了这样的格式:

res = s.replace('!', '').replace('?', '').replace(',', ''). ... .replace('.', '')

运行,res与先前的res8的值完全相同。

当我之后再回想这个问题时,我发现str类型的replace方法返回的值是一个str类型的变量,它又可以调用本身的replace方法。
抽象一些地说,假如一个对象的方法,返回的是同一个类实例对象,那就可以链式地去使用这个方法。

同样地去考虑,当对象的方法返回的是对象本身时,也同样属于同一个类实例对象的范畴。
再多考虑一个维度,我们尝试增加方法的数量,不局限于一个方法。假如这个类的多个实例方法,都可以返回同一个类实例对象,或者返回对象本身时,就可以链式地去使用这些方法。

这即是链式调用设计模式的基本理念。

前面铺垫了诸多内容,此刻终于可以讲到具体如何去实现链式调用。
我们依然以文章最开头的Bob为例,下面是一种实现方式:

class Kid:
    def __init__(self, name: str) -> None:
        self.name = name

    def wake(self) -> self:
        print(f'{self.name} wakes up.')
        return self

    def drink(self, drinks: str) -> self:
        print(f'{self.name} drinks {drinks}.')
        return self

    def brush(self, sth: str) -> self:
        print(f'{self.name} brushes {sth}.')
        return self

    def eat(self, food: str) -> self:
        print(f'{self.name} eats {food}.')
        return self

    def pick(self, sth: str) -> self:
        print(f'{self.name} picks up the {sth}.')
        return self

    def go(self, position: str) -> self:
        print(f'{self.name} goes to {position}.')
        return self

通过这样构造类方法的方式,就可以允许我们以链式调用的形式调用方法。

Bob = Kid(name = 'Bob')
Bob.wake().drink('water').brush('his teeth').eat('a sandwich').eat('a boiled egg').pick('school bag').go('school')

输出结果如下:

>>>  Bob wakes up.
>>>  Bob drinks water.
>>>  Bob brushes his teeth.
>>>  Bob eats a sandwich.
>>>  Bob eats a boiled egg.
>>>  Bob picks up the school bag.
>>>  Bob goes to school.

我将这种构造方法的模式称为:“我返回我自己”

  • 以链式调用设计的第三方API

经过以上两个模块的阐述,我们已经清楚了什么是链式调用,以及如何实现链式调用。
在本模块中,我将以SeleniumPyecharts为例,简要阐述链式调用在实际应用层面的使用。
以下是两个应用的简单Demo。

Selenium

API: Actionchains

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.action_chains import ActionChains

driver = webdriver.Edge()

driver.get('https:\\www.bing.com')

driver.maximize_window()
driver.implicitly_wait(20)


acts = (
   ActionChains(driver)
       .pause(4)
       .move_to_element(
           driver.find_element(By.XPATH,'//*[@id="sb_form_q"]')
       )
       .click()
       .send_keys('how to use action chain in python')
       .click(
           driver.find_element(By.XPATH,'//*[@id="search_icon"]')
       )
       .perform()
)
Actionchains自动化执行过程
Pyecharts

API: Geo

#provinceLst为原始数据,感兴趣的话可以私信我

from pyecharts.charts import Geo
from pyecharts import options
from pyecharts.globals import ChartType, SymbolType

(
   Geo()
       .add_schema(
           maptype = "china",
           itemstyle_opts = options.ItemStyleOpts(
               color = "#F2F2F2",
               border_color = "#81F7F3"
           ),
           label_opts = options.LabelOpts(is_show = True)
       )
       .add(
           "",
           provinceLst,
           type_ = ChartType.LINES,
           effect_opts = options.EffectOpts(
               symbol = SymbolType.ARROW,
               symbol_size = 6,
               color = "blue"
           ),
           linestyle_opts = options.LineStyleOpts(curve = 0.2),
       )
       .set_series_opts(label_opts = options.LabelOpts(is_show = False))
       .set_global_opts(title_opts = options.TitleOpts(title = "轨迹图"))
       .render("footsteps.html")
)
footsteps.html

这两个demo只是用来举例说明,在这两个API中提供了这样的方法供我们使用,由于普通演示脚本不需要考虑太多其他因素,也不需要实现复杂的功能,在这两个API实际应用场景中的链式调用使用方式与demo中略有不同

  • 方法链调用规范

在这个模块中我们依然以前文的Bob场景举例。

在前文中也提到,当方法链过长时,在一行中完成所有方法的调用会大大降低代码的可读性,因此,我们需要尽量避免类似于下面这种写法。

#Bob = Kid(name = 'Bob')
Bob.drink('water').eat('a spicy taco').drink('water').eat('a spicy taco').drink('water').eat('a spicy taco').drink('water')

但我们知道,由于Python的语言特性,即以换行与缩进来区分代码块,我们无法直接将这一行代码直接拆分到多行。

以下提供两种进行格式化代码的方式,进而以提高程序的可读性。

① 续行符 \

Bob.wake()  \
     .drink('water')  \
     .brush('his teeth')  \
     .eat('a sandwich')  \
     .eat('a boiled egg')  \
     .pick('school bag')  \
     .go('school')

② 小括号 ()

(
     Bob
     .wake()
     .drink('water')
     .brush('his teeth')
     .eat('a sandwich')
     .eat('a boiled egg')
     .pick('school bag')
     .go('school')
)

从我个人的编程风格来讲,我更倾向于使用小括号的方案。

  • 在每一行都加入续行符会增加编程的繁琐度,也会一定程度上降低程序的可读性。
  • 对比以上二者,使用小括号的方案在缩进的选择上会更加自由(比如方法链整体可以再增加一个缩进,用以表示方法链属于某个具体实例对象),这也意味着它能够实现更加符合人的阅读习惯的格式化解决方案。

Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.

  • 链式调用的优越性与局限性

  • 优越性
    1.简洁性
            在方法链中无需重复地访问实例对象,相较于一般的设计模式,极大程度地减少了代码的冗余程度。
    2.可读性
            链式调用提供了一种“流式”的、更贴近自然语言的使用方式,符合人的阅读习惯。同时,每个链节点(单独的方法)均可以形成独立的语义整体,使得代码更易于理解。
    3.可组合性
            在方法链中,每个链节点都可以看作是对象自身或包含对象自身的容器,使得可以连续地添加能充当链节点的方法(即在构造时返回对象自身),进而组合成更复杂的功能整体。
    4.可拓展性
            在设计层面,只需要将方法的返回值构造为对象本身,就可以在方法链中使用该方法。
            在应用层面,相较于一般的设计模式,方法链添加、修改或移除操作更加灵活。由于每个方法都是在对象自身上操作,并返回对象自身或包含对象自身的容器,可以轻松地添加新的操作或修改现有的操作。
  • 局限性
    灰箱性
            在一般的设计模式中,我们可以清楚地看到整个代码的逻辑,并且易于加入调试点,用于随时查看在某个节点程序运行的情况。而在链式调用的设计模式中,虽然也可以看到整个代码运行的逻辑,但由于难以加入调试点,我们无法搞清楚逻辑内的数据、信息流究竟是怎么样的。
            这导致了以下两个问题:
    1.调试成本高
            当方法链中有一个错误发生时,虽然可以看到具体是哪一个方法在调用时产生了错误,但我们无法确定在这个方法调用前实例对象此时的状态。即,假如在第n个链节点产生了错误,那么在第n-1个链节点,我们并不确定经过了n-1个链节点后,实例对象此时被方法修改成了什么样子。
            而在一般的设计模式中,调用了n-1次方法后,依然有一个用以访问对象的入口(比如变量名),可以用于查看对象的状态。但在链式调用中,我们只能从第一个方法开始,逐步后推,直到找到错误发生的原因。
    2.难以获取中间变量
            整个方法链可以看作是一个整体,虽然它支持我们随意地组合和延伸链节点,但由于它在形式上省去了重复访问实例对象的过程,我们也无法从链外部获取到中间过程的方法返回值。
            而在一般的设计模式中,虽然在形式上重复地使用实例对象,但它支持我们以一个变量名指向方法的返回值,进而在全局访问到某一个步骤的具体返回值。

因此,虽然链式调用有着诸多的优点,在实际偏向底层的设计中,我们还是应该尽可能地避免过度地使用这种设计模式。

补充
  • 不要过度地使用链式调用不意味着少用,而是应该尽可能地多用。
  • 但这不意味着所有的API设计都应该以链式调用设计,而是应该考虑链式调用的优点与当前场景是否适配。
  • 这如同,过度地抽象必然会带来开发成本的增加以及一系列其他的问题,但不意味着我们在开发过程中不需要抽象,并且我们大多数场景下都是面向对象的。
  • 链式调用的局限性不是完全无法避免,只是相较于一般的设计模式在应用层的实现而言,它会更加繁琐、困难或者是会形成类似于一般设计模式的形式,而无法发挥其优越性。

我十分推崇以链式调用的形式设计API,但由于其局限性,在采取这种设计模式前,我通常至少会考虑以下三点:
1.数据流中是否会发生数据类型的变更(如果会发生,我不建议在同一个类中构造所有的链方法;而是应该设计成多个类,在实际使用时也写成多条链的形式;每一个类应该保证其数据类型统一,这是为了降低错误发生的可能性)。
2.数据流中的数据是否需要在外部被访问到(如果需要,那应该在需要被访问的数据存在的链节点处终止链,以上一条链为初始对象再开始链式调用)。
3.实际的场景中,是否需要连续多次地调用同一对象的多个方法。

相关文章

网友评论

    本文标题:一种基于对象的设计模式——链式调用

    本文链接:https://www.haomeiwen.com/subject/fcyfndtx.html