はじめに
Crystalという言語はご存知でしょうか。
この言語、およびその公式実装の特徴として、
- Ruby にそっくりな書きやすい文法を持つ
- 型アノテーションを記述できる
- ネイティブコードにコンパイルして、実行ファイルを作成できる
- 比較的高速
- 標準ライブラリがとても充実している
- 並列処理が書きやすい
- マクロが比較的清潔で綺麗
といった点が挙げられます。
そのため、ちょっとしたスクリプトをRubyでガリガリ書くような感覚で書くだけで、高速なネイティブバイナリが得られ、
後から型アノテーション等を加えるだけで保守性が格段に上がるという点で、非常に重宝していましたし、比較的熱心なファンとして応援していました1。
ですが最近ふたたび仕事でガッツリ書く機会があり、その中で様々な点がまだまだ発展途上だなと感じてしまったため、今回は敢えてそうした点を挙げたいと思います。
なお、検証等には以下のバージョンの実装を使用しました
$ crystal --version
Crystal 1.8.2 (2023-05-09)
LLVM: 15.0.7
Default target: aarch64-apple-darwin22.5.0
コンパイラの不具合
Crystal(の公式実装)を使用していると、比較的簡単にコンパイラの実装上の不具合に引っ掛かります。
その一例として、 alias
周りのカオスさを紹介したいと思います。
Crystal には型に別名をつける alias
という構文が存在します。
これは元々JSONパーサの実装を目的として、型を再帰させるために導入されたらしいのですが、実際に alias
で再帰データ型を表現しようとすると、あらゆることがバグります。
GitHub の issue を軽く見ただけでも、コンパイラがクラッシュする, 無限再帰するなどの問題があるようです。
よく当たる(そして割と困る)例としては、以下のようなものがあります。
alias
は型の別名でしかないため、通常はエイリアス名と元の型を ==
で比較すると true
が返ります。
icr:1> alias T1 = Hash(String, String)
=> nil
icr:2> T1 == Hash(String, String)
=> true
しかし、再帰的な型を alias
で記述すると、等価判定が意図した通りの結果を返しません。
icr:3> alias T2 = Hash(String, T2)
=> nil
icr:4> T2 == Hash(String, T2)
=> false
再帰型の等価判定は理論的には割と難しいことが知られている2のですが、さすがにこれくらいは直感に反しない結果を返してくれないと、思わぬところでつまづくことになってしまいます。
文法の不統一感
型の糖衣構文まわり
Crystalでは T | Nil
を T?
、Tuple(T1,T2)
を {T1, T2}
と書けるなど、よく使う型には便利な糖衣構文が導入されています。
また、T* #=> Pointer(T)
や T[N] #=> StaticArray(T, N)
など、CFFIなどに触らないかぎりあまり使わないであろう型にまで、こうした構文が導入されています。
しかし、最もよく使う型の代表格であるはずの Array(T)
や Hash(T, U)
には、こうした構文が存在しません。
また、こうした構文は式が書ける箇所には記述することができないため、 arr.is_a?(Int32[4])
はコンパイルが通り、 arr = Int32[4].new(0)
はコンパイルが通らないといった、(仕様を知らなければ)不可解な現象に遭遇します。
コレクションの初期化の記法
Crystal の標準ライブラリにも、他の多くの言語と同じように Array
, Set
, Hash
など、複数の値をまとめる型(コレクション)が用意されています。
しかし、これらの初期化方法に統一感がまるでなく、混乱の元になってしまっている、というのが実情です。
いくつか例を挙げてみましょう。
Array
-
[1,2,3] of Int32
や[1, 2, 3]
、[] of Int32
はOKだが、[]
はダメ(空配列は要素の型を明示する必要がある) -
Array{1,2,3}
やArray(Int32){1,2,3}
はOKだが、Array(Int32){}
はダメ({}
は不完全な空のHash-like
として扱われる) -
Int32[1,2,3]
やInt32[]
はOKだが、String["test"]
やString[]
はダメ(Int32[]
にはマクロが生えているが、String[]
には生えていない)
Hash
-
OKなもの
{"a" => 1}
{} of String => Int32
{"a" => 1} of String => Int32
Hash(String, Int32){"a" => 1}
Hash{"a" => 1}
-
ダメなもの
{} # 空のHashは型を明示する必要がある
Hash{}
Hash(String, Int32){} # パーサの都合上どうしても`{}`の後には `of` が来てほしいらしい
Set
-
OKなもの
Set{1,2,3}
Set(Int32){1,2,3}
Set(Int32).new
-
ダメなもの
Set{} # {} はどうしても記述できない
Set(Int32){}
StaticArray
-
OKなもの
StaticArray(Int32, 3).new { |i| [1, 2, 3][i] }
StaticArray(Int32, 3).new(0)
StaticArray(Int32, 3).new
-
ダメなもの
StaticArray(Int32, 3){1, 2, 3}
StaticArray(Int32, 3){}
標準ライブラリの辛いところ
Time
Crystalの標準ライブラリには、日時を扱う型が Time
しかなく、日付単体を扱う型が存在しません。
どうやら、日付を扱うためには、その日の 00:00:00Z
を代表値として扱うという方針があるように感じます3
しかし、これは典型的なバッドプラクティスです。
他の言語の標準ライブラリでは、
- Rubyは
Date
とDateTime
が分かれている - Java(やKotlin)にも
ZonedDate
,ZonedDateTime
などがある
など、きちんと日付のみを扱う型が分かれているため、Crystalの標準ライブラリも見習ってほしいと感じます。
YAML
Crystalの標準ライブラリには、YAMLを扱うための YAML
というライブラリが存在します。
このライブラリを使用することで、自前のクラス等を簡単にシリアライズ・デシリアライズすることができるのですが、このデフォルトのシリアライザは細かい制御ができません4。
例えば、使用するアンカーの命名法などはこちら側で制御したいことが多いかと思います。実際、JVM言語のの有名ライブラリであるJacksonでは、こうした細かい点について、 JsonIdentityInfo
等のアノテーションで制御できるようになっています。
最後に
Crystal は設計思想的にもその実装的にもいい言語だと思いますが、だからこそ実装の甘い点、発展途上な点が気になってしまいます。個人的には、Crystalコミュニティのますますの発展と、それによる諸問題の解決がされることを期待してやみません5。