面向对象 软件设计 - 响应变化(二)

admin · December 31, 2020 · 16 hits

上一篇中我们谈到在软件开发中使用演进式设计来让软件持续的响应变化。

演进式设计强调对不确定的变化不做提前预估,优先保持设计的“简单性”,避免过度设计。但是对于已经出现的变化也不能响应滞后,当新的变化方向上的“第一颗子弹”出现时,就立即依照“正交设计原则”的指导进行设计调整,并"及时重构"代码,让代码可以在该变化方向上具有弹性。

接下来,我们会深入谈谈代码中应对变化的具体技巧,以作为对演进式设计中缺乏的代码实践部分的补充。

变化分类

引起软件发生变化的原因各种各样。需求变更、技术演进或者性能优化等等,都可能会让软件发生变更。但是经过设计后,转化到既有代码里的变化,可以归纳为以下三类:

  • 数据变化
  • 行为变化
  • 类型变化

本篇我们从 “数据变化” 讲起。

数据变化

从一个简单的例子开始。

isPowerOverload是一个计算物理器件是否功率过载的函数。

它的参数是器件当前的电流(amps)值,算法是根据功率公式 “功率(watts) = 电流(amps) * 电压(volts) ” 算出当前的实际功率,然后和器件的额定功率(rated watts)进行对比。超过额定功率则返回true,表示功率过载。

bool isPowerOverload(U32 amps) {
    U32 volts = 20;
    U32 rated_watts = 240;
    U32 watts = volts * amps;
    return watts > rated_watts;
}

在上例中,器件的电压和额定功率是确定的,分别是20v240w。为了让示例清晰,这里分别用变量来表示不同含义的值。

现在假设发生了新的需求,出现一款新器件 B,它的额定功率是360w,固定电压是12v,算法一样。怎么设计实现?

没错,可以复用上面的代码。于是有人将上面的代码拷贝一份然后修改为:

bool isPowerOverloadForDeviceB(U32 amps) {
    const U32 volts = 12;
    const U32 rated_watts = 360;
    const watts = volts * amps;
    return watts > rated_watts;
}

于是我明白了,很多人理解的代码 “可复用性”,指的是既有系统里面有大量的可供拷贝修改的代码。遗憾的是,这种 “复用” 方式只会让我们的代码重复率越来越高,最后陷入难以维护的 “焦油坑”。

事实上,真正的可复用代码指得是:不对被复用的代码修改任何一行源码,直接就可以调用

当然,遵循 “简单设计” 的系统是不可能一上来就满足各种未曾出现的复用性要求的。但是遵循演进式设计,就是要让软件在响应变化的过程中,逐渐的提高可复用性。

回到这段代码,我们对比这两个函数,会发现发生变化的只是额定功率固定电压的数值,这属于我们说的第一类代码变化: 数据变化

而解决数据变化的方法也很简单,那就是 将变化的数据参数化

将变化的数据参数化

于是我们将代码修改如下:

bool isPowerOverload(U32 amps, U32 volts, U32 rated_watts) {
    U32 watts = volts * amps;
    return watts > rated_watts;
}

这时原有器件 A 的调用处需要改为 bool result = isPowerOverload(amps, 20, 240)

而对新器件 B 的调用则为bool result = isPowerOverload(amps, 12, 360)

可以看到新的isPowerOverload同时支持电流、电压和额定功率的变化,所以代码的可复用性比以前更好了。

讲到这里,可能会觉得,应对 “数据变化” 的方法不就是 “提参数” 嘛,这不是编程入门课里教的东西吗? 可是,别急,让我们把初始的程序改一改。

#define RATED_VOLTS 20
#define RATED_WATTS 240

bool isPowerOverload(U32 amps) {
    return RATED_VOLTS * amps > RATED_WATTS;
}

如果最初的程序实现如上,这时对于同样的新需求(支持额定功率是360w,固定电压是12v),会有不少程序员的实现如下:

#define RATED_VOLTS_FOR_DEVICE_B 12
#define RATED_WATTS_FOR_DEVICE_B 360

bool isPowerOverloadForDeviceB(U32 amps) {
    return RATED_VOLTS_FOR_DEVICE_B * amps > RATED_WATTS_FOR_DEVICE_B;
}

咋看过去,这两段代码没有重复,事实上用 compare 工具比较也显示没有重复。

但是我们看看重复代码的定义。所谓 代码重复 ,指的是 知识的重复 , 即 “代码中对同一项知识出现了的重复的表达”

在上面的实现中,对于 “怎么计算功率” 以及 “如何认定功率过载” 的计算知识,出现了两次重复的表达。而一旦知识发生了变化,重复的表达必然需要同步修改。例如现在要求所有的 “功率计算” 和 “过载比较” 支持浮点数,上面表面看似没有重复的两处代码却都要进行修改。

因此,上面的修改方式仍然是 “拷贝 + 修改” 式复用的惯性结果。究其原因是由于变化的数据成了全局常量(宏),所以 “提参数” 的方法变得稍微不那么显而易见了。

这里更好的改法依然是将变化的数据参数化。只有将变化的数据参数化了,计算的逻辑才能标准化

bool isPowerOverload(U32 amps, U32 volts, U32 rated_watts) {
    return volts * amps > rated_watts;
}
#define RATED_VOLTS_OF_DEVICE_A 20
#define RATED_WATTS_OF_DEVICE_A 240

#define RATED_VOLTS_OF_DEVICE_B 12
#define RATED_WATTS_OF_DEVICE_B 360

如上修改中,依然鼓励了消除魔术数字,将不变的数字常量化,但是这和将计算过程参数化并不矛盾。

现在使用方可以这样调用:

// for device A
bool result = isPowerOverload(amps, RATED_VOLTS_OF_DEVICE_A, RATED_WATTS_OF_DEVICE_A)// for device B
bool result = isPowerOverload(amps, RATED_VOLTS_OF_DEVICE_B, RATED_WATTS_OF_DEVICE_B);

将数据之间的关系显示化

上面修改后的代码,通过将变化的数据参数化,解决了对计算方法重复表达的问题。

不过上述代码存在另一个问题,就是使用方每次调用isPowerOverload的时候,需要负责将电压额定功率的常量值成对选择正确,而且传参的时候还要确保顺序正确。这不仅麻烦而且容易出错,尤其是当调用点比较多的时候。

对于上面的问题,我们可以把对参数如何选择的知识封装起来,给调用方更易用的接口。

static const U32 RATED_VOLTS_OF_DEVICE_A = 20;
static const U32 RATED_WATTS_OF_DEVICE_A = 240;

static const U32 RATED_VOLTS_OF_DEVICE_B = 12;
static const U32 RATED_WATTS_OF_DEVICE_B = 360;

static bool isPowerOverload(U32 amps, U32 volts, U32 rated_watts) {
    return volts * amps > rated_watts;
}

bool isPowerOverloadForDeviceA(U32 amps) {
    return isPowerOverload(amps, RATED_VOLTS_OF_DEVICE_A, RATED_WATTS_OF_DEVICE_A);
}

bool isPowerOverloadForDeviceB(U32 amps) {
    return isPowerOverload(amps, RATED_VOLTS_OF_DEVICE_B, RATED_WATTS_OF_DEVICE_B);
}

上面的修改,可以让调用方变得简单。使用者只用选择调用isPowerOverloadForDeviceA或者isPowerOverloadForDeviceB,避免了重复且易出错的常量选择。

另外,由于使用方不再需要看到电压和额定功率的常量值,所以可以将全局宏常量变成局部于实现文件内的static const类型常量,这样进一步隐藏了实现细节。甚至如果这些常量只用于计算功率过载的函数里,可以进一步将其内置于各自的函数内。

当前的实现中isPowerOverload也已属于内部实现,所以将其用static修饰,作用域限定在当前的文件内。而isPowerOverloadForDeviceAisPowerOverloadForDeviceB是接口,它们分别调用了isPowerOverload,共享了一份计算逻辑的代码表述。

不过在两个接口的实现中,目前依然需要选择电压和额定功率,并需要保证与对应的器件类型是一致的。

这个问题的根本原因是由于电压和额定功率之间本来就是有内在联系的。而将电压和功率分别用独立的U32常量进行表示,实际上将它们之间的关系隐式化了,需要每个调用者保证它们使用时的一致性。

为了避免出现不一致,我们其实是需要进一步把 数据之间的关系用数据结构显示化的表达出来。这个时候 ADT(Abstract Data Type),抽象数据类型就有用了。

struct DeviceConfig {
    U32 volts;
    U32 watts;
};

enum DeviceType {
    DEVICE_A,
    DEVICE_B,
    DEVICE_MAX,
};

static DeviceConfig DEVICE_CONFIG[DEVICE_MAX] = [
    /* volts, watts*/
    {  20   ,  240   },
    {  12   ,  360   }
];

static bool isPowerOverload(U32 amps, U32 volts, U32 rated_watts) {
    return volts * amps > rated_watts;
}

bool isPowerOverloadForDeviceA(U32 amps) {
    const DeviceConfig* cfg = DEVICE_CONFIG[DEVICE_A];
    return isPowerOverload(amps, cfg->volts, cfg->watts);
}

bool isPowerOverloadForDeviceB(U32 amps) {
    const DeviceConfig* cfg = DEVICE_CONFIG[DEVICE_B];
    return isPowerOverload(amps, cfg->volts, cfg->watts);
}

上面的实现使用了 C 语言的常用技巧:结构体和表驱动。由于结构体可以把相互关联的数据聚合在一起,所以上面代码中只用根据DeviceType就可以一次把所有关联的数据全部拿出来,避免了数据分散选择时可能出现的不一致。

使用抽象数据类型 (ADT) 可以表示数据的各种关系。C 语言中结构体可以表示数据的聚合关系,数组链表可以表示数据间的位置关系,而哈希表可以表示数据的字典关系。

善于利用合适的数据结构把数据之间的关系显示化,可以让算法变得简单,甚至性能更高。软件工程很早就说过的 “软件设计的关键就是设计好数据结构” 以及 “程序 = 数据结构 + 算法”。今天来看这些说法有其时代局限性,但是至少体现出数据结构对于软件设计的重要性。

通过上面的示例也看到了,随着封装,代码的行数也在逐渐变多。这是由于我们的示例比较简单,只有两个关联数据。现实中当互相关联的数据越多,使用抽象数据类型把互相关联的数据组织在一起的价值就越大。另外,封装的价值还在于封闭知识,让客户更简单、更不容易出错。

相反的例子是,在代码中大量使用全局变量,而这些全局变量之间的关系又都是隐式的,分布在软件的各个计算逻辑的细节中,最后只能依赖程序员之间口口相传,或者只能依赖某个 “资深” 程序员才能对其做安全的修改。这样的程序最终会变成所有维护者的噩梦。

对数据进行建模

继续回到上面的代码示例。最后的版本中使用了表驱动的方式对不同器件的数据进行了组织。表驱动是 C 语言常用的设计技巧,不过需要注意的是,一个单独的二维的表,只能表达一个方向上的变化。当出现两个及其以上的变化方向时,一个表必然出现出现数据重复。

我们回到器件和单板的例子上。假如某类器件的静态配置数据有 5 项,其中 3 项数据随着器件的自身类型变化,另外 2 项数据随着器件所在的单板变化。假设该类器件有 3 种,一共支持 4 种单板。如果将所有数据项放到一张表里,表里的数据量会是所有变化方向的乘积,也即一共有 5 * 3 * 4 = 60 项。 这些数据里面一定存在需要同步修改的重复数据。

这提醒我们需要按照不同的变化方向对数据进行拆分。

在 C 程序里面容易出现过度使用表驱动的情况。经常见到大量数据被塞入到一张张大表中,表里的数据存在高度的重复。这样不仅维护困难,而且还浪费了大量的静态内存。

表的内容要消除重复,所遵循的设计原则和关系数据库表设计范式一样,需要把数据按照不同的主键(即不同的变化原因)分开。所以上例中需要把结构体拆成两个,一个只包含随着器件自身变化的数据,另一个只包含和器件所在单板变化的数据,两个结构体通过一个键值(代码里对应 ID 或者指针)建立关联。这样两个结构体各自对应一张表,表里的数据就不会再有重复。

这其实就是数据建模的过程。

数据建模除了关注变化原因外,还要关注数据的变化频率。假设我们合理的分表了,表和表之间就会产生关联。那么数据表放在哪些物理代码文件和目录下也是有讲究的。这里的基本原则是 数据和谁更紧密,经常一起变化,就和谁放到一起。不遵循这个原则就会出现关联的数据发生变化后,代码里需要散弹式修改的问题。

我们继续使用器件和单板的例子。例如每个器件都有在不同单板上的静态配置数据。如果经常新增器件的话,那么每个器件在不同单板上的配置数据就应该和器件的代码放在一起,这样每增加一个新器件可以一次内聚的把它应该支持的所有单板的数据都配置好,还不会影响别的器件的数据。然而如果是经常新增加单板的话,那么就需要把这些数据以单板为维度组织在一起。否则的话,每增加一个单板都需要逐一打开每个器件的代码去修改,不仅麻烦还容易遗漏。

数据建模的最后一个考虑点是 数据的生命周期。生命周期一致的数据放在一起,会让数据的生命周期管理变得容易。相关的数据同生同灭,数据的一致性容易得到满足,会减少很多访问过期的数据或悬空指针的头疼问题。

所以总结一下,数据建模可以让数据减少重复,更容易修改和维护。数据建模可以参考关系数据表建模的设计范式要求,但是对于代码还要站在数据的变化原因、变化频率和生命周期的角度去思考,最后再加上前面提到的显示合理的表达数据间关系,这就是数据建模的基本设计方法了。

数据外置

使用表驱动的方式将静态数据存在代码里,这样数据和使用它们的代码离得近,改动方便,而且技术栈一致好管理。

但是当静态数据量变大后,用源码来描述数据和数据间关系的手段就会捉襟见肘,而且所有数据占据着代码的静态区,增加了版本的体积和内存的开销。

因此,当静态数据量比较大,或者数据关系复杂的情况下,建议将静态数据外置出去形成配置文件。

根据数据关系选择合适的配置文件格式是重要的。

常见的配置文件格式有:INI、XML、JSON、YAML 和 TOML 等。

INI 是种比较简单的配置形式,支持对数据进行简单的分段聚合。它最多只能解决一层嵌套,如果需要两层以上嵌套,需要用到数组,就稍微力不从心了。

XML 则是非常灵活的,支持数据的多级树状结构。XML 有 schema 的支持,还有 XSLT 支持格式转换。但是 XML 写起来比较冗余,阅读起来噪音相对比较多。一般 XML 很少让人直接去编辑,大多作为各种工具的中间的文本存储格式。

JSON 是一种非常好的数据存放和传输的格式,并且有内建的数据类型支持。但是 JSON 由于它的多级大括号缩进,阅读起来不方便。而且 JSON 不支持注释(JSON5 之前),同级的数据间没有顺序保证,将 JSON 读入后再导出格式会乱掉。

YAML 也是一种非常灵活的配置文件格式。YAML 靠缩进描述数据间的关系,并且有内建的数据类型,还支持以锚点的方式做相对复杂的数据引用。YAML 在易读性和灵活性中平衡得相对较好,我们熟知的 Swagger 就用 YAML 描述服务的 API。YAML 的语法相对较多,稍微有些复杂,大多数情况下做配置文件都只用到其一个子集。

还有一种推荐的配置文件格式是 TOML,这是 github 觉得 YAML 不太简洁所以自己搞出来的。TOML 的目标是成为一个极简的配置文件格式,它有自己内建的数据类型,并且被设计成可以无歧义地被映射为哈希表,从而被多种语言解析。现在 TOML 被用的越来越广泛,RUST 语言的包管理器 cargo 就是采用 TOML 作为其包描述文件。

上面我们介绍了常见的配置文件格式。用配置文件可以把数据从代码里面独立出来专门维护,再借助各种配置文件的解析工具可以让数据的编辑维护变得更加容易,在需要的时候也可以为配置文件开发一些辅助性工具以提高数据配置和校验的效率。

配置文件可以用来参与构建阶段做代码生成,也可以在运行时被二进制程序加载。另外,将数据独立于配置文件后,我们可以根据目标场景选择性的交付数据,以降低二进制程序的大小和内存占用空间。

再进一步,当数据量大到配置文件也不适合的时候,这时可能需要一款数据库的帮忙了。关于数据库选型的话题超出本文的范畴了,这里推荐我的朋友尉刚强写的一篇文章《如何为业务产品选择一款合适的数据库?》

当选型数据库之后,需要按照数据库的设计范式约束做数据建模。遗憾的是,很多人会遗忘这件事对于用配置文件保存数据同样重要。有时甚至需要把数据模型的元模型也用数据表达出来,以凸显模型背后的关系。这里给大家推荐《企业软件架构模式》一书,里面有很多关于关于此方面的最佳实践。

一点补充

再回到最开始的例子,有一个初始版本的 “判断器件是否功率过载” 的函数实现如下:

bool isPowerOverload(U32 amps) {
    return amps > 12;
}

这个版本为了性能优化,将20v的固定电压和240w的额定功率做了预先计算,最后得出只要电流大于12a就算过载。

这是常见的性能优化手段。一旦算法涉及到固定的数字,为了性能优化就会提前做一些计算,以降低运行时的计算开销。

这里存在一个潜在问题是:当我们打开器件手册,能看到的数字可能仍然是20v240w,它们和代码里的12之间的关系并没有在代码里显示化表达了。假设有一天20v的数字调整了,很容易遗漏去更改代码里面的12

所以代码里的数据和数据源之间的关系是需要维护的。尽可能用代码将这种关系显示化的表达出来,比如上面的问题,可以依赖编译器的编译期自动计算能力。

constexpr U32 AMPS_THRESHOLD(U32 watts, U32 volts) {
    return watts/volts;
}

const static U32 AMPS_THRESHOLD_OF_DEVICE_A = AMPS_THRESHOLD(240, 20);
const static U32 AMPS_THRESHOLD_OF_DEVICE_B = AMPS_THRESHOLD(360, 12);

static inline bool isPowerOverload(U32 amps, U32 threshold) {
    return amps > threshold
}

bool isPowerOverloadForDeviceA(U32 amps) {
    return isPowerOverload(amps, AMPS_THRESHOLD_OF_DEVICE_A);
}

bool isPowerOverloadForDeviceB(U32 amps) {
    return isPowerOverload(amps, AMPS_THRESHOLD_OF_DEVICE_B);
}

如上,我们不仅在代码里显示的维护了数据之间的关系,还同时借助编译器的编译期计算能力,帮我们完成了可以提前进行的数值计算工作,保证了运行时性能。上面的例子里面借助了 C++ 的constexpr的能力。

另外,当数据量比较大,为了避免编译时开销,借助脚本自动计算刷新也是可以的,这时候数据之间的关系就被脚本代码维护着。

总结

本文通过一个非常简单的小例子,总结了代码中应对 “数据变化” 的常用设计技巧。

  • 将变化的数据参数化,使得计算逻辑可以被统一;
  • 将数据之间的关系,通过选择合适的数据结构类型进行显示化表达;
  • 根据数据的变化原因、变化频率和生命周期,对数据进行划分和组织;
  • 如果静态配置数据规模大或者关系复杂,建议将配置数据外置到配置文件中,甚至数据库中;
  • 配置文件和数据库一样,都需要根据数据特征进行合理选型,并对数据进行建模;

由于本文主要站在代码设计的角度,所以选择了和代码设计紧密相关的数据设计话题,不涉及数据库和数据处理相关的话题。另外,示例以 C 语言为主,没有刻意采用面向对象的设计技巧,面向对象的话题会放在后面 “行为变化” 中讨论。

最后想说的是,数据设计是软件设计的基础。后面我们会继续探讨代码中的 “行为变化” 和 “类型变化”,会发现其中很多设计技巧和这里的数据设计之间存在着千丝万缕的关系。

「软件匠艺社区」旨在传播匠艺精神,通过分享好的「工作方式」,让帮助程序员更加快乐高效地编程!

No Reply at the moment.
You need to Sign in before reply, if you don't have an account, please Sign up first.