重来TDD
编译器可能是最容易被遗忘的测试利器。
原本计划着写一个话题叫,“最近我怎么不写测试用例啦”?没成想,这坑一挖,把自己陷进去了。再不停一停的话,怕是我要爬不出来了。
虽不敢说自己是 TDD(Test-Driven Development)2 的狂热信徒,但至少我是一直在用编程测试用例来为开发的代码保驾护航的。与我共事过的朋友应该都还记得,我犯测试覆盖强迫症时的样子。
今天,我要来聊另一个名气没那么大的 TDD(Type-Driven Development)。诶,别急着走,如果你是对高质量代码感兴趣的话,这个 TDD 是不会让你失望的,相信我。
从业早年,听坊间传闻,“某语言只要编译通过了,就没有BUG”。很长时间里,我不得甚解,但大受震撼。直到最近,我有了一点小小的收获,遂拿个小例子与大家众乐一番。
这个例子源自「Type-Driven Development with Idris」一书3,只不过我是从 Scala 的视角来解读 4,接下来言归正传。
Printf
> printf("age: %d", 23)
scala: 23 age
这是一个再常见不过的打印输出函数了,给定一个字符串模版
age %d
,后面的参数 23
会替换掉模版中的占位符
%d
,从而显示出来。
不同的占位符对其参数的类型是其实是有预期的 5。遗憾的是,这无法体现在函数的类型声明上。
> :type printf("age: %d", _)
scalaSeq[Any] => Unit
即,第二参数类型是任意值的序列。也就意味着,编译器对这种类型的错配,无能为力。
> printf("age: %d", "john")
scala.util.IllegalFormatConversionException:
java!= java.lang.String d
编译器真的无能为力吗?我尝试实现一个类型安全的
Printf
,看看效果先。
> :type Printf("age: %d")(_)
scalaInt => Unit
试着犯如上的错误,
> Printf("age: %d")("john")
scala-- [E007] Type Mismatch Error: --
1 |Printf("age: %d")("john")
| ^^^^^^
| Found: ("john" : String)
| Required: Int
1 error found
怎么做到的呢?具体的语言技巧放在后面再讲,先来理清思路。
首先,要能在编译过程中,实现对模版中存在的占位符的解析,这是参数类型推导的前提,这也是最容易想到的。
其次,这里的参数列表是 可变 的,且
类型不尽相同 。用以表达的类型肯定不能是
Array[A]
,Seq[A]
和 List[A]
。
貌似只有 Tuple2[A, B]
, Tuple3[A, B, C]
等等。可惜,它们是固定长度的,满足不了可变的要求。
真的吗?我在上一篇文章里,可是提到了 Scala3 中有关 Tuple
的新特性,正好应对这里的需求。
> :type Printf("no placeholder")
scala=> Unit
EmptyTuple
> :type Printf("age: %d")
scalaInt *: EmptyTuple => Unit
> :type Printf("name: %s, age: %d")
scala(String, Int) => Unit
// (String, Int) =:= String *: Int *: EmptyTuple
看出规律了吧,可变的参数类型可以通过 *:
和
EmptyTuple
衔接来实现。
接下来,我们看要怎么实现编译期的字符串解析。Scala3 提供了 Match Types 6 可以做到。
> type IsA[S] <: Boolean = S match
scala| case "A" => true
| case _ => false
|
> summon[IsA["A"] =:= true]
scalaval res1: true =:= true = generalized constraint
> summon[IsA[""] =:= false]
scalaval res2: false =:= false = generalized constraint
IsA[S]
定义了对类型参数 S
的匹配模式。即,当为 "A"
时,IsA["A"]
的真正类型是 true
。
对于 "A"
是类型表示陌生的朋友,可以了解一下知识点
Literal Types 7。这里多得不说了,看下面几行体会体会。
> val a: "A" = "A"
scalaval a: "A" = A
> val a: "B" = "A"
scala-- [E007] Type Mismatch Error: --
1 |val a: "B" = "A"
| ^^^
|Found: ("A" : String)
|Required: ("B" : String)
1 error found
说回来,有了 Match Types 和 Literal Types ,就差字符串的操作了
> summon[CharAt["age", 0] =:= 'a']
scalaval res3: a =:= a = generalized constraint
像 CharAt
等类似字符串操作的函数,Scala
标准库都提供了对应的 Match Types 8。
余下的,我想你大概可以脑补了,自己动手尝试一下吧,乐趣满满。
到这里还有一个小瑕疵,如下
> Printf("no placeholder")()
scala-- [E171] Type Error: ----------------------
1 |Printf("no placeholder")()
|^^^^^^^^^^^^^^^^^^^^^^^^^^
|missing argument for parameter v1
|of method apply in
|trait Function1: (v1: EmptyTuple): Unit
1 error found
> Printf("no placeholder")(EmptyTuple)
scala no placeholder
Tuple
类型带来参数输入的不便,尤其在参数数量为
0
和 1
的时候。解决办法,我这里提供一个思路。
type Args[S] <: Tuple = ???
trait Result[T <: Tuple]:
type Out
def apply(pattern: String): Out
: Result[EmptyTuple] with
given stringtype Out = Unit
def apply(pattern: String): Out = print(pattern)
假定,Arg[S]
实现了对模版占位符的解析,并推导出参数列表类型 Tuple
。
object Printf:
def apply(s: String)(using
: Result[Args[s.type]]
r): r.Out = r(s)
那么,当 Tuple
不同时,编译器自动选择对应的
Result[T <: Tuple]
派生来应对 9。
完整的代码可阅读原文或参见脚注链接查阅 10。
结语
实事求是,仅就 Printf
这样的类型安全护栏个案,只是蜻蜓点水,对于提升全局工程质量而言,可能是无足挂齿的。
但重要的是,类型驱动开发的思维方式,走在了测试先行的更前面。它能让编译器更懂业务逻辑,前提是我们对类型的思考真正的放在业务的层面。
PS:若觉得以上内容还不错,还请记得转发分享给你的同事或同行,这对我最大的鼓励。
https://www.letsmakebettersoftware.com/2017/09/test-driven-development-tdd.html↩︎
https://www.manning.com/books/type-driven-development-with-idris↩︎
顺带演示一些 Scala3 的新特性。↩︎
https://docs.oracle.com/javase/8/docs/api/index.html?java/util/Formatter.html↩︎
https://docs.scala-lang.org/scala3/reference/new-types/match-types.html↩︎
https://medium.com/@hao.qin/scala-3-enlightenment-unleash-the-power-of-literal-types-41e3436b4df8↩︎
https://www.scala-lang.org/api/current/scala/compiletime/ops/string$.html#Types↩︎
https://docs.scala-lang.org/scala3/book/types-dependent-function.html↩︎
https://github.com/zhongl/type-driven-development-with-scala3/blob/main/ch06/printf.worksheet.sc↩︎