Kotlin という JVM 言語は割と null 安全性を簡潔に書けるようにする努力をしていて、その1つに ?.
演算子があるそうです。それを inverse macro という Scala 拡張を使って実現してみました。(※これは自作の言語機構に関する宣伝記事です)
?.
演算子と難しさ
?.
演算子は null 安全性に気を使ったメソッド呼び出し演算子です。Java 系言語における普通のメソッド呼び出しの .
とは異なり、null チェックをすることで NullPointerException の発生を防ぎます。具体的には、最初に null チェックをし、null だったらメソッド呼び出しをスキップして結果を null にしているようです。Scala でいうと以下のように書いたとき、+(10)
がスキップされて結果が null
になるということです。
null.asInstanceOf[String]?.+(10)
では、早速 .?
演算子を Scala 上に実装し・・・たいところなのですが、聡明な読者にはお分かりのように、それには Scala のパーザーを拡張するところから始める必要があります。よって、?
メソッドと後続のメソッド呼び出しの2段階に分割することで妥協することにしましょう。つまり、以下のように ?
の前に .
を入れるようにするということです。これは Scala の構文を拡張する必要がありません。
null.asInstanceOf[String].?.+(10)
しかし、これも難しいです。+(10)
をスキップするという挙動がクセモノで、普通のメソッドにはこのような後続のコードを飛ばす処理を実現することが困難です。今は黒歴史となった限定継続演算子 shift/reset を使えばできると思う人もいるかもしれません。目の付け所はいいのですがやはり困難です。なぜなら、限定継続演算子を使えば確かにスキップすることができるのですが、スキップ範囲を囲む必要があり、あまり細かい制御に向いていないからです。
Inverse macro による実現
このクセモノの ?
演算子ですが、inverse macro を使うと実現できます。Inverse macro は私が開発している Scala 拡張で特殊なマクロです。継続演算子のような機能が欲しいけど既存のものだと小回りがきかずうまくいかない、というときにさくっとカスタム演算子を作るのに向いた言語機構です。レポジトリが https://github.com/hiroshi-cl/InverseFramework にあります。細かい説明は面倒なので省きます。
実装
では実装を見てみましょう。ソースコードは https://github.com/hiroshi-cl/scala-examples/tree/master/scala-examples-null にあります。
まず、?
メソッドを定義しましょう。そのためには以下のように implicit class を使います。
implicit class NullSafe[T](val v: T) extends AnyVal {
def ? : T@nullsafe = v
}
ここで返り値型に @nullsafe
という型アノテーションが付いているのがわかると思います。このアノテーションが inverse macro を示すマーカーであり、これが付いた型の式が展開されます。
次に inverse macro の中身を定義しましょう。そのためには以下のようにします。
class nullsafe extends inverse_macros.IMAnnotation
object nullsafe extends inverse_macros.IMTransformer {
import scala.reflect.macros.blackbox
override def transform(c: blackbox.Context)(targs: List[c.Type], argss: List[List[c.Tree]])
(api: c.internal.TypingTransformApi)
(head: c.Tree, cont: List[c.Tree]): (List[c.Tree], List[c.Tree]) = {
import c.universe._
head match {
case ValDef(mods, name, tpt, rhs) =>
val sym = head.symbol
val newCont = cont.collect {
case v@ValDef(cmods, cname, ctpt, crhs) if crhs.exists(_.symbol == head.symbol) =>
treeCopy.ValDef(v, cmods, cname, ctpt,
c.typecheck(q"if(${head.symbol} != null) $crhs else null"))
case t if t.exists(_.symbol == head.symbol) =>
c.typecheck(q"if(${head.symbol} != null) $t else null")
case t =>
t
}
List(head) -> newCont
case _ =>
...
}
}
}
まず、IMAnnotation
trait を mix-in しましょう。これによって展開エンジンが nullsafe
を inverse macro に関するアノテーションであると認識します。次にコンパニオンオブジェクトを作り、そこの transform
メソッドに実際の展開方法を定義します。ソースコードを見ても何をやっているかさっぱりだと思うので例を用いて説明します。
以下のようなソースコードがあったとします。
null.asInstanceOf[String].?.+(10)
これを展開エンジンが前処理で以下のように線形化します。
val _1: String = null.asInstanceOf[String].? // 右辺は String@nullsafe
_1.+(10)
1行目の右辺が _@nullsafe
型なのでマクロ展開します。head
と cont
として以下のコードに相当する木を切り取りって transform
メソッドを呼びます。
val _1: String = null.asInstanceOf[String].? // 右辺は String@nullsafe
_1.+(10)
最後に transform
メソッドで、null check コードを挿入します。具体的には cont
の中を検索して変数 _1
の使用を検出したら、if(_1 != null) ... else null
で囲みます。
このように、 inverse macro を使うと ?
が実装できます。
デモ
ここで作った ?
の実際の使用例を https://github.com/hiroshi-cl/scala-examples/blob/master/scala-examples-null/src/test/scala/nullsafe/NullSafeTest.scala に置きました。プロジェクトのルートディレクトリで sbt test
を実行すれば動いていることを確かめられると思います。
なお、このテストコードでは、inverse_macros.transform
というメソッド (中身はマクロ) の呼び出しを挿入することで手動で inverse macro の展開を起こしています。これは、inverse macro 展開エンジンの実装上の手抜きにより、メソッド定義 (コンストラクタ等を除く) のボディ内に書かれた inverse macro しか展開されないという制限によるものです。細かいことは気にしないでください。
Option などコンテナへの拡張
Option などコンテナへの拡張も当然考えられますが、それは少し面倒になります。なぜなら T | Null
はやはり T
でしたが、Option[T]
と T
の間には何の互換性もないため、適切に型変換を差し込む必要があるからです。例えば、以下のように !
を定義してそれを後ろで呼ぶことになります。なお、この !
は静的型チェックのためだけに必要なものなのでマクロ展開後には除去されるものだということに注意してください。
None.asInstanceOf[Option[String]].?.+(10).![Option[String]]
なお、Scala には implicit conversion があるので、それによって面倒なメソッド呼び出しを回避することも可能です。しかしながら、適用範囲が非常に広い型変換を定義することになるので、予期しない暗黙の型変換を起こすことにならないか、あるいは逆に変換漏れに気を使う必要が出てくるためあまりおすすめしません。それでもやりたいという方は、import Predef.{any2stringadd => _,_}
というおまじないくらいは覚えておくと良いでしょう。
余談: 実用性は?
Inverse macro などという大げさな道具を持ちださなくても、以下のように無名関数をとるメソッド形式にすれば Scala 標準の機能だけで簡単に実現することができます。。その上、記述コストの増加も大したことないですね。。
null.asInstanceOf[String].?(_.+(10))
悲しい