可控函数(Controllable Function),是指那种可以通过改变函数的参数或者简单的修改函数在执行过程中的外部依赖的而产生确定结果的一类函数,这是我根据纯函数的概念推广出来的一个新的名词(大概是新的吧?)。
为什么需要可控函数
纯函数在函数式编程的世界中非常的普遍,而且最近它也逐渐在向面向对象的世界中展现出越来越多的魅力,你甚至可以在一些框架或者技术的文档中看到文档作者对纯函数的推崇。在我看来,纯函数能够吸引到更多开发者的关注的一个重要的原因就是它更容易测试,因为我们可以非常肯定的确定在给定输入的情况下纯函数的输出。但是在面向对象的世界中,因为有类的存在,所以我们经常编写的函数(或者说是方法)很难设计成为纯函数 —— 毕竟我们总是会在类的方法中访问类的其他成员。
而可控函数则在对外界环境的访问以及修改的限制较为宽松。不知你是否还记得人教版高中生物书中提到的“控制变量法”,在设计实验的时候,我们需要尽可能地做到仅改变单个对试验结果产生影响的变量。编写单元测试也是这样,如果我们能够非常简单的控制某个函数的全部依赖(包括函数参数以及外部依赖,例如,类的其他成员)的变化,那么我们就可以很容易地使用控制变量法设计我们的测试用例,并简化划分等价类的过程,这对于编写白盒测试来说,好处不言而喻。
是什么让你的函数不可控
如果函数依赖“系统当前时间”这样的无法使用代码来控制的外部变量的话,那么这个函数常常会变得不可控。因为在大多数的编程语言或者技术框架中,修改当前系统时间或者 Mock 获取系统时间的全局变量往往是很困难的。像 JS 可以非常容易的 Mock window.Date
类,但是在 C# 中,Mock DateTime
会非常的艰难。
另一种让函数成为脱缰野马的情况很可能是它依赖了过于复杂的外部对象,例如 EF Core 的 DbContext
。DbContext
很难被完美的 Mock,不管是 InMemory
还是 SQLite InMemory
,都有其限制所在。就更不要说查询姿势(是否 AsNoTracking
,是否使用了第三方 EF 拓展)会对真正的执行过程产生的影响了。本来没有那么复杂逻辑,却因为引入的复杂的外部对象而变得难以预测了。
重新掌控你的函数
在这里我想先介绍一款编程语言 —— Elm
,在 Elm 的基础类库中,它很反常的没有提供直接获取系统时间的函数,相反地,Elm 的设计者认为获取系统时间并不是一个纯函数,因为我们没办法在不同时刻让这个函数返回相同的值。为了描述这一现象,Elm 将这个函数实现为了一种副作用,简单来说就是开发者需要是使用类似于 Callback 的机制来获取系统当前时间,这样就强迫我们解除了对系统当前时间的强依赖,因为我们只能通过 Callback 的参数来获取真正的系统时间。
模仿 Elm 的理念,在使用我们无法使用代码来控制的外部变量的时候,我们可以使用“包装一层”的方法来解决强依赖的问题。例如,在面向对象的世界中,可以通过使用一个 ISystemTimeProvider
接口来获取当前的系统时间,它就像 Elm 中的 Callback 一样,躲在一个抽象层后面,默默无闻地为我们的代码提供当前系统的准确时间。
除了上面提到的我们无法控制的外部依赖之外,还有一些外部依赖是我们难以控制的,它们往往来自于我们所使用的第三方类库,并且它们的组成也通常是比较复杂的,例如 AspNetCore 中的 HttpContext
以及 DbContext
。可以想象,如果一个函数接收这种类型的参数,那么如何去创建一个期望的输入就变成了一件困难的事了。这种时候我们可以试着 Keep it simple and stupid。例如,当我们只需要使用 DbContext
的查询结果的时候,直接让查询函数返回来自 BCL 的类型而不是 IQueryable<T>
,这样,依赖查询结果的函数就可以摆脱对 DbContext
的依赖了。