バックエンド

【コードを記述するコード】Rubyのメタプログラミングという黒魔術

メタプログラミング

reisuta

Webエンジニア | 20代中盤 | 大学時代はGmailすら知らないIT音痴でプログラミングとは無縁の生活を送る → 独学でプログラミングを学ぶ → Web系受託開発企業にエンジニアとして就職 → Web系自社サービス企業に転職 | 実務未経験の頃からVimを愛好しており、仕事でもプライベートでも開発はVimとTmuxを使っているので、VSCodeに疎いのが最近の悩み。何だかんだでやっぱりRubyが好き。

本記事ではメタプログラミングについてふれます。
メタプログラミングは、Rubyの文法の中でも、
かなり応用の部類に入り、Procなどと同様にかなり使いこなすのが難しい文法です。
※ちなみにProcについてはこちらの記事で解説しています。

そもそもメタプログラミングはかなり応用の文法なので、
Rubyの基礎の文法の解説は、
下記の動画などをご参考ください。

メタプログラミングの実際の使用シーンは限定的で、
RailsなどのOSSの内部実装で使われているという印象が強い技術です。

ただ、Rubyを使う上で、
メタプログラミングの知識はやはり欠かせないと思うので、
本記事では、大まかな概要を解説します。

メタプログラミングとは何か?

Rubyは、その柔軟性と表現力豊かな言語機能により、メタプログラミングに非常に適しています。
メタプログラミングは、プログラムが自身の構造や振る舞いを解析・変更する能力を指し、
これにより、より柔軟で効率的なコードを書くことができます。

クラスやメソッドの定義を動的に変更したり、実行時にコードを生成したりすることができ
端的にいうと、コードを記述するコードという感じになります。

Railsのソースコードとかを見ると、
このようなコードがたくさん使われています。

https://github.com/rails/rails/blob/main/activerecord/lib/active_record/enum.rb#L318

例えば、上記のRailsのコードの場合、
define_methodやpublic_sendなどが、
メタプログラミングの代表的なメソッドに該当します。

基本的にメタプログラミングを使うことによる利点は、
DRY原則(Don't Repeat Yourself)を実現し、コードの重複を減らすことができたり、
柔軟性を高め、動的な振る舞いを実現できることなどが挙げられます。

要するに、効率的なコードを書くための手段という感じですね。
有名なメソッドとしては、すでに出てきた、
define_method、public_send(send)や、
instance_eval、method_missing、evalなどが挙げれれます。

define_methodを使ってコードをDRYにする例

試しに、define_methodを使って、
コードをDRYにしてみたいと思います。

まず、下記のようなコードがあるとします。

class Calculator
  def add(a, b)
    puts "Performing add operation:"
    puts "#{a} + #{b} = #{a + b}"
  end

  def subtract(a, b)
    puts "Performing subtract operation:"
    puts "#{a} - #{b} = #{a - b}"
  end

  def multiply(a, b)
    puts "Performing multiply operation:"
    puts "#{a} * #{b} = #{a * b}"
  end

  def divide(a, b)
    puts "Performing divide operation:"
    puts "#{a} / #{b} = #{a / b}"
  end
end

calculator = Calculator.new
calculator.add(5, 3)
calculator.subtract(10, 4)
calculator.multiply(4, 2)
calculator.divide(10, 2)

Calculatroクラスに、
それぞれ足し算、引き算、掛け算、わり算のメソッドが定義されています。

ただ、puts performingの部分がほとんど似通っています。
仮に、このクラスに更に商のあまりを出す計算だったり、累乗とかが追加されれば、
さらに冗長になるおそれがあります。

これをメタプログラミング(define_method)を使って書き直すと、

class Calculator
  [:add, :subtract, :multiply, :divide].each do |operation|
    define_method(operation) do |a, b|
      puts "Performing #{operation} operation:"
      case operation
      when :add
        puts "#{a} + #{b} = #{a + b}"
      when :subtract
        puts "#{a} - #{b} = #{a - b}"
      when :multiply
        puts "#{a} * #{b} = #{a * b}"
      when :divide
        puts "#{a} / #{b} = #{a / b}"
      end
    end
  end
end

calculator = Calculator.new
calculator.add(5, 3)
calculator.subtract(10, 4)
calculator.multiply(4, 2)
calculator.divide(10, 2)

このようにputs performing の部分は共通化することができました。
今回のケースのように単純な例だと、
メタプログラミングを使わないで実装したほうが直感的でわかりやすいかも知れませんが、
仮にputs performing のような各メソッドで共通の処理がさらに追加される場合は、
差が顕著になってきます。

Railsなどのフレームワークは、大規模で複雑なので、
メタプログラミングを使って可能な限り処理をDRYにしているというイメージですかね。

sendとevalを使ったメタプログラミング例

次に、sendとevalを使った例を紹介します。

sendは、先程出てきたpublic_sendと似たようなメソッドで、
send(name, *args)みたいな感じで使用し、
nameで指定したmethodをargsを引数にして実行するという感じです。

https://docs.ruby-lang.org/ja/latest/method/Object/i/send.html

public_sendにすれば、publicメソッドだけ呼び出すことができるという感じです。

evalは、eval(expr)みたいに使用すると、
exprをRubyのプログラムとして実行するというメソッドです。

https://docs.ruby-lang.org/ja/latest/method/Kernel/m/eval.html

ただ、紹介しておいて言うのもあれですが、
基本的にevalの使用については慎重になるべきです。

というのも、evalは何でもできてしまうので、
例えば外部の入力をそのままevalに渡すようなコードを書いてしまうと、
悪意あるユーザーの入力で一発でセキュリティインシデントになります。

ちなみに、Rubocopの警告にもevalについては、
使用しないようにというものがあります。

https://www.rubydoc.info/gems/rubocop/RuboCop/Cop/Security/Eval

あくまで本記事では、
メタプログラミングの活用例としてevalを使うので、
もし実際にevalを使う場合は使用すべきか十分に検討していただけると。

さて、上記を踏まえて、
下記がevalとsendを使ったメタプログラミングのサンプルです。

class DynamicCalculator
  OPERATIONS = %i[add subtract multiply divide]

  def calculate(operation, a, b)
    if OPERATIONS.include?(operation.to_sym)
      send(operation, a, b)
    else
      puts "Invalid operation: #{operation}"
    end
  end

  OPERATIONS.each do |op|
    eval <<-RUBY
      def #{op}(a, b)
        puts "Performing #{op} operation:"
        result = case :#{op}
                 when :add
                   a + b
                 when :subtract
                   a - b
                 when :multiply
                   a * b
                 when :divide
                   a / b
                 end
        puts "\#{a} #{op} \#{b} = \#{result}"
      end
    RUBY
  end
end

calculator = DynamicCalculator.new
calculator.calculate(:add, 5, 3)
calculator.calculate(:subtract, 10, 4)
calculator.calculate(:multiply, 4, 2)
calculator.calculate(:divide, 10, 2)
calculator.calculate(:power, 2, 3)

まず、OPERATIONS 定数で、実行可能な操作のリストを定義し、
:add、:subtract、:multiply、:divide の4つの操作が含まれています。

calculate メソッドは、send メソッドを使用して、与えられた操作名に対応するメソッドを動的に呼び出しています。

OPERATIONS.each do |op| の部分では、eval を使用して動的にメソッドを定義しています。
各操作に対して、その操作名をメソッド名として持ち、引数 a と b を受け取り、その操作を実行します。

インスタンスを作成し、calculate メソッドを呼び出すことで、動的に生成されたメソッドを使用して計算を行います。
与えられた操作が OPERATIONS 定数に含まれていない場合は、"Invalid operation" というメッセージが表示します。

例えば、新しい操作を追加したい場合は、OPERATIONS 定数にその操作を追加するだけで、
その操作を使用できるようになるので、DRYという観点では有効な実装かと思われます、

class_evalとmethod_missingを使ったメタプログラミング

お次は、class_evalとmethod_missingを使った例です。

Rubyでは、定義されていないメソッドを呼び出すと、
おなじみの、undefined method `method_name' for # (NoMethodError)みたいなエラーがでます。

Rubyにおける method_missing は、オブジェクトが存在しないメソッドを呼び出したときに、
呼び出される、BasicObjectのメソッドで、上記のおなじみのエラーはこのmethod_missingメソッドで
定義されているデフォルトの例外にあたります。

https://docs.ruby-lang.org/ja/latest/method/BasicObject/i/method_missing.html

そこで、このmethod_missingをオーバーライドすれば、
存在しないメソッドを呼び出したときの処理を定義することができるため、
動的メソッド定義と似たようなことができそうです。

試しに、

class Sample
  def method_missing(method, *args)
    puts 'hello world'
  end
end

sample = Sample.new
sample.test

みたいなふうにSampleクラスで、
親クラスのmethod_missingをオーバーライドし、
定義されていないtestメソッドを呼び出します。

そうすると、hello worldが出力されます。

これを使えば、確かにメタプログラミングできそうですね。

method_missing を定義することで、オブジェクトが未定義のメソッドを呼び出したときに、
そのメソッド呼び出しをキャプチャし、オブジェクトが適切に応答するようにカスタマイズできます。
Rubyのオブジェクト指向の柔軟性と動的な性質を最大限に活用する手段として使用できるかと思います。

これを踏まえて、method_missingとclass_evalを使用してメタプログラミング例を紹介します。
あるゲームがあって、そのコマンドをmethod_missingで動的に定義している感じです。
(※あくまでこんな感じになるよみたいなイメージの紹介なので中身のコードが少々冗長なのはお許しを)

class Game
  def initialize
    @player_position = [0, 0]
  end

  def method_missing(method_name, *args)
    command_name = method_name.to_s
    if command_name.start_with?('move_')
      direction = command_name.split('_').last
      define_direction_method(direction)
      send(method_name, *args)
    else
      super
    end
  end

  private

  def define_direction_method(direction)
    self.class.class_eval do
      define_method("move_#{direction}") do |steps|
        move_player(direction, steps)
      end
    end
  end

  def move_player(direction, steps = 1)
    case direction
    when 'north'
      @player_position[1] += steps
    when 'south'
      @player_position[1] -= steps
    when 'east'
      @player_position[0] += steps
    when 'west'
      @player_position[0] -= steps
    else
      @player_position[1] *= steps
    end
    puts "Player moved to #{@player_position}"
  end
end

game = Game.new
game.move_north(2)
game.move_east(1)
game.move_south(3)
game.move_west(3)
game.move_straight(5)# 新しい方向を追加した場合

class_evalは、Gameクラスに対して、
動的にメソッドを定義するために使用しています。

さて、例えば、Gameのインスタンスである、
game変数に、move_northというメソッドを呼び出すも、
そんなメソッドはないので、method_missingが呼ばれます。

メソッド名がmoveから始まるので、
ifの中のdefine_direction_methodが呼ばれます。

splitしているので、引数はnorthですね。
それでclass_evalの中でdefine_methodで
動的にmove_notrhというメソッドを定義します。

その中身はmove_playerのcase節のnorthになるという感じですね。

https://docs.ruby-lang.org/ja/latest/method/Module/i/class_eval.html

メタプログラミングの注意点

さて、このようにメタプログラミングについて解説してきましたが、
すでにお察しの通り、メタプログラミングは直感的とは言い難い記述です。

そのため、過度な使用はコードの可読性を低下させる可能性があります。
また、動的な変更やmethod_missingとかを乱用すると、予期せぬ副作用なども生じる可能性があるので、
Rubyに対する深い理解がないとなかなか使いこなすのが難しいのが正直なところです。

言うなれば、より抽象的な記法ともいえるので、
仮にメタプログラミングを使うのであれば、
行き当たりばったりではなく、きちんと設計の手順を踏んだほうが良いでしょう。

また、Rubyにはブロックなどの強力な機能があるため、
メタプログラミングを使わなくても十分なケースもあるかもしれません。

ただ、メタプログラミングはそれだけ扱いが難しいだけあって、
適切な設計のもと、実装することができれば、
非常に美しいコードになります。

実際、Ruby on Railsなどのコードはメタプログラミングをかなり使用しており、
その分、確かにソースコードを読む難易度は上がっていますが、
Railsの黒魔術みたいな便利さはメタプログラミングが支えていると言っても過言ではないので、
パワーは申し分ありません。

それにメタプログラミングを使用しなかったとしても、
メタプログラミングを知っていると知らないのでは、
雲泥の差があります。

既存のコードをメタプログラミングで置き換えられないかなどと
試行錯誤するのも、かなり勉強になると思います。

もし、実務でメタプログラミングを本格的に使用するのであれば、
下記の書籍を一読することをおすすめします。

かなり骨太な本ですが、
メタプログラミングを使いこなすのであれば必読の一冊でしょう。

ちなみに、この本は下記の記事でも紹介している一冊でもあります。
RubyやRailsはもちろんそれ以外の書籍についても、
おすすめを紹介しているので宜しければご参考ください。

参考【技術書マニア厳選】エンジニア必読、技術書おすすめ26選

エンジニアにおすすめの技術書 書籍学習は、エンジニアの嗜みみたいなところがありますが、 良書というものは、意外とそこまで多くもありません。 そこで本記事では「技術書マニアの筆者が厳選した技術書20選」 ...

続きを見る

  • この記事を書いた人
  • 最新記事

reisuta

Webエンジニア | 20代中盤 | 大学時代はGmailすら知らないIT音痴でプログラミングとは無縁の生活を送る → 独学でプログラミングを学ぶ → Web系受託開発企業にエンジニアとして就職 → Web系自社サービス企業に転職 | 実務未経験の頃からVimを愛好しており、仕事でもプライベートでも開発はVimとTmuxを使っているので、VSCodeに疎いのが最近の悩み。何だかんだでやっぱりRubyが好き。

おすすめ記事はこちら

Vim/Neovimプラグイン 1

プラグインをどれだけ入れるかは、その人の思想なども関係するので、一概にこれがいいというのはないかもしれません。 プラグインを全く入れない人もいれば、100個以上入れる人もいます。 ただそれでも、これだ ...

VimとNeovimの比較 2

本記事では、VimとNeovimの違いについて、解説します。 VimとNeovimの違いについては、普段頻繁にVimなどを使う方でなければ、正直、あまり気にしなくてもいいかなと思います。 ただ、Vim ...

Ruby変数やすべてがオブジェクトについて 3

本記事は、Rubyの基礎文法である、変数や真偽値、論理演算子に触れると同時に、「すべてがオブジェクト」というRubyの特徴的な思想についても解説します。 この思想は、Rubyの文法の根幹になっているの ...

4

エンジニアにおすすめの技術書 書籍学習は、エンジニアの嗜みみたいなところがありますが、 良書というものは、意外とそこまで多くもありません。 そこで本記事では「技術書マニアの筆者が厳選した技術書20選」 ...

5

エンジニアになるには? プログラミングは、専門性が高く自分一人で勉強するのが大変に感じることも多いですよね。 そこで本記事では「おすすめのプログラミングスクール5選」を特徴と、現役エンジニア目線で優れ ...

-バックエンド