V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
sadhen
V2EX  ›  程序员

Scala 元编程:实现 lombok.Data

  •  
  •   sadhen ·
    darcy-shen · 2019-01-01 17:26:14 +08:00 · 1468 次点击
    这是一个创建于 2158 天前的主题,其中的信息可能已经有所发展或是发生改变。

    如果你读完了《 Scala 元编程:伊甸园初窥》,理论上你已经具备实现lombok.Data的能力了。

    所以,我建议你不要阅读本文,直接自己尝试。

    定义 lombok.Data 的 Scala 版

    @data
    class A {
      var x: Int = _
      var y: String = _
    }
    

    我们希望通过@data这个注释,自动生成如下代码:

    class A {
      var x: Int = _
      var y: String = _
    
      def getX(): Int = x
      def setX(paramX: Int): Unit = { x = paramX }
      def getY(): Int = x
      def setY(paramY: String): Unit = { y = paramY }
    }
    

    为什么要生成这样的代码呢?就个人而言,我是为了在 Spring Boot 和 Scala 混合编写的项目中无缝地使用 MyBatis。在使用 Java 时,我们可以很方便地使用 lombok.Data 生成我们所需的 Getter 和 Setter。而在 Scala 生态中,已经有了 case class,这种写法其实对于 Pure Scala 的程序员来说,是相当离经叛道的。

    在用 Scala 和 Java 混合编程的时候,我觉得其实最重要的一点是选择。实现一个功能的方式可能有 100 (二进制哦)种,但是最适合的方式,永远只有一种。

    我选择了 MyBatis,不采用元编程的手段(其实这段时间我刚刚学会),我是这样做的:

    import scala.beans.BeanProperty
    class A {
      @BeanProperty var x: Int = _
      @BeanProperty var y: String = _
    }
    

    用 Vim 列编辑,其实也还好。但是我内心其实一直在对自己说:DO NOT REPEAT YOURSELF

    参考实现

          // ...
          annottees.map(_.tree).toList match {
            case q"""
                  class $name {
                    ..$vars
                  }
                  """ :: Nil =>
    
              // Generate the Getter and Setter from VarDefs
              val beanMethods = vars.collect {
                case q"$mods var $name: $tpt = $expr" =>
                  val getName = TermName("get" + name.encodedName.toString.capitalize)
                  val setName = TermName("set" + name.encodedName.toString.capitalize)
                  println(getName)
                  val ident = Ident(name)
                  List (
                    q"def $getName: $tpt = $ident",
                    q"def $setName(paramX: $tpt): Unit = { $ident = paramX }"
                  )
              }.flatten
    
              // Insert the generated Getter and Setter
              q"""
                 class $name {
                   ..$vars
                   ..$beanMethods
                 }
               """
            case _ =>
              throw new Exception("Macro Error")
          }
        }
        // ...
    

    单元测试

    上一篇元编程相关的文章实际上主要是为了强调构建,所以我贴了两次构建定义的代码。

      test("generate setter and getter") {
        @data
        class A {
          var x: Int = _
          var y: String = _
        }
    
        val a = new A
        a.setX(12)
        assert(a.getX === 12)
        a.setY("Hello")
        assert(a.getY === "Hello")
    }
    

    lombok 在 IntelliJ Idea 中有专门的插件,去处理 Idea 无法定位到的程序自动生成的 Getter 和 Setter。如果我们只是为了让 MyBatis 能够识别和使用,我们就没有必要再去为我们的 Scala 版lombok.Data专门定制一个插件。在我们自己的代码中,没有必要使用 Getter 和 Setter,因为 Scala 在语言级别已经支持了(如果你一脸懵逼,我建议你先阅读一下《快学 Scala 》和《 Scala 实用指南》的样章)。

      test("handle operator in the name") {
        @data
        class B {
          var op_+ : Int = _
        }
    
        val b = new B
        b.setOp_+(42)
        assert(b.getOp_+ === 42)
      }
    

    这个地方也涉及到了一个 Scala 相关的知识点,我记得在《快学 Scala 》中看到过。在参考实现中,与这个单测相关的代码是这两行:

    val getName = TermName("get" + name.encodedName.toString.capitalize)
    val setName = TermName("set" + name.encodedName.toString.capitalize)
    

    这里就不展开了。

    单元测试的风格

    Scala 项目的单测,我一直用 ScalaTest,但是 ScalaTest 官网的例子给的是 FlatSpec:

      "A Stack" should "pop values in last-in-first-out order" in {
        val stack = new Stack[Int]
        stack.push(1)
        stack.push(2)
        stack.pop() should be (2)
        stack.pop() should be (1)
      }
    

    大概是这种代码风格。我们需要在两个地方填入一些信息,有点烦人。所以,我推荐 FunSuite 这种风格:

      test("A Stack pop values in last-in-first-out order"){
        val stack = new Stack[Int]
        stack.push(1)
        stack.push(2)
        stack.pop() should be (2)
        stack.pop() should be (1)
      }
    

    我只需要填一句话,不需要考虑主语,对 IDE 也更加友好。

    编译期与运行时

    这是元编程里面两个特别重要的概念。广义上讲,实际上,这些概念都在试图提醒我们,注意一下是谁(那台机器上的那个进程)在运行我们的代码。

    提交代码的时候,不小心忘记把调试用的println(getName)清理掉,索性就不去清理了。

    使用 sbt 去运行我们的单元测试:

    $ sbt
    sbt:paradise-study> test
    // 编译期开始
    [info] Compiling 1 Scala source to $HOME/github/paradise-study/lombok/target/scala-2.12/test-classes ...
    getX
    getY
    getOp_$plus
    [info] Done compiling.
    // 编译期结束
    // 运行
    [info] DataSuite:
    [info] - generate setter and getter
    [info] - handle operator in the name
    [info] Run completed in 437 milliseconds.
    [info] Total number of tests run: 2
    [info] Suites: completed 1, aborted 0
    [info] Tests: succeeded 2, failed 0, canceled 0, ignored 0, pending 0
    [info] All tests passed.
    [success] Total time: 4 s, completed 2019-1-1 16:15:43
    

    小结

    本文是 Scala 元编程的一个 Case Study,完整的工程见:https://github.com/sadhen/paradise-study

    最近几天刚刚学习 Scala 元编程,直觉告诉我,Scala 元编程并不难,当然,这取决于相关的 Domain Knowledge 有没有提前储备好。

    1 条回复    2019-01-02 00:23:31 +08:00
    hepin1989
        1
    hepin1989  
       2019-01-02 00:23:31 +08:00
    写的非常用心,赞一个,不够唯一的是,我们在 Scala 中倒的确是不需要 lombok,不过作者这里也是演示 paradis 的强大之处。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   1039 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 22ms · UTC 20:51 · PVG 04:51 · LAX 12:51 · JFK 15:51
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.