コンテンツへスキップ

メタプログラミング

CrystalでのメタプログラミングはRubyの場合とは異なります。このページのリンクは、それらの違いと、それらを克服する方法についての洞察を提供することを願っています。

RubyとCrystalの違い

Rubyは、実行時にコードを修正するために、sendmethod_missinginstance_evalclass_evalevaldefine_methodremove_methodなどを多用しています。また、実行時に新しいクラスまたはインスタンスメソッドを作成するために、モジュールを別のモジュールに追加するためのincludeextendもサポートしています。ここに、2つの言語の最大の違いがあります。Crystalでは、実行時のコード生成は許可されていません。すべてのCrystalコードは、最終的なバイナリを実行する前に生成およびコンパイルする必要があります。

したがって、上記にリストしたメカニズムの多くは存在すらしていません。上記にリストしたメソッドのうち、Crystalはマクロ機能を使用してmethod_missingのみを部分的にサポートしています。マクロを理解するために公式ドキュメントを読んでください。ただし、マクロはコンパイルステップ中に有効なCrystalメソッドを定義するために使用されるため、すべてのレシーバーとメソッド名は事前に知っておく必要があります。文字列またはシンボルからメソッド名を作成してレシーバーにsendすることはできません。sendのサポートはなく、コンパイルは失敗します。

Crystalはincludeextendをサポートしています。ただし、インクルードまたは拡張されたすべてのコードは、コンパイルするために有効なCrystalである必要があります。

RubyのトリックをCrystalに翻訳する方法

しかし、勇敢なメタプログラマーにとってはすべてが失われたわけではありません。Crystalには、コンパイル時のコード生成のための強力な機能がまだあります。Crystal環境で動作するように、Rubyのテクニックを少し調整するだけで済みます。

extend経由での#newのオーバーライド

Rubyでは、クラスのnewメソッドをオーバーライドすることで、強力なことを実行できます。

module ClassMethods
  def new(*args)
    puts "Calling overridden new method with args #{args.inspect}"
    # Can do arbitrary setup or calculations here...
    instance = allocate
    instance.send(:initialize, *args) # need to use #send since #initialize is private
    instance
  end
end

class Foo
  def initialize(name)
    puts "Calling Foo.new with arg #{name}"
  end
end

foo = Foo.new('Quxo') # => Calling Foo.new with arg Quxo
p foo.class # => Foo

class Foo
  extend ClassMethods
end

foo = Foo.new('Quxo')
# => Calling overridden new method with args ["Quxo"]
# => Calling Foo.new with arg Quxo
p foo.class # => Foo

上記の例でわかるように、Fooインスタンスは通常のコンストラクターを呼び出します。extendしてnewをオーバーライドすると、プロセスにあらゆる種類のものを注入できます。上記の例は、最小限の干渉のみを示し、オブジェクトのインスタンスを割り当てて初期化するだけです。このインスタンスはコンストラクターから返されます。

次の例では、newをオーバーライドして、まったく異なる種類のクラスを返します。

class Bar
  def initialize(foo)
    puts "This arg was an instance of class #{foo.class}"
  end
end

module ClassMethods
  def new(*args)
    puts "Calling overridden new method with args #{args.inspect}"
    Bar.new(allocate) # return a completely different class instance
  end
end

class Foo
  extend ClassMethods

  def initialize(name)
    puts "Calling Foo.new with arg #{name}"
  end
end

foo = Foo.new('Quxo')
# => Calling overridden new method with args ["Quxo"]
# => This arg was an instance of class Foo
p foo.class # => Bar

これにより、実行時に非常に強力なメタプログラミングが可能になります。クラスをプロキシとして別のクラスでラップし、この新しいプロキシオブジェクトへの参照を返すことができます。

Crystalでも同じ種類の魔法が使えるのでしょうか?それが不可能であれば、このセクションを書かなかったでしょう。ただし、後で説明するいくつかの注意点があります。

これは、Crystalの元のクラスと期待される動作です。

module ClassMethods
  macro extended
    def self.new(number : Int32)
      puts "Calling overridden new added from extend hook, arg is #{number}"
      instance = allocate
      instance.initialize(number)
      instance
    end
  end
end

class Foo
  extend ClassMethods
  @number : Int32

  def initialize(number)
    puts "Foo.initialize called with number #{number}"
    @number = number
  end
end

foo = Foo.new(5)
# => Calling overridden new added from extend hook, arg is 5
# => Foo.initialize called with number 5
puts foo.class # Foo

この例では、macro extendedフックを使用しています。このフックは、クラス本体がextendメソッドを実行するたびに呼び出されます。このマクロを使用して、置換用のnewメソッドを記述できます。

(メソッドシグネチャの詳細について明確にする必要があります。@number型の宣言Fooを削除すると、オーバーライドがサイレントに失敗します。"number : Int32"をFooクラスのinitializeシグネチャに追加すると、オーバーライドも失敗します。ここには、私が理解していないメソッドオーバーロードに関するいくつかの微妙な点があります。より多くの実験が必要です。上記の例はまだ機能しますが...)

method_missingマクロによるメソッドの生成

以下は、レシーバーJSONオブジェクトのキーの存在に基づいて、欠落しているメソッドを作成するためにmethod_missingマクロを使用する方法を示す非常に簡単な例です。

class Hashr
  getter obj

  def initialize(json : Hash(String, JSON::Any) | JSON::Any)
    @obj = json
  end

  macro method_missing(key)
    def {{ key.id }}
      value = obj[{{ key.id.stringify }}]

      Hashr.new(value)
    end
  end

  def ==(other)
    obj == other
  end
end

recordと生成されたルックアップテーブルを使用してsendを模倣する方法

サンプルコード+説明

alias_methodに対するCrystalのアプローチ

クラスを再オープンして、以前に定義したメソッドに新しい動作をさせたい場合があります。さらに、元のメソッドにもアクセス可能にしたいでしょう。Rubyでは、この目的のためにalias_methodを使用します。例

class Klass
  def salute
    puts "Aloha!"
  end
end

Klass.new.salute # => Aloha!

class Klass
  def salute_with_log
    puts "Calling method..."
    salute_without_log
    puts "... Method called"
  end

  alias_method :salute_without_log, :salute
  alias_method :salute, :salute_with_log
end

Klass.new.salute
# => Calling method...
# => Aloha!
# => ... Method called

Crystalで同じ作業を実行するのは非常に簡単です。Crystalには、以前に定義したバージョンのメソッドにアクセスできるprevious_defというメソッドが用意されています。Crystalで同じ例を機能させるには、次のようになります。

class Klass
  def salute
    puts "Aloha!"
  end
end

# Reopen the class...
class Klass
  def salute
    puts "Calling method..."
    previous_def
  end
end

# Reopen it again for kicks!
class Klass
  def salute
    previous_def
    puts "... Method called"
  end
end

Klass.new.salute
# => Calling method...
# => Aloha!
# => ... Method called

クラスを再オープンするたびに、previous_defは以前のメソッド定義に設定されるため、これを使用して、Rubyと同じようにコンパイル時にエイリアスメソッドチェーンを構築できます。ただし、チェーンを拡張するたびに、元のメソッド定義へのアクセスは失われます。古いメソッドに別の場所で参照できる明示的な名前を付けているRubyとは異なり、Crystalはその機能を提供していません。

一般的なリソース

Ary Borenszweig(gitterでは@asterite)が2016年の会議でマクロに関する講演を行いました。 こちらで見ることができます。