DexScript 語言簡介
目標
DexScript 致力於讓即使複雜的業務流程也能夠清晰地被開發者閱讀和協作開發。為此對流程定義的方式做出了四個方面的創新:
- 用完就扔的開發方式:傳統的編程語言因果關係可以在一個函數內清楚地表達。但是跨越了函數的因果關係就不那麼清晰了。因為有UI,持久化等需要,不可能把所有的業務流程都寫在一個函數裏。DexScript裏的所有函數都是協程,一個完整的業務流程可以被一個協程完整定義。這種方式除了提高了可讀性,最大意義是鼓勵用完就扔的開發方式。傳統的開發方式一個很大的問題是實驗性的功能添加進來之後很難被移除掉。如果實驗性的功能被封閉到了一起,要被一起移除也變得更加容易。
- 通過組合創新最大化平臺價值:一個複雜流程要被完整復用是很困難的。我們總是有需要把流程的一部分進行差異化處理以支持新的場景。但是所謂平臺,就是大規模流程的復用。平臺價值要通過復用才能彰顯。差異化處理的實現方式是多變數運行時派發(multi variable dynamic dispatch,簡稱 multi-dispatch)。傳統的面向對象編程語言都是基於 single dispatch 的,比 multi-dispatch 能表達的範圍要小。可以認為所有的函數調用都是插件點(類似虛函數), 差異化的需求可以在後期用插件的插入進來實現,而不用去改調用方的代碼。
- 基於靜態類型的SPI(Service Provider Interface)解耦團隊協作:一個大的流程由小的流程複合而來。對於依賴都用 SPI 的形式明確聲明。這種聲明是以類似 TypeScript 的 Structrual Typing 的方式來表達的。只要提供方實現了你定義的結構上的需求,它就自動實現了你的 SPI。使用上非常類似 Python 這樣的動態類型,但是 Structural Typing 是靜態檢查的,能夠提供大型工程所需要的秩序保障。
- 異常流程不會喧賓奪主:傳統編程語言的錯誤處理會使得代碼支離破碎。很多時候看代碼只能看到一堆錯誤處理,根本看不見幾行有用業務邏輯。DexScript 儘可能把上下文無關的錯誤處理抽離出函數體,把上下文相關的錯誤放到上下文發生的地方。例如出錯後關閉文件的錯誤處理應該定義在打開文件這個地方。
更為重要的是,我們希望做減法而非加法。應該在一門類似C語言這樣的極簡語言上用重新定義 function 的方式來實現上述目標,而非在C++這樣已經複雜得非人類的語言上再去添加更多的語法糖。DexScript應運而生。
運行時假設
DexScript 是一個用 Java 編寫的 transpiler。自身是一個Java的庫,能夠把 DexScript 腳本翻譯成 Java 語言定義的 class。通過運行時 classloader 可以載入到 Java 進程內執行。對於用 Java 寫成的項目,集成 DexScript 進來會非常容易。同時要理解 DexScript 的真正行為,可以直接查看翻譯出來的 Java 文件。甚至可以對翻譯出來的 Java 文件進行單步調試。
DexScript假設僅運行在單線程中,所有的阻塞都只是協程之間的調度執行。多個線程執行訪問同一個DexScript對象的行為是未定義的。在DexScript中啟動多線程的行為是未定義的。
DexScript假定所有的function都全局可見,所有import進來的function也是全局可見,無論在哪個file中。
無論是單線程還是全局可見性,都是基於DexScript的單個模塊不會特別大,而是一個極端小的邏輯單元以及物理執行單元(town)。town與town之間通過網路進行通信。也就是把網路服務做為更明確的邊界來區隔。而減少了class/package這些單元的可見性控制的必要。
基本語法
function Square(x: int64): int64 {
return x * x
}
DexScript 的函數定義和 TypeScript 非常類似。x: int64
表示這個 x 參數是 int64 類型的。 function Square(x: int64): int64
表示 Square 函數返回值的類型是 int64 類型的。DexScript 的基礎類型有
- bool
- uint8
- int8
- uint16
- int16
- uint32
- int32
- uint64
- int64
- float32
- float64
- string
不同的基礎類型不會互相隱式轉換。比如 int64 不能直接賦值給 int32,需要類型轉換,例如:
function Square(x: int32): int64 {
x64 := x as int64
return x64 * x64
}
賦值可以使用 :=
這樣的縮寫,也可以用 var
這樣的全稱:
function Square(x: int32): int64 {
var x64 = x as int64
return x64 * x64
}
var 定義的時候可以指定類型:
function Square(x: int32): int64 {
var x64: int64
x64 = x as int64
return x64 * x64
}
協程
DexScript 裏定義的所有 function 都是協程。協程和函數的區別是協程的執行支持「斷點續傳」。DexScript的協程既不是用 yield 定義的,也不是 async/await 定義的。相信你看完下面的例子,你會認為DexScript 的寫法其實是非常簡單易懂的。
首先我們有A和B兩個函數,A調用B。
function A(): string {
return B()
}
function B(): string {
return hello
}
這裡我們使用的寫法是 B()
。括弧代表了創建B這個協程,並等待它的返回值。展開來寫全了就是:
function A(): string {
b := B{}
bReturnValue := <- b
return bReturnValue
}
function B(): string {
return hello
}
這裡使用的寫法是B{}
,花括弧代表創建實例,類似Java裏的new操作符。B{}
返回的是一個promise。而 <- b
代表了等待 b 這個 promise 計算完成,並取其結果。把 b 的類型寫出來就是
function A(): string {
var b: Promise<string> = B{}
var bReturnValue: string = <- b
return bReturnValue
}
function B(): string {
return hello
}
和 <-
對稱的是 ->
,一個是取結果,一個是給結果。我們來看一下:
function A(): string {
b := B{}
promise := b.Step1{}
return <- promise
}
function B() {
await {
case Step1(): string {
hello -> Step1
}}
}
用 Java 的語法來理解,B是一個class,Step1是這個class的一個method。那麼 b.Step1{}
就是調用 b 的 Step1 這個method。而 hello -> Step1
就是把 Step1 的結果設置為 hello
。但是這裡的 Step1 要比 Java 的method更強大。我們可以把Step1這次調用給保存起來,在後面的流程裏去返回。
function A(): string {
b := B{}
return b.Step1()
}
function B() {
var savedTask: Task<string>
await {
case Step1(): string {
savedTask = Step1
}}
hello -> savedTask
}
實際上await和Java對象的method是完全不同的。Java對象的method是始終可用的,只要這個對象不是null。await預期的消息僅在當前狀態有效。function是一個狀態機,它在每個狀態可以預期接收的消息是不同的。function有一個最終的退出狀態,不接收任何消息。
然後我們來看一下await的更多用法。await的模仿對象是Go裡面的select,表示阻塞之後多種解開阻塞的選擇,所以await可以接多個case從句。
function A(): string {
order := Order{}
return order.OrderConfirmed()
}
function Order() {
await {
case OrderConfirmed(): string {
return confirmed
}
case OrderCancelled(): string {
return cancelled
}}
}
除了等待輸入是一種阻塞之外,等待別人的返回也是一種阻塞。所以 await 同時支持這兩個方向的等待:
function A(): string {
b := B{}
c := C{}
await {
case TellMeResult(result: string) {
return result
}
case result := <- b {
return result
}
case result := <- c {
return result
}}
}
function B(): string {
return hello
}
function C(): string {
return world
}
這就是協程的全部語法了。總結一下
->
作用於Task<T>
對象,表示提供結果<-
作用於Promise<T>
對象,表示等待並獲取結果- await 支持多個 case,表示等待任意其一,喚醒自身繼續執行
Structural Typing
DexScript 的類型系統效仿的是 TypeScript 的 Structural Typing。這種類型系統的好處是不同的模塊可以彼此獨立地定義自己預期地輸入和輸出,而不需要全局對於類型樹達成某種默契。這可以極大提高自主性。
function Quack(name: string): string {
return quack: + name
}
如果我們定義了Quack,那麼string就能夠支持Quack這個函數。
interface Duck {
::Quack(Duak): string
}
這個Duck的介面的定義標準是,有一個Quack能夠接受它。
function PrintDuck(duck: Duck) {
print(Quack(duck))
}
這裡定義了一個PrintDuck的函數,它只接受實現了Duck介面的對象做為參數。如果我們傳入string
PrintDuck(donald)
這個是合法的,因為string類型是可以Quack的。如果我們傳入int64
PrintDuck(1024)
這個就不合法,因為沒有Quack函數能夠接收1024做為參數。我們可以在一個介面裏同時要求多個函數存在:
interface Duck {
::Swim(Duck): string
::Quack(Duck): string
}
這裡就要求了鴨子要能同時呱呱叫和游泳才叫鴨子。這個時候string也不符合Duck的定義了。除非添加一個Swim的實現:
function Swim(name: string): string {
return swim: + name
}
function Quack(name: string): string {
return quack: + name
}
這種通過頂層函數定義object的寫法雖然很樸實,但是感覺上不「面向對象」。為了讓寫法看起來很其他OOP的語言類似,我們支持第一個參數寫到前面去:
Swim(donald)
可以寫成
donald.Swim()
也就是所有的 x.y()
實際執行的時候都是 y(x)
。如果有參數,例如 x.y(arg1, arg2)
那麼就是 y(x, arg1, arg2)
。對應的 interface 的定義也可以簡寫:
interface Duck {
Swim(): string
Quack(): string
}
在DexScript中,所有的複合類型都是用Structural Typing的原則來比較類型兼容性的。這個當然包括 function。例如
function FunctionDuck(name: string) {
await {
case Quack(): string {
return quack: + name
}
case Swim(): string {
return swim: + name
}}
}
這裡定義的 FunctionDuck 也實現了 Duck 需要的 Quack 和 Swim,所以也是類型兼容的。await 裏定義的 Quack 和 Swim,相當於定義在頂層的兩個函數:
function FunctionDuck(@Prop name: string) {
// nothing, just properties
}
function Quack(fd: FunctionDuck): string {
return quack: + fd.name
}
function Swim(fd: FunctionDuck): string {
return swim: + fd.name
}
不僅僅是 DexScript 裏定義的 function 是用 Structural Typing的,對於 Java 裏定義的類型也是一樣。假設我們定義了下面兩個 Java 類:
public class JavaDuck {
private final String name;
public JavaDuck(String name) {
this.name = name;
}
public String Quack() {
return "quack: " + name;
}
public String Swim() {
return "swim: " + name;
}
}
public class JavaChicken {
private final String name;
public JavaChicken(String name) {
this.name = name;
}
public String Quack() {
return "quack: " + name;
}
public String Swim() {
return "swim: " + name;
}
}
無論是 JavaDuck 還是 JavaChicken,它們都實現了 Duck 這個介面。在 DexScript 裏引用 Java 類型做為參數,等價於引用定義了相同方法的 interface,也就是忽略了具體的類型,只看結構是否相同。例如
import somepkg.JavaDuck
function PrintDuck(duck: JavaDuck) {
print(duck.Quack())
}
這個 PrintDuck 雖然寫著是接收 JavaDuck 做為參數,但是傳入 JavaChicken 也是可以的。在 DexScript 的世界裡,完全忽視了 Java 類型兼容性的規則,以 Structural Typing 為唯一判斷標準。
在所有的 interface 中,interface{}
最為特殊,它定義了一個空的interface,可以匹配任意的value。
Literal Type 和 類型組合
和 TypeScript 一樣,可以支持literal做為類型,例如字元串和數字。
var productType: Express
這裡 productType只有一個選擇,Express。
var productType: Express | Premium
這裡的productType有兩個選擇,Express或者Premium。這兩個選擇可以取一個名字做為一個類型:
type ProductType = Express | Premium
var productType: ProductType
效果和之前是一樣的。對於 interface 也是一樣可以用 literal 的。
interface ExpressProduct {
ProductType: Express
Price: int64
}
interface PremiumProduct {
ProductType: Premium
PremiumPrice: int64
}
我們也一樣可以把interface組合起來,例如
type CommonProduct = ExpressProduct | PremiumProduct
Structural Typing加上類型組合,我們可以表達非常豐富而靈活的類型分類。對於同一個東西,在不同場景下我們可以用完全不同的分類體系來劃分種類,這個分類甚至可以是基於其值本身。
類型組合還可以用來解決null check的問題。
type NullableString = string | null
如果不是 | null
的情況,我們就認為值裡面不會有null的情況。
函數類型
我們現在已經看到了三種類型定義了
- 基本類型:int64這些
- Literal Type:直接用『express, premium這樣的literal值表達類型
- Structural Type:通過一個類型是否被某些函數支持來定義
還有一種類型是function。比如
function Square(x: int64): int64 {
return x * x
}
對應的類型不是Square
,而是(int64) => int64
。函數類型的定義以及arrow function和TypeScript是一樣的
var Square: (int64) => int64
Square = (x: int64): int64 => {
return x*x
}
實際上function類型也可以用interface來表達。
interface Square {
::Apply__(Square, int64): int64
}
這裡 Apply__
是全局的魔法方法,代表對一個函數的調用。也就是Square這個interface,支持用int64去apply,返回int64。
把function type也統一成一種interface之後,類型其實就兩種
- 靠自己的值定義的類型:例如 literal type 或者 int64 這些
- 靠別的function能夠用自己做為輸入來定義的類型:通過interface表達
interface的聲明裡可能出現下面幾種元素
interface TheInterface {
<T>: interface{} // <>代表了這個參數是類型參數
::globalFunction(TheInterface): string // ::前綴代表了這個是全局函數
method(): string // 不帶::前綴代表了隱含了第一個參數是TheInterface自身
Add__(TheInterface): TheInterface // 某些魔法方法用__結尾
}
Multi-dispatch
分類的目的是區分對待。為了表示不同的對待,需要給一個函數名定義多個函數實現。
function Speak(duck: Duck): string {
return duck.Quack()
}
function Speak(chicken: Chicken): string {
return chicken.Gege()
}
如果傳入的是Duck,則用第一個實現。如果傳入的是Chicken,則用第二個實現。函數重載和OOP的多態不同,它不僅僅取決於第一個參數。例如,我們可以定義
function Speak(speakBy: Duck, speakTo: Duck): string {
return duck.Quack()
}
只有說話的聽話的都是鴨子的時候,才會有這樣的行為。如果聽話的是chicken,行為可以定義為不同
function Speak(speakBy: Duck, speakTo: Chicken): string {
return quack? gege?
}
Multi-dispatch 和靜態函數重載不同。實際函數的選擇是在運行時發生的。會根據實際的變數類型來選擇第一個匹配的實現。
除了用參數的類型來區分,函數自身也可以指定 where 從句,進一步區分自己的適用範圍:
function Speak(speakBy: Duck, speakTo: Duck): string where IsGoodDuck(speakBy) {
return duck.Quack()
}
function IsGoodDuck(duck: Duck): bool {
return true // can be abitary complex
}
泛型
為了進一步提高復用性,引入了泛型的支持。例如
function Square( <T>: interface{}, x: T ): T {
return Multiply(x, x)
}
這裡的 <T>
是一個特殊的函數參數,它是一個類型,約束了輸入x和返回值的類型是相同的。但是這裡有一個問題是,multiply(x, x)
可能沒有實現,比如 <T>
如果是 string 就沒有天然的定義。所以我們需要縮小 <T>
的取值範圍
function Square( <T>: HasMultiply, x: T): T {
return Multiply(x, x)
}
interface HasMultiply {
::Multiply(HasMultiply, HasMultiply): HasMultiply
}
相比之下,與不用泛型的區別
function Square(x: HasMultiply): HasMultiply {
return Multiply(x, x)
}
如果調用Square(2)
,有泛型的返回值類型是int64,而沒有泛型的返回值類型是HasMultiply。通過泛型,我們讓類型更加具體。除了類型推導出<T>
,我們也可以明確指定Square<int64>(2)
。如果輸入Square<int64>(2.2)
,則是類型錯誤。
除了function實現可以用泛型,interface也可以添加類型參數,使得interface更加具體。
interface MyPromise {
<T>: interface{}
Result: T
}
對於MyPromise<string>
,那麼Result的類型就是string。同樣,也可以約束 <T>
的取值範圍:
interface MyPromise {
<T>: HasMultiply
Result: T
}
類型參數可以有多個
interface MyMap {
<K>: interface{}
<V>: interface{}
Get(K): V
}
通過用泛型,multi-dispatching,以及structural typing,我們可以非常抽象地實現業務流程。
容器
雖然泛型不是為了容器而生。但是最常見的可復用的業務流程確實是對容器的get/set操作。所以大部分編程語言的容器都以泛型的方式實現。
DexScript 因為基於 Java 體系實現,所以在容器的語法選擇上和 TypeScript 還是稍有不同。最簡單的容器是把 function 的 stack frame 做為結構體來用:
function IntBox(initialValue: int64) {
value := initialValue
await {
case Set(x: int64) {
value = x
-> Set // Set is done
continue // repeat await
}
case Get(): int64 {
value -> Get // Get is done
continue //repeat await
}}
}
或者,我們可以用@Prop
來簡寫
function IntBox(@Prop value: int64) {
await{ exit! }
}
或者,我們可以把function
換成struct
struct IntBox {
value: int64
}
但是僅僅有Struct是無法表示重複類型的容器。所以,需要引入數組類型。對於任意類型,後面加上[]
就表示了這個類型的數組,和Java的寫法是一致的。
var Ints: IntBox[]
構造數組的時候,要額外指定數組的長度:
Ints := IntBox[]{ 3 } // {} 代表構造對象,3是長度參數
大部分時候,我們不需要使用自己定義的數組,而是使用java.util
下定義的collection。
import java.util.HashMap
import java.util.ArrayList
dict := HashMap<string, int64>{} // {} 代表 new,構造這個類型的對象
list := ArrayList<int64>{} // {} 代表 new,構造這個類型的對象
初始化collection的寫法,最繁瑣的是:
import java.util.HashMap
import java.util.ArrayList
dict := HashMap<string, int64>{}
dict[a] = 1
dict[b] = 2
list := ArrayList<int64>{}
list.add(1)
list.add(2)
為了讓表達更簡練,添加了 []=
操作符,表示逐個賦值的意思:
import java.util.HashMap
import java.util.ArrayList
dict := HashMap<string, int64>{} []= {
a: 1,
b: 2
}
list := ArrayList<int64>{} []= [1, 2]
DexScript不支持變長的參數列表。所以 []=
一定程度上解決了需要便捷地指定n個輸入的情況。
因為HashMap和ArrayList使用非常普遍,所以進一步縮寫為:
dict := {a: 1, b: 2}
list := [1, 2]
Function執行狀態序列化
因為 function 是一個容器。所以執行中的function也可以別做為容器一樣序列化。稍微需要特殊處理的是function的當前執行位置
function A {
Step1()
Step2()
Step3()
}
function Step1() {
}
function Step2() {
}
function Step3() {
}
對於 A 來說,可能停在第一行Step1()
,也可能停在第二行Step2()
,也可能停在第三行Step3()
。我們需要把這個停留的位置保存在status__欄位裏。默認的值可以用調用的函數名,或者用label定義
function A {
State1:
Step1()
State2:
Step2()
State3:
Step3()
}
用 Label 標記了行號,序列化的時候就用 Label 來標記 status__ 欄位。
異常流程
第三個需要創新的地方是異常流程的定義。簡單來說,DexScript 支持錯誤碼的處理模式,以及try/catch自動往上拋的處理模式。如果錯誤碼被忽略,則自動轉換為異常往上拋。
function A(): int64 {
bResult, bError := check! B()
if bError != null {
return 0
}
return bResult
}
function B(): int64 {
panic! err
}
如果嫌逐個 check!
的模式太麻煩,則可以用 handle!
統一進行處理
function A(): int64 {
handle! (err: interface{}) {
return 0
}
return B()
}
function B(): int64 {
panic! err
}
對於 try/finally
的需求,可以用exit!
來實現
function A(): int64 {
res := OpenSomeResource()
exit! CloseSomeResource(res)
return B()
}
function B(): int64 {
panic! err
}
對於await block裏定義的消息而言,->
僅僅能表達正確的返回值,如何表達返回錯誤呢?
function A() {
b := B{}
result := b.Step1()
}
function B() {
await {
case Step1(): string {
hello -> Step1
}}
}
我們可以用 reject!
關鍵字來表達:
function A() {
b := B{}
result, err := check! b.Step1()
}
function B() {
await {
case Step1(): string {
error reject! -> Step1
}}
}
這裡check!
可以從Promise裏取出reject! ->
設置的錯誤值。注意的是 reject!
僅僅把錯誤返回給調用方,並不會使得B本身退出。但是如果
function A() {
b := B{}
result, err := check! b.Step1()
}
function B() {
handle! (err: interface{}) {
// err is error
}
await {
case Step1(): string {
panic error
}}
}
不但A會收到error
做為錯誤,而且B也會handle!
到這個錯誤。
基本上這個錯誤處理模式和Go 2的設計是類似的。但是不同的時,Go對於忽略錯誤碼的默認行為是什麼也不做。而DexScript對於忽略錯誤碼的行為是默認往上拋。Go的默認設計顯然是因為歷史包袱導致的迫不得已的選擇。
因為DexScript的stack trace和普通的Java function的stack trace是完全不同的(考慮到一個函數會有多個函數等待其返回),所以DexScript的stack trace都是獨立記錄的,不是沿用Java的原生機制。所以無論是check!
還是 handle!
,都是和Java異常的行為類似,可以拿到整個調用鏈的stack trace。
異常流程一共有 panic!
reject!
check!
handle!
四個關鍵字。
Destructor
exit!
事實上就是function的析構函數。我們可以加入一種新的變數類型,own!
變數。它和var定義的變數不同,這個變數會在函數退出的時候自動調用它的exit!
。
function UseSomeLock() {
own! Lock{}
// ...
}
function Lock() {
AcquieLock{}
exit! ReleaseLock()
await{ exit! } // wait exit! to be called by own!
}
也可以對own!
變數賦值
function UseSomeFile() {
own! file = File{name=hello.txt}
print(file.read())
}
function File(name: string) {
OpenFile()
exit! CloseFile()
// ...
}
own!
的變數會在函數的退出的時候把自己也進入退出狀態,以調用其定義的exit!。所有使用了這個變數的地方要自己處理狀態不符合預期的問題。所有的函數調用都不能保證對方是能夠接受的,所以不是因為own!
引起的新問題。
魔法方法
最後是一些代碼需要在調用的地方(call-site)消失,因為它不應該出現在那裡。換句話說是,通過魔法方法注入語法糖。最常見的魔法方法,可以重定義+
或者[]
這樣的操作符:
function Add__(x: IntBox, y: IntBox): IntBox {
return IntBox{x.value + y.value}
}
對於訪問 x.y
x上的y的屬性。其實調用的也是魔法方法。通過定義Get__y
,我們可以增加y這個屬性。
function Get__y(x: MyObject): string {
return hello
}
最為通用的魔法方法是 Apply__
,它可以攔截函數調用自身。通過定義Apply__
,我們可以把對函數的benchmarking,logging等通用的異常流程代碼從call-site移除掉,放到統一的地方。
Conclusion
DexScript 目前仍在設計階段。通過上面對於關鍵語法的描述,你已經能夠管中窺豹最終的形態了。其核心的概念其實就只有function
和interface
這兩個。用function定義你的實現,用interface表達你的需求。對於描述流程而言,可以稱得上function is all your need了。是的,OOP不是理想的業務流程描述方式,function套function纔是。
推薦閱讀: