從可逆計算看宣告式程式設計
可逆計算是筆者提出的下一代軟體構造理論,它的核心思想可以表示為一個通用的軟體構造公式
在這一公式中,所謂的領域特定語言(DSL)佔有核心位置,而可逆計算在實踐中的主要策略就是將業務邏輯分解為多個業務切面,針對每個業務切面設計一種DSL來描述。DSL是宣告式程式設計的一種典型範例,因此可逆計算可以被看作是宣告式程式設計的一種實現途徑。透過可逆計算的概念,我們可以獲得對宣告式程式設計的一些新的理解。
一. 虛擬化
DSL是宣告式的,因為它所表達的內容不像是可以直接交由某個物理機器執行的,而必須通過某種interpreter/compiler進行翻譯。不過如果換一個角度去考慮,這個interpreter一樣可以被看作是某種虛擬機器器,只不過它不一定是馮.諾伊曼體系結構
在比較現代的大型軟體結構設計中,多多少少都會體現出構造某種內部虛擬機器器的努力。以Slate富文字編輯器框架為例,它號稱是一個“完全可定製的”框架,核心是一個所謂的“Schema-less core"。也就是說,Slate的核心並不直接知道它所編輯的資料的具體結構,這些結構是通過schema告訴核心的。schema定義了允許哪些節點,節點有哪些屬性,屬性需要滿足什麼樣的格式。
const schema = {
document: {
nodes: [
{
match: [{ type: 'paragraph' },{ type: 'image' }],},],blocks: {
paragraph: {
nodes: [
{
match: { object: 'text' },image: {
isVoid: true,data: {
src: v => v && isUrl(v),}
<Editor
schema={schema}
value={this.state.value}
...
/>
複製程式碼
自定義的render函式類似於直譯器
function renderNode(props,editor,next) {
const { node,attributes,children } = props
switch (node.type) {
case 'paragraph':
return <p {...attributes}>{children}</p>
case 'quote':
return <blockquote {...attributes}>{children}</blockquote>
case 'image': {
const src = node.data.get('src')
return <img {...attributes} src={src} />
}
default:
return next()
}
}
複製程式碼
傳統的富文字編輯器,在核心中需要明確知道bold/italic這樣的概念,而在Slate的核心中,關鍵的關鍵就在於不需要知道具體的業務含義就可以操縱對應的技術元素,這就類似於硬體指令不需要知道軟體層面的業務資訊。通過使用同一個核心,我們可以通過類似配置的方式實現Markdown編輯器,Html編輯器等多種不同用途的設計器。
二. 語法制導
實現虛擬化,最簡單的方式是採用一一對應的對映機制,即將一組動作直接附加到DSL的每條語法規則上,處理到DSL的某個語法節點時,就執行對應的動作,這叫作語法制導(Syntax Directed)。
基於XML或者類XML語法的模板技術,例如Ant指令碼,FreeMarker模板都可以看作是語法制導翻譯的範例。以Vue的模板語法為例,
<template>
<BaseButton @click="search">
<BaseIcon name="search"/>
</BaseButton>
</template>
複製程式碼
template相當於是將抽象語法樹(AST)直接以XML格式展現,處理到元件節點時,將會直接根據標籤名稱定位到對應元件的定義,然後遞迴進行處理。整個對映過程是上下文無關的,即對映過程並不依賴於節點所處的上下文環境,同樣的標籤名總是對映到同樣的元件。
同樣的套路構成了Facebook的GraphQL技術的核心,它通過語法制導將待執行的資料訪問請求傳送到一個延遲處理佇列,通過合併請求實現批量載入優化。 例如,為處理如下gql請求
query {
allUsers {
id
name
followingUsers {
id
name
}
}
}
複製程式碼
後臺只需要針對資料型別指定對應dataLoader
const typeDefs = gql`
type Query {
testString: String
user(name: String!): User
allUsers: [User]
}
type User {
id: Int
name: String
bestFriend: User
followingUsers: [User]
}
`;
const resolvers = {
Query: {
allUsers(root,args,context) {
return ...
}
},User: {
// allUsers呼叫返回的每個User物件,其中只有followingUserIds屬性,它需要被轉換為完整的User物件
async followingUsers(user,{ dataloaders }) {
return dataloaders.users.loadMany(user.followingUserIds)
}
}
};
複製程式碼
為了方便實現語法制導這一模式,現代程式語言已經有了預設的解決方案,那就是基於註解(Annotation)的超程式設計技術。例如,python中的函式註解
def logged(level,name=None,message=None):
"""
Add logging to a function. level is the logging
level,name is the logger name,and message is the
log message. If name and message aren't specified,they default to the function's module and name.
"""
def decorate(func):
logname = name if name else func.__module__
log = logging.getLogger(logname)
logmsg = message if message else func.__name__
@wraps(func)
def wrapper(*args,**kwargs):
log.log(level,logmsg)
return func(*args,**kwargs)
return wrapper
return decorate
# Example use
@logged(logging.DEBUG)
def add(x,y):
return x + y
複製程式碼
將註解看作是函式名,這一觀念非常簡單直觀,TypeScript也採納了同樣的觀點。相比之下,Java的APT(Annotation Processing Tool)技術顯得迂迴冗長,這也導致很少有人使用APT去實現自定義的註解處理器,不過它的作用是在編譯期,拿到的是AST抽象語法樹,因此可以做一些更加深刻的轉化。Rust語言中的過程巨集(procedural macros)則展現了一種更加優雅的編譯期實現方案。
#[proc_macro_derive(Hello)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
// 從token流構建AST語法樹
let ast = syn::parse(input).unwrap();
// 採用類似模板生成的方式構造返回的語法樹
let name = &ast.ident;
let gen = quote! {
impl Hello for #name {
fn hello_macro() {
println!("Hello,Macro! My name is {}",stringify!(#name));
}
}
};
gen.into()
}
pub trait Hello {
fn hello_macro();
}
// 使用巨集為Pancakes結構體增加Hello這個trait的實現
#[derive(Hello)]
struct Pancakes;
複製程式碼
三. 多重詮釋
傳統上一段程式碼只有一種設定的執行語義,一旦資訊從人的頭腦中流出經由程式設計師的手固化為程式碼,它的形式和內涵就固定了。但是可逆計算指出,邏輯表達應該是雙向可逆的,我們可以逆轉資訊的流向,將以程式碼形式表達的資訊反向提取出來,這使得“一次表達,多重詮釋”成為實現宣告式程式設計,分離表達與執行的常規手段。例如,下面一段過濾條件
<and>
<eq name="status" value="1" />
<gt name="amount" value="3" />
</and>
複製程式碼
展現在前臺,對應於一個查詢表單,應用到後臺,對應於Predicate介面的實現,傳送到資料庫中,轉化為SQL過濾條件的一部分。而這一切,並不需要人工編碼,它們只是同一資訊的多重詮釋而已。
隨著編譯技術的廣泛傳播,傳統上的指令式程式設計經過再詮釋,現在也具有了宣告式的意味。比如,Intel的OpenMP(Open Multi-Processing)技術
int sum = 0;
int i = 0;
#pragma omp parallel for shared(sum,i)
for(i = 0; i < COUNT;i++){
sum = sum + i;
}
複製程式碼
只要在傳統的命令式語句中增加一些標記,即可把序列執行的程式碼轉化為並行程式。
而在深度學習領域,編譯轉換技術更是被推進到了新的深度。pytorch和tensorflow這樣的框架均可將形式上的python函式編譯轉換為GPU上執行的指令。而TVM這樣的大殺器,甚至可以直接編譯得到FPGA程式碼。
多重詮釋的可能性,使得一段程式碼的語義永遠處於開放狀態,一切都是虛擬化的。
四. 差量修訂
可逆計算將差量作為第一性的概念,將全量看作是差量的特例。按照可逆計算的設計,DSL必須要定義差量表示,允許增量改進,同時,DSL展開後的處理邏輯也應該支援增量擴充套件。以Antlr4為例,它引入了import語法和visitor機制,從而第一次實現了模型的差量修訂。
在Antlr4中,import語法類似面向物件程式語言中的繼承概念。它是一種智慧的include,當前的grammar會繼承匯入的grammar的所有規則,tokens specifications,names actions等,並可以重寫規則來覆蓋繼承的規則。
在上面的例子中,MyElang通過繼承ELang得到若干規則,同時也重寫了expr規則並增加了INT規則。終於,我們不再需要每次擴充套件語法都要拷貝貼上了。
在Antlr4,不再推薦將處理動作直接嵌入在語法定義檔案中,而是使用Listener或者Visitor模式,這樣就可以通過面嚮物件語言內建的繼承機制來實現對處理過程的增量修訂。
// Simple.g4
grammar Simple;
expr : left=expr op=('*'|'/') right=expr #opExpr
| left=expr op=('+'|'-') right=expr #opExpr
| '(' expr ')' #parenExpr
| atom=INT #atomExpr
;
INT : [0-9]+ ;
// Generated Visitor
public class SimpleBaseVisitor<T> extends AbstractParseTreeVisitor<T> implements SimpleVisitor<T> {
@Override public T visitOpExpr(SimpleParser.OpExprContext ctx) { return visitChildren(ctx); }
@Override public T visitAtomExpr(SimpleParser.AtomExprContext ctx) { return visitChildren(ctx); }
@Override public T visitParenExpr(SimpleParser.ParenExprContext ctx) { return visitChildren(ctx); }
}
class MyVisitor<Double> extends SimpleBaseVisitor<Double>{
...
}
複製程式碼
五. 自動微分
如果說宣告式程式設計的理想是人們只需要描述問題,由機器自動找出解決方案,那麼我們從哪裡去找一類足夠通用,而且又能夠自動求解的問題呢?幸而自牛頓以降,科學昌明,我們還是積攢了幾個這樣的祖傳問題的,其中一個就是自動微分。
只要指定幾個基礎函式的微分表示式,我們就可以自動計算大量複合函式的微分,這一能力是目前所有深度學習框架的必備技能。可逆計算理論指出,自動計算差量這一概念可以被擴充套件到數學或者演演算法領域之外,成為一種有效的軟體結構構造機制。
以k8s為例,這一容器編排引擎的核心思想是通過宣告式的API來指定系統的“理想”狀態,然後通過監控測量不斷髮現當前狀態與理想狀態的偏差,自動執行相應的動作來“糾正”這些偏差。它的核心邏輯可以總結為如下公式:
k8s所採用的這種設計原理可以稱為是狀態驅動(State Driven),它關注的重點是系統的狀態以及狀態之間的差異,而不再是傳統的基於動作概念的API呼叫和事件監聽。從動作(Action)到狀態(State)的這種思維轉換其實類似於物理學中從力的觀點過渡到以勢能函式(Potential)為基礎的場(Field)的觀點。
從狀態A遷移到狀態B,無論經過什麼路徑,最終得到的結果都是一樣的,因此勢的概念是路徑無關的。擺脫了路徑依賴極大簡化了我們對系統的認知。而所謂的力,隨時可以通過對勢函式求導,從勢函式的梯度得到。同樣,在k8s中,對於任意的狀態偏差,引擎都可以自動推導得到相應需要執行的動作。
從狀態A遷移到狀態B有多條可行的路徑,在這些路徑中按照成本或者收益原則選擇其一,這就是所謂的優化。
從動作到狀態的轉換是整體思維模式的一種變革,它要求我們用新的世界觀去思考問題,並不斷調整相應的技術實現去適應這種世界觀。這一變革趨勢正在逐漸加強,也在越來越多的應用領域促生著新的框架和技術。
勢的觀念要求我們對狀態空間有著全面的認知,每一個可達的狀態都有著合法的定義。有的時候,對於特定應用而言,這種要求可能過於嚴苛,例如,我們可能只需要找到從特定狀態A到特定狀態B的某一條可行的道路即可,沒必要去研究所有狀態構成的狀態空間自身,此時傳統的命令式的做法就足夠了。
六. 同構轉化
太陽底下沒有新鮮事。在日常程式設計中,真正需要人們去創造的新的邏輯是很少的,絕大多數情況下我們所做的只是某種邏輯關係的對映而已。比如說,日誌收集這件事情,為了採集日誌檔案內容進行分析,一般需要使用類似logstash這樣的工具解析日誌文字到json格式,然後投遞到ElasticSearch服務。但是,如果在列印日誌的時候,我們就保留物件格式,那麼實際上可以不需要中間logstash的解析過程。如果需要對屬性過濾或者進行再加工,也可以直接對接一個通用的物件對映服務(可以通過視覺化介面進行對映規則配置),而不需要為日誌處理領域單獨編寫一套實現。
// 保持物件格式輸出日誌
LOG.info(日誌碼,{引數名:引數值});
複製程式碼
很多時候,我們之所以需要程式設計師去編寫程式碼,原因在於跨越邊界時出現了資訊丟失。例如,以文字行形式列印日誌時,我們丟失了物件結構資訊,從文字反向恢復出結構的工作很難自動完成,它必須藉助程式設計師的頭腦,才能消除解析過程中可能出現的各種歧義情況。程式設計師頭腦中的資訊包括我們所處的這個世界的背景知識,各種習慣約定,以及整體架構設計思想等。因此,很多看似邏輯上等價的事情往往無法通過程式碼自動完成,而必須通過增加人這個變數來實現配平。
現代數學是建立在同構概念基礎之上的,在數學上我們說A就是B,潛臺詞說的是A等價於B。等價歸併大幅削減了我們所需要研究的物件,加深了我們對系統本質結構的認識。
為什麼 3/8 = 6/16,因為這就是分數的定義!(3/8,6/16,9/24...)這一系列表示被定義為一個等價類,它的代表元素就是3/8(參見 彭羅斯《通向實在之路--宇宙法則的完全指南》一書的前言)。
可逆計算強調邏輯結構的可逆轉化,從而試圖在軟體構造領域建立起類似數學的抽象表達能力,而這隻有當上下游軟體各個部分都滿足可逆原則時才能夠實現效用的最大化。例如,當細粒度元件和處理過程均可逆時,視覺化設計器可以根據DSL直接生成,而不需要進行特殊編碼
在現實開發過程中實現可逆性的一個障礙在於,目前軟體開發的目的性都是很強的,因此與當前場景無關的資訊往往無處安放。為瞭解決這個問題,必須在系統底層增加允許自定義擴充套件的元資料空間。
對應於A'部分的資訊在當前的系統A中不一定會使用,但是為了適應系統B的應用邏輯,我們必須找到一個地方把這些資訊儲存下來。這是一種整體性的協同處理過程。你注意到沒有,所有能稱得上現代的程式語言都經歷了戴帽子工程改造,都支援某種形式的自定義註解(Annotation)機制,一些擴充套件的描述資訊會存在帽子裡隨身攜帶。換句話說,(data,metadata)配對才是資訊的完整表達,這和訊息物件總是包含(body,headers)是一個道理。
世界如此複雜,目的為何唯一?在宣告式的世界中,我們有必要持有一種更加開放的態度。戴帽子不為了擋風擋雨,也不為了遮陽防晒,我就為了好看不行嗎?metadata是宣告式的,一般我們說它是描述資料的資料,但實際上它就算當前不描述任何東西可以有自己存在的理由,不是說有一種用叫“無用之用”嗎。