追記あり
Crystal Advent Calendar 2015のネタを書くにあたって,改めてドキュメントやAPIリファレンスを読み返してみると,クラス定義周りのマクロが非常に充実していることに気づいたので,備忘録代わりにまとめてみました。
# はじめに
自作クラスを定義しようとすると,お約束のように定義することになるメソッドがいくつか存在します。
インスタンス変数に対するアクセサなどはRubyでも専用の書式が用意されていましたが,Crystalではそれだけでなく等価演算子 #==
,Hash
キーの同一性チェックに使用される#hash
,さらにはインスタンス変数へのメソッドの移譲についてまでマクロが用意されています。
# アクセサ定義
アクセサ定義系のマクロには,対象とするインスタンス変数名をシンボルリテラル(:name
),文字列リテラル("name"
),もしくは名前ベタ書き(name
)で指定します。
getter :name
getter "name"
getter name
getter :name, :age
(以下の説明は,各マクロに :name
が指定された場合を想定します)
## getter, getter!, getter?
Rubyのattr_reader
に相当するのがgetter
マクロです。getter
マクロによってインスタンス変数の値を取得するためのアクセサメソッドが定義されますが,Crystalにはgetter
だけでなくgetter!
とgetter?
という派生版も用意されています。
素のgetter
マクロでは,インスタンス変数がnil
だとしてもそのまま返す#name
が定義されます。
class Person
getter :name
end
john = Person.new
john.name #=> nil
getter!
でも,#name
は定義されますが,getter!
によって定義された#name
はインスタンス変数@name
がnil
(もしくは未定義)の場合には例外を挙げます。その代わり,インスタンス変数@name
がnil
でもそのまま値を返す#name?
も同時に定義されます。
class Person
getter! :name
end
john = Person.new
john.name #=> Error: Nil assertion failed (Exception)
john.name? #=> nil
最後のgetter?
では#name
は定義されず,getter!
の場合と同様の#name?
のみが定義されます。
class Person
getter? :name
end
john = Person.new
john.name #=> Error: undefined method 'name' for Foo
john.name? #=> nil
個人的には#name?
が定義されるのであれば,getter!
の動作がデフォルトでも良いんじゃないかと思ったりします。
## setter
Rubyのattr_writer
に相当するのがsetter
マクロです。setter
マクロによって,インスタンス変数の値を更新するためのアクセサメソッド#name=(new_name)
が定義されます。こちらは特に派生はありません。
class Person
setter :name
end
john = Person.new
john.name = "John"
今のところsetter
マクロで作成される#name=
には引数の型を指定できないようで,文字列(String
)で初期化されたインスタンス変数を数値(Int32
)で更新するようなことができてしまいます。
Crystalでは,こうした場合どんどん更新後の値の型が合成されていいく((String | Int32)
)ことになるため,思わぬところで「合成元すべての型が有するメソッドしか使用できない」という合成型の制約にぶつかる場合があります。
特定の型のみを想定するインスタンス変数については,セッタメソッドは個別に定義した方が安全かも。
## property, property!, property?
Rubyのattr_accessor
に相当するのがproperty
マクロです。同じインスタンス変数に対してgetter
とsetter
を1度で指定できます。property
にも,getter
側の動作によってproperty!
,property?
という派生があります。
同一性定義
## def_equals
Crystal で自作のクラスを作る際,固有の#==
メソッドを定義しないと,どんな状態のインスタンス同士を比較しても結果はfalse
になります。オブジェクトの状態はインスタンス変数によって表現されるので,あるオブジェクトと別のオブジェクトが等しいかかどうかを判断するということは,たいていの場合インスタンス変数(場合によってはその一部)を比較することに他なりません。てなわけで,Crystalにはdec_equals
マクロが用意されています。
def_equals
マクロにインスタンス変数名のリストを指定すると,与えられたインスタンス変数がすべて等しい場合にtrue
を返す#==(other : self)
が定義されます。
class Person
def initialize(@name, @age)
end
def_equals @name
end
john = Person.new("John", 20)
ken = Person.new("Ken", 20)
john2 = Person.new("John", 18)
john == ken #=> false
john == john2 #=> true
このマクロの興味深いところは,該当のクラスにdef_equals
マクロで指定したインスタンス変数(この場合@name
に対するゲッタメソッドが宣言されている必要がなく,またdef_equals
マクロによってもゲッタメソッドが定義されるわけではない点です。
puts john.name #=> Error: undefined method 'name' for Person
同じことをマクロを使わずに自分で書こうとすると,何らかの形で外部からインスタンス変数の値を参照可能にする必要がありますが,このマクロを使う限りにおいてはインスタンス変数を隠蔽したまま等価演算子を定義することができるようです。(どうやらそうでもないっぽい。 追記参照)
マクロすごいね!!
ちなみに,クラスではなく構造体(struct
)を定義する場合,すべてのインスタンス変数が等しい場合にtrue
を返す#==
がデフォルトで用意されていますので,「一部のインスタンス変数を等価判別の対象から外したい」とかでない限りそもそもこのマクロを使う必要がありません。
## def_hash
等価演算子#==
がtrue
を返してくるだけではHash
のキーとしては同値とはみなされません。
class Person
def initialize(@name, @age)
end
def_equals @name, @age
end
john = Person.new("John", 20)
john2 = Person.new("John", 20)
hash = {john => true}
john == john2 #=> true
hash.has_key?(john2) #=> false
Hash
でのキーの判別には,#==
の他に#hash
が使用されます。(まず#hash
で求めたハッシュ値が同じかどうか篩いにかけてから,同じハッシュ値でも#==
が成り立たなければ同一でないとみなす,みたいなイメージ?)
ところが,独自クラスのインスタンスの場合,#hash
のデフォルトの返り値はそれぞれのオブジェクトに個別に与えられる#object_id
の値(UInt64
)となっており,#==
による等価が成立したとしても#hash
は同じ値になりません。
john.hash #=> 4504120832
john2.hash #=> 4504120800
そのため,オブジェクトをHash
キーとして使用するためには#hash
を再定義する必要があるのですが,インスタンス変数が数値だけであればまだしも,文字列や他のオブジェクトが絡んでくるとどうやってInt
に丸め込んだものか意外と悩みドコロだったりします。
Object#hash
のドキュメントには「a == b
であればa.hash == b.hash
でなければならない」みたいなことも書かれてますしね。
こんな時,def_equals
と同じようにdef_hash
マクロを使用すれば,指定したインスタンス変数からハッシュ値を計算する#hash
を定義してくれて非常に便利。
class Person
def initialize(@name, @age)
end
def_equals @name, @age
def_hash @name, @age
end
john = Person.new("John", 20)
john2 = Person.new("John", 20)
hash = {john => 1}
john == john2 #=> true
hash.has_key?(john2) #=> true
なお,クラスではなく構造体(struct
)を定義する場合,やはりすべてのインスタンス変数からハッシュ値を算出する#hash
がデフォルトで用意されてますので,改めてdef_hash
マクロを使用する必要はありません。
## def_equals_and_hash
#hash
を再定義したいときって,基本的に#==
とセットで使われることが多く,#hash
と#==
で使用されるインスタンス変数も基本的に同じ組み合わせになるはずです。
というわけで,両者を一度に指定できるdef_equals_and_hash
マクロが用意されています。
class Person
def initialize(@name, @age)
end
def_equals_and_hash @name, @age
end
たいていの場合はこちらを使っておけば問題ないのではないかと。
メソッド移譲定義
## delegate
delegate
マクロにメソッド名と移譲先のインスタンス変数を指定するだけで,一部のメソッド呼び出しを特定のインスタンス変数へ簡単に移譲することができます。
class Person
def initialize(@name, @age)
end
delegate downcase, @name
end
john = Person.new("John Doe", 20)
john.downcase #=> "john doe"
## forward_missing_to
別のオブジェクトのラッパクラスを書くような場合には,ラッパ側で定義されていないメソッドをすべてインスタンス変数へ移譲したいことがあります。こうした際にdelegate
マクロでメソッドを1つずつ指定することもできますが,ラップ元の型を変更したりするとすべて手直しが必要になってしまいます。
そんな時は,forward_missing_to
マクロを使用すると,未定義のメソッドが呼ばれた際に,特定のインスタンス変数へ処理を移譲することができるようになります。
class Person
def initialize(@name, @age)
end
forward_missing_to @name
end
john = Person.new("John Doe", 20)
john.downcase #=> "john doe"
john.split #=> ["John", "Doe"]
john.size #=> 8
# おわりに
なんというか,Crystalのマクロの可能性を垣間見た感じです。自分で使いこなせるとイロイロできそうな気はするのですが,まだまだちゃんと自分の中のマクロのイメージが固まっていない,というのが正直なところ。
とはいえ,動作原理を完全に理解していなくとも,このクラス定義周りのマクロは便利なものばかりですので積極的に活用していきたいですね。
上ではdef_equals
について,ゲッタメソッドが定義されないのでインスタンス変数を外部から隠蔽できる,と書きましたが,どうやら正確ではなかったようです。
確かに,ゲッタとして一般的な形のメソッドは定義されませんが,@ 付きのインスタンス変数そのままの形のゲッタメソッドが暗黙的に定義されるようです。それも,def_equals
マクロを使用するしないに関わらず,すべてのインスタンス変数に対して。
class Person
def initialize(@name, @age)
end
end
john = Person.new("John Doe", 20)
john.@name #=> "John Doe"
john.@age #=> 20
ちなみに,通常は未定義のインスタンス変数を参照するとエラーにならずnil
が取得できますが,クラス宣言内で全く言及されていないインスタンス変数に対して,この暗黙のゲッタを呼ぶと普段あまり見かけないエラーが発生しました。
john.@sex #=> Error: Person doesn't have an instance var named '@sex'
いずれにしろ,(少なくとも現バージョンの)Crystalではインスタンス変数はすべてpublic状態で参照可能,ということのようです。
この仕様,カプセル化とか考えるとなんとなくモヤモヤする動きですね。
あと,やはりというか何というか,def_equals
マクロで指定するのはインスタンス変数でなくとも,public なメソッドなら何でもOKみたい。(private なメソッドや,存在しないメソッドを指定するとエラーが発生する)
class Person
def_equals name_and_age
def initialize(@name, @age)
end
def name_and_age
"#{@name}/#{@age}"
end
end
john = Person.new("John Doe", 20)
john2 = Person.new("John Doe", 20)
john == john2 #=> true
そもそもがdef_equals
マクロで指定していたのは,インスタンス変数ではなく public な暗黙のゲッタメソッドだったわけで,だったら,暗黙のゲッタメソッドとか使わないで明示的に public として宣言したメソッドを指定するようにしちゃダメなのかな?(存在しないメソッドや private なメソッドを指定すればそのようにエラーが出るんだし)
と思ったけど,そうすると構造体(struct
)でデフォルトの#==
が定義できなくなるのか……。
Ruby だと構造体の各要素には自動的に public なゲッタメソッドが定義されてけど……。
う〜ん,色々と悩ましいですね。
-- Crystal-JP Slackで指摘していただいたpacuumさんに感謝します。