【Rails】extract_options!メソッドの挙動と使われるところ

## はじめに

Railsのコードリーディングをしていたところ`extract_options!`というメソッドの存在を知りました。
<br>

この記事では`extract_options!`の挙動について、Railsのコードで使われている箇所とともにまとめます。

## `extract_options!`メソッドとは？

`extract_options!`メソッドは、配列の最後の要素がハッシュであればそれを取り出し、残りの配列を返すメソッドです。
<br>

メソッド名の末尾の`!`が示すように元の配列を破壊的に変更する点に注意が必要です。
<br>

サンプルコードで確認します。
<br>

```ruby
sample-app> params = [:name, :age, { city: 'Tokyo', country: 'Japan' }]
=> [:name, :age, {:city=>"Tokyo", :country=>"Japan"}]
sample-app> options = params.extract_options!
=> {:city=>"Tokyo", :country=>"Japan"}
sample-app> params
=> [:name, :age]
sample-app> options
=> {:city=>"Tokyo", :country=>"Japan"}
```
<br>

上記の例では、`extract_options!`の呼び出しによって`params`配列自体からハッシュ要素が削除され、`[:name, :age]`のみが残っています。
<br>

また、最後の要素がハッシュでなければ空のハッシュを返します。
<br>

```ruby
sample-app> params = [:name, :age, :city, :country]
=> [:name, :age, :city, :country]
sample-app> options = params.extract_options!
=> {}
sample-app> params
=> [:name, :age, :city, :country]
sample-app> options
=> {}
```

## `extract_options!`のコードを見てみる

`extract_options!`メソッドは Rails 内で Ruby の Array クラスを拡張して実装されています。

https://github.com/rails/rails/blob/main/activesupport/lib/active_support/core_ext/array/extract_options.rb

<br>

```ruby:activesupport/lib/active_support/core_ext/array/extract_options.rb
# frozen_string_literal: true

class Hash
  # By default, only instances of Hash itself are extractable.
  # Subclasses of Hash may implement this method and return
  # true to declare themselves as extractable. If a Hash
  # is extractable, Array#extract_options! pops it from
  # the Array when it is the last element of the Array.
  def extractable_options?
    instance_of?(Hash)
  end
end

class Array
  # Extracts options from a set of arguments. Removes and returns the last
  # element in the array if it's a hash, otherwise returns a blank hash.
  #
  #   def options(*args)
  #     args.extract_options!
  #   end
  #
  #   options(1, 2)        # => {}
  #   options(1, 2, a: :b) # => {:a=>:b}
  def extract_options!
    if last.is_a?(Hash) && last.extractable_options?
      pop
    else
      {}
    end
  end
end
```
<br>

配列の末尾の要素がハッシュであるかチェックし、ハッシュの場合はpopメソッドでハッシュだけを取り除いて返しています。
<br>

`extractable_options?`によって Hash のサブクラスではなく純粋な Hash インスタンスのみを対象としています。

## Railsのソースコードで`extract_options!`が使われているところ

Railsのソースコードで`extract_options!`が使われている代表例として`validates`メソッドがあります。

https://github.com/rails/rails/blob/main/activemodel/lib/active_model/validations/validates.rb#L111

<br>


```ruby:activemodel/lib/active_model/validations/validates.rb
  def validates(*attributes)
    defaults = attributes.extract_options!.dup  # ここで使われている
    validations = defaults.slice!(*_validates_default_keys)

    raise ArgumentError, "You need to supply at least one attribute" if attributes.empty?
    raise ArgumentError, "You need to supply at least one validation" if validations.empty?

    defaults[:attributes] = attributes

    validations.each do |key, options|
      key = "#{key.to_s.camelize}Validator"

      begin
        validator = key.include?("::") ? key.constantize : const_get(key)
      rescue NameError
        raise ArgumentError, "Unknown validator: '#{key}'"
      end

      next unless options

      validates_with(validator, defaults.merge(_parse_validates_options(options)))
    end
  end
```
<br>

たとえば以下のコードがあるとします。
<br>

```ruby:app/models/book.rb
class Book < ApplicationRecord
  validates :title, presence: true
end
```
<br>

このとき、`validates`メソッドの流れは次のようになります。
<br>

```ruby:activemodel/lib/active_model/validations/validates.rb
  def validates(*attributes)  # attributes = [:title, { presence: true }]
    defaults = attributes.extract_options!.dup  # ここで { presence: true } を抽出
    # この時点で attributes と defaults の中身は次のようになる
    # attributes = [:title]
    # defaults = { presence: true }
    
    # 後続の処理に続く...
```
<br>

引数`attributes`からオプションの部分だけを引き抜いていますね。
<br>

このように`extract_options!`は、可変長引数（args）を取るメソッドです。
<br>

`validates`メソッドのように、オプションとしてハッシュを最後に渡すパターンで頻繁に使用されます。

## おわりに

`extract_options!`の存在は初めて知りましたが、わりと便利そうな印象です。
<br>

オプションの指定としてハッシュを使っているメソッドは他にもあると思うので、そこでも`extract_options!`が使われていないか確認してみます。
<br>

また、`extract_options!`が配列を破壊的に変更する点を踏まえて使用する際には注意が必要です。