1. 程式人生 > >一篇入門 — Scala 宏

一篇入門 — Scala 宏

原理 any pre tree self ngs prefix 子類 osi

前情回顧

上一節, 我簡單的說了一下反射的基本概念以及運行時反射的用法, 同時簡單的介紹了一下編譯原理知識, 其中我感覺最為的地方, 就屬泛型的幾種使用方式了.
而最抽象的概念, 就是對於符號和抽象樹的這兩個概念的理解.

現在回顧一下泛型的幾種進階用法:

  • 上界 <:
  • 下界 >:
  • 視界 <%
  • 邊界 :
  • 協變 +T
  • 逆變 -T

現在想想, 既然已經有了泛型了, 還要這幾個功能幹嘛呢? 其實可以類比一下, 之前沒有泛型, 而為什麽引入泛型呢?

當然是為了代碼更好的服用. 想象一下, 本來一個方法沒有入參, 但通過參數, 可以減少很多相似代碼.

同理, 泛型是什麽, generics. 又叫什麽, 類型參數化. 本來方法的入參只能接受一種類型的參數, 加入泛型後, 可以處理多種類型的入參.

順著這條線接著往下想, 有了逆變和協變, 我們讓泛型的包裝類也有了類繼承關系, 有了繼承的層級關系, 方法的處理能力又會大大增加.

泛型, 並不神奇, 只是省略了一系列代碼, 而且引入泛型還會導致泛型擦除, 以及一系列的隱患. 而類型擦除其實也是為了兼容更早的語言, 我們束手無策.
但泛型在設計上實現的數據和邏輯分離, 卻可以大大提高程序代碼的簡潔性和可讀性, 並提供可能的編譯時類型轉換安全檢測功能. 所以在可以使用泛型的地方我們還是推薦的.

編譯時反射

上篇文章已經介紹過, 編譯器反射也就是在Scala的表現形式, 就是我們本篇的重點 宏(Macros).

Macros 能做什麽呢?

直白一點, 宏能夠

Code that generates code

還記得上篇文章中, 我們提到的AST(abstract syntax tree, 抽象語法樹)嗎? Macros 可以利用 compiler plugincompile-time 操作 AST, 從而實現一些為所以為的...任性操作

所以, 可以理解宏就是一段在編譯期運行的代碼, 如果我們可以合理的利用這點, 就可以將一些代碼提前執行, 這意味著什麽, 更早的(compile-time)發現錯誤, 從而避免了 run-time錯誤. 還有一個不大不小的好處, 就是可以減少方法調用的堆棧開銷.

是不是很吸引人, 好, 開始Macros的盛宴.

黑盒宏和白盒宏

黑盒和白盒的概念, 就不做過多介紹了. 而Scala既然引用了這兩個單詞來描述宏, 那麽兩者區別也就顯而易見了. 當然, 這兩個是新概念, 在2.10之前, 只有一種宏, 也就是白盒宏的前身.

官網描述如下:
Macros that faithfully follow their type signatures are called blackbox macros as their implementations are irrelevant to understanding their behaviour (could be treated as black boxes).
Macros that can‘t have precise signatures in Scala‘s type system are called whitebox macros (whitebox def macros do have signatures, but these signatures are only approximations).

我怕每個人的理解不一樣, 所以先貼出了官網的描述, 而我的理解呢, 就是我們指定好返回類型的Macros就是黑盒宏, 而我們雖然指定返回值類型, 甚至是以c.tree定義返回值類型, 而更加細致的具體類型, 即真正的返回類型可以在宏中實現的, 我們稱為白盒宏.

可能還是有點繞哈, 我舉個例子吧. 在此之前, 先把二者的位置說一下:

2.10

  • scala.reflect.macros.Context

2.11 +

  • scala.reflect.macros.blackbox.Context
  • scala.reflect.macros.whitebox.Context

黑盒例子

import scala.reflect.macros.blackbox

object Macros {
    def hello: Unit = macro helloImpl

    def helloImpl(c: blackbox.Context): c.Expr[Unit] = {
        import c.universe._
        c.Expr {
              Apply(
                    Ident(TermName("println")),
                    List(Literal(Constant("hello!")))
              )
        }
    }
}

但是要註意, 黑盒宏的使用, 會有四點限制, 主要方面是

  • 類型檢查
  • 類型推到
  • 隱式推到
  • 模式匹配

這裏我不細說了, 有興趣可以看看官網: https://docs.scala-lang.org/overviews/macros/blackbox-whitebox.html

白盒例子

import scala.reflect.macros.blackbox

object Macros {
    def hello: Unit = macro helloImpl

    def helloImpl(c: blackbox.Context): c.Tree = {
      import c.universe._
      c.Expr(q"""println("hello!")""")
    }
}

Using macros is easy, developing macros is hard.

了解了Macros的兩種規範之後, 我們再來看看它的兩種用法, 一種和C的風格很像, 只是在編譯期將宏展開, 減少了方法調用消耗. 還有一種用法, 我想大家更熟悉, 就是註解, 將一個宏註解標記在一個類, 方法, 或者成員上, 就可以將所見的代碼, 通過AST變成everything, 不過, 請不要變的太離譜.

Def Macros

方法宏, 其實之前的代碼中, 已經見識過了, 沒什麽稀奇, 但剛才的例子還是比較簡單的, 如果我們要傳遞一個參數, 或者泛型呢?

看下面例子:

object Macros {
    def hello2[T](s: String): Unit = macro hello2Impl[T]

    def hello2Impl[T](c: blackbox.Context)(s: c.Expr[String])(ttag: c.WeakTypeTag[T]): c.Expr[Unit] = {
        import c.universe._
        c.Expr {
            Apply(
                Ident(TermName("println")),
                List(
                    Apply(
                        Select(
                            Apply(
                                Select(
                                    Literal(Constant("hello ")),
                                    TermName("$plus")
                                ),
                                List(
                                    s.tree
                                )
                            ),
                            TermName("$plus")
                        ),
                        List(
                            Literal(Constant("!"))
                        )
                    )
                )
            )
        }
    }
}

和之前的不同之處, 暴露的方法hello2主要在於多了參數s和泛型T, 而hello2Impl實現也多了兩個括號

  • (s: c.Expr[String])
  • (ttag: c.WeakTypeTag[T])

我們來一一講解

c.Expr

這是Macros的表達式包裝器, 裏面放置著類型String, 為什麽不能直接傳String呢?
當然是不可以了, 因為宏的入參只接受Expr, 調用宏傳入的參數也會默認轉為Expr.

這裏要註意, 這個(s: c.Expr[String])的入參名必須等於hello2[T](s: String)的入參名

WeakTypeTag[T]

記得上一期已經說過的TypeTagClassTag.

scala> val ru = scala.reflect.runtime.universe
ru @ 6d657803: scala.reflect.api.JavaUniverse = scala.reflect.runtime.JavaUniverse@6d657803

scala> def foo[T: ru.TypeTag] = implicitly[ru.TypeTag[T]]
foo: [T](implicit evidence$1: reflect.runtime.universe.TypeTag[T])reflect.runtime.universe.TypeTag[T]

scala> foo[Int]
res0 @ 7eeb8007: reflect.runtime.universe.TypeTag[Int] = TypeTag[Int]

scala> foo[List[Int]]
res1 @ 7d53ccbe: reflect.runtime.universe.TypeTag[List[Int]] = TypeTag[scala.List[Int]]

這都沒有問題, 但是如果我傳遞一個泛型呢, 比如這樣:

scala> def bar[T] = foo[T] // T is not a concrete type here, hence the error
<console>:26: error: No TypeTag available for T
       def bar[T] = foo[T]
                       ^

沒錯, 對於不具體的類型(泛型), 就會報錯了, 必須讓T有一個邊界才可以調用, 比如這樣:

scala> def bar[T: TypeTag] = foo[T] // to the contrast T is concrete here
                                    // because it's bound by a concrete tag bound
bar: [T](implicit evidence$1: reflect.runtime.universe.TypeTag[T])reflect.runtime.universe.TypeTag[T]

但, 有時我們無法為泛型提供邊界, 比如在本章的Def Macros中, 這怎麽辦? 沒關系, 楊總說過:

任何計算機問題都可以通過加一層中間件解決.

所以, Scala引入了一個新的概念 => WeakTypeTag[T], 放在TypeTag之上, 之後可以

scala> def foo2[T] = weakTypeTag[T]
foo2: [T]=> reflect.runtime.universe.WeakTypeTag[T]

無須邊界, 照樣使用, 而TypeTag就不行了.

scala> def foo[T] = typeTag[T]
<console>:15: error: No TypeTag available for T
       def foo[T] = typeTag[T]

有興趣請看
https://docs.scala-lang.org/overviews/reflection/typetags-manifests.html

Apply

在前面的例子中, 我們多次看到了Apply(), 這是做什麽的呢?
我們可以理解為這是一個AST構建函數, 比較好奇的我看了下源碼, 搜打死乃.

class ApplyExtractor{
    def apply(fun: Tree, args: List[Tree]): Apply = {
        ???
    }
}

看著眼熟不? 沒錯, 和ScalaList[+A]的構建函數類似, 一個延遲創建函數. 好了, 先理解到這.

Ident

定義, 可以理解為Scala標識符的構建函數.

Literal(Constant("hello "))

文字, 字符串構建函數

Select

選擇構建函數, 選擇的什麽呢? 答案是一切, 不論是選擇方法, 還是選擇類. 我們可以理解為.這個調用符. 舉個例子吧:

scala> showRaw(q"scala.Some.apply")
res2: String = Select(Select(Ident(TermName("scala")), TermName("Some")), TermName("apply"))

還有上面的例子:
"hello ".$plus(s.tree)

Apply(
    Select(
        Literal(Constant("hello ")),
        TermName("$plus")
    ),
    List(
        s.tree
    )
)

源碼如下:

class SelectExtractor {
    def apply(qualifier: Tree, name: Name): Select = {
        ???
    }
}

TermName("$plus")

理解TermName之前, 我們先了解一下什麽是Names, Names在官網解釋是:

Names are simple wrappers for strings.

只是一個簡單的字符串包裝器, 也就是把字符串包裝起來, Names有兩個子類, 分別是TermNameTypeName, 將一個字符串用兩個子類包裝起來, 就可以使用Select 在tree中進行查找, 或者組裝新的tree.

官網地址

宏插值器

剛剛就為了實現一個如此簡單的功能, 就寫了那麽巨長的代碼, 如果如此的話, 即便Macros 功能強大, 也不易推廣Macros. 因此Scala又引入了一個新工具 => Quasiquotes

Quasiquotes 大大的簡化了宏編寫的難度, 並極大的提升了效率, 因為它讓你感覺寫宏就像寫scala代碼一樣.

同樣上面的功能, Quasiquotes實現如下:

object Macros {
    def hello2[T](s: String): Unit = macro hello2Impl[T]

    def hello2Impl[T](c: blackbox.Context)(s: c.Expr[String])(ttag: c.WeakTypeTag[T]): c.Expr[Unit] = {
        import c.universe._
        val tree = q"""println("hello " + ${s.tree} + "!")"""
        
        c.Expr(tree)
    }
}

q""" ??? """ 就和 s""" ??? """, r""" ??? """ 一樣, 可以使用$引用外部屬性, 方便進行邏輯處理.

Macros ANNOTATIONS

宏註釋, 就和我們在Java一樣, 下面是我寫的一個例子:
對於以class修飾的類, 我們也像case class修飾的類一樣, 完善toString()方法.

package com.pharbers.macros.common.connecting

import scala.reflect.macros.whitebox
import scala.language.experimental.macros
import scala.annotation.{StaticAnnotation, compileTimeOnly}

@compileTimeOnly("enable macro paradis to expand macro annotations")
final class ToStringMacro extends StaticAnnotation {
    def macroTransform(annottees: Any*): Any = macro ToStringMacro.impl
}

object ToStringMacro {
    def impl(c: whitebox.Context)(annottees: c.Expr[Any]*): c.Expr[Any] = {
        import c.universe._

        val class_tree = annottees.map(_.tree).toList match {
            case q"$mods class $tpname[..$tparams] $ctorMods(...$paramss) extends ..$parents { $self => ..$stats }" :: Nil =>

                val params = paramss.flatMap { params =>
                    val q"..$trees" = q"..$params"
                    trees
                }
                val fields = stats.flatMap { params =>
                    val q"..$trees" = q"..$params"
                    trees.map {
                        case q"$mods def toString(): $tpt = $expr" => q""
                        case x => x
                    }.filter(_ != EmptyTree)
                }
                val total_fields = params ++ fields

                val toStringDefList = total_fields.map {
                    case q"$mods val $tname: $tpt = $expr" => q"""${tname.toString} + " = " + $tname"""
                    case q"$mods var $tname: $tpt = $expr" => q"""${tname.toString} + " = " + $tname"""
                    case _ => q""
                }.filter(_ != EmptyTree)
                val toStringBody = if(toStringDefList.isEmpty) q""" "" """ else toStringDefList.reduce { (a, b) => q"""$a + ", " + $b""" }
                val toStringDef = q"""override def toString(): String = ${tpname.toString()} + "(" + $toStringBody + ")""""

                q"""
                    $mods class $tpname[..$tparams] $ctorMods(...$paramss) extends ..$parents { $self => ..$stats
                        $toStringDef
                    }
                """

            case _ => c.abort(c.enclosingPosition, "Annotation @One2OneConn can be used only with class")
        }

        c.Expr[Any](class_tree)
    }
}

compileTimeOnly

非強制的, 但建議加上. 官網解釋如下:

It is not mandatory, but is recommended to avoid confusion. Macro annotations look like normal annotations to the vanilla Scala compiler, so if you forget to enable the macro paradise plugin in your build, your annotations will silently fail to expand. The @compileTimeOnly annotation makes sure that no reference to the underlying definition is present in the program code after typer, so it will prevent the aforementioned situation from happening.

StaticAnnotation

繼承自StaticAnnotation的類, 將被Scala解釋器標記為註解類, 以註解的方式使用, 所以不建議直接生成實例, 加上final修飾符.

macroTransform

def macroTransform(annottees: Any*): Any = macro ToStringMacro.impl

對於使用@ToStringMacro修飾的代碼, 編譯器會自動調用macroTransform方法, 該方法的入參, 是annottees: Any*, 返回值是Any, 主要是因為Scala缺少更細致的描述, 所以使用這種籠統的方式描述可以接受一切類型參數.
而方法的實現, 和Def Macro一樣.

impl

def impl(c: whitebox.Context)(annottees: c.Expr[Any]*): c.Expr[Any] = {
    import c.universe._
    ???
}

到了Macros的具體實現了. 這裏其實和Def Macro也差不多. 但對於需要傳遞參數的宏註解, 需要按照下面的寫法:

final class One2OneConn[C](param_name: String) extends StaticAnnotation {
    def macroTransform(annottees: Any*): Any = macro One2OneConn.impl
}

object One2OneConn {
    def impl(c: whitebox.Context)(annottees: c.Expr[Any]*): c.Expr[Any] = {
        import c.universe._
        
        // 匹配當前註解, 獲得參數信息
        val (conn_type, conn_name) = c.prefix.tree match {
            case q"new One2OneConn[$conn_type]($conn_name)" =>
                (conn_type.toString, conn_name.toString.replace("\"", ""))
            case _ => c.abort(c.enclosingPosition, "Annotation @One2OneConn must provide conn_type and conn_name !")
        }
        
        ???
    }
}

有幾點需要註意的地方:

  1. 宏註解只能操作當前自身註解, 和定義在當前註解之下的註解, 對於之前的註解, 因為已經展開, 所以已經不能操作了.
  2. 如果宏註解生成多個結果, 例如既要展開註解標識的類, 還要直接生成類實例, 則返回結果需要以塊(Block)包起來.
  3. 宏註釋必須使用白盒宏.

Macro Paradise

Scala 推出了一款插件, 叫做Macro Paradise(宏天堂), 可以幫助開發者控制帶有宏的Scala代碼編譯順序, 同時還提供調試功能, 這裏不做過多介紹, 有興趣的可以查看官網: Macro Paradise

一篇入門 — Scala 宏