これは、NTTテクノクロス Advent Calendar 2020の初日の記事です。
はじめに
こんにちは。NTTテクノクロスの中野です。
結構前になってしまいますが、会社ブログでGroovy/Grailsの記事を書いてました。ちなみに、この社外ブログでの最新の執筆記事は、2020/10/02に公開したNTT Performance Tuning Challenge 2020 で優勝してきたです(ドヤァ)。このコンテストでもGo言語版を選択したように、最近は業務でもGo言語を使うことが多いのですが、もちろん今もGroovy/Grailsが大好きです。
というわけで、今日は久しぶりにGroovyの情報を紹介していきたいと思います。
Groovy 3.0 がリリースされました!
もう少しで1年経ちそうなくらいなので「何をいまさら」な感じですが、今年の2020/02/10にGroovy 3.0がリリースされています。主なポイントを以下に紹介します。
Javaとのシンタックス互換性の飛躍的向上
Daniel Sun氏の活躍で実装されたParrotパーサーによって、Javaとのシンタックス互換性が一気に高まりました。
元々Groovy 2.x系でもJava 7以前との互換性はかなり高く、Java 7のソースコードをほぼ修正なしでGroovyのソースコードとして扱えたのですが、Java 8以降に採用されたシンタックスへの追随がかなりビハインド状態で、「Javaシンタックスとの親和性」について声高にアピールしづらいもやもや期間が長く続いていました。この状況が、Groovy 3.0のParrotパーサーによって、ついに解消されたわけです。
新しくサポートされたシンタックスをいくつか紹介すると...
- Groovyのクロージャを書く時に、Javaのラムダ記法がつかるようになった
- for文の初期化式としてカンマ区切りで複数の変数の初期化が書けるようになった
- (例):
for(int i = 0, j = 10; i < j; i++, j--) {..}
- (例):
- Javaと同じ配列の初期化記法が使えるようになった
- (例):
new int[]{1, 2, 3}
- (例):
- do-while文が使えるようになった
- try-with-resourcesが使えるようになった
- スコープを分けるブロックとして機能するだけの波括弧が使えるようになった
- 以前は、クロージャ記法なのかブロックなのかが曖昧になるため、エラーとしていた
- メソッド参照が使えるようになった
- などなど
- ※ 動作可能なサンプルコードがみたい方はこちらのGistを参照のこと
これで「GroovyはJavaシンタックスとの親和性が高い」と胸を張っていえるようになりましたね。
とはいえ、JavaはJavaでどんどん改善が進んでいて新しいシンタックスが増えていっているので、いたちごっこはまだまだ続くわけですが、新しいParrotパーサーではそういった新しいシンタックスへの追随も柔軟に対応できるようなので、それほど悲観的になる必要はないでしょう。
なお、Groovy 3.0には従来のレガシーパーサーも同梱されているので、以前の挙動を望む場合には自分で切り替えることもできます(Groovy 4.0で削除予定)。
JPMS(Jigsaw)対応
JPMS(Jigsaw)では、モジュールごとに個別のパッケージ名を付ける必要があります。元々Groovyには独自の「モジュール」がありましたが、これはJPMSが要求するようなパッケージ分離性を満たしていませんでした。JPMS要件を満たすためには、多数のクラスを元とは異なるパッケージに移動する必要がありました。しかし、いきなり「移動」してしまうと、既存のGroovyソースコードがGroovy 3.0でコンパイル/実行できなくなってしまいます。そこで、Groovy 3.0では移行期間として、ひとまず「移動」ではなく「コピー」することになりました。
- 実際にコピーされたクラスの例
-
groovy.util.XmlParser
→groovy.xml.XmlParser
-
groovy.util.XmlSlurper
→groovy.xml.XmlSlurper
-
groovy.util.GroovyTestCase
→groovy.test.GroovyTestCase
-
Groovy 3.0では、groovy.util.XmlParser
などの古いパッケージ側のクラスも残されていますが、これはGroovy 4.0で削除される予定です。今から実装する場合は、新しいパッケージ側のクラスを使うようにしましょう。
というわけで、実際にJPMS準拠となるのはGroovy 4.0からの予定です。Groovy 3.0の段階ではまだJPMS準拠のモジュールとしては使えません。従来通り、クラスパスに配置する必要があります。
Groovy 4.0 はどうなる?
今のところリリース時期は未定のようですが、次のメジャーバージョンであるGroovy 4.0の開発が着々と進められています。
ちなみにアルファ版(4.0.0-alpha-1)はすでに公開されていて、SDKMAN!を使っていれば、以下のコマンドで簡単にインストールして試すことができます。
> sdk install groovy 4.0.0-alpha-1
さて、Groovy 4.0でのおもな変更点をピックアップして紹介していきましょう。
JPMS(Jigsaw)対応
前述したとおり、Groovy 3.0で仕込んでおいたJPMS対応の大詰めです。互換性のためにGroovy 3.0で残してあった旧パッケージ側のクラス(または旧パッケージそのもの)がすべて削除されます。古いパッケージのクラスを使っているソースコードは、新しいパッケージの方を使うように修正する必要があります。これによって、JPMS準拠のモジュールとしてGroovyを利用できるようになります(のはず)。
レガシーパーサーの削除
Parrotパーサーのみが同梱されて、以前のレガシーパーサーは削除されます。
Maven/GradleのグループID変更
かなり前になりますが、GroovyはCodehaus1というホスティングサービスを使ってホスティングしていた時期があります。その時期に付けられたorg.codehaus.groovy
というMaven/GradleのグループIDがいまだにそのまま使われ続けていたのですが、ついにというかやっとというか、org.apache.groovy
に変更されます。ビルドスクリプトなどの依存関係定義で、Groovyのバージョンを4に変更するときには、忘れずにグループIDも修正しましょう。
新機能: 組み込みの型チェッカー
従来からGroovyの静的型付けチェックには、選択的に特定の型チェックを弱めて動的なコードを通しやすくしたり、逆にむしろJavaよりも厳しくチェックしたりする機能があったようです(そうだっけ?)。一部では使われていたもののあまり広まってなかったので、普及のために使えそうなものを組み込みで用意してみたよ、ということのようです。
たとえば、以下のコードには問題があるのですが、どこかわかりますか?
def whenIs2020Over() {
def newYearsEve = '2020-12-31'
def matcher = newYearsEve =~ /(\d{4})-(\d{1,2})-(\d{1,2}/ // --[1]
//...
}
そうですね。[1]の部分の正規表現の末尾で閉じ丸括弧が漏れているため、実行するとPatternSyntaxException
がスローされてしまいます。従来であれば、これは実行時エラーとしてしか検出できませんが、Groovy 4.0からは以下のようにアノテーションを付与すると...
import groovy.transform.TypeChecked
@TypeChecked(extensions = 'groovy.typecheckers.RegexChecker')
def whenIs2020Over() {
def newYearsEve = '2020-12-31'
def matcher = newYearsEve =~ /(\d{4})-(\d{1,2})-(\d{1,2}/
//...
}
コンパイル時に検出できるようになります。
1 compilation error:
[Static type checking] - Bad regex: Unclosed group near index 26
(\d{4})-(\d{1,2})-(\d{1,2}
at line: 6, column: 19
これは、オプションのgroovy-typecheckersモジュールの機能として提供されます。
新機能: 組み込みのマクロメソッド
Groovy 2.5でGroovy Macroという機能が導入されました。C/C++のマクロのように、グローバル関数のようなものをソースコードに書いておくと、AST変換によって実コードが展開されてコンパイルされる、という仕組みなのですが、こっちも普及のためにいくつか使えそうなものを標準で用意してみることにしたよ、という話のようです。
その一つとして、昔ながらのプリントデバッグを支援するマクロが用意されています。たとえば、次のようにいくつかの変数を定義したとします。
def num = 42
def list = [1, 2, 3]
def range = 0..5
def string = 'foo'
ここで、デバッグ目的でこれらをプリントしたいとします。
println "num=$num, list=$list, range=$range, string=$string"
と頑張って書けば、
num=42, list=[1, 2, 3], range=[0, 1, 2, 3, 4, 5], string=foo
と出力できますが、ちょっとタイピング量が多すぎる感じがありますね。
新しく追加されたNV
マクロメソッドを使ってみます。
println NV(num, list, range, string)
これを実行すると、次のように出力されます。
num=42, list=[1, 2, 3], range=[0, 1, 2, 3, 4, 5], string=foo
変数名だけ列挙していけば良いので、たしかにちょっと手軽になった感じがします。
同じ系統で、NVI
とNVD
という2つのバリエーションがあります。それぞれの違いは、値の文字列化の方式です。
-
NV
: 値の文字列化にtoString()
を使う -
NVI
: 値の文字列化にGroovy JDKのinspect()
を使う -
NVD
: 値の文字列化にGroovy JDKのdump()
を使う
さきほどのrange
変数を出力して違いをみてみると、こんな感じです。
println NV(range)
//=> range=[0, 1, 2, 3, 4, 5]
println NVI(range)
//=> range=0..5
println NVD(range)
//=> range=<groovy.lang.IntRange@14 from=0 to=5 reverse=false inclusive=true modCount=0>
これは、オプションのgroovy-macro-libraryモジュールの機能として提供されます。
新機能: JavaShell (incubating)
Groovyのソースコードを動的にコンパイルして実行するGroovyShell
というクラスが元々ありますが、これのJava版として、Javaのソースコードを動的にコンパイルして実行するJavaShell
が追加されます。たとえば、JDK 14で追加されたrecord機能を使った文字出力内容を検証するためのGroovyコードは次のようにかけます。
import org.apache.groovy.util.JavaShell
def opts = ['--enable-preview', '--release', '14']
def src = 'record Coord(int x, int y) {}'
Class coordClass = new JavaShell().compile('Coord', opts, src)
assert coordClass.newInstance(5, 10).toString() == 'Coord[x=5, y=10]'
また、標準バンドルされているGroovy Consoleで、エディタ上のソースコードをJavaとして実行するための「Run as Java」メニューが追加されます。
新機能: POJO Annotation (incubating)
これは相当に野心的・実験的な機能です。
基本的に、GroovyでPOJOのようなクラスを書いたとしても、コンパイルしたクラスの実行時にはGroovyのJARファイルが必要になります。Groovyのクラスが持つ動的性質を実現するために、専用の下回りの機構が必要となるためです。これに対して、クラスに新しい@POJO
アノテーション(と既存の@CompileStatic
)をつけることで、そのクラスの動的性質に関わるすべてをクラスファイルに組み込んでしまい、実行時にGroovyのJARファイルがなくてもピュアJavaの世界でそのまま実行できるようにしてしまおう、というのがこの新機能になります。
もし、これが実用化できると、Groovyをプリプロセッサとして利用できる可能性がでてきます。Lombokに似ていますが、Groovyの言語パワーを活かせるため、より高度な応用が期待できそうです。
ただし、徐々に改善していけるという見込みはあるらしいものの、現状でGroovyのすべての動的性質をサポートできているわけではないので、まだまだincubating状態が続きそうな気配です。
新機能: recordライクなクラス (incubating)
Java 14で、プレビュー機能としてrecordが導入されました。これを受けて、Groovyでもrecordライクなクラスを書くためのサポートが追加されます。
元々Groovyでは、Groovy Beansとして、Java Beansを強力に拡張する機能が用意されています。たとえば、こんな感じです。
class Book {
String title
String author
}
// Mapを受け取るコンストラクタが自動生成されている
def book = new Book(title: "プログラミングGROOVY", author: "..., 中野")
// getterが自動生成されている
assert book.getTitle() == "プログラミングGROOVY"
// setterが自動生成されている
book.setTitle("JavaからGroovyへ")
assert book.getTitle() == "JavaからGroovyへ"
// インスタンス変数への直接参照のようにみえるがgetterを経由している
assert book.title == "プログラミングGROOVY"
// インスタンス変数への直接代入のようにみえるがsetterを経由している
book.title = "JavaからGroovyへ"
assert book.title == "JavaからGroovyへ"
また、Groovyには複数のAST変換アノテーションを集約して1つのアノテーションとして再利用可能にするメタアノテーションという仕組みがあります。たとえば、クラスの不変性を実現するための@Immutable
アノテーションを1つ指定するだけで、次に列挙するサブアノテーションが持っている各機能がAST変換によって付与されます。
// @Immutableとしてのベース
@ImmutableBase
// クラスを不変(immutable)にするための各種サブアノテーション
@ImmutableOptions
@PropertyOptions(propertyHandler = ImmutablePropertyHandler)
@KnownImmutable
// toString()を自動生成する
@ToString(cache = true, includeSuperProperties = true)
// equals()とhashCode()を自動生成する
@EqualsAndHashCode(cache = true)
// インスタンス変数の定義順で受け取るコンストラクタを自動生成する
@TupleConstructor(defaults = false)
// Mapを受け取るコンストラクタを自動生成する
@MapConstructor
さて、Java 14のrecordクラスは、このGroovyの@Immutable
を付与したクラスとある程度類似しているのですが、完全には一致していません。とはいえ、Groovyのメタアノテーションを使えば、recordとの差分を調整した新しい@RecordType
アノテーションを追加するのは比較的簡単です。これには、以下のようなサブアノテーションが集約されます。
// @RecordTypeとしてのベース
@RecordBase
// ピュアJavaな世界で実行可能なクラスファイルを生成する(前項で説明)
@POJO
// クラスを不変(immutable)にするための各種サブアノテーション(@Immutableと共通)
@ImmutableOptions
@PropertyOptions(propertyHandler = ImmutablePropertyHandler)
@KnownImmutable
// toString()を自動生成する(@Immutableと共通)
@ToString(cache = true, includeNames = true)
// equals()とhashCode()を自動生成する(@Immutableと共通)
@EqualsAndHashCode(cache = true, useCanEqual = false)
// インスタンス変数の定義順で受け取るコンストラクタを自動生成する(@Immutableと共通)
@TupleConstructor(defaults = false)
// Mapを受け取るコンストラクタを自動生成する(@Immutableと共通)
@MapConstructor
これを使った例を見てみましょう。
import groovy.test.GroovyAssert
@groovy.transform.RecordType
class Book {
String title
String author
}
// インスタンス変数の定義順で受け取るコンストラクタが自動生成されている
def book = new Book("プログラミングGROOVY", "..., 中野")
// インスタンス変数名と同名のgetterが自動生成されている
assert book.title() == "プログラミングGROOVY"
// Groovy Beansとしてのgetter/setterは生成されない
// (最後まで一通り実行できるように、期待する例外がスローされたかどうかをチェック
// するGroovyのアサーション機能を使っている)
GroovyAssert.shouldFail(MissingMethodException) {
book.getTitle()
//=> Caught: groovy.lang.MissingMethodException: No signature of method: Book.getTitle() ...
}
GroovyAssert.shouldFail(MissingMethodException) {
book.setTitle("JavaからGroovyへ")
//=> Caught: groovy.lang.MissingMethodException: No signature of method: Book.setTitle()
}
// インスタンス変数への代入は禁止されている
// (Groovy BeansとしてのsetTitle()は生成されていないため、これはインスタンス変数への直接代入を意味する)
GroovyAssert.shouldFail(ReadOnlyPropertyException) {
book.title = "JavaからGroovyへ"
//=> Caught: groovy.lang.ReadOnlyPropertyException: Cannot set readonly property: title for class: Book
}
// toString()が自動生成されている
assert book.toString() == "Book(title:プログラミングGROOVY, author:..., 中野)"
// equals()が自動生成されている
assert book == new Book("プログラミングGROOVY", "..., 中野")
assert book != new Book("プログラミングGROOVY", "..., なかの")
将来のリリースでは、Javaのrecordと同等の記述ができるように、次のようなシンタックシュガーを提供する可能性があります。
// 注意: この構文はGroovy 4.0ではサポートされない
record Book(String title, String author){}
ひとまず当面は、@RecordType
を提供してみてユーザからのフィードバックを求める、という方針のようです。
新機能: Groovy Contracts (incubating)
契約による設計(Design by Contract)のために、クラス不変条件/前提条件/事後条件の指定をサポートするアノテーションを提供します2。付与されたアノテーションに従って、AST変換によって必要に応じてコンストラクタ/メソッドにチェックロジックが挿入されて、以下の3点を確認します。
- メソッドが実行される前に前提条件が満たされていること
- メソッドが実行された後に事後条件が保持されていること
- メソッドが呼び出される前後にクラス不変条件が真であること
たとえば、ロケットを表現するクラスで試してみるとこうなります。
import groovy.contracts.Ensures
import groovy.contracts.Invariant
import groovy.contracts.Requires
import groovy.test.GroovyAssert
import org.apache.groovy.contracts.ClassInvariantViolation
import org.apache.groovy.contracts.PostconditionViolation
import org.apache.groovy.contracts.PreconditionViolation
@Invariant({ speed() >= 0 }) // --[1]
class Rocket {
boolean started = false
int speed = 0
@Requires({ isStarted() }) // --[2]
@Ensures({ old.speed < speed }) // --[3]
def accelerate(inc) { speed += inc }
def isStarted() { started }
def speed() { speed }
}
// 初期速度が負の場合、[1]のクラス不変条件違反が検出されて例外がスローされる
// (最後まで一通り実行できるように、期待する例外がスローされたかどうかをチェック
// するGroovyのアサーション機能を使っている)
GroovyAssert.shouldFail(ClassInvariantViolation) {
new Rocket(speed: -1)
//=>
// Caught: org.apache.groovy.contracts.ClassInvariantViolation: <groovy.contracts.Invariant> Rocket
//
// speed() >= 0
// | |
// -1 false
}
def r = new Rocket()
assert !r.started
assert r.speed == 0
// start=falseの場合、[2]によって事前条件違反が検出されて例外がスローされる
GroovyAssert.shouldFail(PreconditionViolation) {
r.accelerate(5)
//=>
// Caught: org.apache.groovy.contracts.PreconditionViolation: <groovy.contracts.Requires> Rocket.java.lang.Object accelerate(java.lang.Object)
//
// isStarted()
// |
// false
}
// 事前条件違反なので、内部状態自体は更新されていない
assert r.speed == 0
// start=trueの場合、[2]に違反しないため、速度を加速できる
r.started = true
r.accelerate(5)
assert r.speed == 5
// 減速(負の加速)は、[3]によって事後条件違反が検出されて例外がスローされる
GroovyAssert.shouldFail(PostconditionViolation) {
r.accelerate(-1)
//=>
// Caught: org.apache.groovy.contracts.PostconditionViolation: <groovy.contracts.Ensures> Rocket.java.lang.Object accelerate(java.lang.Object)
//
// old.speed < speed
// | | | |
// | 5 | 4
// | false
// ['speed':5]
}
// 事後条件違反なので、内部状態自体は更新済み
assert r.speed == 4
なかなか使い勝手が良さそうに見えますね。
新機能: GINQ (under investigation)
Parrotパーサーの立役者であるDaniel Sun氏が今まさに精力的に開発中の、.NETの統合言語クエリ(LINQ: Language-Integrated Query)という機能のGroovy版となるGINQです。
統合クエリ言語といってもSQLやDBとは直接は関係ありません。ざっくりいうと、様々なコレクション的な入力3に対する操作を専用のシンタックスで記述できるというものです。Groovyでは元々each
, map
, findAll
などのコレクション操作がかなり充実しているので「既存のそれと何が違うの?」というのは自然な疑問だと思います。かく言う自分もまだあまりよくわかっていないのですが、GINQユーザガイド(2020/11/28版)などをみると、既存のGroovyのコレクション操作がLINQ的に言う「メソッド構文」であるのだとしたら、今回のGINQはLINQ的に言う「クエリ構文」を実現したものなのかな、という感じがします。
ここでいう、クエリ構文というのはSQLに似たLINQ固有のDSLです。ちなみに、筆者はまだLINQよくわかってない勢なので、GINQのシンタックスが本家や他のLINQ実装のシンタックスと共通なのか、なにか違いがあるのか、などは不明です。
さて、GINQを利用したごく簡単なサンプルコードをいくつか紹介するとこんな感じです。
assert [2, 4, 6] == GQ {
from n in [1, 2, 3]
select n * 2
}.toList()
// Groovyの既存コレクション操作で書くと...
assert [2, 4, 6] == [1, 2, 3].collect { it * 2 }
assert [[1, 1], [2, 4], [3, 9]] == GQ {
from v in (
from n in [1, 2, 3]
select n, Math.pow(n, 2) as powerOFN
)
select v.n, v.powerOFN
}.toList()
// Groovyの既存コレクション操作で書くと...
assert [[1, 1], [2, 4], [3, 9]] == [1, 2, 3].collect {
[it, Math.pow(it, 2) as int]
}
どうなんでしょう。上の例だと、既存のコレクション操作の方が圧倒的に簡潔でわかりやすいですが、もっと複雑なことをしようとしたときに何かメリットがでてくるのでしょうか。
入力の多様性という観点からすれば、入力となるデータソースが、コレクション(java.lang.Iterable
)、ストリーム(java.util.stream.Stream
)、配列、JSON、RDBMS、先行するGINQの出力結果のいずれであっても、変わらずに同じシンタックスでクエリがかける、というのはデータサイエンティスト的にはうれしい場面がありそうな気もします。
というわけで、まだ全然わかっていないので、今後も注目していきたいと思います。
ちなみに、GINQは、現在公開されているGroovy 4.0のアルファ版(4.0.0-alpha-1)には含まれていないようで、上記のサンプルは実行できませんでした。GINQユーザガイドも高頻度で更新されつつある状況なので、手元で試せるのはもうちょっと先になりそうな雰囲気です。
なお、すでにPHPにもGinqというLINQクローンがあって大変紛らわしいのですが、GrailsによるORマッパーの「GORM」に対して、Go言語によるORマッパーの「gorm」が後発で登場するなど、昨今は名前カブりに対してあまり気にしない世の中なんでしょうかね。ググラビリティが低くなるので、ユーザとしては困り物ですが。
おわりに
さて、だいぶ長くなってしまいましたが、Groovyの最新動向はいかがでしたでしょうか。
明日は、j-yamaによるElastic Stackに関する記事です。引き続き、NTTテクノクロス Advent Calendar 2020をお楽しみください。
参考URL
- [InfoQ] 新しく改良されたパーサを備えたGroovy 3.0への長い道
- Release notes for Groovy 3.0
- Release notes for Groovy 4.0
- GINQユーザガイド
-
Codehausはすでにサービス終了しています。 ↩
-
かなり昔にあったGContractsの標準版という感じです。なお、GContractsはだいぶ前からメンテナンスが停止していて、アーカイブ化されています。 ↩
-
コレクション(
java.lang.Iterable
)、ストリーム(java.util.stream.Stream
)、配列、JSON、RDBMS、先行するGINQの出力結果、などが入力のデータソースとして使用できます。 ↩