ある日のこと、業務中にチームメンバーから非常に面白い質問がありました。
「インスタンス作成時の値によって関数の定義を変えることって可能ですかね?」 ...とのことです。
どういうことかをもう少し詳しく聞いてみると、以下のようなイメージを持っているようでした。
class Sample def initialize(kind) end end # kindがfooだったら Sample.new(:foo).func(:hello) # kindがbarだったら Sample.new(:bar).func(:hello, :world)
これをやりたい理由としては、kind: foo
の時にはfuncを実行するのにxの値があれば十分なのに対して、kind: bar
の場合にはxとyの値がなければfuncを実行することが出来ないからとのことでした。
「kindがfooでもxとyの値を渡せば良いんじゃないの?」と聞いてみたら「できれば必要ない値を渡したくない...」と正論が返ってきました。すごく分かる。
ということでインスタンス作成時の値によって関数の定義を変える方法の紹介と改善案の提案をしたいと思います。
黒魔術(メタプログラミング)を使う
この手のことがやりたい場合、正攻法では何ともなりません。
Rubyにはメタプログラミングを行うための関数が提供されています。今回はクラスに対して自由に関数を定義したいのでdefine_method
が使えそうです。過去にOSSのコードを読んだ際にも何度も登場している関数の一つです。
ただし、インスタンス作成時、すなわちinitialize関数内部ではdefine_method
を呼び出すことはできません。
他の関数の内部でも同様です。
class Sample def initialize define_method(:func, Proc.new { puts "func!" }) end end Sample.new.func # Main.rb:3:in `initialize': undefined method `instance_method' for #<Sample:0x0000154611404220> (NoMethodError) # define_method(:func, instance_method(Proc.new { puts "func!" }))
class直下であれば問題なく呼び出しが可能です。
class Sample define_method(:func, Proc.new { puts "func!" }) end Sample.new.func # func!
class_execを使う
ということでclass_exec
との合わせ技で何とかします。
class_exec
のブロック内部ではdefine_method
が呼び出し可能となるため、クラスの定義を自由に拡張することが出来ます。
class Sample def initialize(kind) self.class.class_exec do case kind when :foo define_method(:func, Proc.new { |x| x }) when :bar define_method(:func, Proc.new { |x, y| [x, y] }) end end end end
特に実行したい処理が思いつかなかったので適当に値を返すようにしました。
さて、期待通りの挙動をしてくれるでしょうか...。
foo = Sample.new(:foo) puts "foo: #{foo.func(:hello)}" # foo: hello bar = Sample.new(:bar) puts "bar: #{bar.func(:hello, :world)}" # bar: [:hello, :world]
インスタンス作成時に指定した値によってfunc
関数の定義が異なることが確認できました。
メタプログラミングは用法用量を守って使用すれば非常に便利ですが、やりすぎには注意しましょう。
とはいったものの...
元々の課題はクリアできたものの、本当にこれで良いのでしょうか。
そもそも値によって関数の定義が変わるものが、同一の関数として定義されるということに違和感を感じますし、メタプログラミングは本当に最終手段であってほしいです。他に実現する方法はないのでしょうか。
先ほどのコードをより明確かつ保守しやすい形に書き換えてみます。
クラスへの分割
先ほどの例ではSampleクラス1つに条件によってkind: foo
とkind: bar
の両方の振る舞いが定義されていました。
これは単一責任の原則に反していますし、今後、kindによって振る舞いが変わる別の関数が追加されることも予測されます。そのため、クラスへの分割を行うのが良いでしょう。
まずはそれぞれのkindに対応するクラスとfunc関数を定義します。
class Kind class Foo def func(x) x end end class Bar def func(x, y) [x, y] end end end
あとは使い勝手が良くなるようにKindクラスに指定された値によって、対応するクラスのインスタンスを作成して返す関数(クラスメソッド)を定義します。
class Kind def self.load_class(kind) case kind when :foo Foo.new when :bar Bar.new else raise ArgumentError, '存在しないkindが指定されています' end end end
呼び出し方は少し変わりましたが、これで先ほどと同じような結果が得られるようになりました。
foo = Kind.load_class(:foo) puts "foo: #{foo.func(:hello)}" # foo: hello bar = Kind.load_class(:bar) puts "bar: #{bar.func(:hello, :world)}" # bar: [:hello, :world]
最後に
非常に面白い質問だったので色々と考えを巡らせたのですが、最終的にはクラス分割を勧めました。
メタプログラミングはパワフルですが安易に使うことは避けた方が良いです。今回のような場合はそもそも単一責任の原則に反しているので、メタプログラミングを使うかどうか以前の問題です。
とはいえ「こんな書き方も出来るよ」というRubyのメタプログラミングのパワーを紹介しつつ自分へのメモとして今回の記事を執筆しました。
おまけ
Elixirだと同名の関数が複数定義できる上に、引数パターンマッチがあるので非常に楽です。
defmodule Sample do def func(:foo, x), do: x def func(:bar, x), do: x def func(:bar, x, y), do: [x, y] end
少しでも「ええな〜」と思ったらはてなスター・はてなブックマーク・シェアを頂けると励みになります。