Crystal : initialize
中の self
利用
追記あり
メモ
動的型付け言語のRubyでその恩恵を目一杯受けていたコードを,Crystalなど静的型付け言語へ移植しようとすると,思わぬ小石に躓くことが多い。
今日の小石は inialize
中の self
。
親子関係のあるオブジェクト間で相互参照したいようなとき,基本的に「実際に使用される瞬間の状態がすべて」な Ruby だと
class Parent
attr_reader :name
attr_reader :child
def initialize(_name)
@name = _name
@child = Child.new(self)
end
end
class Child
attr_reader :parent
def initialize(_parent)
@parent = _parent
end
end
the_parent = Parent.new("John")
the_child = the_parent.child
puts the_child.parent.name
みたいに書いても,(相互参照自体の是非はひとまず置くとして)なんとなく動いてしまう。
$ ruby parent_child.rb
John
同じようなことを Crystal でやろうとして,
class Parent
getter :name
getter :child
def initialize(@name)
@child = Child.new(self)
end
end
class Child
getter :parent
def initialize(@parent)
end
end
the_parent = Parent.new("John")
the_child = the_parent.child
puts the_child.parent.name
とか書いてみると,
$ crystal
Error in line 25: undefined method 'parent' for Nil
...
と怒られる。
「なぜ Nil ?」と思って the_child.class
を見ても Child クラスだと返ってくるので余計混乱したりして。
puts the_child.class # -> "Child"
エラーメッセージを最後まで読んでいくと
Error: 'self' was used before initializing instance variable '@child', rendering it nilable
と書かれている通り,原因は Parent#initialize
内で @child
に値が代入される前に self
を使っちゃったせいで,@child
が nilable だ(値として nil
も取りうる)と判断されてしまったせいらしい。(Crystal のエラーメッセージには,どの変数が nil
だったのか,とか initialize
のどの辺りで self
が使われているのか,とかまで詳しく出力してくれるので,ちゃんと読めば問題箇所の特定は比較的楽)
正確ではないかもしれないけれど,自分の理解としては,
-
Child.new
に渡された時点では,self
は@child
が未定義(nil
)な状態の Parent オブジェクトである。 - もし万が一,
Child#initialize
の中で,@parent.child
を参照されたりするとその時点では未定義な状態の@child
が参照されうる。 - その結果,(実際に参照されるタイミングの状態とは無関係に)
@child
の型は Child?(Child | Nil)だとCrystalは理解する。
(上でも書いたとおりthe_child.class
は Child を返すんだけど,同じような動きをすると思ってたtypeof(the_child)
はちゃんと Child? を返してくる) - nilableな型をはじめとした,複数のクラスからなるunion型をもつオブジェクトは,その型に含まれるすべてのクラス(この場合は Child と Nil)が共通して持つメソッドしか利用できない。
- 当然ながら,
Nil#parent
は定義されていない。
みたいな流れなのかなと。
なお,Parent#initialize
内で self
を使う前に値が代入されている @name
は,未定義な状態を参照されるタイミングがないので,Parent#name
の方はなんの問題もなく String として扱える。
puts the_parent.name.length # -> "4"
ちなみに,他のオブジェクトに渡さなくても,self
の中身が評価されるタイミングがあるとダメらしく,こんなのも×。
class Foo
getter :foo
def initialize()
self.to_s
@foo = "FOO!"
end
end
puts Foo.new.foo.size # -> Error
class Bar
getter :bar
def initialize()
buz()
@bar = "BAR!"
end
def buz()
self.to_s
end
end
puts Bar.new.bar.size # -> Error
回避策
initialize
の中での self
の利用をできるだけ遅らせて,その前に必要なインスタンス変数の初期化をすべて終えてしまうというのが一つ目の回避策。ただし,上の例のようにインスタンス変数の初期化に self
が出てくる場合には使えない。
二つ目の回避策は,nialbleな型のオブジェクトであっても,状況的に nil
でないことを確定させてやれば,もう一方の(本来の)クラスのオブジェクトとして扱うことができる,というもの。
if the_child
# the_child が nil だとココには来ない
puts the_child.parent.name # -> "John"
end
とはいえ,使う頃には @child
には確実に値が入っている事がわかっているのに,いちいち返り値をチェクして使うのも煩わしいので,Parent#child
の返り値が Child になっていてくれたほうが嬉しい。
というわけで第三の方法。
要は,「@child
が nilable であっても Parent#child
が Child オブジェクト以外返さないようにすれば良い」というわけで,「もし @child
が nil
だったら例外で処理を止める」というかなり乱暴な getter メソッドを自分で定義してひとまず回避。
class Parent
def child
@child || raise Exception.new
end
end
puts the_child.parent.name # -> "John"
追記
開発者の方からコメントいただきました。
@child
が nil
にはなり得ない事が自明である場合に,getter! child
とすることでまさに第三手法の動きをする getter メソッドが定義されるとのことです。
そもそも論
initialize
中で self
を使わなくても良いような設計にすべきだよね。うん,分かってる。