近期的工作中,需要在 Spark 任务中做一些字符串处理。但比较尴尬的是,过去的 Jaskell Core,我一直是以 2.13 为目标开发的,而现在 Spark的各种发布版,普遍使用的是 2.11 和 2.12 。
再专门为 2.11 和 2.12 编写独立的项目,就非常不经济了,这个时候,要把 Jaskell 变成跨版本项目。
SBT 通过 cross version 设置支持跨版本。我们首先设定 supportScalaVersions :
lazy val scala213 = "2.13.6"
lazy val scala212 = "2.12.15"
lazy val supportedScalaVersions = List(scala213, scala212)
version := "0.7.3"
scalaVersion := scala213
通过 supportedScalaVersions 和 scalaVersion 的配置,我们指明:
- 这个项目支持 Scala 2.12 和 Scala 2.13
- 默认版本为 scala 2.13
- 2.12 配置为 2.12.15
- 2.13 配置为 2.13.6
开发工具,例如 Intellij,会根据 scalaVersion 配置编译器。现在它会按照 scala 2.13 的语法处理项目代码。如果切换编译器,可以修改这个变量,然后刷新项目。
如果我们想要在命令行执行sbt 任务,例如执行单元测试,只需要用 ++ 选项指明编译器版本,例如我们如果想用 Scala 2.12 测试 Jaskell Core,可以执行:
sbt ++2.12.15 test
通过测试,我发现我对 Parsec 的 Monad 实现并不能用于 Scala 2.12:
object Parsec {
def apply[E, T](parser: State[E] => Try[T]): Parsec[E, T] = parser(_)
implicit def toFlatMapper[E, T, O](binder: Binder[E, T, O]): (T)=>Parsec[E, O] = binder.apply
implicit def mkMonad[T]: Monad[({type P[A] = Parsec[T, A]})#P] =
new Monad[({type P[A] = Parsec[T, A]})#P] {
override def pure[A](element: A): Parsec[T, A] = Return(element)
override def fmap[A, B](m: Parsec[T, A], f: A => B): Parsec[T, B] = m.ask(_).map(f)
override def flatMap[A, B](m: Parsec[T, A], f: A => Parsec[T, B]): Parsec[T, B] = state => for {
a <- m.ask(state)
b <- f(a).ask(state)
} yield b
}
}
按照 Scala 2的语法设计,typeclass 可以通过 implicit class 实现。但是 implicit class 只能支持将目标class 作为泛型参数。于是我这里采用了一个迂回的方法,先定义了一个基于 implicit 变量的抽象类作为 ops 类型:
abstract class MonadOps[A, M[_]](implicit I: Monad[M]) {
def self: M[A]
def map[B](f: A => B): M[B] = I.fmap(self, f)
def <:>[B](f: A => B): M[B] = I.fmap(self, f)
def flatMap[B](f: A => M[B]): M[B] = I.flatMap(self, f)
def liftA2[B, C](f: (A, B) => C): M[B] => M[C] = m => I.liftA2(f)(self, m)
// ...
然后通过一系列隐式转换规则,将不同的类型映射为对应的 Monad 。例如对 List 和 Seq 和 Try 的映射:
implicit val listMonad: Monad[List] = new Monad[List] {
override def pure[A](element: A): List[A] = List(element)
override def fmap[A, B](m: List[A], f: A => B): List[B] = m.map(f)
override def flatMap[A, B](m: List[A], f: A => List[B]): List[B] = m.flatMap(f)
}
implicit val seqMonad: Monad[Seq] = new Monad[Seq] {
override def pure[A](element: A): Seq[A] = Seq(element)
override def fmap[A, B](m: Seq[A], f: A => B): Seq[B] = m.map(f)
override def flatMap[A, B](m: Seq[A], f: A => Seq[B]): Seq[B] = m.flatMap(f)
}
implicit val tryMonad: Monad[Try] = new Monad[Try] {
override def pure[A](element: A): Try[A] = Success(element)
override def fmap[A, B](m: Try[A], f: A => B): Try[B] = m.map(f)
override def flatMap[A, B](m: Try[A], f: A => Try[B]): Try[B] = m.flatMap(f)
}
而 Parsec 的映射比较特殊,严格来说 Parsec[E, T] 并不应该成为 Monad ,它是一个 E=>T 的算子,记录的是计算规则。 Monad 化的应该是它的计算结果,即 Try[T] 。但是在 Haskell 中我们可以借助 curry 语法干净的实现 Parsec 算子的组装。在 Scala 中这就比较困难。
所以我借助 Type Lambda 语法,强行将 Parsec[E, T] 封装成 Monad[T] 类型,模拟了 Haskell 的效果:
object Parsec {
def apply[E, T](parser: State[E] => Try[T]): Parsec[E, T] = parser(_)
implicit def toFlatMapper[E, T, O](binder: Binder[E, T, O]): (T)=>Parsec[E, O] = binder.apply
implicit def mkMonad[T]: Monad[({type P[A] = Parsec[T, A]})#P] =
new Monad[({type P[A] = Parsec[T, A]})#P] {
override def pure[A](element: A): Parsec[T, A] = Return(element)
override def fmap[A, B](m: Parsec[T, A], f: A => B): Parsec[T, B] = m.ask(_).map(f)
override def flatMap[A, B](m: Parsec[T, A], f: A => Parsec[T, B]): Parsec[T, B] = state => for {
a <- m.ask(state)
b <- f(a).ask(state)
} yield b
}
}
这个 Type Lambda 在 Scala 2.12 中不能被正确识别,所以我需要对不同的版本分离出不同的实现。
SBT 提供了内置的支持,我们可以在代码目录中建立不同版本的子目录:
当我们用 ++ 选项指定版本时,sbt会根据编译器版本,将 scala-2.12 或 scala-2.13目录下的版本包含进去。
而 Intellij 也会根据 scalaVersion 选择对应的子版本目录。
对于 2.12 版本,我并不追求类型设计的效果,只要能够正常的解析字符串就可以。因此对于 2.12 的 Parsec 定义,我做了降级,首先将各种 Monad 方法,包含 <* 、>>= 等运算符的实现,都直接写进 Parsec 定义,其次也不追求实现一个支持 [E, T] 的完整的 toMonad 方法,而是定义一个支持 Parsec[Char, T] 类型的 toMonad。因为只需要支持 Parsec[Char, T],所以就可以视作一个单类型参数的简单泛型类。在这个前提下,2.12 和 2.13 版本的使用方法完全一致,所有测试都正常通过。
其实我还试验了 scala 2.11,这个版本似乎不支持 SAM(即 Java 8 随 Lambda 语法引进的那个单方法实现类型自动识别),也就是说,Scala 2.12/2.13共享的大部分方法,对于 2.11 需要重写,如果我加入 2.11 支持,就要在sbt里设定 2.11 包含一整个独立编译的代码结构。这个部分要如何组织才合理,我还没有想好。
最后,根据sbt文档,我应该可以通过
sbt + publish
发布跨版本的项目。因为我需要向 sonatype 的开放源码库发布项目,所以需要调用 publishSigned 任务。
但是不知道是否 publishSigned 子任务还不支持 + ,还是我的 sbt 定义有问题,我最终是修改 scalaVersion,将 2.12 和 2.13 的版本各发布了一遍。
就在刚刚,经过社区同行的指点,我解决了 2.11 的问题。主要通过这样几个步骤:
首先,在sbt 的 crossVersions 中加入 2.11
lazy val scala213 = "2.13.6"
lazy val scala212 = "2.12.15"
lazy val scala211 = "2.11.12"
lazy val supportedScalaVersions = List(scala213, scala212, scala211)
version := "0.7.4"
scalaVersion := scala213
libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.12" % "test"
libraryDependencies += "org.xerial" % "sqlite-jdbc" % "3.36.0.3" % "test"
crossScalaVersions := supportedScalaVersions
然后,对 2.11 加入一个单独的编译选项,使其支持 SAM 。
def scalacOptionsVersion(scalaVersion: String): Seq[String] = {
Seq(
"-unchecked",
"-deprecation",
"-Xlint",
"-Xfatal-warnings",
"-Ywarn-dead-code",
"-encoding", "UTF-8"
) ++(CrossVersion.partialVersion(scalaVersion) match {
case Some((2, scalaMajor)) if scalaMajor == 11 => Seq("-Xexperimental")
case _ => Nil
})
}
val appSettings = Seq(
scalacOptions := scalacOptionsVersion(scalaVersion.value)
)
这样,我们可以在 2.11 版本中使用 SAM,但是 2.11 的类型推导能力比后续版本要弱一些,其它版本中形如
val parser: Parsec[E, _] = s => for {
的SAM类型定义,要修改为
val parser: Parsec[E, _] = (s: State[E]) => for {
,为参数指定类型后,代码就可以正常编译了。
此外还有一个与 Parsec 的类型推导无关的兼容问题,scala 2.11 中没有 scala.collection.mutable.TreeMap 类型,因此我对TxtState 代码做了一些修改,让它使用 java.collection 。
package jaskell.parsec
import scala.collection.JavaConverters.asScalaSetConverter
class TxtState(val txt: String, val newLine:Char = '\n') extends CommonState[Char] {
override val content: Seq[Char] = txt.toCharArray.toSeq
val lines: java.util.SortedMap[scala.Int, scala.Int] = {
val result = new java.util.TreeMap[scala.Int, scala.Int]();
result.put(0, 0);
for(index <- Range(0, txt.length)){
val c = txt.charAt(index)
if(c == newLine) {
val lastIndex = result.lastKey
result.put(lastIndex, index);
if(index < txt.length - 1) {
result.put(index+1, index+1)
}
}
}
result
}
def lineByIndex(index: scala.Int): scala.Int = {
var i = 0
for(idx:scala.Int <- lines.keySet().asScala){
if(idx <= index && index <= lines.get(idx)) {
return i
}
i += 1
}
-1
}
}
object TxtState {
def apply(txt: String, newLine: Char='\n'): TxtState = new TxtState(txt, newLine)
}
需要注意的是,代码中使用的 import scala.collection.JavaConverters 在 2.12 和 2.13 中已经 deprecated 。所以后续的版本中,scala 2.12 和 2.13 的实现,我仍然改回会使用 scala.collection.mutable.TreeMap 。目前的这个版本只是一个暂时存在的遗漏。但是它对暴露在外的功能接口没有影响。
最后,publish的问题也找到了,我在 + 后面多写了一个空格,正确的发布命令应该是
sbt +publishSigned