今年もアドベントカレンダーの季節がやってきました。この1年のClojure界隈の動きを振り返ってみましょう。
Clojure 1.12
次期バージョンであるClojure 1.12は昨年から開発が進められていますが、年を越して今になってもまだリリースには達していません。昨年の1.12.0-alpha1
のリリースからこれまで1.12.0-alpha5
までの4つのアルファリリースが加えられています。
今年新たに1.12のリリースに含められるとアナウンスされている変更のうち、主要なものは以下の3つです:
add-lib
- Java interopの強化
- 仮想スレッドサポートに向けた修正
以下でそれぞれについて詳しく見ていきましょう。
add-lib
add-lib
はREPLから動的に依存ライブラリを追加するための機能です。
これまでのClojureでは、依存ライブラリを新たに追加する場合、そのライブラリをロードするためにはREPLを起動し直す必要がありました1。add-lib
機能を使うと、REPLから依存ライブラリを追加し、REPLを再起動することなくライブラリをロードすることができるようになります。
add-lib
はClojure 1.12からREPLで使えるようになる関数で、追加するライブラリを引数に指定します:
(add-lib 'ring/ring-mock)
;=> [cheshire/cheshire … ring/ring-codec ring/ring-mock tigris/tigris]
戻り値として新たにロードされたライブラリ(指定したライブラリの依存ライブラリ等も含む)のコレクションが返されます。
ライブラリのバージョンをオプショナルに指定することもできます:
(add-lib 'ring/ring-mock '{:mvn/version "0.4.0"})
他にも以下のような関数が追加され、これらもadd-lib
同様、REPLから使えるようになります:
-
add-libs
: 複数のライブラリを一度に追加する(それらの依存ライブラリも同時に解決される) -
sync-deps
:deps.edn
に追加した新たなライブラリをロードする
もともと、Clojureにはクラスローダ(DynamicClassLoader
)の機能を使ってライブラリを動的にロードすることはできましたが、依存ライブラリを追加するためには依存ライブラリ自身の依存を解決する機能がClojure本体に欠けていました。
add-lib
機能の実現にあたっては、この欠けていた依存ライブラリ解決の機能をClojure CLIに委譲するという方法が採られました。add-lib
関数を呼び出すと、内部的にはClojure CLIが別プロセスで起動され、そのプロセス内で依存ライブラリが解決されて、add-lib
を呼び出したClojureのクラスローダでライブラリをロードする、という仕組みになっています。
また、add-lib
実現に伴って、以下のような基盤機能も追加されています:
add-lib
機能はClojure 1.12以降を使っていれば、基本的にはどのように起動されたREPLからでも使えるようになっていますが、上記の通り仕組みのうえでClojure CLIを呼び出しているため、少なくとも環境にClojure CLIがインストールされていなければいけない点に注意が必要です。
参考
- https://clojure.org/news/2023/04/14/clojure-1-12-alpha2
- CLJ-2761: REPL support for adding libs and syncing deps.edn
Java interopの強化
1.12では、ClojureからJavaの機能を呼び出す、いわゆる Java interop の機能強化も予定されています。強化される予定の機能は以下のとおりで、Java 8で導入されたStream APIやJavaのラムダ式といった機能のサポート等、比較的新しいJavaの機能への対応が主な変更です:
- 関数型インタフェースのサポート強化
- Java Streamのサポート強化
- メソッドがファーストクラスの関数に
- 引数タグメタデータ
- 配列型の新記法
関数型インタフェースのサポート強化
Java 8でラムダ式が導入され、最近Javaに追加されるAPIではラムダ式が使えることを前提にしたもの多くなってきました。たとえば、JavaのStream APIは以下のようなラムダ式を多用したインターフェースを提供します:
IntStream.range(0, 10)
.filter(x -> x % 2 == 0)
.map(x -> x * x)
.forEach(System.out::println);
このコードは以下のラムダ式を使わないコードと意味的には等価ですが、map
やfilter
等のメソッドが具体的にどのような型を引数にとるのかをユーザが明示する必要がないため、ラムダ式を使った方が全体的に簡潔な記述になります:
IntStream.range(0, 10)
.filter(new IntPredicate() {
public boolean test(int x) {
return x % 2 == 0;
}
})
.map(new IntUnaryOperator() {
public int applyAsInt(int x) {
return x * x;
}
})
.forEach(new IntConsumer() {
public void accept(int x) {
System.out.println(x);
}
});
この例の中のIntPredicate
やIntUnaryOperator
等のように、メソッドを1つしか持たないインタフェースは関数型インタフェース(functional interface)と呼ばれ、Javaのラムダ式はこのような関数型インタフェースに対してその実装を与えるための簡単な方法を提供します。
ClojureでJava interopの機能を使ってこのような関数型インタフェースを要求するAPIを使おうとすると、これまではこの冗長なコード例をそのままClojureに翻訳したようなコードを書く必要がありました:
(-> (IntStream/range 0 10)
(.filter
(reify IntPredicate
(test [_ x]
(even? x))))
(.map
(reify IntUnaryOperator
(applyAsInt [_ x]
(* x x))))
(.forEach
(reify IntConsumer
(accept [_ x]
(println x)))))
1.12以降では、関数型インタフェースに対してClojureの関数を直接渡せるようになる予定のため、先のサンプルコードは以下のように簡潔に記述できるようになります:
(-> (IntStream/range 0 10)
(.filter even?)
(.map #(* % %))
(.forEach println))
この例で示している通り、even?
のようにdefn
で定義した名前つきの関数も #(...)
などの無名関数も、どちらも関数型インタフェースとして渡すことができます。
参考
Java Streamのサポート強化
1.12では、JavaのStream APIに対するサポートも強化される予定です。
具体的には、以下のようなStream
を消費する(いわゆる終端操作)関数が追加されます:
-
stream-seq!
:Stream
をClojureのシーケンスに変換する -
stream-into!
:Stream
版のinto
-
stream-reduce!
:Stream
版のreduce
-
stream-transduce!
:Stream
版のtransduce
Clojureで積極的にStream
を使う必要性はあまりありませんが、JavaのAPIから渡ってくるStream
をClojureで消費するだけの使い方であれば、これらの関数を使う方が便利な場面もありそうです。
参考
メソッドがファーストクラスの関数に
関数型インタフェースを期待するJavaのメソッドにClojureの関数を渡せるようにするのと対をなす変更として、1.12ではJavaのメソッドをClojureの関数として使えるようにする変更も予定されています。
これまでは、Javaのメソッドはファーストクラスの値ではなかったので、たとえばmap
のような高階関数に直接渡して使うことはできませんでした:
(map Long/parseLong ["1" "2" "3"])
;; Syntax error compiling at (REPL:1:1).
;; Unable to find static field: parseLong in class java.lang.Long
;; これまではこう書く必要があった
(map #(Long/parseLong %) ["1" "2" "3"])
1.12以降では、Long/parseLong
をフォーストクラスの値として高階関数に渡したり、関数の戻り値として使うことができるようになります:
(map Long/parseLong ["1" "2" "3"])
;=> (1 2 3)
上の例はstaticメソッドを呼び出す例でしたが、コンストラクタやインスタンスメソッドも同様にClojureの関数として扱うことができるようになります。
コンストラクタを値として扱う場合には、通常のコンストラクタ呼び出しと同じように、クラス名の後に.
を後置して参照します:
(repeatedly 3 Object.)
;=> (#object[java.lang.Object 0x7c853486 "java.lang.Object@7c853486"]
; #object[java.lang.Object 0x174e1b69 "java.lang.Object@174e1b69"]
; #object[java.lang.Object 0x1046498a "java.lang.Object@1046498a"])
インスタンスメソッドを値として扱う場合には、今回新しく入る.<class name>/<method name>
というシンタックスによってメソッドが属するクラスを明示します。たとえば、String
クラスのtoUpperCase
メソッドを値として参照する場合は以下のように記述します:
(map .String/toUpperCase ["a" "b" "c"])
;=> ("A" "B" "C")
ちなみに、この.<class name>/<method name>
というレシーバクラスを明示するシンタックスは通常のメソッド呼び出しでも使えるようになります:
(.String/toUpperCase "a")
;=> "A"
参考
引数タグメタデータ
メソッドやコンストラクタを値として扱う際、そのメソッドやコンストラクタがオーバーロードされていると、どのオーバーロードを呼び出せばいいか一意に特定できないためエラーになります。
たとえば、String
クラスのvalueOf
メソッドは各プリミティブ型や配列型のために計9つのオーバーロードも持ちます。そのため、String/valueOf
をそのまま値として使おうとすると、呼び出すメソッドが特定できずエラーになります:
String/valueOf
;; Syntax error (IllegalArgumentException) compiling at (REPL:0:0).
;; Ambiguous arg-tags for valueOf in class java.lang.String
このような場合を解決するために、新しく引数タグ(arg-tags)というメタデータが追加されました。引数タグは、^[Type1 ... TypeN]
という形式のメタデータでメソッドの引数型の組Type1
, ..., TypeN
を指定することでオーバーロードされたメソッドを特定します。
String/valueOf
の例では、以下のように引数タグでメソッドの引数型を指定することでオーバーロードを解決できるようになります:
(map ^[int] String/valueOf [1 2 3])
;=> ("1" "2" "3")
(map ^[double] String/valueOf [1 2 3])
;=> ("1.0" "2.0" "3.0")
コンストラクタやインスタンスメソッドについても同様に引数タグをつけてオーバーロードを解決できます:
(map Long. [1 2 3])
;; Syntax error (IllegalArgumentException) compiling at (REPL:1:1).
;; Ambiguous arg-tags for java.lang.Long in class java.lang.Long
(map ^[long] Long. [1 2 3])
;=> (1 2 3)
(map ^[String] Long. ["1" "2" "3"])
;=> (1 2 3)
(let [f .String/getBytes]
(f "abc" "UTF-8"))
;; Execution error (ArityException) at user/eval401 (REPL:1).
;; Wrong number of args (2) passed to: user/eval401/dot--getBytes403--405
(let [f ^[String] .String/getBytes]
(f "abc" "UTF-8"))
;=> #object["[B" 0x514cd540 "[B@514cd540"]
また、オーバーロードは複数存在していても、引数の数だけ指定すればオーバーロードを一意に特定できるような場合には、プレースホルダーとして型のかわりに_
(アンダースコア)を指定することもできます。
たとえば、Long/toString
には引数の数が1つのオーバーロードと2つのオーバーロードが存在します。ここで、引数の数が1つのオーバーロードを呼ぼうとする場合、以下のように引数型を明示的に指定することもできますが、
(map ^[long] Long/toString [1 2 3])
;=> ("1" "2" "3")
以下のように、引数の数が1つであることだけを示すことでオーバーロードを解決することも可能です:
(map ^[_] Long/toString [1 2 3])
;=> ("1" "2" "3")
なお引数タグは、通常のメソッド呼び出しに対しても指定できるようになり、従来オーバーロードを解決するのに引数に対して型ヒントを与えていたのを置き換える役割で使うことができます:
(^[long] String/valueOf 42)
;=> "42"
(^[boolean] String/valueOf true)
;=> "true"
参考
配列型の新記法
1.12では、Javaの配列型のサポートも強化される予定です。
これまで、配列型の型ヒントを書く際には、Javaの内部的な表現を使って[Ljava.lang.String;
(String
の配列)や [[D
(double
の二次元配列)のように書く必要がありました。これは実装都合が表出した必要以上に複雑な表現で、初心者にとってもとっつきにくいものでした。
1.12からはより簡潔で分かりやすい配列型の表現が追加されます。具体的には、<type name>-*
のように型名に-*
を後置することで配列型を表現できるようになります。末尾につけられる*
の数が配列の次元数を表し、二次元配列は-**
、三次元配列は-***
、…というようになります。
この記法によって、String
の配列はString-*
、double
の二次元配列はdouble-**
と書けるようになります。この記法は、型ヒントや引数タグとして使える他、値としてのクラスを表現するのにも使えます:
(class? String-*)
;=> true
(instance? long-* (long-array [1 2 3]))
;=> true
(let [^long-* arr (long-array [1 2 3])]
(alength arr))
;=> 3
;; 引数タグでも使える
(^[double-* double] java.util.Arrays/binarySearch (double-array [1.0 2.0 3.0]) 2.0)
;=> 1
参考
仮想スレッドサポートに向けた修正
1.12にはさらに、Javaの仮想スレッドサポートに向けた修正も含まれます。仮想スレッド (virtual thread)はJava 21で正式に採用された軽量スレッドで、今後利用シーンが広がっていくことが予想されます。
Clojureからも仮想スレッドはJava interopを介してそのまま使うことができます。しかし、遅延シーケンスやdelay
等の実装の中で、任意のユーザコードがsynchronized
ブロック中で実行される箇所があり、そこでスレッドを長期間ブロックしてしまう可能性がありました。この潜在的な問題を解決するために、遅延シーケンスやdelay
の実装がsynchronized
ベースの排他制御から仮想スレッドと併用しても問題ないReentrantLock
ベースの排他制御に書き換えられました。
こちらの修正はすでに1.12.0-alpha5
としてリリースされていて利用可能です。この変更によって遅延シーケンスの性能がわずかに悪くなると言われているので注意が必要です。
参考
- https://clojure.org/news/2023/10/20/clojure-1-12-alpha5
- CLJ-2804: User code with I/O under synchronized blocks created by e.g. LazySeq/Delay pins virtual threads in Loom
Rich Hickey、Nubankを退職
今年8月、Clojure開発者のRich Hickeyが、所属するNubankの退職と商用ソフトウェア開発からの引退を発表しました。
引退は長期的に計画されていたもので、Clojureの開発自体には引き続き関わっていくことになるようです。Clojureの開発チームが所属し、Nubankの子会社でもあるCognitectとNubankの関係もこれまでと変わりません。Alex MillerやMichael Fogus等、Clojureの開発にメインで関わっているメンバーは引き続きRich Hickeyと協力してClojureの開発を進め、Stuart HallowayはDatomicの開発をリードしていきます。
「引退」と聞くとネガティブな響きもありますが、アナウンスがあったときのRichの言葉によれば、
Retirement returns me to the freedom and independence I had when originally developing Clojure. The journey continues!
(引退後は、もともとClojureを開発していたときのような自由と独立が戻ってきます。旅は続く!)
とのことで、今後のClojureの開発についても前向きな姿勢であることが窺えます。
Clojure/conj復活
コロナ禍の影響で2020年から開催が中止されていたClojure/conjが今年4年ぶりに開催されました。
Clojure/conj は世界最大級のClojureカンファレンスで、今年は2トラック25講演がありました。また、今回は初の試みとして配信チケットが販売され、リアルタイムでのオンライン配信が実施されました。
講演の内容はYouTubeで視聴可能になっています。
参考
https://clojure.org/news/2023/01/06/deref
Babashka Conf開催
今年は初めてBabashka単独のカンファレンスも開かれました。
Babashka はMichiel Borkent(@borkdude)が開発する非公式のClojureインタプリタで、GraalVMでビルドされ起動が速いため、Clojureライクな言語でスクリプトを書く用途で近年人気を集めています。当日は7講演と5つのLTがありました。
こちらも講演内容はYouTubeで視聴可能です。
参考
Clojure CLIがパッケージ管理ツールのシェア首位に
今年行われたClojureサーベイで、Clojure CLIが初めてClojureのパッケージ管理ツールとしてLeiningenを抜いて首位になったことが分かりました。
Clojure CLIは2017年に登場して以降年々シェアを拡大し、これまでのデファクトスタンダードだったLeiningenに迫っていましたが、今年になってLeiningenユーザの割合が60%超に対してClojure CLIユーザの割合が70%超と大勢が逆転しました。
これは、Clojure CLIではGitリポジトリやローカルディレクトリを直接依存ライブラリとして使える柔軟性があることや、Babashka等の外部ツールと依存ライブラリの定義を共有がしやすいこと、またtools.build
の登場によりClojure CLIでもビルドが容易になったこと等の複合的な理由によるものではないかと考えられます。
参考
REBLの後継ツール、Morseリリース
ClojureのインスペクタであるREBLの後継ツールとして、Morseが新たにリリースされました。
Morseは、前身のREBLと同じくREPLから使えるグラフィカルインスペクタで、操作性についてはおおむねREBLを踏襲しています。
REBLとの大きな違いは、MorseではReplicantという仕組みによって、別プロセス上にあるリモートオブジェクトを辿る際に、オブジェクトの一部のみを透過的にフェッチできることにより、巨大なマップや遅延シーケンスを効率的にトラバースできる点にあります。Replicantのサーバおよびクライアントはこのような機能を提供する独立したライブラリになっており、MorseはこのReplicant機能のデモンストレーション的な立ち位置になっています。
参考
Datomicが無償化
今年のClojure/conj開催に合わせて、Datomicが無償化されるアナウンスがありました。
Datomic はCognitectが開発する分散データベースで、一度追加したデータを変更できないデータモデルやDatalogというクエリ言語等の特徴を持ちます。Datomicは本格的な開発用途に対しては有料ライセンスで提供されていましたが、現在は無償で使えるようになっています(なお、オープンソース化はされていません)。
さらに6月には、クラウド(AWS)上で動くDatomic Cloudも後に続いて無償化が発表されました:
今後ますますDatomicの活用事例が増えていくかもしれません。
おわりに
しばらく落ち着き気味だったClojure界隈ですが、今年発表された機能改善は久しぶりにコンパイラに手の入るようないくつもあり、また待望されていたadd-lib
機能が入ったので、それらが今後の言語の使用感や開発者体験にどれだけ大きな影響を与えるのか、期待して待ちましょう!
-
そういう機能を提供する Pomegranate 等のサードバーティのライブラリはありましたが、公式にはそういった機能はありませんでした ↩