特定の条件下で attribute のデフォルト値が書き変わってしまう問題と、その対策として Rails/AttributeDefaultBlockValue を少し紹介


ActiveRecord や ActiveModel::Attributes の attribute メソッドによって属性を定義する際に、第二引数の cast_type を指定せず、かつデフォルト値を指定している場合に、この属性に対して破壊的な変更をするとデフォルト値が書き変わってしまう。

以下はデフォルト値に空の配列を指定した場合の例。

```rb
class C
  include ActiveModel::Attributes

  attribute :hoge, default: []
end

c = C.new
c.hoge << :x

c = C.new
c.hoge #=> [:x] # [] が期待値のはず
```

この挙動によるバグを引き起こしやすい `attribute :hoge, default: []` や `attribute :hoge, default: {}` のような記述が、アプリケーションコードに入らないようにするための Cop が `Rails/AttributeDefaultBlockValue` である。

https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsattributedefaultblockvalue

上記ドキュメントをみると配列やハッシュ以外にも対策されていることがわかる。

この Cop が作られた経緯については以下のあたりを参照。

https://github.com/rubocop/rubocop-rails/pull/339

https://github.com/rubocop/rubocop-rails/pull/369

ただし、この Cop を手元で試してみたところうまく動かなかった。
調べたところ些細なバグだったので直してPRした。

https://github.com/rubocop/rubocop-rails/pull/723

(迅速にレビュー＆マージしていただけた。ありがとうございます:pray:)

このPRがリリースに入るまでは、以下をアプリケーション側の設定に記述するのがワークアラウンドとなる。

```rubocop.yml
Rails/AttributeDefaultBlockValue:
  Include:
    - app/models/**/*.rb
```

---

↓以下、このような attribute 周りの挙動について ruby-jp の Slack ([#rails](https://ruby-jp.slack.com/archives/CM0E8MA5T) チャンネル) で得られた反応

kkitadate  9:46 PM
ActiveRecord や ActiveModel::Attributes の attribute メソッドについてですが、デフォルト値に Array や Hash を指定した時の挙動に驚きました

```rb
#
# String を指定した場合は想定通りに動く
#
class C
  include ActiveModel::Attributes

  attribute :str, default: ‘’
end

c = C.new
c.str = ‘s’

# インスタンスごとにデフォルト値で初期化されるのが期待値
c = C.new
c.str # => “”

#
# Array の場合、インスタンスを生成するたびに初期化されるのではなく、クラス自体に保存される？
#
class C
  include ActiveModel::Attributes

  attribute :array, default: []
end

c = C.new
c.array << :x

c = C.new
c.array # => [:x] ※ [] が期待値のはず

#
# Hash の場合も Array と同様
#
class C
  include ActiveModel::Attributes

  attribute :hash, default: {}
end

c = C.new
c.hash[:x] = :y

c = C.new
c.hash # => {:x=>:y} ※ {} が期待値のはず
```

chiastolite  10:33 PM
クラス読み込んだときに評価されちゃうから、←で作られたオブジェクトが毎回参照されちゃうやつかな (edited) 
10:33
(表現合ってるか?)
10:34
やったことないから正しく動くかわからないけど `default: -> { [] }` とかしたらどうだろう?

chiastolite  10:40 PM
↑これでよさそう

koheisg  10:43 PM
えーまじか〜辛い。。。

kkitadate  10:46 PM
> default: -> { [] }

ですね。あとこの用途であれば attr_accessor でよさそうという感じなんですが、オブジェクトを reload したあとの結果が異なるという罠がありました

chiastolite  10:52 PM
Railsでもよく `if: -> { … }` みたいな書き方が必要なことあるけど、大体こういうのが理由ですよね

mksava  11:02 PM
ちょっとそれちゃうんですが、そもそも attribute ってhashやarrayってサポートしていましたっけ？
昔ここを見てサポートしてなくてカスタムタイプ作った記憶がありまして…
https://github.com/rails/rails/tree/main/activemodel/lib/active_model/type (edited) 

chiastolite  11:05 PM
typeを指定してなかったら任意の値が入れられるのかなと
(もちろんカスタムタイプ作れるなら作ったほうがよさそう)

zunda  3:13 AM
先日Pythonのメソッドの定義時のデフォルト値の評価で同じような話がありましたねー

kkitadate  8:43 AM
> オブジェクトを reload したあとの結果が異なる

これは ActiveRecord オブジェクトの場合でした。
（ActiveModel::Attributes を include しただけのクラスであれば関係なさそう）

```rb
class Post < ActiveRecord::Base
  attribute :tags, default: -> { [] }
end

post = Post.new
post.tags << :ruby
post.save!

p post.tags        #=> [:ruby]
p post.reload.tags #=> []
```

```rb
class Article < ActiveRecord::Base
  attr_accessor :tags

  after_initialize { self.tags ||= [] }
end

article = Article.new
article.tags << :ruby
article.save!

p article.tags        #=> [:ruby]
p article.reload.tags #=> [:ruby]
```