元程式設計

我們這裡說的元程式設計(metaprogramming)是什麼意思呢?好吧,對於本文要介紹的這些內容,這是我們能夠想到的最能概括它們的詞。因為我們今天要講的東西,更多是關於流程 ,而不是寫程式碼或更高效的工作。本節課我們會學習構建系統、程式碼測試以及依賴管理。在您還是學生的時候,這些東西看上去似乎對您來說沒那麼重要,不過當您開始實習或出社會的時候,您將會接觸到大型的程式碼,本節課講授的這些東西也會變得隨處可見。必須要指出的是,元程式設計 也有用於操作程式的程式” 之含義,這和我們今天講座所介紹的概念是完全不同的。

構建系統

如果您使用 LaTeX 來編寫論文,您需要執行哪些命令才能編譯出您想要的論文呢?執行效能測試、繪製圖表然後將其插入論文的命令又有哪些?或者,如何編譯本課程提供的程式碼並執行測試呢?

對於大多數系統來說,不論其是否包含程式碼,都會包含一個“構建過程”。有時,您需要執行一系列操作。通常,這一過程包含了很多步驟,很多分支。執行一些命令來產生圖表,然後執行另外的一些命令產生結果,然後在執行其他的命令來得到最終的論文。有很多事情需要我們完成,您並不是第一個因此感到苦惱的人,幸運的是,有很多工具可以幫助我們完成這些操作。

這些工具通常被稱為”構建系統”,而且這些工具還不少。如何選擇工具完全取決於您當前手頭上要完成的任務以及項目的規模。從本質上講,這些工具都是非常類似的。您需要定義依賴目標規則。您必須告訴構建系統您具體的構建目標,系統的任務則是找到構建這些目標所需要的依賴,並根據規則構建所需的中間產物,直到最終目標被構建出來。理想的情況下,如果目標的依賴沒有發生改動,並且我們可以從之前的構建中復用這些依賴,那麼與其相關的構建規則並不會被執行。

make 是最常用的構建系統之一,您會發現它通常已被安裝到在所有基於UNIX的系統中。make並不完美,但是對於中小型項目來說,它已經足夠好了。當您執行make 時,它會去參考當前目錄下名為Makefile 的檔案。所有構建目標、相關依賴和規則都需要在該檔案中定義,它看上去是這樣的:

paper.pdf: paper.tex plot-data.png
	pdflatex paper.tex

plot-%.png: %.dat plot.py
	./plot.py -i $*.dat -o $@

這個檔案中的指令,即如何使用右側檔案構建左側檔案的規則。或者,換句話說,引號左側的是構建目標,引號右側的是構建它所需的依賴。縮排的部分是從依賴構建目標時需要用到的程式。在make 中,第一條指令還指明了構建的目的,如果您使用不帶參數的make,這便是我們最終的構建結果。或者,您可以使用這樣的命令來構建其他目標:make plot-data.png

規則中的% 是一種模式,它會匹配其左右兩側相同的字符串。例如,如果目標是plot-foo.pngmake 會去尋找foo.datplot.py 作為依賴。現在,讓我們看看如果在一個空的原始碼目錄中執行make 會發生什麼?

$ make
make: *** No rule to make target 'paper.tex', needed by 'paper.pdf'.  Stop.

make 會告訴我們,為了構建出paper.pdf,它需要paper.tex,但是並沒有一條規則能夠告訴它如何構建該檔案。讓我們構建它吧!

$ touch paper.tex
$ make
make: *** No rule to make target 'plot-data.png', needed by 'paper.pdf'.  Stop.

有趣的是,我們是構建plot-data.png 的規則的,但是這是一條模式規則。因為原始檔案foo.dat 並不存在,因此make 就會告訴您它不能構建plot-data.png,讓我們創建這些檔案:

$ cat paper.tex
\documentclass{article}
\usepackage{graphicx}
\begin{document}
\includegraphics[scale=0.65]{plot-data.png}
\end{document}
$ cat plot.py
#!/usr/bin/env python
import matplotlib
import matplotlib.pyplot as plt
import numpy as np
import argparse

parser = argparse.ArgumentParser()
parser.add_argument('-i', type=argparse.FileType('r'))
parser.add_argument('-o')
args = parser.parse_args()

data = np.loadtxt(args.i)
plt.plot(data[:, 0], data[:, 1])
plt.savefig(args.o)
$ cat data.dat
1 1
2 2
3 3
4 4
5 8

當我們執行make 時會發生什麼事?

$ make
./plot.py -i data.dat -o plot-data.png
pdflatex paper.tex
... lots of output ...

看!它幫我們產生了PDF! 如果再次執行 make 會怎樣?

$ make
make: 'paper.pdf' is up to date.

什麼事情都沒做!為什麼?好吧,因為它什麼都不需要做。make會去檢查之前的依賴才決定是否需要再次構建。讓我們試試修改paper.tex 並重新執行make

$ vim paper.tex
$ make
pdflatex paper.tex
...

注意 make沒有重新構建 plot.py ,因為沒必要。plot-data.png 的所有依賴都沒有發生改變。

依賴管理

更宏觀來說,你的項目中的依賴可能本身也是其他的項目。您也許會依賴某些程式(例如python)、系統套件(例如openssl)或相關程式語言的函式庫(例如matplotlib)。現在,大多數的依賴可以通過某些軟體倉庫(repository)來獲取,這些倉庫會在一個地方託管大量的依賴,我們則可以通過一套非常簡單的機制來安裝他們。例如 Ubuntu 系統下面有 Ubuntu 套件庫,您可以通過 apt 這個工具來訪問, RubyGems 則包含了 Ruby 的相關套件,PyPi 包含了 Python 套件庫,Arch Linux 用戶貢獻的套件庫則可以在 Arch User Repository 中找到。

由於每個倉庫、每種工具的運行機制都不太一樣,因此我們並不會在本節課深入講解其細節。我們會介紹一些通用的術語,例如版本控制。大多數被其他項目所依賴的項目都會在每次發布新版本時創建一個版本號。通常看上去像 8.1.3 或 64.1.20192004 。版本號一般是數字構成的,但也並不絕對。版本號有很多用途,其中最重要的作用是保證軟體能夠運行。試想一下,假如我的套件要發布一個新版本,在這個版本中我重命名了某個函數。如果有人在我的套件升級版本後,仍希望基於它構建新的軟體,那麼很可能構建會失敗,因為它希望呼叫的函數已經不存在了。有了版本控制就可以很好的解決這個問題,我們可以指定當前項目需要基於某個版本,甚至某個範圍內的版本,或是某些項目來構建。這麼做的話,即使某個相依套件發生了變化,依賴它的軟體仍可基於其之前的版本進行構建。

這樣還並不理想!如果我們發布了一項和安全相關的升級,它並沒有影響到任何公開接口(API),但是處於安全的考慮,依賴它的項目都應該立即升級,那應該怎麼做呢?這也是版本號包含多個部分的原因。不同項目所用的版本號其具體含義並不完全相同,但是一個相對比較常用的標準是語義版本號,這種版本號具有不同的語義,它的格式是這樣的:主版本號.次版本號.補丁號。相關規則有:

<!– - If a new release does not change the API, increase the patch version.

這麼做有很多好處。現在如果我們的項目是基於您的項目構建的,那麼只要最新版本的主版本號只要沒變就是安全的,次版本號不低於之前我們使用的版本即可。換句話說,如果我依賴的版本是 1.3.7 ,那麼使用 1.3.81.6.1 ,甚至是 1.3.0 都是可以的。如果版本號是 2.2.4 就不一定能用了,因為它的主版本號增加了。我們可以將Python 的版本號作為語義版本號的一個實例。您應該知道,Python 2 和 Python 3 的程式碼是不相容的,這也是為什麼 Python 的主版本號改變的原因。類似的,使用 Python 3.5 編寫的程式碼在 3.7 上可以運行,但是在 3.4 上可能會不行。

使用依賴管理系統的時候,您可能會遇到鎖文件(lock files)這一概念。鎖文件列出了您當前每個依賴所對應的具體版本號。通常,您需要執行升級程序才能更新依賴的版本。這麼做的原因有很多,例如避免不必要的重新編譯、可重複創建的軟體版本或禁止自動升級到最新版本(可能會包含bug)。還有一種極端的依賴鎖定叫做vendoring,它會把您的依賴中的所有程式碼直接拷貝到您的項目中,這樣您就能夠完全掌控程式碼的任何修改,同時您也可以將自己的修改添加進去,不過這也意味著如何該依賴的維護者更新了某些程式碼,您也必須要自己去拉取這些更新。

持續整合系統

隨著您接觸到的項目規模越來越大,您會發現修改程式碼之後還有很多額外的工作要做。您可能需要上傳一份新版本的文檔、上傳編譯後的文件到某處、發布程式碼到pypi,執行測試套件等等。或許您希望每次有人提交程式碼到GitHub 的時候,他們的程式碼風格被檢查過並執行過某些基準測試?如果您有這方面的需求,那麼請花些時間了解一下持續整合。

持續整合,或者叫做 CI 是一種雨傘術語(umbrella term),它指的是那些“當您的程式碼變動時,自動運行的東西”,市場上有很多提供各式各樣 CI 工具的公司,這些工具大部分都是免費或開源的。比較大的有Travis CI、Azure Pipelines 和 GitHub Actions。它們的工作原理都是類似的:您需要在程式碼倉庫中添加一個文件,描述當前倉庫發生任何修改時,應該如何應對。目前為止,最常見的規則是:如果有人提交程式碼,執行測試套件。當這個事件被觸發時,CI 提供方會啟動一個(或多個)虛擬機,執行您制定的規則,並且通常會記錄下相關的執行結果。您可以進行某些設置,這樣當測試套件失敗時您能夠收到通知或者當測試全部通過時,您的倉庫主頁會顯示一個徽章。

本課程的網站基於 GitHub Pages 構建,這就是一個很好的例子。Pages 在每次 master 有程式碼更新時,會執行 Jekyll 靜態網站生成器,然後使您的網站可以通過某個 GitHub 域名來訪問。對於我們來說這些事情太瑣碎了,我現在我們只需要在本地進行修改,然後使用 git 提交程式碼,發佈到遠端。CI 會自動幫我們處理後續的事情。

測試簡介

多數的大型軟體都有“測試套件”。您可能已經對測試的相關概念有所了解,但是我們覺得有些測試方法和測試術語還是應該再次提醒一下:

<!– - Test suite: a collective term for all the tests

課後練習

  1. 大多數的 makefiles 都提供了一個名為 clean 的構建目標,這並不是說我們會生成一個名為 clean 的文件,而是我們可以使用它清理文件,讓 make 重新構建。您可以理解為它的作用是“撤銷”所有構建步驟。在上面的makefile 中為 paper.pdf 實現一個 clean 目標。您需要構建 phony。您也許會發現 git ls-files 子命令很有用。其他一些有用的make 構建目標可以在這裡找到。

  1. 指定版本要求的方法很多,讓我們學習一下Rust的構建系統的依賴管理。大多數的包管理倉庫都支持類似的語法。對於每種語法(插入符、波浪號、萬用字元、比較、乘積),構建一種場景使其具有實際意義。
  2. Git 可以作為一個簡單的 CI 系統來使用,在任何 git 倉庫中的.git/hooks 目錄中,您可以找到一些文件(當前處於未啟用狀態),它們的作用和腳本一樣,當某些事件發生時便可以自動執行。請編寫一個 pre-commit hook,當執行 make 命令失敗後,它會執行 make paper.pdf 並拒絕您的提交。這樣做可以避免產生包含不可構建版本的提交信息。
  3. 基於 GitHub Pages 創建任意一個可以自動發布的頁面。添加一個 GitHub Action 到該倉庫,對倉庫中的所有shell 文件執行shellcheck (方法之一)。
  4. 構建屬於您的 GitHub action,對倉庫中所有的 .md 文件執行 proselintwrite-good,在您的倉庫中開啟這一功能,提交一個包含錯誤的文件看看該功能是否生效。

Edit this page.

Licensed under CC BY-NC-SA.