LoginSignup
9
1

Crystalのちょっと気になるところ

Last updated at Posted at 2023-06-27

はじめに

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 | NilT?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は DateDateTime が分かれている
  • Java(やKotlin)にも ZonedDate, ZonedDateTime などがある

など、きちんと日付のみを扱う型が分かれているため、Crystalの標準ライブラリも見習ってほしいと感じます。

YAML

Crystalの標準ライブラリには、YAMLを扱うための YAML というライブラリが存在します。
このライブラリを使用することで、自前のクラス等を簡単にシリアライズ・デシリアライズすることができるのですが、このデフォルトのシリアライザは細かい制御ができません4

例えば、使用するアンカーの命名法などはこちら側で制御したいことが多いかと思います。実際、JVM言語のの有名ライブラリであるJacksonでは、こうした細かい点について、 JsonIdentityInfo 等のアノテーションで制御できるようになっています。

最後に

Crystal は設計思想的にもその実装的にもいい言語だと思いますが、だからこそ実装の甘い点、発展途上な点が気になってしまいます。個人的には、Crystalコミュニティのますますの発展と、それによる諸問題の解決がされることを期待してやみません5

  1. 日本ユーザコミュニティの第一回ミーティングに出たことが自慢です

  2. らしいです

  3. YAML のデシリアライザの挙動などに見て取れます

  4. どうしても制御したければ、to_yaml を自作してしまえばよいだけですが

  5. とはいえ、今回だいぶ様々なことにハマりましたし、満足なLSP実装もないしなので、次にこの用途で書くならCrystalではなくKotlin/Nativeあたりかなぁという気がしています。

9
1
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
1