やわらかテック

興味のあること。業務を通して得られた発見。個人的に試してみたことをアウトプットしています🍵

RubyでArrayクラスを継承させると面白いことが起きる

後に改めて記事を書きますが、最近はRubocopのコードを読み進めています。
そんな中で「面白いなぁ...」と感じたコードがあったので紹介がてら動作を確認してみたい思います。
なんと定義されたクラスがArrayクラスを継承させているではありませんか。

module RuboCop
  module Formatter
    # Arrayクラスを継承させている!
    class FormatterSet < Array
      :
    end
  end
end

rubocop/formatter/formatter_set.rb#L10

FormatterSetクラスは別ファイルに定義された、様々なFormatter(結果をどのように出力するか管理しているクラス)をコレクションとして扱うためのクラスです。つまり、使用したいFormatterがあればFormatterSetの内部にadd_formatter関数経由で追加されて保持しているようです。

def add_formatter(formatter_type, output_path = nil)
  :
  self << formatter_class(formatter_type).new(output, @options)
end

しれっとselfに対して<<を呼び出して配列へ要素の追加を行っていますが、ここでのselfは一体、何を参照しているのでしょうか。
自分だったら、この手の実装はインスタンス変数(@formattersみたいな)を作成してFormatterクラスを管理しそうなものですが、Arrayクラスを継承させる方法を選んだのなぜなのでしょうか。

selfが参照するもの

まずはArrayクラスを継承した場合にselfが参照するものから見ていきましょう。

class Sample < Array
  def whats_self
    self
  end
end

self_ = Sample.new.whats_self

puts "self: #{self_}" # self: []
puts "self.class: #{self_.class}" # self.class: Sample
puts "self.class: #{Sample.superclass}" # self.class: Array

予想通り...ではありますがSampleクラスのselfが参照しているのは初期状態では空の配列([])でした。
先ほどFormatterSetクラスの内部でself << formatter_class(...)が記述されていたことにも納得です。実質、空のインスタンス変数を作成して値を追加するという動作と非常に似たものであると考えて良いでしょう。

この結果を踏まえて、以下のようなコードが通りました。
Arrayクラスを継承すると、そのクラスは配列として扱うことが可能であると考えるのが自然な気がしてきました。

class Sample < Array
  def set_val
    self << 1
    self << 2
    self << 3
    
    self
  end
end

self_ = Sample.new.set_val
puts "self: #{self_}" # self: [1, 2, 3]

Arrayクラスを継承させる理由

selfが参照しているのは初期状態では空の配列であることが分かりました。
しかし、これだけのためにArrayクラスを継承させるというのは少しやり過ぎな気がします。他に何か理由はあるのでしょうか。 先ほどのFormatterSetクラスを見てみると各所でeach関数を呼び出していることに気づきました。

def file_started(file, options)
  @options = options[:cli_options]
  @config_store = options[:config_store]
  each { |f| f.file_started(file, options) }
end

rubocop/formatter/formatter_set.rb#L44

確かにArrayクラスを継承しているのでArrayクラスに定義された関数を呼び出すことが可能です。
また一部の関数の振る舞いを変化させたいのであればオーバーライドすることも可能と...使いようによっては非常なパワフルな働きをしてくれると思います。FormatterSetの内部ではeach関数をそのまま呼び出しているだけですが、今後、他の関数や振る舞いを変化させたいときは変更がしやすそうです。

class Sample < Array
  def initialize
    self << 1
    self << 2
    self << 3
  end
end

Sample.new.each { |v| puts "elm: #{v}" }
# elm: 1
# elm: 2
# elm: 3

eachのオーバーライド

class Sample < Array
  :  
  def each(&block)
    puts "each!"
  end
end

ins = Sample.new
ins.each { |v| puts "elm: #{v}" } # each!

インスタンスの作成

Arrayクラスを継承することで継承先クラスのインスタンス作成時に引数が渡せるようになります。
インスタンス作成時にはArrayクラスのnew関数を呼び出すわけですが、ドキュメントを見てみるとnew関数は引数が異なるものが3つ定義されています。それらに倣って実行してみます。

class Sample < Array
end

puts "pattern1: #{Sample.new(5, 1)}"
puts "pattern2: #{Sample.new([1,1,1,1,1])}"
puts "pattern3: #{Sample.new(5) { 1 } }"

# pattern1: [1, 1, 1, 1, 1]
# pattern2: [1, 1, 1, 1, 1]
# pattern3: [1, 1, 1, 1, 1]

class Array (Ruby 3.2 リファレンスマニュアル)

RubocopのFormatterSetクラスでもnew関数から初期値のFormatterを指定しているのかと思いきや、FormatterSetクラスではinitializeがオーバーライドされており、new関数経由での初期値の登録はしていませんでした。

class FormatterSet < Array
  def initialize(options = {})
    super()
    @options = options # CLI options
  end
end

# 呼び出し元 @optionsはHash
# rubocop/lib/rubocop/runner.rb:416
set = Formatter::FormatterSet.new(@options)

まとめ

Arrayクラスを継承すると...

  • selfの初期値には空の配列が保持される
  • self << valueを実行するとselfに値の追加が可能
  • Arrayクラスに定義された関数が利用可能
  • new関数に初期値の指定が可能
  • Arrayクラスの関数をオーバーライドすることで振る舞いを簡単に変えることが可能

組み込みクラスを継承したことがなかったので非常に興味深い結果になりました。
同じようにデータをコレクションとして扱いたいのであれば、FormatterSetクラスのようにArrayクラスを継承してみるとスマートな実装ができるかもと感じました。

少しでも「ええな〜」と思ったらイイネ!・シェア!・はてなブックマークを頂けると励みになります。