如何用ScalaCheck生成带Numeric上下文绑定的AnyVal类型任意值
问题
如何用ScalaCheck的arbitrary API与生成器,生成带有Numeric[A]上下文绑定、且A为AnyVal类型的任意数值(结果为Arbitrary或Gen均可)?
具体场景
Squants库允许在Numeric上下文绑定下用任意数值构造Quantity,核心签名如下:
trait LengthUnit extends UnitOfMeasure[Length] with UnitConverter { def apply[A](n: A)(implicit num: Numeric[A]) = Length(n, this) }
测试该apply方法时,尝试编写如下代码但出现编译错误:
import squants.space.Length // 简化示例 class LengthSuite extends MyDefaultSpec: "Length" - { "can be constructed using any number" in: // 将Number改为Double可解决编译错误,但会排除非Double的AnyVal类型 forAll( arbitrary[ Number ] ): // 生成的是java.lang.Number类型 value => import squants.space.LengthConversions._ // 编译错误:Number没有meters方法 value.meters should be( value.meters ) } end LengthSuite
问题根源:ScalaCheck的arbitrary[Number]会生成多种AnyVal类型的java.lang.Number实例,这些AnyVal本身都有对应的Numeric实例,但java.lang.Number没有Numeric实例,导致编译失败。
补充:Gen提供的posNum等方法仅能生成单一类型的AnyVal,而arbitrary[Number]可生成所有类型。已尝试的失败方案包括手动写生成器导致联合类型问题、匹配类型获取Numeric实例但编译器无法统一类型、向下转型获取Numeric实例等。
解决方案
一、可以实现目标生成器:生成带Numeric约束的AnyVal数值
核心思路是创建一个封装具体数值类型及其对应Numeric实例的代数数据类型(ADT),通过ADT将数值和上下文绑定打包,让编译器能正确推导类型约束。
步骤1:定义ADT封装数值与Numeric实例
import scala.math.Numeric sealed trait NumericAnyVal { type A <: AnyVal val value: A val num: Numeric[A] } case class NByte(value: Byte)(implicit val num: Numeric[Byte]) extends NumericAnyVal { type A = Byte } case class NShort(value: Short)(implicit val num: Numeric[Short]) extends NumericAnyVal { type A = Short } case class NInt(value: Int)(implicit val num: Numeric[Int]) extends NumericAnyVal { type A = Int } case class NLong(value: Long)(implicit val num: Numeric[Long]) extends NumericAnyVal { type A = Long } case class NFloat(value: Float)(implicit val num: Numeric[Float]) extends NumericAnyVal { type A = Float } case class NDouble(value: Double)(implicit val num: Numeric[Double]) extends NumericAnyVal { type A = Double }
步骤2:编写Gen[NumericAnyVal]生成器
将ScalaCheck提供的各数值类型生成器组合,随机选择生成:
import org.scalacheck.Gen import org.scalacheck.Arbitrary val numericAnyValGen: Gen[NumericAnyVal] = Gen.oneOf( Gen.byte.map(NByte(_)), Gen.short.map(NShort(_)), Gen.int.map(NInt(_)), Gen.long.map(NLong(_)), Gen.float.map(NFloat(_)), Gen.double.map(NDouble(_)) ) // 可选:转为Arbitrary实例,方便直接用arbitrary调用 implicit val arbitraryNumericAnyVal: Arbitrary[NumericAnyVal] = Arbitrary(numericAnyValGen)
步骤3:在测试中使用
import squants.space.Length class LengthSuite extends MyDefaultSpec: "Length" - { "can be constructed using any numeric AnyVal" in: forAll(numericAnyValGen): nav => import squants.space.LengthConversions._ // 利用ADT中携带的Numeric上下文调用构造方法 val length = meters[nav.A](nav.value)(nav.num) // 验证构造结果与原始数值一致(以转换为Double为例) length.toMeters shouldBe nav.num.toDouble(nav.value) } end LengthSuite
二、替代方案:针对每种数值类型单独测试
如果不想定义ADT,可直接针对所有支持的AnyVal数值类型逐一编写测试,覆盖所有场景:
class LengthSuite extends MyDefaultSpec: "Length" - { "can be constructed using Byte" in: forAll(Gen.byte): b => import squants.space.LengthConversions._ b.meters.toMeters shouldBe b.toDouble "can be constructed using Short" in: forAll(Gen.short): s => import squants.space.LengthConversions._ s.meters.toMeters shouldBe s.toDouble "can be constructed using Int" in: forAll(Gen.int): i => import squants.space.LengthConversions._ i.meters.toMeters shouldBe i.toDouble // 同理添加Long、Float、Double的测试案例 } end LengthSuite
为什么直接生成A <: AnyVal with Numeric[A]不可行?
Scala的类型系统中,Numeric[A]是上下文约束而非类型上界,无法直接定义Gen[A]同时满足A <: AnyVal和Numeric[A]。ScalaCheck生成器需要明确的具体类型,而Byte | Short | Int | ...这类联合类型无法自动推导每个分支对应的Numeric实例,导致编译器无法在测试代码中找到合适的上下文绑定。
内容的提问来源于stack exchange,提问作者Joost Papendorp

