实战组合式设计

需求

leetcode 上有一道关于强密码校验器的练习题,如下所述:

一个强密码应满足以下所有条件:

  • 由至少6个,至多20个字符组成。
  • 至少包含一个小写字母,一个大写字母,和一个数字。
  • 同一字符不能连续出现三次 (比如 "...aaa..." 是不允许的, 但是 "...aa...a..." 是可以的)。

编写函数 strongPasswordChecker(s),s 代表输入字符串,如果 s 已经符合强密码条件,则返回0;否则返回要将 s 修改为满足强密码条件的字符串所需要进行修改的最小步数(插入、删除、替换任一字符都算作一次修改)。

需求分析

需求中有两个要点:

  • 强密码考虑了三个维度:长度、连续性、字符种类
  • 允许使用三种修改方法:插入、删除、替换

我们下面使用表格来分析一下这几种修改方法对这几个纬度的影响:

修改方法 长度 字符种类 连续性
插入 +1 +1 (if < 3) 连续性长度-2
删除 -1 不影响 连续性长度-1
(连续性长度%3的值越小优先级越高)
替换 不影响 +1 (if < 3) 连续性长度-3

说明如下:

  • 修改长度:对长度的修改是确定的(长了就删除,短了就插入),但是要注意插入和删除也会修改连续性和字符种类,所以要先让密码满足长度的要求,然后使用替换来改进连续性和字符种类
  • 修改字符种类:在修改长度和修改连续性后,如果字符种类还不是3,则应该通过替换增加字符种类
  • 修改连续性:对于插入来说,密码长度肯定小于6,那么只可能有一个连续性字符子串(3<=子串长度<=5),而插入一个字符会使得该子串长度减2,这时要么已没有连续性字符子串,要么还有一个长度为3的连续性字符子串(再做一次替换即可);对于删除来说,密码长度肯定大于20,那么连续性字符子串可能有多个,这时应该选择子串长度模3的值最小的子串,然后将该子串长度减1

软件设计

组合式设计概述

七巧板,是大家熟知的一种源自中国的古老智力游戏:

七巧板.png

由这么七块简单的小素材,可以拼出变化无穷的图案,受限的只是你的想象力:


灵活的七巧板组合形状.png

这个简单的游戏,蕴含这一种极具价值的设计思想:组合

基本语义规则都是小素材,基本语义规则通过组合形成新的对用户有价值的语义规则就是图案。对于强密码校验器这个需求来说,要实践组合式设计,首先要拆出小素材,然后再将小素材层层组合成强密码校验器图案。其实,软件设计就是一门研究系统“如何分”和“怎么合”的学问,目的是为了在让软件在长期范围内容易应对变化,而组合式设计就是将系统正交拆分且低成本组合的最佳软件设计实践。

于是,问题来了,系统究竟该如何分?在回答这个问题之前,我们先看看什么是好的软件设计。

我们在软件开发中,经常随口就说简单设计,但简单设计不是一句语义模糊的无法评判的空洞口号,而是具有明确的衡量标尺。
关于这个标尺的定义,Kent Beck 给出了清晰的答案:

  • 通过所有测试(Passes all tests)
  • 尽可能消除重复 (Minimizes duplication)
  • 尽可能清晰表达 (Maximizes clarity)
  • 更少代码元素 (Has fewer elements)

以上四个原则的重要程度依次降低。

这就是简单设计四原则,只有第四条强调简单,其他三条分别从需求、易修改性和可理解性方面对简单进行了约束:

  • 你不能为了简单而不去实现客户需求
  • 你不能为了简单而不去消除重复
  • 你不能为了简单而不注重清晰表达

固然前面三条的价值比简单这个价值更重要,但是简单也同样会约束前面三条:

  • 为了简单,不要实现客户暂时还用不到的需求
  • 为了简单,不要在变化方向未出现之前就对类进行额外的抽象
  • 为了简单,不要增加额外的代码元素,除非能增强表达力

我们在工作中应按照需求、易修改性、可理解性和复杂度这四个纬度的重要程度由高到底对任务进行排序。比如,我们重构时,先消除重复,然后再增强代码的表达力。

如果一个软件设计满足简单设计四原则的要求,我们就说这个软件设计是好的软件设计。

为了让简单设计四原则中的第二条(尽可能消除重复)的达成有章可循,袁英杰大师提出了正交设计四原则

  • 消除重复
  • 分离不同的变化方向
  • 缩小依赖范围
  • 向着稳定的方向依赖

第一条是重复出现之后被动的消除重复,而后面三条是重复出现之前的主动预防策略。同时,前面两条是为了驱动模块内向高内聚方向演进,后面两条是为了驱动模块间向低耦合方向演进。

于是,我们给出组合式设计的定义:应用正交设计四原则,将系统分解成很多单一职责的小类或函数(函数式),然后再将它们根据需要而灵活的组合起来

对于组合手段来说,最常见且通用的手段是依赖注入,其他手段与编程语言相关,比如 C++ 可以使用多重继承来实现组合。

语义模型

我们对强密码校验器这个需求的通用语言进行一下梳理:

  1. 强密码考虑了三个纬度:长度记作 len,连续性记作 cont,字符种类记作 types;
  2. 允许使用三种修改方法:插入记作 insert,删除记作 delete,替换记作 replace;
  3. 原子操作记作 atom,需求中有四个原子操作,分别为密码长度小于 6 的操作 len_less_than_atom, 密码长度大于 20 的操作,len_more_than_atom, 字符种类小于 3 的操作 types_atom,同一个字符连续出现 3 次的操作 cont_atom
  4. 每个原子操作 atom 包含两部分,即匹配器和执行器二元组,记作(matcher, action),那么针对四个原子操作,就有(len_less_than_matcher, insert_action)、(len_more_than_matcher, delete_action)、 (types_matcher, replace_action) 和 (cont_matcher, replace_action)
  5. 需求中有多个规则 rule,atom 是最基本的 rule,repeat(多次执行) 是修饰语义的 rule,allof (“与”的关系)和 anyof(“或”的关系)是组合语义的 rule,rule 通过组合可以产生新 rule

我们使用统一语言形式化表达一下需求:

password = newPassword(s)
len_less_than_atom_rule = atom(len_less_than_matcher, insert_action)
len_more_than_atom_rule = atom(len_more_than_matcher, delete_action)
types_atom_rule = atom(types_matcher, replace_action)
cont_atom_rule = atom(cont_matcher, replace_action)

len_rule = anyof(repeat(len_less_than_atom_rule), repeat(len_more_than_atom_rule))
types_rule = repeat(types_atom_rule)
cont_rule = repeat(cont_atom_rule)

rule = allof(len_rule, types_rule, cont_rule)
steps = rule(password)

从上面的形式化描述,可以很容易的得到强密码校验器的语义模型:

rule: Password -> int
matcher: Password -> bool
action: Password -> int

rule 的几种形态:

rule: atom | repeat | allof | anyof

我们用类图表达一下强密码校验器的组合式设计:


rule.png

matcher-action.png

我们再用表格来表达一下 Rule 的分类:

分类 Rule
原子语义 LenLessThanAtom
LenMoreThanAtom
TypesAtom
ContAtom
修饰语义 Repeat
组合语义 AnyOf
AllOf

软件实现

《聊聊编程范式》一文中,我们梳阐述了常见的三种编程范式(结构化编程、面向对象编程和函数式)的基本设计和架构风格,梳理了编程范式之间的关系。

从结构化编程到面向对象编程,再到函数式编程,离图灵机模型越来越远,但抽象程度越来越高,与领域问题的距离越来越近。

对于组合式设计的实现,我们既可以选择面向对象编程,也可以选择函数式编程。函数式编程更抽象一些,但代码更简洁;面向对象编程部分代码稍显繁杂,但相对来说易于理解。本文选择以函数式编程为例来实践组合式设计,读者可以自行使用面向对象编程来尝试一下,大同小异。

API 实现

需求中给出了 API 的定义:

strongPasswordChecker(s)

Golang 代码的实现如下:与统一语言的形式化表达基本一样

func StrongPasswordChecker(s string) int {
    password := newPassword(s)

    len_less_than_atom_rule := atom(len_less_than_matcher, insert_action)
    len_more_than_atom_rule := atom(len_more_than_matcher, delete_action)
    types_atom_rule := atom(types_matcher, replace_action)
    cont_atom_rule := atom(cont_matcher, replace_action)

    len_rule := anyof(repeat(len_less_than_atom_rule), repeat(len_more_than_atom_rule))
    types_rule := repeat(types_atom_rule)
    cont_rule := repeat(cont_atom_rule)

    rule := allof(len_rule, types_rule, cont_rule)
    steps := rule(password)
    return steps
}

函数式设计的基本方法为:借助闭包的单一接口的标准化和高阶函数的可组合性,通过规则串联设计,完成数据从源到结果的映射描述。这里的映射是通过多个高阶函数的形式化组合完成,描述就像写数学公式一样放在那,等源数据从一头传入,然后经过层层函数公式的处理,最后变成你想要的结果。数据在形式化转移的过程中,不仅仅包括数据本身,还包括规则的创建、返回和传递。

数据(对象)实现

我们下面看看数据本身 Password 的设计,该数据的表现形式是一个对象。

Password 数据结构

type Password struct {
    initialStr   string
    Len          int
    Steps        int
    TypesNum     int
    hasDigital   bool
    hasLowerCase bool
    hasUpperCase bool
    contNumbers []*ContNumber
}

type ContNumber struct {
    initialChar  byte
    initialIndex int
    times        int
}

Password 结构中包括了密码字符串的原始信息、预处理信息和改造过程中的信息。密码中可能包括多个连续性字符组成的数,我们通过 []*ContNumber 来表示。

Password 对象的构造

func newPassword(s string) *Password {
    pwd := &Password{initialStr: s, Len: len(s), Steps: 0, TypesNum: 0,
        contNumbers: make([]*ContNumber, 0)}
    typesInit(pwd)
    contInit(pwd)
    return pwd
}

说明:共有三步

  • 构造 Password 数据,记录密码字符串 s 的初始值和长度
  • 遍历密码字符串,初始化字符种类
  • 遍历密码字符串,初始化连续性数的数组

Password 对象的方法

下面这些方法都是在 TDD 的实践过程中,为了达成正交设计四原则的目标,通过 extract 重构手法逐步提炼出来的:

func (p *Password) increaseLen() {
    p.Len++
}

func (p *Password) decreaseLen() {
    p.Len--
}

func (p *Password) increaseSteps() {
    p.Steps++
}

func (p *Password) increaseTypesNum() {
    if p.TypesNum < maxTypesNum {
        p.TypesNum++
    }
}

func (p *Password) consumeContNumber(quota int) {
    ...
}

func (p *Password) consumeContNumberByPrio() {
    ...
}

rule 的实现

这一部分是组合式设计的核心。

数据在形式化转移过程中,源数据是 Password,层层函数的输入输出数据都是 Password ,同时目标数据也是 Password 的 Steps 字段,所以我们使用 Password 指针类型来表达数据类型。

rule 的抽象表达

我们知道,rule 包含两部分,即匹配器和执行器二元组:

type Matcher func(pwd *Password) bool
type Action func(pwd *Password) int
type Rule func(mather Matcher, action Action) func(pwd *Password) int

原子语义的实现

atom 的实现很简单,就是返回一个闭包:当匹配器满足时,就返回执行器的结果(steps)

func atom(matcher Matcher, action Action) func(pwd *Password) int {
    return func(pwd *Password) int {
        if matcher(pwd) {
            return action(pwd)
        }
        return 0
    }
}

原子语义的 rule 有四个,分别为 LenLessThanAtom、LenMoreThanAtom、TypesAtom 和 ContAtom,核心是设计它们的匹配器(4个)和执行器(3个)。

匹配器实现:

func len_less_than_matcher(pwd *Password) bool {
    if pwd.Len < minLen {
        return true
    }
    return false
}

func len_more_than_matcher(pwd *Password) bool {
    if pwd.Len > maxLen {
        return true
    }
    return false
}

func types_matcher(pwd *Password) bool {
    if pwd.TypesNum < 3 {
        return true
    }
    return false
}

func cont_matcher(pwd *Password) bool {
    if len(pwd.contNumbers) > 0 {
        return true
    }
    return false
}

执行器实现:

func insert_action(pwd *Password) int {
    pwd.increaseLen()
    pwd.increaseTypesNum()
    pwd.consumeContNumber(2)
    pwd.increaseSteps()
    return pwd.Steps
}

func delete_action(pwd *Password) int {
    pwd.decreaseLen()
    pwd.consumeContNumberByPrio()
    pwd.increaseSteps()
    return pwd.Steps
}

func replace_action(pwd *Password) int {
    pwd.increaseTypesNum()
    pwd.consumeContNumber(3)
    pwd.increaseSteps()
    return pwd.Steps
}

修饰语义的实现

修饰语义的 rule (新)只有一个,即 repeat,含义是重复执行被修饰的 rule(旧),直到符合预期:

func repeat(rule func(pwd *Password) int) func(pwd *Password) int {
    return func(pwd *Password) int {
        for ;; {
            ret :=  rule(pwd)
            if ret == 0 {
                return pwd.Steps
            }
        }
    }
}

组合语义的实现

组合语义的 rule 有两个,即 allof 和 anyof,分别表达“与”的关系和“或”的关系:

func allof(rules... func(pwd *Password) int) func(pwd *Password) int {
    return func(pwd *Password) int {
        steps := 0
        for _, rule := range rules {
            pwd.Steps = 0
            steps += rule(pwd)
        }
        return steps
    }

}

func anyof(rules... func(pwd *Password) int) func(pwd *Password) int {
    return func(pwd *Password) int {
        steps := 0
        for _, rule := range rules {
            steps += rule(pwd)
            if steps > 0 {
                return steps
            }
        }
        return 0
    }
}

小结

本文概述了组合式设计的要点,并通过实战强密码校验器的案例,向读者展示了组合式设计落地的主要过程和步骤,希望对读者有一定的收益。

如果说正交设计是软件设计的皇冠,那么组合式设计就是皇冠上的明珠。应用组合式设计产出的代码扩展性强且易于理解,你是否深有感触?如果你也曾写过或读过强密码校验器的实现代码,你都发现过哪些坏味道?欢迎大家留言探讨!

本文实战过程中的源码及 TDD 用例,作者都放到 github 了:https://github.com/agiledragon/strong-password,感兴趣的同学可以查阅。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268

推荐阅读更多精彩内容