4
3

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 : initialize 中の self 利用(追記あり)

Last updated at Posted at 2015-06-25

Crystal : initialize 中の self 利用

追記あり

メモ

動的型付け言語のRubyでその恩恵を目一杯受けていたコードを,Crystalなど静的型付け言語へ移植しようとすると,思わぬ小石に躓くことが多い。

今日の小石は inialize 中の self

親子関係のあるオブジェクト間で相互参照したいようなとき,基本的に「実際に使用される瞬間の状態がすべて」な Ruby だと

parent_child.rb
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 でやろうとして,

parent_child.cr
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 が使われているのか,とかまで詳しく出力してくれるので,ちゃんと読めば問題箇所の特定は比較的楽)

正確ではないかもしれないけれど,自分の理解としては,

  1. Child.new に渡された時点では,self@child が未定義(nil)な状態の Parent オブジェクトである。
  2. もし万が一,Child#initialize の中で,@parent.child を参照されたりするとその時点では未定義な状態の @child が参照されうる。
  3. その結果,(実際に参照されるタイミングの状態とは無関係に)@child の型は Child?Child | Nil)だとCrystalは理解する。
    (上でも書いたとおり the_child.classChild を返すんだけど,同じような動きをすると思ってた typeof(the_child) はちゃんと Child? を返してくる)
  4. nilableな型をはじめとした,複数のクラスからなるunion型をもつオブジェクトは,その型に含まれるすべてのクラス(この場合は ChildNil)が共通して持つメソッドしか利用できない。
  5. 当然ながら,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#childChild オブジェクト以外返さないようにすれば良い」というわけで,「もし @child が nil だったら例外で処理を止める」というかなり乱暴な getter メソッドを自分で定義してひとまず回避。

class Parent
  def child
    @child || raise Exception.new
  end
end

puts the_child.parent.name # -> "John"

追記

開発者の方からコメントいただきました。

@childnil にはなり得ない事が自明である場合に,getter! child とすることでまさに第三手法の動きをする getter メソッドが定義されるとのことです。

そもそも論

initialize 中で self を使わなくても良いような設計にすべきだよね。うん,分かってる。

4
3
1

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
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?