最近用Python和Go各写了一个小项目,在工作量上都差不多,可能是我自身的问题,在开发Python的项目时,发现虽然单元测试覆盖率很快就能上去,但是在使用过程中还是会遇到许多代码安全方面的问题。实际上,Python已经在逐渐开始解决这方面的问题,详见我的这篇文章

而在Go项目中,天然的强类型系统给代码安全带来了巨大的好处。越来越感受到一个良好的类型系统,可以大大减少测试的工作量开发。在阮一峰的博客中,看到了这篇文章,很好的说明了测试与类型的关系。

翻译自 Tests and types

考虑一个简单的函数:rgbToHex,它需要三个参数,都是0到255之间的整数,然后将其转换为十六进制字符串。

这个函数在动态弱类型语言的定义可能如下所示:

1
2
3
rgbToHex(red, green, blue) {
// …
}

我确信我们都同意“程序正确性”是至关重要的。 我们不想要任何的Bug,那么就得编写测试。

1
2
3
assert(rgbToHex(0, 0, 0) == '000000')  
assert(rgbToHex(255, 255, 255) == 'ffffff')
assert(rgbToHex(238, 66, 244) == 'ee42f4')

由于我们的测试,可以确定函数实现是按预期工作的。

但是真的是这样吗?

我们实际上只测试了 16777216 种颜色组合中的三种。但人类的逻辑能力告诉我们,如果函数对这三种情况起作用,那么对于所有颜色组合都是有效的。

但如果我们传入double而不是int呢?

1
rgbToHex(1.5, 20.2, 100.1)

或者传入允许范围之外的数字呢?

1
rgbToHex(-504, 305, -59)

传入null呢?

1
rgbToHex(null, null, null)

传入strings呢?

1
rgbToHex("red", "green", "blue")

传入的参数数量不正确呢?

1
2
3
rgbToHex()  
rgbToHex(1, 2)
rgbToHex(1, 2, 3, 4)

如果是上面情况的组合呢?

我可以很容易地想到我们测试的五个边缘情况,然后才能确定我们的程序做了它需要做的事情。 这说明我们至少需要编写八个测试——相信还可以想出一些其他的测试。

这些是类型系统旨在部分解决的问题。 请注意“部分”,之后我们会看到为什么是“部分”。

如果我们按类型来过滤输入,你会发现很多测试都是不需要的。

假设我们只允许参数类型为int

1
2
3
4
rgbToHex(Int red, Int green, Int blue)  
{
// …
}

让我们来看看由于已经知道是int类型而不再需要进行的测试:

  • 输入是否为数字

  • 输入是否是整数

  • 输入是否为空

实际上,我们可以做得更好:我们仍然需要检查输入数字是否在0到255之间。

不幸的是,在这一点上,许多类型系统的限制都是无法做到的。

当然我们可以使用int,虽然在很多情况下(和我们一样),这种类型描述的类别对于我们的业务逻辑来说,范围仍然太大了。 有些语言有uint或“无符号整数”类型; 但这仍然是“数字类型数据”的一个子集。

幸运的是,有很多方法可以解决这个问题。

一种方法可以是使用“可配置(configurable)”或泛型类型,例如int <min,max>。 泛型的概念在许多编程语言中都是提供的。

尽管我不知道是不是所有语言可以让你配置标量类型,例如int。但是,从理论上讲,类型可以预先配置,使其足够聪明,可以了解你的业务逻辑。

缺少此类泛型类型的语言通常需要构建自定义类型。 作为一名OO程序员,我会用类来做这件事。

1
2
3
4
5
6
7
8
class MinMaxInt 
{
public MinMaxInt(Int min, Int max, Int value)
{
assert(min <= value <= max)
this.value = value
}
}

如果我们使用MinMaxInt的实例,我们可以确定它的值被限制在int的子集中。

尽管如此,这个MinMaxInt类对我们来说还是太宽泛了。 如果我们用它作为rgbToHex的参数,我们仍然不确定具体的的整数范围是什么:

1
2
3
4
rgbToHex(MinMaxInt red, MinMaxInt green, MinMaxInt blue)  
{
// …
}

我们需要一个更具体的类型:RgbValue。 添加它同样取决于编程语言和个人偏好。 我会扩展MinMaxInt,但随意做任何最适合你的事情。

1
2
3
4
5
6
7
class RgbValue extends MinMaxInt 
{
public RgbValue(Int value)
{
parent(0, 255, value)
}
}

现在我们已经找到了一个有效的解决方案。 通过使用RgbValue类型,我们的大多数测试都不在需要了。

1
2
3
4
rgbToHex(RgbValue red, RgbValue green, RgbValue blue)  
{
// …
}

我们现在可以通过一个测试来测试整个函数的业务逻辑:给定三种RGB有效颜色,此函数是否返回正确的HEX值?

这是一个很大的改进!

注意事项

一些读者已经可以想到一两个反驳论点,让我们来解决它们。、

Tests are just moved

如果我们正在构建自定义类型,我们仍然需要测试它们。 这在我的例子中是正确的,这受到我工作的语言的影响。

这取决于语言的功能。 给定一种允许这样的语言:

1
2
3
4
5
6
7
rgbToHex(
Int<0, 255> red,
Int<0, 255> green,
Int<0, 255> blue
) {
// …
}

那么额外测试就都不需要了,因为这些功能已融入语言本身。

但即使我们不得不构建自定义类型并测试它们:不要忘记它们在整个代码库中都是可重用的。

尽可能重复使用你正在制作的大部分类型; 因为这些自定义类别最有可能适用于您的业务,并在整个过程中都在使用。

Verbosity

接下来,许多人会在实际使用它时认为我的解决方案过于冗长:

1
2
3
4
5
rgbToHex(    
new RgbValue(60),
new RgbValue(102),
new RgbValue(79)
);

虽然我个人并不介意这种冗长 - 我知道更强大的类型系统的好处。

我想请你仔细思考一下。 这个论点不是针对强类型的,而是针对你的编程语言的。

冗长是由于语言缺乏适当的语法造成的。 幸运的是,我可以想出问题可以解决的方法。

一种解决方案是type juggling。 动态语言实际上非常擅长。 假设您传递一个简单的整数作为输入,编译器可以尝试将该整数转换为RgbValue的对象。 它甚至可以知道可以转换为RgbValue的可能类型,因此您仍然需要编译时错误检测。

Example in isolation

另一个反对意见可能是真实代码库明显不同于简单的rgbToHex函数。

我更赞同相反的观点:这个例子背后的推理可以应用于代码的任何部分。实际的困难在于使用的语言和框架:如果强大的类型不是从头开始构建的,那么你将很难充分利用它们。

这是我应该建议你观看Gary Bernhardt的演讲,不到30分钟。在其中,他采用类型系统的主题,并用我们自己的偏见和意识形态来探讨这些问题。之后,您可以将这种思维应用于您正在使用的当前框架和语言。

虽然我的示例是孤立的示例,但如果基础结构支持,则可以轻松扩展更强类型解决的基础问题。

所以我在建议你应该抛弃你的整个语言栈,或者你是一个使用弱类型语言的坏程序员?

当然不是!

我自己每天用PHP编程,它没有以前那么糟糕了。 PHP引入了一个opt-in类型的系统,因此可以编写相当强类型的代码,即使该语言最初并不是为它构建的。想到的另一个例子是使用TypeScript的JavaScript。

因此,尽可能利用类型系统,即使是最初并非为其构建的。但这需要你转变你的思维方式。根据我的经验,这是值得的。

Limitations

最后,让我们在类型系统中解决一个显而易见的问题。 强类型语言就不需要测试了吗?

我希望很明显,由于强大的类型系统可能会省略许多测试,但仍是需要编写测试。声称你不必用强类型语言编写测试的人是绝对错误的。

还记得前面提到的部分内容吗?

在理想的世界中,完美的类型系统将能够解释您的业务所需的所有特定类别。 但这是不可能的,因为计算机和编程语言只有有限的资源可供使用。

因此,虽然强类型可以帮助我们确保程序的正确性,但是一些测试始终是确保业务正确性的必要条件。 这是“and”的问题,而不是“or”。