打破常规,回归直觉—— Feel 语言的语法探索

设计背景

几年前我在开发多平台的 XyKey 时,由于当时的跨平台方案还未成熟,所以我没有选择跨平台实现,而是选择了每个平台都使用官方指定的语言进行开发,为此我接触了 Java/Kotlin(Android)、Swift/OC(iOS)、C#(UWP)。于此同时我本职工作方向是区块链技术,现有主流区块链方案也大量使用了 JS + Go 的组合开发前后端产品。

在不同语言之间来回切换学习之后,我对不同语言表达同一种功能的语法差异性产生了兴趣,随后开始了语法设计方面的研究探索,最终诞生了 Feel 语言。

语言设计问题之一

很多语言里面,函数存在不止一种表达方法。

我们需要为同样的需求设计不同的语法吗?

以下我举一些我使用过的语言中函数的表示方法,所有的 eg 都是函数。

Go: 大部分时候函数都使用 func 开头声明,算是一致性比较好的设计之一,但在 interface 中还是使用了不一样的描述方式。

func eg1(x int) int {
	return 1
}

var eg2 = func(x int) int {
	return 1
}

type foo struct {
	eg3 func(int) int
}

type bar interface {
	eg4(int) int
}

C#: 大部分时候都使用了 C 式的描述方式,但在函数类型中使用了反直觉的泛型类型,并且 Lambda 语法也看不出与函数的联系。

int eg1(int x) 
{
    Func<int,int> eg2 = (int x) => 
    {
        return 1;
    };
    return 1;
}

interface foo 
{
    int eg3(int x);
}

Action<int> eg4;

Kotlin: 函数定义、函数类型、Lambda 是三种风格。

fun eg1(x: Int): Int {
    val eg2: (Int) -> Int = { i ->
        1
    }
    return 1
}

val eg3 = fun(x: Int): Int {
    return 1
}

interface name {
    fun eg4(x: Int)
    val eg5: (Int) -> Unit
}

Swift: swift比较好的地方是函数定义和函数类型使用了同样的箭头表示,但在 Lambda 中却使用了 in 来分割。

func eg1(x: Int) -> Int {
    let eg2: (Int) -> Int = { i in
        return 1
    }
    return 1
}

protocol name {
    func eg3(x: Int)
}

var eg4: (Int) -> ()

为一个资源绑定一个名称,同样也有很多不同的写法。

以下我举一些我使用过的语言中标识符表示方法,所有的 eg 都是某个资源的标识符。

Swift: 为不同类型绑定标识符使用不同前缀,算是比较好的实践之一。

var eg1 = 1

let eg2 = 1

func eg3() {}

class eg4 {}

protocol eg5 {}

Go: 变量、常量和函数使用了一种风格,定义类型使用了另一种风格。

var eg1 = 1

const eg2 = 1

func eg3() {}

type eg4 struct{}

type eg5 interface{}

C#: 类和接口使用了一种风格,变量、常量、函数使用了三种不同的风格。

int eg1 = 1
const int eg2 = 2
int eg3() {}
class eg4 {}
interface eg5 {}

语言设计问题之二

一旦我们开始开发一个具备一定规模的项目,就一定会反复强调编码规范的重要性。形式不一的代码风格会给我们的协作带来不小的困难。

如果规范如此重要,我们是否应该在语言级别强制?

下面我给出几种不同的代码风格,在不统一风格的情况下,我们可能会见到如下几种代码并存:

if (foo == 0) 
{
  	print(foo)
} 
else if (foo == 1) 
{
  	print(foo)
} 
else 
{
  	print(foo)
}
//////////////
if (foo == 0) {
  	print(foo)
} 
else if (foo == 1) {
  	print(foo)
} 
else {
  	print(foo)
}
//////////////
if (foo == 0) {
  	print(foo)
} else if (foo == 1) {
  	print(foo)
} else {
  	print(foo)
}

当然,也可能有人会写出下面这样的:

if (foo == 0) 
		{
    print(foo)
  	} 
else if (foo == 1) 
		{
    print(foo)
  	} 
else 
		{
    	print(foo)
  	}
//////////////
if (foo == 0) print(foo)
else if (foo == 1) print(foo)
else print(foo)

语言设计问题之三

一些语言是动态的,一些语言是静态的,它们各有各的好,但是我们却尝试在动态语言中加入静态特性(TypeScript),也尝试在静态语言中加入动态特性(Go)。

这静态与动态中间是否存在一个平衡的方案?

TypeScript:通过隐式接口,给动态类型加上类型检查,只有满足接口要求的对象才能被使用。

interface LabelledValue {
    label: string;
}

function printLabel(labelledObj: LabelledValue) {
    console.log(labelledObj.label);
}

let myObj = { size: 10, label: "Size 10 Object" };
printLabel(myObj);

Go:通过隐式接口,实现了静态鸭子类型,只有满足函数签名要求的对象才能被使用。

type LabelledValue interface {
	Label() string
}

func printLabel(labelledObj LabelledValue) {
	println(labelledObj.Label())
}

type Obj struct {
	size int
}

func (this Obj) Label() string {
	return "Size 10 Object"
}

语言设计问题之四

不同语言的关键字有多有少,但实际上大部分语言都可以实现图灵完备。

我们需要很多关键字吗?或者说,如果没有关键字,是否也可以?

下面是某个语言的关键字,还有部分上下文关键字未展示出来。

| keyword   |            |           |           |
| --------- | ---------- | --------- | --------- |
| abstract  | as         | base      | bool      |
| break     | byte       | case      | catch     |
| char      | checked    | class     | const     |
| continue  | decimal    | default   | delegate  |
| do        | double     | else      | enum      |
| event     | explicit   | extern    | false     |
| finally   | fixed      | float     | for       |
| foreach   | goto       | if        | implicit  |
| in        | int        | interface | internal  |
| is        | lock       | long      | namespace |
| new       | null       | object    | operator  |
| out       | override   | params    | private   |
| protected | public     | readonly  | ref       |
| return    | sbyte      | sealed    | short     |
| sizeof    | stackalloc | static    | string    |
| struct    | switch     | this      | throw     |
| true      | try        | typeof    | uint      |
| ulong     | unchecked  | unsafe    | ushort    |
| using     | virtual    | void      | volatile  |
| while     |            |           |           |

关键问题

一、同一种功能是否需要多种语法?

我们不需要多种语法,相同的需求可以有机统一。

二、强制规范是否有必要?

强制规范可以减少代码阅读和维护的压力,在语言级实施规范可以提升所有使用者的协作效率。

三、我们想要的是静态类型还是动态类型?

我们既想要静态检查,也想要动态自由度。静态鸭子类型可能是一个方案。

四、关键字是必要的吗?

如果语法结构足够少,我们可以试试移除关键字。

函数

我们对函数语法需求可以总结为如下几点:

  • 函数也是值,定义函数的方式与定义函数变量的方式应该一致。
  • 函数定义里的类型,与函数的类型应该一致。
  • Lambda 表达式与函数定义应该具有明显的相似度。

假设我们所有资源的定义方式都使用 let id : type = XXX 的形式。

那么我们可以先给出这样一个函数语法:

let foo = func(x : int) int {
    return 1
}

这个语法非常普通,使用 func 关键字定义类型,() 里声明形参,后面声明返回值。

接下来我们思考一下现代函数设计通常允许多返回值,因此我们需要使用 () 包装更多返回值。

let foo = func(x : int, y : bool) (int, bool) {
    return (1, true)
}

这样形参和返回值类型的 () 太接近了,阅读起来比较费力,需要一个分隔符,我们可以引入->

let foo = func(x : int, y : bool) -> (int, bool) {
    return (1, true)
}

-> 加入之后,()->() 就可以构成一个函数的类型结构,此时 func 的存在就没有必要性了,所以我们去掉这个关键字。

let foo = (x : int, y : bool) -> (int, bool) {
    return (1, true)
}

每次都要写两遍 () 也挺麻烦的,其实我们不太需要两个 (),可以将形参和返回值类型放在一起,使用 -> 分割。顺便也可以将 return() 也省略掉。

let foo = (x : int, y : bool -> int, bool) {
    return 1, true
}

等等,我们还需要 return 这个关键字吗?我想应该是不需要了,我们可以使用更好看的 <-

let foo = (x : int, y : bool -> int, bool) {
    <- 1, true
}

函数左右好像有点不平衡,我们可以强制返回值类型也加上一样的名称描述,让它们看起来更一致,并且也能给使用者更友好的说明。

let foo = (x : int, y : bool -> a : int, b : bool) {
    <- 1, true
}

到这里函数的描述方式已经成型,我们使用 (->) 表达函数类型,使用 {} 描述函数的逻辑。

只要具备一定的类型推导能力,我们就可以继续省略参数类型和返回值类型,lambda 和函数声明都可以共用一种描述。

let foo : (int, bool -> int, bool) = (x, y) {
    <- 1, true
}

无参函数与函数参数的例子:

let foo = () {}
let bar = (fn : (->)) {}

定义

我们对定义语法需求可以总结为如下几点:

  • 所有创建名称的行为都是定义,应该使用同一种方式。
  • 名称比类型的阅读优先级更高,名称应该在前。
  • 区分可变与不可变。

假设我们先使用 let xxx : type = value 定义不变量,使用 var xxx : type = value 定义变量。

let foo : int = 0
var bar : int = 0

使用 var 相当于多了一种定义声明的方式,不如使用 mut 来声明可变性,这样可能具备一致性。

let foo : int = 0
let mut bar : int = 0

我们思考一下,这种结构其实不需要 let 这个关键字也能成立,所以我们去掉它。

foo : int = 0
mut bar : int = 0

现在只剩下 mut 这个关键字了,在很多规范里面,都建议使用大写描述常量,小写描述变量。我们干脆直接让它成为语法。

Foo : int = 0
bar : int = 0

到这里,只要支持类型推导,我们也不需要明确写类型了,省略掉类型后,可以组合成 :=

Foo := 0
bar := 0

当然,如果不一定带值,我们也可以省略右边,保留类型。

Foo : int
bar : int

现在我们的定义语法已经完成了,替换前面函数的例子,不需要换多少东西。

Foo := (x : int, y : bool -> a : int, b : bool) {
    <- 1, true
}

选择结构

if 这种选择结构的形态和功能已经非常成熟了,但我们仍有从格式上进一步简化的可能性。

我们先给出一个常见的 if 语句,并且假定 { 不可以换行。

if (foo == 0) {
    ......
} else if (foo == 1) {
    ......
} else if (foo == 2) {
    ......
} else {
    ......
}

去掉 () 似乎不会影响什么,我们先去掉它。

if foo == 0 {
    ......
} else if foo == 1 {
    ......
} else if foo == 2 {
    ......
} else {
    ......
}

我们假定 else if 也不可以换行,必须跟在 } 后面。有了这个约束,我们就可以省略 else if

if foo == 0 {
    ......
} foo == 1 {
    ......
} foo == 2 {
    ......
} else {
    ......
}

同样的道理,else 其实也可以不需要,我们可以使用 | 来替换它。

为了保持协调,我们在 else if 的位置也使用 |

if foo == 0 {
    ......
} | foo == 1 {
    ......
} | foo == 2 {
    ......
} | {
    ......
}

现在只剩下一个 if 了,我们用 ? 来表示可选择性,替换掉它。

? foo == 0 {
    ......
} | foo == 1 {
    ......
} | foo == 2 {
    ......
} | {
    ......
}

现在我们完成了选择结构的基本形态,通过强制规范来压缩更多代码。

循环结构

for 是最常见的一种循环结构,我们继续尝试进一步简化。

先给出最简单的例子:

for (let i in foo) {
    ......
}

省略掉作用不大的 ()

for let i in foo {
    ......
}

let 是为了定义从集合里获取的对象,我们可以改成前面的定义语法。

for i := in foo {
    ......
}

我们可以用另外一个语法 ... 表示某个集合的展开,于是可以替换掉 in

for i := foo... {
    ......
}

现在只剩 for 这个关键字了,@ 非常适合用来指明选定的事物,我们替换掉它。

@ i := foo... {
    ......
}

这样我们就得到了循环结构的基本形态。

对象模版

类型是我们描述对象的数据和行为的一种方式,我们既可以用它充当构造数据的模版,也应该能把它视为描述行为的接口。

这样我们就可以将它作为静态的鸭子类型使用,同时承载了数据、行为以及约束的能力。

假定我们将它称为 class

class Foo {
    var label = "I am Label"
    let Show = func() {
        Print(label)
    }
}

Foo 同时包含了字段及函数,它们能被一致的使用。

我们先将语法替换为统一的定义方式。

Foo := class {
    label := "I am Label"
    Show := (->) {
        Print(label)
    }
}

我们可以将唯一的关键字 class 去掉,替换为另一个经常用来表示模版的 $

Foo := $ {
    label := "I am Label"
    Show := (->) {
        Print(label)
    }
}

每个类型都需要通过某种方式构造数据对象,这个行为其实跟函数非常像。我们传入字段需要的值,就返回一个对象。因此我们不如将类型与函数联系起来,使用参数来构造字段。

Foo := $(label := "I am Label") {
    Show := (->) {
        Print(label)
    }
}

这样我们就能在字段的位置复用函数的特性,比如命名参数、默认参数等。

现在我们像函数那样构建对象了。为了一定的区分度,这里在调用处保留了 $

a := Foo$()
a.Show()

基于鸭子类型的特性,我们可以定义一个 Shower 的接口,去使用 Foo 的行为。

一般接口是不具备构造函数的抽象类型,有了上面的语法,我们只要去掉 () 就可以描述接口。

Shower := $ {
    Show : (->)
}

Use_Shower := (s : Shower->) {
    s.Show()
}

Use_Shower( Foo$() )

因为 Foo 具备了 Show 方法,它就能被视为 Shower 使用。

我们再引入一个语法,在对象模版内引入委托,委托会根据它指定的类型自动隐含它所有的东西,这样很便利复用代码。

例如 Reader 包含 ShowerReader 就包含了 Show

Reader := $ {
    Shower

    Read : (->v : string)
}

那么我们也可以让 Bar 包含 FooBar 就包含了 labelShow

Bar := $(foo := Foo$()) {
    foo

    Read := (->v : string) {
        <- label
    }
}

这时我们就能将 Bar 视为 Reader 使用了。

Use_Reader := (r : Reader->) {
    r.Show()
    Print(r.Read())
}

Use_Reader( Bar$() )

我们借助委托和鸭子类型,不依赖继承实现了部分面向对象的特性,在静态类型与动态类型之间选择了一个平衡点。

最后

欢迎 star https://github.com/kulics-works/feel

Feel 语言目前还处于实验阶段,不具备生产条件,部分设计可能会随着后端的推进而改变,欢迎讨论邮件(kulics@outlook.com)或issue讨论。

再贴一段 leetcode#16 的代码供参考

ThreeSumClosest := (nums : List[Int], target : Int -> v : Int) {
    length := nums.Size()
    nums.sort()
    closs := nums.(0) + nums.(1) + nums.(2)
    @ i := 0.Up_until(length)... {
        l, r := i + 1, length - 1
        @ l < r {
            sum := nums.(i) + nums.(l) + nums.(r)
            ? abs(sum - target) < abs(closs - target) {
                closs = sum
            } | sum > target {
                r -= 1
            } | {
                l += 1
            }
        }
    }
    <- closs
}
comments powered by Disqus