これは,Crystal Advent Calendar 2015 12月7日分の記事です。
はじめに
Ruby の文法に強くインスパイアされ,シンプルなものであれば Ruby スクリプトがそのまま動いてしまうこともある Crystal ですが,それだけに思わぬところで Ruby の流儀が通用しなくて戸惑うことがあります。
例えば,暗黙の文字列変換を指定しようとして#to_s
をオーバーライドしても予想通りの動きをしないとか,初期化のタイミングによってインスタンス変数がコンパイラにnilable
だと判断されるなどなど。
たいていの問題はドキュメントやAPIリファレンスをじっくり読むと解決したりするのですが,縁あって「Crystal Advent Calendar 2015」にお誘いいただいたので,個人レベルで数年に渡って日曜プログラミングとしてRubyと付き合ってきた人間が,Crystal で個人使用のライブラリ を作る際に躓いた小さなポイントをいくつかまとめてみました。
(本記事はCrystal 0.9.1,およびRuby 2.2.3を基に書かれています)
実行時の値の型 と compile-time type
Rubyだと変数は「使用する時点でどんな値なのか」がすべてなので,その値がどういった経緯で得られたものであるかは考慮されません。なので,パラメータによって数値か文字列を返すメソッドを用いた以下のコードも問題なく動作します。
def foo(return_int)
return_int ? 1 : "1"
end
bar = foo(false) #=> "1"
bar.size #=> 1
一方,上記のコードを Crystal で実行しようとすると,以下のようなエラーが発生します。
undefined method 'size' for String (compile-time type is (String | Int32))
Crystal はコンパイル時に各メソッドに対して,「返り値として取りうる型」を調べるようで,複数の型返す可能性があるメソッドの返り値は取りうる全ての型の合成型(union type)になります。そして合成型の変数は,何らかの方法(if bar.is_a?(String)
とか)で変数の型を特定しない限り,合成元の全ての型が共通して持っているメソッドしか使用できません。
上記コード内ではbar
に対するのメソッドfoo()
の呼び出しは必ずString
を返しますが,foo()
自体はInt32
とString
を返す可能性があるため,コンパイル時にbar
はInt32
とString
の合成型である(Int32 | String)
として解釈され,Int32
には存在しない#size
の呼び出しはエラーになってしまいます。
エラーメッセージの後に(compile-time type is (String | Int32))
とヒントを出してくれてはいますが,はじめは「String#size
が存在するにもかかわらず,それと矛盾するメッセージが出る」という状況にかなり混乱したものです。
この状態の bar
に対して bar.class
を確認すると String
だと返ってくるのも混乱に拍車をかけます。確かにこの時のbar
はString
の値を持っているのですが,「実行時に変数が持っている値の型」と「コンパイル時にコンパイラが推定する変数の型」は必ずしも一致ないという点には注意が必要です。後者を調べたい場合,bar.class
ではなくtypeof(bar)
を使うと目的の情報が得られます。
bar.class #=> String
typeof(bar) #=> (String | Int32)
Foo
とFoo#class
は同じように使えない
Rubyでは,あるクラスFoo
があったとして,
class Foo
BAR = "bar"
end
Foo#class
によって得られるクラスオブジェクトは,#is_a?()
の引数として利用したり,はたまた,そこからクラス定数にアクセスしたりと,定数Foo
と同じように使えます。
foo = Foo.new
foo.is_a?(foo.class) #=> true
foo.class::BAR #=> "bar"
一方,Crystalで同じことをしようとすると,それぞれ以下のようなエラーが発生します。
Syntax error in ./src/ipaddr-test.cr:5: expecting token 'CONST', not 'foo'
foo.is_a?(foo.class)
^
Syntax error in ./src/ipaddr-test.cr:6: unexpected token: ::
foo.class::BAR
^
エラーメッセージを見る限り,Foo#class
の返り値がFoo
と異なっているというよりは,構文解析上の問題のようです。
なお,前者はfoo.class == bar.class
で代替可能,後者はクラス定数の代わりにクラスメソッドを定義するなどして同じような使い方ができます。
```rb:#class
からでもクラスメソッドなら呼び出せる
class Foo
BAR = "bar"
def self.bar
BAR
end
end
p Foo.new.class.bar #=> "bar"
# `String#split` の第2引数
文字列を指定したパターンで分割した配列を返す`String#split`ですが,パターンだけを指定した場合は配列の末尾に存在する空文字列(`""`)が省略されます。
```rb:RubyでもCrystalでも同じ
"a,b,c,,,".split(/,/) #=> ["a", "b", "c"]
Ruby でも Crystal でもString#split
に第2引数として最大分割数を指定することができますが,この動作が微妙に異なっているので注意が必要です。
いちばん大きな違いは,第2引数に負の数が指定された場合です。Rubyだと末尾の空文字列を省略しない配列が返りますが,Crystalでは分割されない元の文字列1つを含む配列が返ってきます。
"a,b,c,,,".split(/,/, -1) #Ruby => ["a", "b", "c", "", "", ""]
#Crystal => ["a:b:c:::"]
また,第2引数として空文字も含めた分割数と同じかそれより大きな数を指定した場合の挙動も微妙に異なっています。
"a,b,c,,,".split(/,/, 6) #Ruby => ["a", "b", "c", "", "", ""]
#Crystal => ["a", "b", "c", "", ""]
Crystalでは,なぜか末尾の空文字が1つ少なく返ってくるのです。(前者はともかく,後者はバグのような気がしないでもありません)
データ中にカンマを含まないCSVのエントリをカンマでsplitして,返ってきた要素数で簡易なフォーマットチェックしようとしても,第2引数に負の値を指定してa,b,c,
とa,b,c
を区別することができません。また,あらかじめ要素数がわかっているので第2引数に4を指定しても,現状ではa,b,c,
に対して["a", "b", "c"]
が返ってきてしまい,やはりa,b,c
と区別がつきません。
さしあたり,フォーマットチェックは正規表現などを使って別の方法で行う必要がありそうです。
メソッドの有無
Crystalの各クラスに定義されているメソッド類は,多くがRubyと同じ名前で定義されていますが,すべてが同じとは限りません。
例えば,同じ動作をする複数のメソッドの多くは片方に統一されています。String#size
は存在しますがString#length
は存在しませんし,Enumerable(T)#map
は存在しますがEnumerable(T)#collect
は存在しません。
また,なくなったわけではないのですが,Enumerable(T)#include?
ではなくEnumerable(T)#includes?
と現在形になっているのも地味に引っかかるポイントかもしれません。Enumerable(T)#find
とかEnumerable(T)#reject
なんかは原型のままなので統一感がないようにも思いましたが,よくよく考えるとObject#is_a?
なんかはRubyでも現在形なんですよね。
なんとなく,「レシーバ メソッド名 パラメータ」をそれぞれ「S V O」に当てはめて文章が成り立つ時は現在形の方が自然なのかも,と思ったりします。
おわりに
というわけで,(ドキュメント類をあまりちゃんと読み込まずに)Crystal に手を出した Ruby 書きが躓いた小石たちの一例でした。
このような細々した躓きポイントはあるものの,クラスやモジュールの構造,メソッドチェーン,イテレータの使い方,ブロック付きのメソッド呼び出しなど,Ruby で使っていた 作法 がほぼそのまま流用できるため,Ruby の経験者が Crystal を習得するのは比較的容易ではないかと思います。
また,静的型付けやネイティブコンパイルによる速度向上以外にも,Crystal ならではのメリットもたくさんあると感じます。例えば,メソッドのオーバーロードが可能なので,パラメータとして複数の型(数値と文字列とか)を受け入れるメソッドなんかは,実装をパラメータの型ごとに分離できてソースがスッキリしますね。
自分は今のところ Ruby の置き換えとしてしか Crystal を使えていませんが,マクロやCバインディングなど Crystal ならではの機能にもチャレンジしてみたいと思います。