10
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 1 year has passed since last update.

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

Last updated at Posted at 2021-12-24

はじめに

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

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

OpenStructとは?

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

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

OpenStructを読む

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

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

# frozen_string_literal: true

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

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

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

Rubyのclonedupはshallow copyで参照先まではコピーしません。しかし、それでは不便な場合もあるのでinitialize_cloneinitialize_dup などのメソッドが定義されているようです。clonedup にも微妙な違いがあるようで、「dup はオブジェクトの内容, taint 情報をコピーし、 clone はそれに加えて freeze, 特異メソッドなどの情報も含めた完全な複製を作成します。 」と書かれています。initialize_dupupdate_to_values!Marshal.loadと一緒に使うようになっています。

initialize_clone

  # 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

  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のバージョンによって異なるメソッドを定義しているところがあります。

  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を返すイテレータ

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

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

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

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

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

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

  def marshal_dump # :nodoc:
    @table
  end
alias_method :marshal_load, :update_to_values! # :nodoc:

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

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

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

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

  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へ対応するべきかどうか?

  def freeze
    @table.freeze
    super
  end

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

9. method_missingのエラー処理

  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
  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. 引数には文字列もシンボルも取れるようにするべきか?

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

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

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

  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へ独自に対応するべきか?

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

  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. 比較演算子へ対応するべきかどうか?

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

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

YAML対応

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

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

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

  # 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の数だけスタイルがあるかもしれません。
そのようなことを考えること自体が、ある種の文化的な営みで悪いことじゃないんじゃないかなと思います。

この記事は以上です。

10
4
3

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