跳至內容

後設程式設計

Crystal 中的後設程式設計與 Ruby 中不同。此頁面上的連結有望提供對這些差異的一些見解,以及如何克服它們。

Ruby 和 Crystal 之間的差異

Ruby 大量使用 sendmethod_missinginstance_evalclass_evalevaldefine_methodremove_method 等方法,以便在執行階段修改程式碼。它也支援 includeextend,將模組新增至其他模組,以便在執行階段建立新的類別或實例方法。這就是這兩種語言之間最大的差異:Crystal 不允許在執行階段產生程式碼。所有 Crystal 程式碼都必須在執行最終二進位檔之前產生和編譯。

因此,上面列出的許多機制甚至不存在。在上面列出的方法中,Crystal 僅透過巨集機制支援 method_missing。請閱讀關於巨集的官方文件以了解它們,但請注意,巨集用於在編譯步驟期間定義有效的 Crystal 方法,因此所有接收者和方法名稱必須事先知道。您不能從字串或符號建立方法名稱並將其 send 至接收者;不支援 send,並且編譯將會失敗。

Crystal 確實支援 includeextend。但是所有包含或擴展的程式碼都必須是有效的 Crystal 程式碼才能編譯。

如何將一些 Ruby 技巧轉換為 Crystal

但是,對於勇敢的後設程式設計師來說,一切並非絕望!Crystal 仍然具有強大的編譯時期程式碼產生功能。我們只需要稍微調整我們的 Ruby 技術,使其能在 Crystal 環境下運作。

透過 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 方法。

(需要釐清方法簽章的詳細資訊。從 Foo 中移除 @number 類型宣告會導致覆寫靜默失敗。將 "number : Int32" 新增至 Foo 類別初始化簽章也會導致覆寫失敗。這裡有一些我遺漏的方法重載細節。需要更多實驗。但上面的範例仍然有效...)

透過 method_missing 巨集產生方法

以下是一個非常簡單的範例,示範如何使用 method_missing 巨集,根據接收者 JSON 物件的鍵是否存在來建立遺失的方法

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

範例程式碼 + 解釋

Crystal 的 alias_method 方法

有時我們想重新開啟一個類別並重新定義先前定義的方法,使其具有一些新的行為。此外,我們可能也希望原始方法仍然可以存取。在 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 (@asterite 在 gitter 上) 在 2016 年的會議上發表了一篇關於巨集的演講。可以在這裡觀看。