逻辑与数据

在编程入门的时候常会听说一句话:程序的本质是数据结构+算法。 在这句话中,我们可以看到程序中的两个至关重要的元素。逻辑数据。 这个两个元素联系非常紧密,但是特性却截然想法,如同阴阳中的两极一般,既对立又统一。

什么是逻辑?什么是数据?

在讨论逻辑与数据的对立统一时,首先我们应该对逻辑数据做出一个定义。

逻辑是指解决一类问题的具体步骤。比如想要知道一杯未知液体是否是酸性的, 我们可以用下面的步骤来判断:

  1. 使用 pH 试纸测量液体的 pH 值;
  2. 如果 pH 值大于 7,则被测液体为酸性,否则液体为碱性。

严格而言,在本文中, 逻辑与算法的定义是等价的。

数据指的是一种概念的具体化的产物。比如, 文章这个概念可以具体化为一个含有标题内容等字段的记录。 在数据库中,文章表中的一列可以看作一个数据。

如果把程序看作一个工厂,数据就是原料,而逻辑就是机器。我们把原料放到机器里, 最终的产物就是我们期望的结果。任何有意义的程序都是逻辑与数据的有机结合。 就连最简单的 Hello World 也不例外。

然而,在和谐共存的表面下,逻辑和数据在灵活性却有着截然相反的特性。 逻辑就像房屋的骨架,在整个生命周期里都是固定的;而数据就像是房屋的外墙, 可以红色油漆来粉刷,也可以用黄色油漆来粉刷,在生命周期里可以多次改变。

不可变的逻辑

逻辑在程序的整个生命周期是不可变的。如果想要改变程序的逻辑, 必须要更改程序的源代码,有必要时需要重新编译,最后重新运行。 比如,有这样一个程序,当用户的积分大于 800 时, 赋给用户一些高级权限。用伪代码描述如下:

1
2
3
4
5
function processPrivilege() {
if (user.score > 800) {
user.grantPrivilege();
}
}

有一天,由于营销策略的变更,需要将这个积分阈值改成 700。此时, 我们无法不停机地(on the fly)满足这一需求。 要满足需求,我们需要将这一段代码更改为:

1
2
3
4
5
function processPrivilege() {
if (user.score > 700) {
user.grantPrivilege();
}
}

之所以我们无法不停机地满足这一需求,是因为,我们把积分阈值 800 当作了逻辑的一部分。 在之前这段代码中,我们解决何时赋给用户权限问题的方法是—— 当用户的积分大于 800 时, 赋给用户一些高级权限。很显然,在解决上一需求的现场,我们并没有考虑到积分阈值可能是会变化的。 我们轻率地把积分阈值硬编码到了逻辑中。当需求变更时,我们必须通过调整逻辑来满足最新的需求。 在实际项目的开发中,类似这种需求是非常常见的。

逻辑的不可变性进一步会带来开发效率的下降。调整逻辑需要研发全链路的参与, 产品需要提出正式的需求文档、开发人员需要根据需求文档调整代码、QA 需要对需求进行测试验收、 最后可能还需要发版或者上线才能使改动的逻辑生效。 有时,涉及代码改动也许仅仅几行,然而整个流程会被拉得很长。

可变的数据

与逻辑的不可变性相对的,数据具有高度的可变性。

以一个寻路程序为例, 假设我们有一个方法 findBestRouteBtw(origin, dest) 可以找到从出发地origin 到目的地 dest 的最佳路径。如果, 我们想找从北京西站到北京站的最佳路径,我们只需要调用 findBestRouteBtw('北京西站', '北京站') 就可以得出答案。 想要找从天安门到首都机场的最佳路径也没有问题,调用 findBestRouteBtw('天安门', '首都机场') 即可。

对于方法 findBestRouteBtw(origin, dest)而言,origindest 表示的是一种概念,origin 表示的是一个地点,是出发地。dest 表示的也是一个地点, 是目的地。它不与某个概念的具现所绑定,因此具有极为强大的灵活性。 我们可以传入各种各样的地点(数据),它都能够给出从目的地到出发地的最佳路径。 计算机的强大也来源于此。

对于一些需求变更,也可以通过变更数据来满足。例如,需求需要有一个程序, 能够实时展示当时上证综指的指数。很明显,上证综指是一个实时变化的量。 这个量我们需要通过某个接口去获取,用伪代码表示如下:

1
2
const sseIndex = await getSSEIndex();
show(sseIndex);

可以看到,获取上证综指已经委托给了一个方法 getSSEIndex 。 这个方法可以通过调用上海证券交易所的接口,也可以调用其他第三方接口获取指数。 显然,指数每次变化都不需要重新编码或者重新部署程序。

逻辑与数据的互相变化

逻辑和数据是可以互相转化。从数据转化为逻辑很容易, 比如硬编码某个值就是把数据转化为逻辑的一种。这里不再赘述。

逻辑也可以转化为数据,以用户提权例子为例。当用户积分大于 800 时,赋予用户一些高级权限。 如果我们意识到,积分阈值是可变的,我们就可以把积分阈值当作一个参数。

1
2
3
4
5
6
7
function processPrivilege(privilegeThreshold) {
if (user.score > privilegeThreshold) {
user.grantPrivilege();
}
}

processPrivilege(800);

在上述代码中,800 仍然是硬编码的。但是,将积分阈值作为一个形参抽象出来, 给积分阈值的变化带来了可能。我们稍作一些变化:

1
2
3
4
5
6
7
8
9
10
11
function getPrivilegeThreshold() {
// ...读取配置文件、从数据库中获取、读取命令行、环境变量等等来获取阈值
}

function processPrivilege(privilegeThreshold) {
if (user.score > privilegeThreshold) {
user.grantPrivilege();
}
}

processPrivilege(getPrivilegeThreshold());

这样的话,积分阈值完全成为了一个数据。我们可以从任何地方获取积分阈值这个数据, 比如从数据库获取、从某个微服务接口中获取等等。此时, 如果有一天积分阈值需要从 800 变到 700,我们只需要把数据源中的对应值改成 700 即可。 不需要修改源代码,不需要提测,不需要上线,可以节省大量时间。

在理想世界中,我们希望任何需求都可以通过修改数据的方式来实现。显然, 这种方式是最有效率的。但是,很多时候我们很难看清楚逻辑与数据的边界, 因此错误地把数据当作逻辑,或者把数据当作逻辑来实现。

误把数据当作逻辑的例子有很多,比如各种硬编码的值。 误将逻辑当作数据实现不常见,但是也存在。 这种错误最典型就是抽象过度,例如一个计算圆的周长的函数

1
2
3
4
5
6
function getPI() {
return 3.14159;
}
function calculatePerimeter(radius) {
return getPI() * 2 * radius;
}

这里圆周率并不是一个可变量,并不需要可变性。因此把获取圆周率抽象成一个函数是不恰当的。 更好的方法是把圆周率作为一个常量看待:

1
2
3
4
function calculatePerimeter(radius) {
const PI = 3.14159;
return PI * 2 * radius;
}

抽象过度会极大地损害程序的可读性与可维护性。因此也是需要极力避免的。

因为我们很难一次性弄清楚逻辑与数据的边界,所以, 随着业务需求变化对程序进行调整是必要的。我们需要把一些逻辑调整为数据, 或者把数据调整为逻辑。这种调整就是重构。 重构本身是非常大的话题,在本文中就不展开了。

总结

  • 逻辑与数据是程序的核心要素。逻辑与数据在程序内是对立统一的;
  • 在可变性上,逻辑在生命周期中是不可变的,数据是可变的;
  • 对于需求变更,一般情况下,我们希望通过调整数据来解决;
  • 对于某个特定需求很难弄清楚数据与逻辑的边界;
  • 在项目的生命周期里,需要通过重构去找到最合适的边界。