7
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 3 years have passed since last update.

attr_accessorで定義したセッターをクラス内で使う際の落とし穴

Last updated at Posted at 2021-05-13

TLDRはこちら。

attr_accessorとは

attr_accessor はインスタンス変数に対するゲッター・セッターを同時に定義するための便利メソッド。
例えば、以下のコードでは @earth_shape というインスタンス変数に対する #earth_shape (ゲッター) 、#earth_shape= (セッター) を同時に定義してくれる。

class TopSecret
  attr_accessor :earth_shape

  def initialize
    @earth_shape = 'flat'
  end
end

secret = TopSecret.new
# ゲッターを使って、インスタンス変数の値を得る
puts secret.earth_shape # 'flat' を出力
# セッターを使って、インスタンス変数の値を変える
secret.earth_shape = 'round'
puts secret.earth_shape # 'round' を出力

定義されるのは通常のメソッドと変わりないので、クラス内でゲッター・セッターメソッドを呼ぶこともできる。

class TopSecret
  attr_accessor :earth_shape

  def initialize
    @earth_shape = 'flat'
  end

  def reveal
    # クラス内からゲッターを呼ぶ
    puts "The Earth is #{earth_shape}."
  end
end

secret = TopSecret.new
secret.reveal # 'The Earth is flat.' と出力

セッターを使う際の落とし穴

しかし、クラス内でセッターを使う際には落とし穴がある:

class TopSecret
  attr_accessor :earth_shape

  def initialize
    @earth_shape = 'flat'
  end

  def reveal
    # クラス内からセッターを呼ぶ?
    earth_shape = 'round'
    # クラス内からゲッターを呼ぶ?
    puts "The Earth is #{earth_shape}."
  end
end

secret = TopSecret.new
secret.reveal # 'The Earth is round.' と出力

上の #reveal メソッドは一見セッターとゲッターを呼んでいるように見えるが、実はそうではない。
コードの末尾に以下を追加すれば、何かがおかしいことが分かるはず:

puts secret.earth_shape # 'flat' と出力

#reveal メソッドでセッターが呼ばれて @earth_shape の値が 'round' に変わったのであれば、上のコードは 'round' と出力するはずである。
では、どこがおかしいのか?

問題は、 #reveal メソッド内の以下の行だ:

    # クラス内からセッターを呼ぶ?
    earth_shape = 'round'

実は、Rubyはこの式をローカル変数の代入式として解釈するのである。
つまり、この時点でローカル変数 earth_shape が誕生し、その値に 'round' が入ったということだ。

さらに、 attr_accessor で定義されたゲッターメソッド、 #earth_shape は、「たまたま」そのローカル変数 earth_shape と名前が同じだったことで、ローカル変数の背後に隠れてしまう (この挙動は「シャドーイング」と呼ばれる) 。
よって、以下の

    # クラス内からゲッターを呼ぶ?
    puts "The Earth is #{earth_shape}."

も、実はゲッターを呼び出しているのではなく、ローカル変数の値を参照しているのだ。

このため、 #reveal メソッド内ではインスタンス変数 @earth_shape には指一本触れていないので、その値は元々の 'flat' のままということだ。

puts secret.earth_shape # 'flat' と出力

さらなる落とし穴: 条件分岐

この程度の落とし穴にハマるものかという方に、以下のコードを見ていただきたい:

class TopSecret
  attr_accessor :earth_shape

  def initialize
    @earth_shape = 'flat'
  end

  def reveal(redact:)
    earth_shape = 'round' if redact
    puts "The Earth is #{earth_shape}."
  end
end

secret = TopSecret.new
secret.reveal(redact: true) # 何が出力される?
puts secret.earth_shape # 何が出力される?
secret.reveal(redact: false) # 何が出力される?
puts secret.earth_shape # 何が出力される?

4つの質問の答え、分かるだろうか?

最初の2つに関してはif節がない版のコードと同様で、ゲッターとセッターがローカル変数 earth_shape に隠されて、インスタンス変数 @earth_shape は変化しない。

secret.reveal(redact: true) # 'The Earth is round.' と出力
puts secret.earth_shape # 'flat' と出力

残りの2つが異端児である。
先に解答を見てみよう:

secret.reveal(redact: false) # 'The Earth is .' と出力
puts secret.earth_shape # 'flat' と出力

インスタンス変数 @earth_shape の値が 'flat' のままなのは分かるとして、 'The Earth is .' という出力は予想外ではないだろうか。

理由は、条件分岐内での変数代入の意外ともいえる挙動にある。
#reveal メソッドを redact: false で呼んだ場合、以下のコードでローカル変数への代入が行われないのはお分かりだろう。

    earth_shape = "round" if redact

変数への代入が行われなかったので、シャドーイングは発生せずに earth_shape でゲッターメソッドが呼ばれると考えた方もいるはずだ:

    # ゲッターを呼んでいる?
    puts "The Earth is #{earth_shape}." # 'The Earth is flat.' と出力?

残念ながらそうではない。
変数への代入が行われなかったのは事実だが、 earth_shape はどのみちローカル変数を指すのである。
なぜなら、if節・case節などの分岐の中で変数への代入を行った場合、たとえその分岐が実行されなくとも変数の「作成」は行われるというRubyの言語上の決まりがあるからだ。
そして、作成直後の変数の値は nil となるため、文字列に埋め込まれたときに空文字列に置換され、 'The Earth is .' という出力になったのである。

どうすれば回避できるか

前述の通り、クラス内でゲッター・セッターメソッドを使う場合「シャドーイング」による落とし穴に気をつけなければならない。
予期せぬシャドーイングを未然に防ぐには、「self キーワードを使う方法」や「インスタンス変数を直接使う方法」などがある。

self キーワードを使う方法

self キーワードをインスタンスメソッド内で使うと、そのメソッドを呼び出す際に使ったクラスのインスタンスを指す (「レシーバー」という言い方もある) 。
ゲッター・セッターメソッドの呼び出しの前に self. をつけることで、「ローカル変数ではなくインスタンスメソッドを参照しています」という意図をRubyに伝えることができる:

class TopSecret
  attr_accessor :earth_shape

  def initialize
    @earth_shape = 'flat'
  end

  def reveal
    self.earth_shape = 'round'
    puts "The Earth is #{self.earth_shape}."
  end
end

secret = TopSecret.new
secret.reveal # 'The Earth is round.' と出力
puts secret.earth_shape # 'round' と出力

実行結果から、ローカル変数の作成は行われずにインスタンス変数が直接変更されていることが分かる。

ただし、Rubocopを使ってリントをしている方は、以下のようなお節介アドバイスをされるかもしれない:

super_secret.rb:10:26: C: [Correctable] Style/RedundantSelf: Redundant self detected.
    puts "The Earth is #{self.earth_shape}."
                         ^^^^^^^^^^^^^^^^

Rubocopののデフォルト設定では、余計な self を書くと怒られるようになっている。
理由としては、メソッド内に earth_shape というローカル変数がないのだから、わざわざ self. をつけなくともゲッターの呼び出しだと分かるというところだろう。

Rubocopに逆らわない善良な市民であれば、以下のように書くこととなる:

  def reveal
    self.earth_shape = 'round'
    puts "The Earth is #{earth_shape}."
  end

個人的に、セッターの前にだけ self. が付いていることに不釣り合いさを感じる。
さらには、メソッドが長くなった場合、一目見てローカル変数かゲッターメソッドかが分からないという問題もあるだろう。

あくまでデフォルト設定なので、Rubocopの設定ファイルをいじってRubocopの権力乱用に反旗を翻すのも一つの手である。
ただ、その前に2つ目の方法を見ていただきたい。

インスタンス変数を直接使う方法

attr_accessor を使うと、ゲッターと同じ名前のインスタンス変数が作られることはご存知だろう。
インスタンスメソッド内であればインスタンス変数を使えるのだから、以下のように書くことができる:

class TopSecret
  attr_accessor :earth_shape

  def initialize
    @earth_shape = 'flat'
  end

  def reveal
    @earth_shape = 'round'
    puts "The Earth is #{@earth_shape}."
  end
end

secret = TopSecret.new
secret.reveal # 'The Earth is round.' と出力
puts secret.earth_shape # 'round' と出力

この方法であれば、ローカル変数とインスタンス変数の混乱もなく分かりやすいだろう。
こちらのほうがむしろ自然という見方もできる。
attr_accessor はあくまでクラスの外部からインスタンス変数にアクセスするセッターとゲッターを定義するためのものと考えるのであれば、クラスの内部でそれらを使う必要はないからだ。

ただし、こちらはこちらでいくつか考察に値する点がある。

まず、インスタンス変数の性質上、名前を間違えてもエラーが検知され辛いという点がある:

class TopSecret
  attr_accessor :earth_shape

  def initialize
    @earth_shape = 'flat'
  end

  def reveal
    # つづりこれで合ってたっけ?
    @earf_shape = 'round'
    puts "The Earth is #{@earf_shape}."
  end
end

secret = TopSecret.new
secret.reveal # 'The Earth is round.' と出力
puts secret.earth_shape # 'flat' と出力

ここでは、 @earth_shape とは別のインスタンス変数 @earf_shape が作成され、その値が読み書きされている。
単体テスト等を念入りに書いていれば検知はできるだろうが、ゲッター・セッターを使っていた場合は存在しないメソッドを呼び出した時点でランタイムエラーとなることと比較してほしい。
「間違っているけど何故か動く」コードよりは「間違っていたからエラー終了する」コードのほうが、長期的に見れば負債は少ないことも合わせて考えたいところだ。

また、 attr_accessor で定義されるゲッター・セッターは値を直接読み書きするだけの単純なメソッドであるが、仮にゲッターの初回呼び出し時に値を初期化したい場合や、セッターにバリデーション処理を加えた場合などはどうなるだろう?

class TopSecret
  def earth_shape
    @earth_shape ||= 'flat'
  end

  def earth_shape=(shape)
    raise "We can't allow that." if shape == 'round'

    @earth_shape = shape
  end

  def reveal
    # `self.earth_shape` を経由しないので、インスタンス変数は初期化されない
    puts "The Earth is #{@earth_shape}." # 'The Earth is .' と出力

    # `self.earth_shape=` を経由しないので、バリデーションは実行されない
    @earth_shape = 'round'
  end
end

(もはや attr_accessor の面影がなくなってしまっているが、実は主役はゲッター・セッターなので気にしないでほしい。)

インスタンス変数に直接アクセスすることによって、ゲッター内の「インスタンス変数の初期化」、セッター内の「値のバリデーションによる例外」といった「副作用」を通り越してしまっている。
ゲッター・セッターが副作用を持っていることが問題なのだろうか、それとも副作用を持っているゲッター・セッターを無視してインスタンス変数にアクセスしていることが問題なのだろうか。
深追いするとなかなか奥が深そうなので、余力があれば機会を改めて考えてみたい。
ひとまずは、 attr_accessor で事足りないようなゲッター・セッターの場合は、前述の self キーワードを使う方法に頼らざるを得ないというところか。

まとめ

  • インスタンスメソッド内でセッター earth_shape= を呼びたい場合、 self.earth_shape = 'round' のように self を使わないとローカル変数が作られてしまう
  • self を使いたくない場合、 @earth_shape = 'round' のようにインスタンス変数に直接アクセスすることもできる
7
4
5

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
7
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?