💎

【Crystalと○○】 Crystalとは

2020/10/20に公開

📖【Crystalと○○】コンテンツ一覧

「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]、翌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. 型推論を備えた静的型付け言語であること

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

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) ↩︎

Discussion