【Crystalと○○】 Crystalとは

公開:2020/10/20
更新:2020/11/04
9 min読了の目安(約8700字TECH技術記事

「Crystalと○○」と題して、プログラミング言語Crystalの機能紹介を書けたら、というシリーズの第一回なのですが、いきなり「Crystalと 」という変則的なタイトルで申し訳ありません。

初回ということで、ここではまずCrystal言語の生い立ちからご紹介することにしましょう。

「Slick as Ruby, Fast as C」をスローガン(?)に掲げるCrystalは、アルゼンチンのIT企業 Manas Technology Solutions(Manas.Tech)が中心となって開発されているプログラミング言語で、その生みの親であるManas.Techのエンジニア Ary Borenszweig はCrystalが生まれた経緯についてこう語っています。

自分等はRubyが本当に好きで、Manas Tech.でも様々なところで利用していた。ただ、実行速度の問題で他の言語にコードを移植する必要に迫られることがあって悲しい思いもしていた。もっと型に安全で、速く動作するRubyがほしかったけれど、その動的な性質を考えれば、Ruby自身ではそれを実現できそうにないこともわかっていた。だから、自分達のための言語を作り始めたんだ。[1]

Aryによって、「Joy」という名前のプログラミング言語が初めてGitHubへコミットされたのは2011年6月21日[2]のことです。この時点でのコンパイラの実装は ruby-llvm を使用した Ruby 1.9.2 によるものでした。

その3日後、JoyはCrystalと名前を変え[3]、それから1年少し経った2012年の9月4日にAryの個人プロジェクトからManas Tech.のプロジェクトとして再始動することになります。

さらに翌2013年の11月14日にはCrystalコンパイラ自身をCrystal言語によって実装するというセルフホスティング化を実現。そして2014年6月18日には初のリリース crystal v0.1.0[4] が公開されました。

以来6年と少し、時に大きな構文的な変化も取り入れながら、このドキュメント執筆時点(2020年10月)ではバージョンもv0.35.1まで進み、次のリリースはついにv1.0.0になるのではないかと言われています。まだそれほどメジャーな言語ではありませんが、近年では国内でもDMMで採用されていた[5]ほか、海外に目を向けると車載コンピュータの実装に採用される[6]など正規のプロダクト開発に使用される事例も増えてきました。

では、実際のCrystalとはどんな言語なのでしょうか。

Crystalの特徴

プログラミング言語としてのCrystalの特徴を3つ挙げるとすると、以下のような内要になるのではないでしょうか。

  1. Rubyとよく似た構文のオブジェクト指向言語であること
  2. 高速な実行バイナリを生成できるコンパイル型言語であること
  3. 型推論を備えた静的型付け言語であること

と言うわけで、これらの特徴について非常にザックリとご紹介しましょう。

Crystalの誕生した経緯から、どうしてもRubyとの比較が多くなりますがご了承ください。

Rubyとよく似た構文のオブジェクト指向言語である

Crystalの構文はRubyから非常に大きな影響を受けています。どの程度影響を受けているかと言えば、ものによってはRubyとCrystalの両方で実行可能なコードを書けてしまうほどです。

例えば、次のサンプルはコマンドライン引数で指定されたn番目のフィボナッチ数を返すRubyのコードですが、このコードはそのままCrystalでも実行可能です。

fib.rb
module Fibonacci
  CACHE = [0, 1]

  def self.[](n)
    raise "n must be greater than 0." if n < 0
    until CACHE.size > n
      CACHE << CACHE[-1] + CACHE[-2]
    end
    CACHE[n]
  end
end

ARGV.each do |n|
  puts Fibonacci[n.to_i]
end
$ ruby fib.rb 10
55
$ crystal build fib.rb
$ ./fib 10
55

Rubyの構文をご存知であれば、この短いサンプルコードの中だけでも、

  • モジュールや定数の定義
  • 配列のリテラル表現
  • #<<メソッドによる配列への要素追加
  • def構文による(クラス)メソッド定義
  • 定数ARGVを利用した実行時オプションの取得
  • #eachメソッドによるイテレーション処理
  • do ... endブロックとブロック引数
  • #to_iメソッドによる文字列の整数変換

などなど、様々な構文がRubyから取り入れられていることがお分かりいただけると思います。

Crystalは、このほかにもオブジェクト指向言語としての考え方の多くをRubyから受け継いでいます。数値も文字列も全てがオブジェクトで、基底のObject型から派生したクラスツリーに属さないプリミティブな型は基本的に存在しません。代入演算子=を除けば、算術演算子(+ - * /など)や、比較演算子(== < > <= >=など)なども原則メソッドとして定義されています。また、単一継承の型にモジュールによる機能の mix-in が可能な点や、型(主にモジュール)の包含関係が名前空間を構成する点などもRubyから取り入れられた考え方です。

とはいえ、先ほどのfib.rbのようにRubyのコードがそのままCrystalで動いてしまうような事例は非常に稀です。むしろ、実際のRubyのプロダクトコードがこのように無修正のままCrystal上で動作するようなことはほぼありません。Crystal自身も、あるころから「静的型付けでコンパイル可能なRuby」を目指すのではなく、「Crystalとしてあるべき姿」を追求するようになり、Rubyの構文との同一性維持には拘らない方針を明確にしています。

だとしても、Rubyの構文に慣れ親しんだ方であれば、Crystalの習得は比較的容易なのではないでしょうか。

高速な実行バイナリを生成できるコンパイル型言語である

基本的に処理系がインタプリタであるRubyに対して、Crystalはコンパイラ(crystal buildコマンド)によって実行形式のネイティブバイナリ(実行ファイル)を生成するコンパイル型言語です。

例えば、crystal buildコマンドにソースファイル(拡張子は .cr)を指定して実行すると、ソースファイル名から拡張子を取り除いた実行ファイルがカレントディレクトリに生成されます。

$ crystal build program.cr
$ ./program

構文上明らかなエラーは実行前のコンパイル時点で捕捉/指摘されますので、コード終盤のtypoやメソッドに渡す型の齟齬によって「1時間かけた処理が最後の最後で落ちる」といった状況はある程度回避されます。

err.cr
very_long_variable_name = "長い変数名"
puts very_long_valiable_name
#                ^- typo
$ crystal build err.cr 
Showing last frame. Use --error-trace for full trace.

In err.cr:2:6

 2 | puts very_long_valiable_name
          ^----------------------
Error: undefined local variable or method 'very_long_valiable_name' for top-level
Did you mean 'very_long_variable_name'?

しかし、実装中に動作確認のために毎回crystal build program.cr ./programを実行するのは地味に面倒です。そうした状況を想定して、Crystalにはコンパイルと実行を同時に行うcrystal runコマンドが用意されています。

これを使うと、前述のサンプルプログラムの実行は以下のように書くことも可能です。

$ crystal run fib.rb 10
55

この場合、実行バイナリは一時的な匿名フォルダ内に作成され、上記の例にあった実行ファイルfibは生成されません。また、runを省略して以下のようにしても上の例と同じ動作になります。

$ crystal fib.rb 10
55

さて、ではコンパイルされた実行ファイルはどの程度高速に動作するのでしょうか。それを確かめるために、次のようなコードを用意しました。これは与えられ整数n以下の素数を調べるための「エラトステネスの篩」を素直にRuby実装したもので、最後にn以下で最大の素数を標準出力に返します。このコードもまた、Crystalでそのまま実行可能なコードになっています。

eratosthenes.rb
module Eratosthenes
  def self.prime_numbers(n)
    raise "n must be greater than 2." unless n > 2
    route_n = Math.sqrt(n)
    primes = [2]
    list = (2..n).select{|i| i.odd? }.to_a

    while prime = list.shift
      primes << prime
      break if prime > route_n
      list = list.select{|i| i % prime != 0}
    end

    primes += list
  end
end

puts Eratosthenes.prime_numbers(ARGV.first.to_i).last
$ ruby eratosthenes.rb 20
19
$ crystal eratosthenes.rb 20
19

では、このコードを使って10,000,000(1千万)以下で最大の素数を計算する時間を、RubyとCrystalとで比べてみましょう。なお、実行環境は私が使っている13インチのMacBook Pro 2018年モデル(2.7 GHz クアッドコア Intel Core i7、16GBメモリ)で、RubyとCrystalのバージョンは、執筆時点で最新のruby 2.7.2、crystal 0.35.1を使用しています。また、全てのパターンで、10回実行したうち、トータル実行時間(real)が一番短い結果を示しています。

$ /usr/bin/time -l ruby eratosthenes.rb 10000000
9999991
       21.33 real        20.63 user         0.61 sys
 299085824  maximum resident set size
                ...
$ /usr/bimn/time -l crystal run eratosthenes.rb 10000000
9999991
        8.81 real         7.16 user         1.57 sys
 129785856  maximum resident set size
                ...

最近ではJITコンパイラの採用などで高速化が図られているRubyですが、それでもCrystalの方が約2.42倍ほど高速に動作しており、消費メモリも半分以下です。とはいえ、あえて言うほど速くもありませんね。

でも待ってください、crystal runコマンドの実行時間には、コンパイルの時間も含まれています。特にcrytalの実行結果でsystem時間が大きくなっているのは、コンパイルに伴う一時ファイルをテンポラリディレクトリに書き出す処理が多く含まれているためだと思われます。

ですので、次はcrystal buildで生成した実行ファイルで試してみましょう。

$ crystal build eratosthenes.rb
$ /usr/bimn/time -l ./eratosthenes 10000000
9999991
        6.50 real         6.45 user         0.04 sys
  92463104  maximum resident set size
                ...

その差は3.28倍まで広がり、使用メモリも1/3以下になりました。

ここでさらにもう一押し。crystal buildコマンドは、そのままではコンパイラの持っている最適化機能を使用しません。その分コンパイル位時間が短縮できるため、開発中のテストコンパイルには重宝するのですが、ここに--releaseオプションを追加すると、コンパイラによる最適化が行われます。

Crystalの公式ドキュメントでも、最終的なプロダクトとしての実行ファイル生成する際には、必ず--releaseオプションを使用することが推奨されています。ですので、最後にcrystal build --releaseコマンドでコンパイルした実行ファイルで試してみましょう。

$ crystal build --release eratosthenes.rb
$ /usr/bimn/time -l ./eratosthenes 10000000
9999991
        1.84 real         1.80 user         0.04 sys
  97361920  maximum resident set size
                ...

この例では、何も考えずにRubyと全く同じ処理をするだけでも、最終的に11.6倍まで高速化され、消費メモリも1/3以下に削減できました。(最適化によって最適化前よりも使用メモリが若干増えていますが理由はよくわかりません)

全てがこのような結果になるとは言い切れませんが、特定の処理にボトルネックが発生しているような場合は、Crystalがその回答になるかもしれません。

型推論を備えた静的型付け言語である

動的な型システムを持ち、実行時に変数に実際に保持されている値の型がすべてのRubyに対して、Crystalは静的な型システムを持っています。ですので、メソッドの仮引数や返り値の型を宣言しておけば、不正な値がメソッドに渡されることをコンパイル時にチェックできるようになります。

明示的な型宣言は、型を指定したい対象(変数名や、仮引数名、メソッド宣言など)の後ろに : 型 を記述します。次の例はString(文字列)型の引数を受け取り、その文字列の文字数をInt32型(32bit符号付き整数)で返すメソッドです(実用上の意味はほぼありません)ので、ここにString型でない値を与えるとコンパイルの段階でエラーになります。

def string_size(str : String) : Int32
  str.size
end

string_size("Hello")
#=> 5

string_size(1)
# Error: no overload matches 'string_size' with type Int32

ですが、これまでの例では、特に変数のやメソッド引数の型を宣言したりはしていませんでした。それでもプログラムが動作できてしまう理由は、Crystalが強力な型推論の仕組みを持っているためです。

予め型が宣言されていなかったとしても、大抵の場合、変数へ代入された値やメソッドの返り値の型によってコンパイラはある時点のその変数の型を推論してくれます。

# Int32型の値が代入された
a = 1 

# つまり、変数aはInt32型
typeof(a) #=> Int32

# 今度は変数aにString型の値が代入された
a = "Hello" 

# この時点の変数aはString型
typeof(a) #=> String

# 変数cにstring_sizeメソッドの返り値を代入
b = string_size(a)

# string_sizeメソッドはInt32型を返すので変数bもInt32型
typeof(b) #=> Int32

前出のサンプルでは、メソッド引数の型や返り値の型も省略されていますが、これもコンパイラが適宜型推論してくれています。

また、文脈による型推論が強力なおかげで、ある変数やメソッドの返り値が状況によってnilになりうるような際も、nilを許容しない文脈で使用されるとコンパイラが補足してエラーを挙げてくれたりします。いわゆるnull安全が保証されているわけですね。

Crystalの型システムと型推論の詳細は、改めてご紹介できればと考えていますが、私自身はこの機能により厳密な型システムコーディングのしやすさが程よいバランスで実現されているように感じています。

今回のまとめ

Crystalとは、Rubyと非常によく似た構文ながら、コンパイラによって高速なネイティブバイナリを生成でき、厳密な型システムによりnull安全を保証しつつも、強力な型推論による高いコーディングパフォーマンスを実現したプログラミング言語である、と言えます。

もう少し先の話にはなりますが、v1.0.0の公開に合わせて皆さんもCrystalに触れてみてはいかがでしょうか。

脚注
  1. The story behind #CrystalLang(Manas Tech.) ↩︎

  2. https://github.com/asterite/crystal/commit/50db22908a11b070c56524cc9bbd74332ad3a7c7 ↩︎

  3. https://github.com/asterite/crystal/commit/dd7c47ce82e786d26b59a15ff3f542f6661fdf1a ↩︎

  4. Release 0.1.0 · crystal-lang/crystal ↩︎

  5. Ruby のように書きやすく C のように速いプログラミング言語「Crystal」(DMM inside) ↩︎

  6. Nikola Motor Company: Crystal powered dashboards on the trucks of the future(Crystal Official Blog) ↩︎