はじめに
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のclone
やdup
はshallow copyで参照先まではコピーしません。しかし、それでは不便な場合もあるのでinitialize_clone
やinitialize_dup
などのメソッドが定義されているようです。clone
と dup
にも微妙な違いがあるようで、「dup はオブジェクトの内容, taint 情報をコピーし、 clone はそれに加えて freeze, 特異メソッドなどの情報も含めた完全な複製を作成します。 」と書かれています。initialize_dup
はupdate_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_values
は initialize_dup
でも見かけました。dup
とセットにして実装するといいかもしれません。
marshal_dump
と marshal_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_with
と init_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の数だけスタイルがあるかもしれません。
そのようなことを考えること自体が、ある種の文化的な営みで悪いことじゃないんじゃないかなと思います。
この記事は以上です。