[crystal] Ruby書きが躓いた小石集

  • 11
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

これは,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だと変数は「使用する時点でどんな値なのか」がすべてなので,その値がどういった経緯で得られたものであるかは考慮されません。なので,パラメータによって数値か文字列を返すメソッドを用いた以下のコードも問題なく動作します。

RubyだとOK
def foo(return_int)
  return_int ? 1 : "1"
end

bar = foo(false)  #=> "1"
bar.size          #=> 1

一方,上記のコードを Crystal で実行しようとすると,以下のようなエラーが発生します。

Crystalだとエラー
undefined method 'size' for String (compile-time type is (String | Int32))

Crystal はコンパイル時に各メソッドに対して,「返り値として取りうる型」を調べるようで,複数の型返す可能性があるメソッドの返り値は取りうる全ての型の合成型(union type)になります。そして合成型の変数は,何らかの方法(if bar.is_a?(String)とか)で変数の型を特定しない限り,合成元の全ての型が共通して持っているメソッドしか使用できません。

上記コード内ではbarに対するのメソッドfoo()の呼び出しは必ずStringを返しますが,foo()自体はInt32Stringを返す可能性があるため,コンパイル時にbarInt32Stringの合成型である(Int32 | String)として解釈され,Int32には存在しない#sizeの呼び出しはエラーになってしまいます。

エラーメッセージの後に(compile-time type is (String | Int32))とヒントを出してくれてはいますが,はじめは「String#sizeが存在するにもかかわらず,それと矛盾するメッセージが出る」という状況にかなり混乱したものです。

この状態の bar に対して bar.class を確認すると String だと返ってくるのも混乱に拍車をかけます。確かにこの時のbarStringの値を持っているのですが,「実行時に変数が持っている値の型」と「コンパイル時にコンパイラが推定する変数の型」は必ずしも一致ないという点には注意が必要です。後者を調べたい場合,bar.classではなくtypeof(bar)を使うと目的の情報が得られます。

bar.class    #=> String
typeof(bar)  #=> (String | Int32)

FooFoo#classは同じように使えない

Rubyでは,あるクラスFooがあったとして,

class Foo
  BAR = "bar"
end

Foo#classによって得られるクラスオブジェクトは,#is_a?()の引数として利用したり,はたまた,そこからクラス定数にアクセスしたりと,定数Fooと同じように使えます。

RubyだとOK
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で代替可能,後者はクラス定数の代わりにクラスメソッドを定義するなどして同じような使い方ができます。

`#class`からでもクラスメソッドなら呼び出せる
class Foo
  BAR = "bar"
  def self.bar
    BAR
  end
end
p Foo.new.class.bar #=> "bar"

String#split の第2引数

文字列を指定したパターンで分割した配列を返すString#splitですが,パターンだけを指定した場合は配列の末尾に存在する空文字列("")が省略されます。

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 ならではの機能にもチャレンジしてみたいと思います。