The Lie Programming Language (谎言)
The Lie Programming Language (谎言)
官方概念与底层架构规范 (v2.1 Draft)
0. 核心哲学 (Core Philosophy)
在传统的编程语言中,函数被视为接收输入的黑盒。但在 Lie 中,我们认为这是一种错觉。Lie 是一门彻底解构了传统控制流与参数传递的现代编程语言,它的核心建立在以下三大哲学支柱之上:
- 真相是词法的,谎言是动态的 (Truth is Lexical, Lies are Dynamic): 局部变量严格遵循静态词法作用域,绝不污染环境;而函数执行所需的依赖与副作用,全部从动态作用域中“索取”。
- 调用即欺骗 (To Call is to Lie): 改变下游函数行为的唯一方式,是在调用时显式地将静态变量注入为动态环境(编织谎言)。
- 语法的终极大一统 (The Grand Unification): 变量赋值、模式匹配与函数调用在底层是完全同构的——它们都是向当前环境链压入新的绑定,并将其作为上下文传递给后续的执行流。
第一部分:语法的表象 (The Illusion of Syntax)
这一部分描述了开发者在日常编写 Lie 代码时的直觉与规则。
1. 声明与索取 (Declarations & Demands)
在 Lie 中,函数签名中的标识符不是传统意义上的“形参”,而是对动态环境的索取声明。
// 声明:greet 函数执行时,需要从动态环境中查找到 user 和 greeting
fn greet(user: str, greeting: str) {
print(greeting + ", " + user);
}规则:函数体内部除了声明的动态依赖外,所有其他变量(如内置的 print)均严格遵循静态词法作用域查找。
2. 词法作用域、无 let 赋值与 with 语法糖 (Lexical Scope & with)
Lie 彻底消灭了早期 Lisp 语言中的“动态作用域地狱”,并推向了更极致的纯粹性:Lie 没有 let、var 这样的声明关键字。
虽然在语义层面(Semantic Level),串行块 {} 中的赋值本质上是向后续代码的“子闭包作用域”压入了一个同名参数并进行遮罩(这也是为什么重名赋值没有副作用,且不需要 let)。但这只是理解心智模型。在实现层面(Implementation Level),Lie 后端会通过逃逸分析和 SSA 将这些闭包调用最大限度地就地转换为栈上可变赋值运算,以保证执行效率(详见后端部分)。
所有的局部变量绑定(如 a = 1)只会对其后方的词法作用域生效,且绝对不会自动泄漏到动态作用域中。
with 关键字在 Lie 中没有任何动态绑定的魔法,它仅仅是词法作用域声明的“倒装句”语法推导:为一个代码块或复合表达式提供依赖注入。
// 写法 A:使用 with 语法糖 (do 块本质上就是一个普通表达式)
do {
greet(user = my_user, greeting = my_greeting);
} with {
my_user = "Alice"; // 仅仅是作用域遮罩赋值,不需要 let
my_greeting = "Hello";
}
// 任意表达式后都可以追加 with,用于捕获代数效应(如错误处理):
// 注意:Lie 没有内置的 try-catch,错误处理完全依赖处理器的双重返回
read_file(path) with {
fn on_error(err: Error) { print("Ignored"); continue(); }
}3. 符号的终极正交:数据 () 与 代码 {} []
在主流语言中,数据和代码块的界限常常是混淆的。Lie 对三种括号进行了极其严格的语义正交划分:
- 复合数据
():非函数的复合数据均由圆括号表示。无论是位置模式的(如数组/元组),还是键值对模式的(如字典),核心规则是:每次构造数据时,要么全是位置参数,要么全是字典参数,严禁混合。 - 串行执行块
{}:创建一个内部表达式顺序执行的函数体。它隐式包含一个名为it的参数对象(代表传入的整个复合数据,而非特定字典)。在串行执行中,it会在每次表达式执行后被不断覆盖为上一行表达式的计算结果。串行块的值即为最后一行的结果。 - 并行执行块
[]:创建一个内部表达式完全独立、并发执行的函数体。每一行表达式都能抓取到同一个原始的it输入,不会逐行覆盖。并行块的值是所有表达式求值后收集而成的结果列表。
推论: do {} 和 do [] 只是对这两种函数体的立即执行形式,因此它们会分别返回“最后一行结果”和“并行结果列表”。 真正的函数调用形式只有一种:f a。 我们在 Lie 中写出 f(a, b) 时,并不是所谓的多参数调用,而是将一个复合数据对象 (a, b) 作为一个单一的右值参数,传递给了函数 f。
4. 万物皆表达式、UFCS (.) 与 中缀流 (Infix)
为了实现优雅的函数调用与链式管道,Lie 提供了两种不同优先级的调用形态,彻底消灭了嵌套括号的深渊:
- 高优先级:UFCS 统一函数调用 (
.)。类似 Koka 等现代语言,Lie 支持通过.进行显式倒装调用。x.f(y)会被编译器直接视作传递了复合数据对象的f(x, y);而x.f等价于f(x)。这适合紧密的对象操作。 - 低优先级:中缀符调用降维。既然代码块就是隐式包含
it的函数,只要函数注册为中缀符,就能作为宏观管道写出极具自然语言表现力的代码:所有的控制流(如if/else)完全可以通过标准库借由中缀符实现,而不再是关键字!
如果你觉得隐式的 it 在多层嵌套中不够清晰,Lie 同样允许在使用 {} 或 [] 时,在头部使用 x -> 显式声明匿名函数的参数名。
urls = ("http://a.com", "http://b.com", "http://c.com"); // 圆括号表示纯数据
// do 本质上是立即执行一个单参数函数。串行中的 it 总是等于上一行的结果。
do { print(it) };
// 中缀符调用与隐含的 it
doubled = numbers map { it * 2 };
// 显式声明 Lambda 参数名(同理也适用于 [] 并行块)
tripled = numbers map n -> {
n * 3
};
// 控制流可以完美演化为中缀函数的级联调用
status = (score > 60) then {
"Pass"
} else {
"Fail"
};5. 函数与控制流流转 (>>, ?>, and/or)
受到 Floc 的启发,Lie 吸纳了极其强大的函数复合与短路语义,完全兼容其 it 推导哲学。
- 函数复合
>>(管道流转): 允许将两个函数或代码块直接组合成一个新的函数(即A >> B等价于生成x -> B(A(x)))。配合隐式的it,你可以将多个独立的转换逻辑预先拼装成一条数据流水线,而无需提前注入数据。 - 短路中缀
?>(安全透传): 结合 Lie 一切皆表达式的特性,如果左侧求值为null(或者抛出默认的空异常),?>操作符会直接终止后续链条的执行并返回空,不再继续向右计算。 - 逻辑运算符升维: 当
and或or的两侧接收的由于中缀演化而来的代码块{}或 函数 时,它们会自动升维成一个新的复合函数(即x -> A(x) and B(x))。这使得数据过滤变得极度优雅:
// 假设经过一系列流转,我们在过滤用户
safe_users = users filter { it != null } and { it.age > 18 };
// 短路调用:如果 config 提取失败,后续流程不会崩溃
db = load_config() ?> connect() ?> init_tables();6. 并发维度的语法级延伸:[] (Parallel Blocks)
在 Lie 中,凡是涉及代码块 {} 的地方,都可以直接无缝替换为 [] 并行块。这就意味着语言底层统一了串并行的概念规则:
- 立即执行:
do {}立即执行串行块,do []立即执行并行块。 - 函数声明:
fn foo() {}声明串行函数体,fn foo() []声明并行函数体。 - 参数传递:任何能接收
{}的函数(如控制流或基于高阶函数的 for 循环),都可以同样接收[]。
// 假设 for 就是一个普通的可重载函数(此处展示为中缀形式 urls for)
// 串行下载(按顺序一个一个下)
urls for {
download(it);
}
// 并行下载(同时发起三个下载请求!)
// 差别仅仅在于传入 for 的 Lambda 体变成了 [] 并行块
urls for [
download(it);
] with {
// 追加 with 捕获这批并发任务可能产生的统取代数效应(错误)
fn on_error(e) { log(e); }
}
// 函数本身也可以直接声明为并行体
fn initialize_services() [
start_db();
start_redis();
]7. 显式注入与透明透传 (Explicit Injection & Transparent Forwarding)
既然局部变量不会自动变成动态变量,如何满足下游函数的动态依赖? 答案:必须在函数调用点,通过强制命名传参的方式,显式注入。
一旦变量被显式注入到动态环境中,它将自动穿透所有未声明该依赖的中间函数,彻底解决“属性透传(Prop Drilling)”问题。
// 底层:声明需要 theme
fn Button(theme: str) { print("Button theme: " + theme); }
// 中间层:完全不知道 theme 的存在,保持纯净
fn Dialog() { Button(); }
// 顶层:注入谎言
fn App() {
theme = "Dark";
Dialog(theme = theme); // 语法糖可简写为 Dialog(theme)
}8. 代数效应与双重返回:定界续体与有状态节点
在 Lie 中,数据和函数没有区别,它们都是动态环境中的值。 当底层函数调用了一个从动态环境中注入的效应处理器(即代数效应)时,底层函数的执行会被瞬间挂起(Suspend)。
要理解 Lie 的控制流,必须触及它的底层本质:代数效应的底层就是续体(Continuation)。
在 Lie 的执行流中,不同位置的函数会获得不同的隐式续体:
- 单次续体
continue(val): 每个函数都隐式包含一个回到调用处的单次续体。 对于普通函数而言,这就是普通的返回;对于效应处理器而言,这就是恢复深层调用栈的执行(resume)。它将值交还给触发点,让代码继续往下跑。 - 额外的逃逸续体
break(val): 当一个函数的定义处本身正处于当前的调用栈上时(例如被with附着的局部函数),它就能被动态作用域索引找到。当底层动态触发这个调用时,该函数除了continue,还会获得一个额外的隐式续体break。调用break会直接丢弃(或封存)深层挂起的调用栈,直接把值跳出给定义这个处理器所在的外层作用域。
这使得看似复杂的非局部跳转与状态机,变为了最纯净的两种单次调用返回形式。
核心性能妥协:有状态的连续体与 Actor 的由来 在传统的纯函数式续体(如 Scheme 的 Call/CC)中,保存续体会像做时间机器一样“快照”离开时的全部状态。但在 Lie 中,基于极端的性能考量,我们做出了一个决定性妥协: 当你通过续体回到一个节点时,它应该按照“当下”执行位置的实际状态继续,而不是曾经离开时的快照。
这意味着:节点(闭包/上下文环境)是具有真实的物理状态的。 它的局部变量会随着执行就地演进。这解释了为什么语言底层可以把闭包降级为栈上的可变赋值(见第10节),同时也铺垫了一个不可回避的并发问题——既然状态是就地改变的,多个并发流试图回到同一个有状态的处理器节点时就会产生竞争。这就是为什么 Lie 必须在并发中引入 Actor 模型(即 sync 关键字,详见第12节)的根本原因。
/* Lines 123-140 omitted */
// 底层:声明需要一个可能会抛弃流的 check 行为
fn validate_user(check: fn(int)) {
check(1); // 挂起,携带 continue 续体跳回 my_check
print("This might be skipped");
check(2);
}
fn main() {
do {
validate_user(check = my_check);
} with {
// 定义静态的 check 处理函数。因为它在栈上,所以自动获得了 break 续体
fn my_check(val: int) {
(val > 1) then {
break("Too large"); // 触发逃逸续体,直接跳至主函数的 do 块外层!
} else {
continue(); // 触发回归续体,正常返回给 validate_user,继续执行 print
}
}
}
}第二部分:执行的真相 (The Reality of Execution)
这一部分深入 Lie 的编译器前端与虚拟机(VM)后端,揭示上述优雅语法是如何在底层严谨运作的。
9. 类型系统与双向推导 (Strong Typing & Bidirectional Inference)
Lie 是一门强类型语言。动态环境的匹配必须是名字与类型同时一致。真正的索引键是「全限定名变量」与「类型」的复合。这种设计不仅使 LSP(语言服务器协议)能够利用这套索引提供极其精准的智能补全与跳转,更重要的是,编译器正是基于这套严格的索引来完成自动静态绑定的,而非依赖运行时的模糊查找。
静态多重分派 (Multiple Dispatch) 与底层的统一: 这种「名字+类型」的匹配既然可以用于动态环境,自然也可以直接用于静态词法作用域。在主流语言中,面对多种类型的重载,往往要引入复杂的 OOP 机制。但在 Lie 中,函数签名原本就是需要进行类型验证的左值模式。这意味着,使用不同类型的组合(如 fn process(a: int) 与 fn process(a: str)),在编译器看来只是对不同的“复合键”进行了静态路由。这使得静态的函数多重分派与动态的环境依赖注入在底层完美共享了同一套机制!
编译器不仅推导返回值,更负责推导函数的**“效应行(Effect Row)”**。
- 向上冒泡: 如果函数
A调用了B,而B需要config: Config,编译器会自动推导A的签名隐式包含了对config: Config的需求。 - 向下校验: 顶层注入环境时,编译器严格校验注入的变量名和类型是否满足了底层冒泡上来的需求树。
- 可选依赖: 支持
?语法。fn foo(?config: Config)表示如果外层未提供该依赖,其值为null。 - 可选限定名 (Optional Qualified Names): 在绝大多数情况下,
变量名 + 类型的双重校验已足以避免冲突。但为了应对大型工程中的极端重名场景、提高代码可读性或获得更精准的错误提示,Lie 允许使用限定名。例如:fn connect(db::config: Config, redis::config: Config)。
10. 语法的终极统一:赋值 = 调用 = 模式匹配
在 Lie 的底层 AST(抽象语法树)中,没有传统的“顺序执行”概念,一切都是续体传递(CPS)。核心规则:赋值就是对后续上下文的函数调用,而函数调用在底层实现为对当前环境的赋值(模式匹配)。
在这里,语法上的 : 用于声明左值的类型预期,而 = 用于右值的赋值或字典声明。
// 写法 A(传统赋值与模式匹配:左值声明 = 右值数据)
(a: str, b: int) = (a = "hello", b = 3);
print a;
// 写法 B(函数调用:本质是环境中的模式匹配)
// 形参是左值声明,调用时相当于执行了一次模式匹配赋值
fn subsequent_code(a: str, b: int) { print a; }
subsequent_code (a = "hello", b = 3);模式匹配与环境注入机制: 函数调用时,系统会拿一个单一的复合右值对象,去匹配函数签名以及环境依赖中声明的多个左值。在环境查找时,它极度依赖前文提到的「完整限定名 + 类型」作为严格的匹配键。
这也呼应了前文的语法强约束:传入的右值对象,要么全是位置参数(如 (1, 2, 3)),要么全是字典参数(如 (a=1, b=2)),严格禁止混合出现。 在底层的统一模型中,对环境键值(Key)的模式匹配才是最本质的;所谓的位置列表模式,仅仅是这种严格键匹配的一种语法糖投影。只有这样,底层统一的模式匹配虚拟机才能保持极简与无歧义。
尾递归优化与基于栈的降级 (TCO & Stack Degradation): 虽然在语义层面,每次赋值(x = 1)都相当于创建了一个新的子闭包域并遮罩了外层(纯函数式的CPS传递)。但在实现层面,频繁创建闭包会导致极大的性能开销。 通过逃逸分析(Escape Analysis)和静态一次性赋值(SSA)分析,VM 的编译器后端会识别出这些串行块({})中从未向外逃逸的续体流。在深层优化时,VM 会将这些无逃逸的等价 CPS 调用安全地“降级”,展平为基于寄存器和栈帧的普通可变赋值运算(Mutable Assignment)。 尤其是当嵌套调用位于执行流的最末端(隐式返回 it)时,这种底层视角的同构自然而然地实现了**尾递归优化(TCO)**并消除了词法闭包分配的开销。这保证了 Lie 语言在拥有极高数学抽象和纯洁语义的同时,依旧能获得与 C/Rust 相当的执行指令效率。
11. 内存模型:代理链与调用树 (Proxy Chains & Call Trees)
为了支持强大的代数效应和协程,Lie 的动态环境(Env)绝对不是一个原地修改的全局字典。
- 不可变代理链: 每次发生变量注入,VM 都会生成一个新的环境节点。这个节点包含当前绑定的变量,并持有一个指向父节点的指针(类似原型链)。
- 从链到树: 当程序产生协程(Coroutine)或生成器时,线性的调用链会自然分叉,变成一棵调用树(Call Tree)。这棵动态的调用树,与代码的静态词法树产生了完美的对称。协程 A 和 B 可以共享同一个祖先环境,但它们各自的分叉叶子节点互不干扰。
12. 并发与 Actor 模型 (Concurrency & Actor Model)
当引入 [] 并发块时,代数效应面临一个世界级难题:如果多个并发的子任务同时触发代数效应,试图使用 resume 回到同一个父节点处理器中,父节点的状态(局部变量)就会发生数据竞争。
默认栈运行与 sync 升维同步:
在 Lie 中,性能是第一位的。默认情况下,所有的效应处理器都在当前的栈帧上超高速运行,这也意味着如果不对并发做限制,数据将是不安全的。然而,为了保证内存安全与逻辑的确定性,Lie 引入了 sync 关键字妥协且升华了 Actor 模型。
在并发环境下,任何被注入到动态作用域的效应处理器,只要它涉及状态修改并且需要多线程同步,就可以通过 sync 关键字被声明为安全临界区。此时它隐式升维成一个 Actor 邮箱的处理器。
fn main() {
do [
// 启动两个并发任务([]内部默认并行)
worker(id = 1);
worker(id = 2);
] with {
counter = 0; // 状态
// 使用 sync 声明:这个 log 处理器在被并发调用时,隐式成为 Actor 邮箱,保证串行执行
sync fn log(msg: str) {
counter = counter + 1;
print("[" + counter + "] " + msg);
// 即使 worker 1 和 2 同时触发 log,
// VM 也会将它们转化为消息进行排队,串行唤醒这个上下文,绝不产生数据竞争。
continue();
}
}
}总结 (Conclusion)
Lie 语言通过解构传统参数传递,创造了一种全新的编程范式。 在表象上,它用最极简的规则(词法隔离、动态索取、命名注入)消灭了冗长的参数列表和复杂的异步状态机;在底层,它通过 CPS 同构、代理树和隐式 Actor 模型,构建了一个坚不可摧的运行时。
在 Lie 的世界里,编写代码就是一门编织上下文的艺术。