𝚲

重来TDD

编译器可能是最容易被遗忘的测试利器。

图片来自网络

原本计划着写一个话题叫,“最近我怎么不写测试用例啦”?没成想,这坑一挖,把自己陷进去了。再不停一停的话,怕是我要爬不出来了。

虽不敢说自己是 TDD(Test-Driven Development)2 的狂热信徒,但至少我是一直在用编程测试用例来为开发的代码保驾护航的。与我共事过的朋友应该都还记得,我犯测试覆盖强迫症时的样子。

今天,我要来聊另一个名气没那么大的 TDD(Type-Driven Development)。诶,别急着走,如果你是对高质量代码感兴趣的话,这个 TDD 是不会让你失望的,相信我。

从业早年,听坊间传闻,“某语言只要编译通过了,就没有BUG”。很长时间里,我不得甚解,但大受震撼。直到最近,我有了一点小小的收获,遂拿个小例子与大家众乐一番。

这个例子源自「Type-Driven Development with Idris」一书3,只不过我是从 Scala 的视角来解读 4,接下来言归正传。

Printf

scala> printf("age: %d", 23)
age: 23

这是一个再常见不过的打印输出函数了,给定一个字符串模版 age %d,后面的参数 23 会替换掉模版中的占位符 %d,从而显示出来。

不同的占位符对其参数的类型是其实是有预期的 5。遗憾的是,这无法体现在函数的类型声明上。

scala> :type printf("age: %d", _)
Seq[Any] => Unit

即,第二参数类型是任意值的序列。也就意味着,编译器对这种类型的错配,无能为力。

scala> printf("age: %d", "john")
java.util.IllegalFormatConversionException: 
  d != java.lang.String

编译器真的无能为力吗?我尝试实现一个类型安全的 Printf,看看效果先。

scala> :type Printf("age: %d")(_)
Int => Unit

试着犯如上的错误,

scala> Printf("age: %d")("john")
-- [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 的新特性,正好应对这里的需求。

scala> :type Printf("no placeholder")
EmptyTuple => Unit

scala> :type Printf("age: %d")
Int *: EmptyTuple => Unit

scala> :type Printf("name: %s, age: %d")
(String, Int) => Unit
// (String, Int) =:= String *: Int *: EmptyTuple

看出规律了吧,可变的参数类型可以通过 *:EmptyTuple 衔接来实现。

接下来,我们看要怎么实现编译期的字符串解析。Scala3 提供了 Match Types 6 可以做到。

scala> type IsA[S] <: Boolean = S match
     |   case "A" => true
     |   case _   => false
     |

scala> summon[IsA["A"] =:= true]
val res1: true =:= true = generalized constraint

scala> summon[IsA[""] =:= false]
val res2: false =:= false = generalized constraint

IsA[S] 定义了对类型参数 S 的匹配模式。即,当为 "A" 时,IsA["A"] 的真正类型是 true

对于 "A" 是类型表示陌生的朋友,可以了解一下知识点 Literal Types 7。这里多得不说了,看下面几行体会体会。

scala> val a: "A" = "A"
val a: "A" = A

scala> val a: "B" = "A"
-- [E007] Type Mismatch Error: --
1 |val a: "B" = "A"
  |             ^^^
  |Found:    ("A" : String)
  |Required: ("B" : String)
1 error found

说回来,有了 Match TypesLiteral Types ,就差字符串的操作了

scala> summon[CharAt["age", 0] =:= 'a']
val res3: a =:= a = generalized constraint

CharAt 等类似字符串操作的函数,Scala 标准库都提供了对应的 Match Types 8。 余下的,我想你大概可以脑补了,自己动手尝试一下吧,乐趣满满。

到这里还有一个小瑕疵,如下

scala> Printf("no placeholder")()
-- [E171] Type Error: ----------------------
1 |Printf("no placeholder")()
  |^^^^^^^^^^^^^^^^^^^^^^^^^^
  |missing argument for parameter v1 
  |of method apply in 
  |trait Function1: (v1: EmptyTuple): Unit
1 error found

scala> Printf("no placeholder")(EmptyTuple)
no placeholder

Tuple 类型带来参数输入的不便,尤其在参数数量为 01 的时候。解决办法,我这里提供一个思路。

type Args[S] <: Tuple = ???

trait Result[T <: Tuple]:
  type Out
  def apply(pattern: String): Out

given string: Result[EmptyTuple] with
  type Out = Unit
  def apply(pattern: String): Out = print(pattern)

假定,Arg[S] 实现了对模版占位符的解析,并推导出参数列表类型 Tuple

object Printf:
  def apply(s: String)(using 
    r: Result[Args[s.type]]
  ): r.Out = r(s)

那么,当 Tuple 不同时,编译器自动选择对应的 Result[T <: Tuple] 派生来应对 9。 完整的代码可阅读原文或参见脚注链接查阅 10

结语

实事求是,仅就 Printf 这样的类型安全护栏个案,只是蜻蜓点水,对于提升全局工程质量而言,可能是无足挂齿的。 但重要的是,类型驱动开发的思维方式,走在了测试先行的更前面。它能让编译器更懂业务逻辑,前提是我们对类型的思考真正的放在业务的层面。

PS:若觉得以上内容还不错,还请记得转发分享给你的同事或同行,这对我最大的鼓励。


  1. https://www.letsmakebettersoftware.com/2017/09/test-driven-development-tdd.html↩︎

  2. https://en.wikipedia.org/wiki/Test-driven_development↩︎

  3. https://www.manning.com/books/type-driven-development-with-idris↩︎

  4. 顺带演示一些 Scala3 的新特性。↩︎

  5. https://docs.oracle.com/javase/8/docs/api/index.html?java/util/Formatter.html↩︎

  6. https://docs.scala-lang.org/scala3/reference/new-types/match-types.html↩︎

  7. https://medium.com/@hao.qin/scala-3-enlightenment-unleash-the-power-of-literal-types-41e3436b4df8↩︎

  8. https://www.scala-lang.org/api/current/scala/compiletime/ops/string$.html#Types↩︎

  9. https://docs.scala-lang.org/scala3/book/types-dependent-function.html↩︎

  10. https://github.com/zhongl/type-driven-development-with-scala3/blob/main/ch06/printf.worksheet.sc↩︎