OpenStructを見ながら感じる、ちゃんとしたRubyのクラスを実装するために必要なこと

## はじめに

Rubyは人間にやさしい言語なので、雰囲気でRubyを書いていても、それなりに動作してくれます。

雰囲気でコードを書いている自分のために、どうすれば今よりもちゃんとしたRubyのクラスを書けるのか考えてみます。
題材としてOpenStructというクラスをとりあげます。

## OpenStructとは？

https://github.com/ruby/ostruct

オープンストラクトとは、あとから自由にメンバを追加できる構造体です。Rubyの場合は、Hashがあるので、オープンストラクトを使う機会は少ないと思います。

けれどもOpenStructはすべてRubyで実装されていています。C拡張を使っていません。コード量も１ファイル472行で、そのほとんどはコメントです。OpenStructを読めば最低限「きちんとした」Rubyのクラスの書き方がわかるはずです。（19人もコントリビュータがいて、よく保守されています）


## OpenStructを読む

気になったポイントをコードのスニペットとともに挙げていきます。

### 1. 文字列は変更不可能にするべきか？

```
# frozen_string_literal: true
```

このコメント、入れるかどうか迷うところもありますが、Ruby標準ライブラリのOpenStructでは入れる方針となっているようです。最近は bundler で gem のテンプレートを作成した時や、`rubocop -A` で自動修正をかけたときも、このコメントが入るようになっていますね。

メリットは、文字列の破壊的操作を禁止することでバグを減らすことができます。また高速化できるケースもあるかもしれません。デメリットとしては、`"aaa" << "bbb"` という便利な文字列の結合イディオムが使えなくなってしまうことです。私はこの文字列の結合が結構好きなので、コメントを入れないことが多いのですが、「ちゃんとしたRubyのクラス」を実装するときには文字列は変更不可能にしておいたほうが良いでしょう。

### 2. オブジェクトのディープコピーへの対応するべきか？

Rubyの`clone`や`dup`はshallow copyで参照先まではコピーしません。しかし、それでは不便な場合もあるので`initialize_clone`や`initialize_dup` などのメソッドが定義されているようです。`clone` と `dup` にも微妙な違いがあるようで、「[dup はオブジェクトの内容, taint 情報をコピーし、 clone はそれに加えて freeze, 特異メソッドなどの情報も含めた完全な複製を作成します。 ](https://docs.ruby-lang.org/ja/latest/method/Object/i/clone.html)」と書かれています。`initialize_dup`は`update_to_values!`を`Marshal.load`と一緒に使うようになっています。

#### initialize_clone

```ruby
  # Duplicates an OpenStruct object's Hash table.
  private def initialize_clone(orig) # :nodoc:
    super # clones the singleton class for us
    @table = @table.dup unless @table.frozen?
  end
```

#### initialize_dup

```ruby
  private def initialize_dup(orig) # :nodoc:
    super
    update_to_values!(@table)
  end

  private def update_to_values!(hash) # :nodoc:
    @table = {}
    hash.each_pair do |k, v|
      set_ostruct_member_value!(k, v)
    end
  end
```

ディープコピーに対応するべきかどうかは、コピー先のオブジェクトが参照しているインスタンス変数を変更した時に、コピー元のオブジェクトの動作が変化してほしいかどうかによって変わってくると思います。OpenStructはコピー先を編集したときに、コピー元の挙動が変わると都合が悪いので、ディープコピーに対応するようになっていると思います。

しかし、Rubyにおいては、そもそもオブジェクトのクローン自体があまり良いパターンではなくて、できるだけコピーはせず、`new`を使うべきだと思います。

### 3. 後方互換性に配慮するべきかどうか？

if文を使って、Rubyのバージョンによって異なるメソッドを定義しているところがあります。

```ruby
  if {test: :to_h}.to_h{ [:works, true] }[:works] # RUBY_VERSION < 2.6 compatibility
    def to_h(&block)
      if block
        @table.to_h(&block)
      else
        @table.dup
      end
    end
  else
    def to_h(&block)
      if block
        @table.map(&block).to_h
      else
        @table.dup
      end
    end
  end
```

あまりきれいなコードではないと思うので、Rubyバージョン2.6以降にしか対応しないと決めて、バッサリと切ってしまうのも一つの手です。しかしRuby標準ライブラリでは後方互換性を捨てられないので、こうしたテクニックが必要になるでしょう。

### 4. 戻り値はコピーしてから渡すべきかどうか？

先程のメソッドですが、単にクラスの内部の@tableを渡すのではなく、きちんと `dup` してから渡すようにしていますね。Rubyだと参照渡しをあまり意識することはありませんが、それが原因でバグを作ることもあります。これも先程のディープコピーと同じで、渡した先で変数が変更された時に、元のオブジェクトの動作が変更されるべきかどうかによって、コピーすべきかどうかは変わってくると思います。

### 5. ブロックを引数にとらないときに、Enumeartorを返すイテレータ

```ruby
  def each_pair
    return to_enum(__method__) { @table.size } unless block_given!
    @table.each_pair{|p| yield p}
    self
  end
```

これも「まれによく使う」パターンのコードだと思います。ブロック引数がないときに`Enumerator`を返すパターンです。このパターンは忘れてしまいがちで、必要になるとその都度調べている気がします。

```ruby
# まれによく使う記法
return to_enum(__method__) { @table.size } unless block_given!
```

`__method__` などのイディオムも興味深いですね。こういのを調べずにササッと書けるとプロだなぁという感じがします。（個人の感想です）

### 6. オブジェクトの読み込み・保存に対応するべきかどうか？

余裕があれば `marshal_dump` を実装してオブジェクトの保存にも対応しておきたいものです。

```ruby
  def marshal_dump # :nodoc:
    @table
  end
```

```ruby
alias_method :marshal_load, :update_to_values! # :nodoc:
```

`update_to_values` は `initialize_dup` でも見かけました。`dup` とセットにして実装するといいかもしれません。

`marshal_dump` と `marshal_load` を実装するメリットはデータをファイルに保存することができることです。ほかの用途としては、drubyへの対応が思い浮かびます。

問題は、データをファイルに保存する必要があるクラスはさほど多くないことです。一部のクラスでは、`Marshal.dump` は頻回に使われる可能性があります。具体的には数値計算やワークフロー的な操作が絡むもので、途中経過や状態を保存したい場合です。このケースでは`Marshal.dump`は大活躍すると思います。自分の作成するクラスを`dump` するシーンがあるかどうか考えて、ありそうなら実装するぐらいのスタンスだと思います。

### 7. Ractorに対応するべきかどうか？

```ruby
  def new_ostruct_member!(name) # :nodoc:
    unless @table.key?(name) || is_method_protected!(name)
      if defined?(::Ractor)
        getter_proc = nil.instance_eval{ Proc.new { @table[name] } }
        setter_proc = nil.instance_eval{ Proc.new {|x| @table[name] = x} }
        ::Ractor.make_shareable(getter_proc)
        ::Ractor.make_shareable(setter_proc)
      else
        getter_proc = Proc.new { @table[name] }
        setter_proc = Proc.new {|x| @table[name] = x}
      end
      define_singleton_method!(name, &getter_proc)
      define_singleton_method!("#{name}=", &setter_proc)
    end
  end
```

Ractorがよくわからないのですが、Ractorに対応しているかどうかでコードを分けているようです。
しかし、`nil.instance_eval{}` は難しくて何をやっているのかよくわかりません。Ractorに対応したコードを書くのはなかなか大変そうです。

### 8. freezeへ対応するべきかどうか？

```ruby
  def freeze
    @table.freeze
    super
  end
```

これも野良のクラスは対応していないものが多いんじゃないかと思うんですよね。しかしRuby標準ライブラリのクラスはしっかり対応します。

### 9. method_missingのエラー処理

```ruby
  private def method_missing(mid, *args) # :nodoc:
    len = args.length
    if mname = mid[/.*(?==\z)/m]
      if len != 1
        raise! ArgumentError, "wrong number of arguments (given #{len}, expected 1)", caller(1)
      end
      set_ostruct_member_value!(mname, args[0])
    elsif len == 0
      @table[mid]
    else
      begin
        super
      rescue NoMethodError => err
        err.backtrace.shift
        raise!
      end
    end
  end
```

```ruby
  def dig(name, *names)
    begin
      name = name.to_sym
    rescue NoMethodError
      raise! TypeError, "#{name} is not a symbol nor a string"
    end
    @table.dig(name, *names)
  end
```

別に変わったことはしていないのですが、さすが標準ライブラリだけあってエラー処理の網目がきっちりしてると感じます。ミスすることが多いのでそもそもできれば`method_missing`は使うなという話もあります。


### 10. 引数には文字列もシンボルも取れるようにするべきか？

```ruby
  def [](name)
    @table[name.to_sym]
  end
```

一般的にはRubyのライブラリでは両方とも取れるようにしておく方が行儀が良いと思います。しかし、個人的には、これは必ずしも守る必要はないかなと思います。シンボルしか取らないメソッド、文字列しか取らない方が実装するのは楽ですし、ユーザーも間違いに気が付きやすくなります。しかし、Rubyの標準ライブラリは、きちんと両方とも取れるようにしています。

### 11. 不要になったメソッドを忘れずに消去するべきか？

```ruby
  def delete_field(name)
    sym = name.to_sym
    begin
      singleton_class.remove_method(sym, "#{sym}=")
    rescue NameError
    end
    @table.delete(sym) do
      return yield if block_given!
      raise! NameError.new("no field `#{sym}' in #{self}", sym)
    end
  end
```

`remove_method` を使って不要になったメソッドを消しています。特に変わったことはしていないと思うのですが、人間は「必要なものを付け加える」ことは得意なのですが、「不必要なものを適切なタイミングで除去する」ことは大変苦手としています。

動的にメソッドを追加することはよくありますが、動的にメソッドを削除する機会はそれほど多くないと思うんですよね。しかし、それは本当はおかしくて、動的にメソッドを追加するシーンがあるのならば、それと同じぐらい動的にメソッドを削除するシーンがあってしかるべきだと思います。OpenStructの場合は、不要になったメソッドを除去するのは必須なので忘れないと思いますが、「消しても消さなくてもどちらでもいい」みたいなケースは結構あると思います。動的に追加したメソッドの消し忘れには注意したいと思いました。

### 12. inspectへ独自に対応するべきか？

しておいたほうがいいですよね。

```ruby
  def inspect
    ids = (Thread.current[InspectKey] ||= [])
    if ids.include?(object_id)
      detail = ' ...'
    else
      ids << object_id
      begin
        detail = @table.map do |key, value|
          " #{key}=#{value.inspect}"
        end.join(',')
      ensure
        ids.pop
      end
    end
    ['#<', self.class!, detail, '>'].join
  end
  alias :to_s :inspect
```

実はこのコードを読むまでは、Rubyの標準ライブラリのオブジェクトの`inspect`ってもっと自動的に生成されているのかなと思ってました。でも実際にはそうではなくて、こんな風に何行もコードを書いて普通に生成していたのですね。`inspect` は野良のライブラリでもちゃんと実装しているケースがしばしば見かけますが、きちんと実装するときはこのようにすればよいということで参考になります。

一方で、実用上は`inspect`を独自に実装しなくても問題がない場合が多いです。必要がないものは実装しないほうがコードがスッキリするので、必要だと感じた時だけ実装すれば十分だろうと思います。

### 13. 比較演算子へ対応するべきかどうか？

```ruby
  def ==(other)
    return false unless other.kind_of?(OpenStruct)
    @table == other.table!
  end
```

```ruby
  def eql?(other)
    return false unless other.kind_of?(OpenStruct)
    @table.eql?(other.table!)
  end
```

これも、個人的にはほとんど実装したことがないのですが、オブジェクト同士を比較したい場合は確かにあります。けれども、ほとんどのオブジェクトというのは、`new` した時点でそれぞれ全然別のものであることが多く、`同一`あるいは`大小の判定`が必要になるものは限定的だと思います。なので、そういうシーンがありそうかどうかで、実装するべきか否かを決めればよいのではないでしょうか。

### YAML対応

`encode_with` と `init_with` メソッドを実装しておけばいいらしい。YAMLに書き出す可能性がある場合は実装。これは必ずしもちゃんとしたクラスなら実装しなければならないというよりは、OpenStructで必要として実装されたものだと思います。

### 14. メソッド名の微調整をどう行うか？

メソッドをあとからまとめて調整する需要ってまぁまぁあると思うんですよね。メソッド名の語尾を微妙に変化させたりとか。ここではインスタンスメソッド一覧を取り出して、正規表現でフィルタリングし、あたらしい名前のメソッドをエイリアスで作っています。そこまで行儀が良くない方法のような感じもしますが、標準ライブラリで使われている方法なら、そうやってもいいんだなという安心感はあります。

```ruby
  # Make all public methods (builtin or our own) accessible with <code>!</code>:
  give_access = instance_methods
  # See https://github.com/ruby/ostruct/issues/30
  give_access -= %i[instance_exec instance_eval eval] if RUBY_ENGINE == 'jruby'
  give_access.each do |method|
    next if method.match(/\W$/)

    new_name = "#{method}!"
    alias_method new_name, method
  end
```

### 15. 複数のRuby実行環境に対応するべきかどうか？

上のコードには、`if RUBY_ENGINE == 'jruby'` でJRubyを場合分けしています。自分はJRubyのコードを実行する機会はほとんどないので、JRubyの特徴がよくわかっていません。

これに関しては考え方が難しいですが、一般論としてはJRubyへの個別の対応は必要ないと思っています。JRubyに個別に対応するぐらいであれば、しっかりとリファクタリングを行って機構を単純化して、Ruby2.4ぐらいまでの保守的な記法に徹して、さまざまなRuby処理系で動くことを期待するという方が常道ではないかなと思います。しかし、視界の届く範囲のユーザーにJRubyユーザーが存在するとか、GithubのissueにJRubyに関するポストが寄せられたとか、個人的にCRuby以外の実装に興味があるとか、仕事でJRubyを使っているとかそういうケースでは、きちんと対応していくことが求められると思います。

### 16. ドキュメントを書くべきかどうか？

OpenStructのコード全体を見て感じるのは、ドキュメントが非常に多いということでしょう。Rubyの標準ライブラリは、たくさんのユーザーがいます。多くのユーザーにライブラリの使い方を説明するために、丁寧なコメントが書き込まれています。通常私たちが書くコードでここまでドキュメントが多いものはほとんどないでしょう。

これもユーザーの総数を考えて、Rubyの標準ライブラリのように「のべ」ユーザー数が非常に多くなりそうな場合には丁寧にコメントを追加して、あまりユーザーが多くないような場合は、ドキュメントの作成を省いてコードに語らせる、といったバランスになると思います。

## おわりに

#### どこまでやるべきか？　答えなき問い

こうやってみるとOpenStructはかなり細かく実装していますけど、「きちんとしたクラス」を目指しているわけでなければ、ここまでしっかり実装する必要はないと思います。物事にはすべてメリットと、デメリットがあり、きちんとしたクラスを目指して実装すれば、実装のスピードは犠牲になりますし、複雑さも上昇することになるので、場合によっては不要なバグを埋め込んでしまう結果にもなりかねません。一方で、「きちんとしたクラス」を意識することは不特定多数のユーザーが使うようなライブラリを作成するときには、頭の片隅に置いても損はないと思われます。（もしもそんな機会があれば。）

そもそも「どこまでやるべきか？」というのは答えのない問であり、Rubyistの数だけスタイルがあるかもしれません。
そのようなことを考えること自体が、ある種の文化的な営みで悪いことじゃないんじゃないかなと思います。

この記事は以上です。