はじめに
Rubyで独自に定義したクラスのインスタンスを配列に入れてsortしようとしたとき、
ArgumentError: comparison of ... failedというエラーに遭遇したことはありませんか?
例えば、サポートチケットを管理するシステムを実装していて、チケットの「緊急度」で並べ変えたいケースを考えてみます。本記事では、このエラーが「なぜ起きるのか」、そして「どう解決するのか」を、Rubyの本体であるC言語のソースコード(Cruby)の内部挙動まで潜って解説します。
デフォルト状態でのソート
まずは、以下のような新しく作ったばかりのSupportTicketインスタンスを配列に入れてソートしてみます。
class SuportTicket
attr_reader :id, :urgency
def initialize(id, urgency)
@id = id
@urgency = urgency # 緊急度(数値が大きいほど優先)
end
end
tickets = [
SupportTicket.new(1, 50),
SupportTicket.new(2, 100)
]
# さあ、並び替えてくれ
tickets.sort
これを実行すると、無情にも以下のエラーが発生します。
ArgumentError: comparison of SupportTicket with SupportTicket failed
なぜ、ただのソートでエラーになるのでしょうか?この時、C言語レベルでは以下のルートを辿ってクラッシュしています。
C言語の内部で起きていること(失敗ルート)
- 現場監督(
array.c)が起動
Array#sortの実体はC言語のarray.cにあります。クイックソートなどのアルゴリズムが起動し、要素A(1)と要素B(2)を掴みます。 - C言語からRubyへ問い合わせ
C言語自身はRubyオブジェクトの大小を知らないため、rb_funcallというC言語の関数を使って、Rubyの世界の<=>(宇宙船演算子)を呼び出し、「どっちが大きいの?」と聞きます。 -
Object#<=>に行き着く
しかし、SupportTicketクラスには<=>が定義されていません。Rubyは親クラスを辿り、最終的にすべてのオブジェクトの頂点であるObjectクラスのデフォルト実装に到達します。
このObject#<=>の実装は、C言語のobject_cに以下のように定義されています。
static VALUE rb_obj_cmp(VALUE obj1, VALUE obj2)
{
if (rb_equal(obj1, obj2)) // 全く同じメモリ上のインスタンスか?
return INT2FIX(0);
return Qnil; // 違うなら nil を返す
}
ここで使われているrb_equalは、Ruby側の==メソッドを呼び出す関数です。
今回、SupportTicketクラスには==メソッドを定義していません。そのため、親クラスであるBasicObject#==(デフォルトではオブジェクトの同一性)、つまり、「全く同じオブジェクト(オブジェクトIDが一致する)か?」 を判定する)が呼び出されます。
SupportTicket.newで作られた2つのチケットは、新しく生まれた別々のインスタンス(メモリの住所が違う)です。そのため、==の結果はfalseになり、結果としてQnil(nil)が返却されます。
これを受け取ったC言語の現場監督(array.c)は、「-1, 0, 1の数値が返ってこないと並べ替えられない!」とパニックになり、ArgumentErrorを吐いて終了してしまうのです。
補足 :もし、==をオーバーライドしていたら?
仮にSupportTicketクラスでdef ==(other); true; endのように常にtrueを返すようにオーバーライドしていた場合、rb_equalは常に真となり、<=>が未定義でも0が返るため、なんとエラーにならずにsortを完走することができます。
補足2 : Array.new(100, C.new) だとエラーにならない理由
上記を踏まえると、面白い挙動が確認できます。空っぽのクラス C を用意して配列を作った場合、配列の作り方によって sort の結果が変わります。
class C; end
# ① ブロックで生成した場合 ➔ エラーになる💥
Array.new(100) { C.new }.sort
# ② 第二引数で生成した場合 ➔ エラーにならない✨
Array.new(100, C.new).sort
①はブロックが100回実行されるため「100個の別々のインスタンス」が生成されます。そのため BasicObject#== は false を返し、比較不能でエラーになります。 しかし、②は「1回だけ生成した同一インスタンス」を100個並べた配列になります。すべてが全く同じオブジェクト(オブジェクトIDが一致する)であるため、デフォルトの BasicObject#== の判定が true となり、<=> が未定義でも見事に sort を完走するのです。
書き換え : Ruby側で<=>をオーバーライド
新しいインスタンス同士を比較するには、「チケットの緊急度(urgency)で比べる」というルールを教えてあげる必要があります。
class SupportTicket
attr_reader :id, :urgency
def initialize(id, urgency)
@id = id
@urgency = urgency
end
# Rubyの世界でルールを上書き(オーバーライド)する!
def <=>(other)
self.urgency <=> other.urgency
end
end
tickets = [
SupportTicket.new(1, 50)
SupportTicket.new(2, 100)
]
tickets.sort
#=> [#<SupportTicket @id=1, @urgency=50>, #<SupportTicket @id=2, @urgency=100>] # 今度は成功する!
C言語の内部で起きていること(成功ルート)
ルールを追加したことで、内部の挙動は劇的に変わります。
- 現場監督(
array.c)が<=>を呼び出す
先ほどと同じく、C言語から要素へ問い合わせが発生します。 - オーバーライドしたメソッドが反応
今度はSupportTicketクラスに<=>があるため、親のObjectまで行かず、先ほど定義したRubyのコードが実行されます。 -
Integer#<=>への委譲
self.urgency(50)とother.urgency(100)が取り出されます。これは数値同士の比較になるため、今度はIntegerクラスの<=>(C言語のnumeric.c)にバトンタッチされます。 - ソート完了!
C言語のnumeric.cが「50と100なら、100の方が大きい」と判断し、-1を返します。現場監督(array.c)がこの-1を受け取り、無事に並び替えが完了します。
まとめ
新しく new したばかりのオブジェクトは、メモリ上の住所が完全に異なるため、デフォルトの Object#<=> に任せると 「自分自身と全く同じインスタンスでない限り、比較不能(nil)」 という判定を下されます。(厳密にはBasicObject#==による判定)。
だからこそ、独自のオブジェクトを並べ替えるときは、必ず 「インスタンスの中のどのプロパティ(文字や数値)を、Ruby標準クラス(StringやInteger)に委譲するか」 を <=> を用いて明示的に書く必要があります。
-
C言語(配列のソートアルゴリズム)
-
Ruby(自作の
<=>によるビジネスルール) -
C言語(Integerなどの標準クラスによる比較)
Rubyの sort は、言語の壁を行ったり来たりしながら、美しいコンテキストスイッチとオブジェクト指向のメッセージングによって成り立っています。 この記事が、Rubyの内部挙動を理解する一助になれば幸いです!