やわらかテック

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

インスタンス作成時の値によって関数の定義を変える方法

ある日のこと、業務中にチームメンバーから非常に面白い質問がありました。
「インスタンス作成時の値によって関数の定義を変えることって可能ですかね?」 ...とのことです。

どういうことかをもう少し詳しく聞いてみると、以下のようなイメージを持っているようでした。

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のコードを読んだ際にも何度も登場している関数の一つです。

www.okb-shelf.work

ただし、インスタンス作成時、すなわち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: fookind: 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

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