5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

[Crystal] クラス定義周りの便利マクロ

Last updated at Posted at 2015-12-07

追記あり

Crystal Advent Calendar 2015のネタを書くにあたって,改めてドキュメントAPIリファレンスを読み返してみると,クラス定義周りのマクロが非常に充実していることに気づいたので,備忘録代わりにまとめてみました。

# はじめに

自作クラスを定義しようとすると,お約束のように定義することになるメソッドがいくつか存在します。
インスタンス変数に対するアクセサなどはRubyでも専用の書式が用意されていましたが,Crystalではそれだけでなく等価演算子 #==Hashキーの同一性チェックに使用される#hash,さらにはインスタンス変数へのメソッドの移譲についてまでマクロが用意されています。

# アクセサ定義

アクセサ定義系のマクロには,対象とするインスタンス変数名をシンボルリテラル(:name),文字列リテラル("name"),もしくは名前ベタ書き(name)で指定します。

全部同じ
getter :name
getter "name"
getter name
1回に複数指定可
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はインスタンス変数@namenil(もしくは未定義)の場合には例外を挙げます。その代わり,インスタンス変数@namenilでもそのまま値を返す#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マクロです。同じインスタンス変数に対してgettersetterを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マクロによってもゲッタメソッドが定義されるわけではない点です。

def_equalsを行っても#nameは未定義
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さんに感謝します。

5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?