dexscript / design

DexScript - for Better Developer EXperience
http://dexscript.com
Apache License 2.0
4 stars 0 forks source link

DexScript 语言简介 #26

Open taowen opened 5 years ago

taowen commented 5 years ago

目标

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 的基础类型有

不同的基础类型不会互相隐式转换。比如 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'
}

这就是协程的全部语法了。总结一下

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的情况。

函数类型

我们现在已经看到了三种类型定义了

还有一种类型是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之后,类型其实就两种

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 目前仍在设计阶段。通过上面对于关键语法的描述,你已经能够管中窥豹最终的形态了。其核心的概念其实就只有functioninterface这两个。用function定义你的实现,用interface表达你的需求。对于描述流程而言,可以称得上function is all your need了。是的,OOP不是理想的业务流程描述方式,function套function才是。