无知的 tonyseek

Yet Another Seeker

我对策略模式的理解

策略模式(Strategy Pattern)来自四人帮的《设计模式:可复用面向对象软件的基础》一书。先看维基百科的描述:

  • 维基中文站的词条“策略模式” [1]
  • WIKIPEDIA(EN) Strategy Pattern [2]

而我对它的理解,是“隔离出**有多种选择的部分**”,就如工具箱里的多用螺丝刀——一把螺丝刀可以装上十字头、一字头甚至是锥子头。

多用螺丝刀图片,来自维基共享资源,采用知识共享署名-相同方式共享 3.0 Unported 许可协议授权

这种“有多种选择”的情况,可以很容易举出例子。例如网站的数据库中,保存了每个用户的 first name 和 last name,也保存了中国人的姓和名。中国人是姓在前名在后,但是按照西方习惯,first name 是名,last name 是姓。这个不是问题,因为我们的网站只面向国内。

class User(object):
    """ 网站注册用户 """

    def __init__(self, first_name, last_name, **profile):
        self.first_name = first_name
        self.last_name = last_name
        self.profile = profile

    @property
    def screen_name(self):
        """ 屏幕显示的姓名 """
        # 姓在前, 名在后
        return self.last_name + self.first_name

# 视图层调用,只需要如下
signed_user = User("某某", "张")
print(signed_user.screen_name)

很快问题来了,我们要面向国际化了 —— 如果还按照原来的方式显示,就会把非中国人的 first name 和 last name 颠倒过来显示。我们可以修改一下代码解决这个问题。

@property
def screen_name(self):
    """ 屏幕显示的姓名 """
    # Locale 是个虚构出来的类, 用于获取当前区域
    if Locale.current == Locale.ZH_CN:
        # 姓在前, 名在后
        return self.last_name + self.first_name
    else:
        return "%s %s" % (self.first_name, self.last_name)

但是不得不说,这是一个尽管有效但是丑陋的做法。尽管现在看起来不丑陋,但是如果某个我们不熟悉国家以更加奇怪的方式组织名字呢?我们应该继续在判断中添加 elif ?还是用 switch ?(很高兴 Python 语法里没有 switch 结构)

其实现在问题有点像我们的多用螺丝刀头了,我们应该把控制名字显示方式的逻辑分离出来,这样无论出现多少种情况我们都能从容应对。

# 建立一个 screenname 模块

def chinese(first_name, last_name, mid_name):
    """ 中国组织名字的策略 """
    assert(type(last_name) is unicode)
    if len(last_name) > 1:
        # 对于复姓, 放置一个空格
        return u"%(last_name)s %(first_name)s" % locals()
    else:
        return last_name + first_name

def western_normal(first_name, last_name, mid_name):
    """ 西方一般名字策略 """
    if mid_name:
        return u"%(first_name)s %(mid_name)s %(last_name)s"
               % locals()
    else:
        return u"%(first_name)s %(last_name)s" % locals()

def western_normal(first_name, last_name, mid_name):
    """ 更 pythonic 一些的西方一般名字策略 """
    # 上面那个实在太罗嗦
    name = [first_name,mid_name,last_name]
    return " ".join(name)

def c_programmer(first_name, last_name, mid_name):
    """ C 程序员 """
    name = [first_name.lower(),
            last_name.lower(),
            mid_name.lower()]
    return "_".join(name)

def sage(first_name, last_name, mid_name):
    """ 圣人专用 Saint Peter """
    name = ["Saint",first_name,last_name,mid_name]
    return " ".join(name)

然后情况就简单多了:

import screenname

class User(object):
    """ 网站注册用户 """
    screenname_strategies = {}
    default_screenname_strategy = None

    @classmethod
    def add_screenname_strategy(cls, locale, formatter):
        cls.screenname_strategies[locale] = formatter

    def __init__(self, first_name, last_name, mid_name=""):
        self.first_name = first_name
        self.last_name = last_name
        self.mid_name = mid_name

    @property
    def screen_name(self):
        """ 屏幕显示的姓名 """
        strategies = self.screenname_strategies
        default = self.default_screenname_strategy

        strategy = strategies.get(Locale.current, default)
        return strategy(self.first_name, self.last_name,
                        self.mid_name)

User.default_screenname_strategy = screenname.chinese

L = Locale
User.add_screenname_strategy(L.ZH_CN, screenname.chinese)
User.add_screenname_strategy(L.EN_US, screenname.western_normal)
User.add_screenname_strategy(L.EN_UK, screenname.western_normal)
User.add_screenname_strategy(L.MARTIAN, screenname.c_programmer)

signed_user = User("某某", "张")
print(signed_user.screen_name)

可以看出,把策略分离出来管理之后,更加复杂的情况都能应对得很有序了。我们甚至能在上述基础上做更多动作 —— 现在我们是根据 Locale 来判断使用哪个策略的,我们还可以把判断依据都分离出来,提供一组 lambda 表达式作为判断依据,从参数传入。这样我们就能从更多维度去处理,而对 User 模型没有丝毫侵入。没有侵入模型意味着极低的耦合,这也是我们所追求的。

事实上,策略模式的大多数用武之地比这个例子要复杂的多。维基百科举出的就是一个和实际很贴近的例子:税率计算。但我想它们的本质是一样的,把有多种选择的部分分离出来,作为**可装卸**部件。这是设计模式中诸多组合用法的一种,和其他用到组合思想的模式相比,策略模式比较突出的特点在于,策略模式着重于分离算法,尤其是复杂算法。我们的 screen name 其实是一种非常简单的情况,所以我选择把函数作为策略对象。在一些规模更加大型的应用中,策略往往会非常复杂,开发者往往会选择定义若干个策略类,然后把策略类的实例作为策略对象。这也是大多数用 Java/C# 描述策略模式的例子中的做法。(事实上 Java 只能选择后者,而不能像 Python/Ruby 一样把函数/代码块作为策略对象,即使策略情景非常简单)

上述例子中,User 类作为不被入侵的模型,为策略对象提供了运行的上下文,我们一般称为 Context 。而 screenname 模块中的各个策略对象,才是我们的主角 Strategy 。策略模式的通用做法,就像多用螺丝刀一样,往 Context 上安装 Strategy,在不同的情景下使用不同的 Strategy 来工作。

剩下的问题,就是什么时候该使用策略模式了,或者称之为“使用的时机”。

大二的时候我初涉设计模式,恨不得把所有的模式搬出来用一用,就像学了新招式一定要试一试一样。甚至有时候会可笑到在注释中写上我用了什么模式。后来一位博客哥说了句“把设计模式的名字写出来,就像比武之前要把招式喊出来一样好笑”,我彻底石化。

初学者很容易犯这类可笑的错误,现在自己想起来都好笑。但问题是,已经不是初学者之后,应该如何把握模式使用的时机呢?

就拿策略模式而言,我个人的看法是:被动地使用。不要一开始设计的时候就假想某地方将来会多么多变,给它一个策略模式。这种做法是一个危险的信号,过度设计带来的混乱绝对不亚于设计不足。我认为应该在开始设计的时候先彻底忘掉策略模式,除非已经有领域经验,告诉我们此地一定会需要策略分离 —— 这种情况则会非常明显的在设计/建模的文档中指出。除此之外,我们应该等闻到一丝丝代码坏味道了才开始分离策略。就像上述的 name screen ,添加西方国家命名方式的时候,我开始有了混乱的感觉,开始担忧如果有更多命名方式怎么办。此时才在文档注释中标识将可能面临策略分离(而不是现在就动手)。当我的担忧开始变为现实,更多的命名模式真的开始到来了,这是我在文档注释中把“可能”二字删去,标识上 “<em>todo:</em>” ,然后动手开始分离策略。

不得不说,策略模式理解起来不难。其实其他设计模式也是如此。真正值得我们去思考的,是应该如何使用这些模式。教我们统一建模语言的陈昊老师和教软件需求课的尹剑飞老师都曾这样描述“模式”二字:模式是抽取可重用的经验。所以既然是从经验中抽取出来的而不是从定义推导出来的,我们就不应该像套公式一样去套模式。这些模式仅仅是提供我们一种思维方向,提供一种解决问题的思路,而不是给我们照套。正因为如此,程序员们才有了不会失业的保证 —— 如果写程序套公式是可行的套路,那么为何不用程序来自动生成程序呢。

本文的例子举的非常简单,实际场景中策略模式往往用在非常复杂的情况下,所以那些场景下的策略对象绝不会是一个小函数。这类例子,推荐下列文章:

  • jokes000 在博客园的博文 “设计模式--策略模式” [3]
  • justinw 在博客园的博文 “鸭子-策略模式(Strategy)” [4]
[1]维基百科的“策略模式”词条
[2]维基百科英文站的“Strategy
[3]jokes000 在博客园的博文 “设计模式--策略模式”
[4]justinw 在博客园的博文 “鸭子-策略模式(Strategy)”

Comments