ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

计算机语言设计为何应尽可能简单?——从Go到DSL的工程实践

计算机语言设计为何应尽可能简单?——从Go到DSL的工程实践 计算机语言的设计应当尽可能地简单这句话我琢磨了很多年。刚开始接触编程那会儿我完全不认同——一门语言如果太简单能表达的东西肯定有限那还不如直接用汇编算了。后来自己用过的语言多了也参与过内部 DSL 和配置语言的设计才慢慢明白简单在语言设计里的分量——它跟简陋完全是两码事。所有能长期活下来的计算机语言几乎都遵循了某种形式的设计收敛把复杂的语义隐藏在简洁的语法之后而把真正的复杂性留给明确、可控的边界。这篇文章我想从一个长期写代码、也被各种语言坑过的从业者视角把这句应当尽可能地简单拆开来讲。会涉及语言设计的基本原理、经典语言的设计取舍、我实操中遇到的典型问题和排查思路也包含一些对语言选型和做小型语言设计时的经验教训。不管你是想搞懂语言为什么这么设计还是准备自己写一个 DSL或者只是选型时想少踩坑这篇文章应该都能给你一点参考。1. 简单性不是形容词而是一条工程原则1.1 简单指的是认知负担而不是语法数量很多人一提到语言简单第一反应是语法少、关键字少。但这其实是个严重误解。Python 的语法比 Java 少很多但 Python 的逻辑诡雷一点都不少Go 的语言特性少到极致但用 Go 写出可维护的大型项目完全没问题。真正的简单应当指向写代码的人需要同时放进脑子的状态量最小。我倾向把语言简单性拆成三个维度语法的简单书写形式是否直观是否需要记住大量特殊的组合规则。语义的简单一段代码的实际执行行为是否容易预测是否符合直觉。复用的简单组织代码时语言内置的抽象方式是否容易理解、是否难以误用。语法简单是最容易被感知到的所以很多新手教材喜欢说XX语法简洁。但语义的简单才是决定一个语言长期价值的核心。C 语言语法不算多但它的指针、数组、函数指针的各种隐式转换规则让语义复杂度极高。Rust 语法比 C 多出一大截但它的所有权系统和生命周期规则约束了大多数危险行为在语义层面反而比 C 更可预测。在设计或评估一门语言时不能只看它提供了多少简单写法更要看这些写法背后是否隐藏了难以察觉的规则。举个实际例子很多脚本语言喜欢提供隐式类型转换比如字符串和数字相加自动转成字符串写起来确实省事。但你调试一句1 1 到底等于多少的时候就会发现这种简单牺牲了语义的可预测性。1.2 为什么这句名言是设计原则而不是口号计算机语言的设计应当尽可能地简单这句话经常被简化成少即是多。但在真正的工程语境里它是一个约束条件帮助设计者在面对无限可能性时做出取舍。设计一门语言本质上是在设计一套约束系统。你给程序员提供的每一个特性都意味着编译器或解释器需要更多的实现代码语言的规范文档需要更多的篇幅来说明规则程序员的学习成本会上升不同特性组合时可能出现新的交互逻辑。每增加一个特性不只是一行 feature 列表的增量而是一张复杂度网络的增量。举例来说Java 早期没有泛型后来加入泛型为了兼容旧代码引入了类型擦除结果泛型和重载、反射、可变参数之间产生了一大堆边界规则。这些规则让语言本身变得不再简单。所以尽可能地简单的实际含义是每一个新增特性都必须用它解决了什么核心问题和它将增加多少认知成本来反复权衡。只有当收益显著大于代价时才应该被纳入语言。这正是很多设计者在评估提案时的思考路径——不是这个功能好酷而是没有这个功能程序员是否会用显著更差的方式解决同一个问题。1.3 简单性要服务于可靠性和可维护性有人说简单性会不会让写代码变得束手束脚会但这是必要的。语言设计的目标不只是让打字舒服更重要的是让系统在演化多年后仍然可以被理解和修改。团队里接手老项目的同学应该都有体会代码难维护有时不是因为业务复杂而是因为语言允许了太多绕来绕去的写法。以 C 为例同一个操作可以有几十种写法模板元编程、重载、继承、多态、智能指针、裸指针……项目里每个作者都有自己的风格读起来极其吃力。这种复杂度的根源很大程度来自语言本身的过度设计。而更简单的语言比如 Go反而在大型团队中表现出色。它没有继承、没有泛型早期版本、没有运算符重载、没有异常机制。你写出来的代码风格高度统一任何一个人接手都能快速理解另一块代码的意图。简单在这里变成了协作效率的基石。2. 简单性设计的底层逻辑把复杂性挡在门外的艺术2.1 语言的复杂性与程序员的复杂度守恒这里有一个我经常用的类比语言设计者、编译器、程序员三方之间的复杂度是守恒的。如果语言设计者不去约束语法和语义那么复杂度不会消失只会转移到程序员身上——他们必须用大量约定、代码审查规则、设计模式、文档注释来弥补语言表达力带来的失控。举个例子C 语言把内存管理的复杂度完全暴露给程序员。语言本身的设计确实简单但每个 C 程序员都必须时刻记住这块内存是谁分配的谁来释放悬挂指针怎么避免结果就是 C 工程的开发效率被这种隐性复杂度拖累。而 Java 选择引入垃圾回收语言运行时变复杂了但程序员写业务代码时就不用考虑手动释放内存整体工程效率提升明显。这就是为什么简单不等于原始。语言设计者应当主动承担一部分复杂度把常见错误和痛点封装到运行时或编译器的规则中让程序员面对的问题更简单。简单性是让使用者的心智负担最小化而不是让语言设计者的工作量最小化。2.2 最小完整性与最小可用性的边界在设计语言时有一个核心问题特性的边界在哪里如果只追求最小可能连函数、循环都省略了那这门语言就只是个玩具如果追求完整又容易掉进功能膨胀的陷阱。这里可以参考 Unix 哲学里的做一件事做好它。语言设计也一样核心特性必须形成一个完整的体系让程序员可以用有限的规则组合出无限的程序。比如函数、变量、条件判断、循环、数据结构这几乎是通用语言不可删减的骨架。在这个骨架上每一层扩展都要谨慎——如果缺少它程序员就无法优雅地表达某个场景才值得考虑如果只是别的语言有我们也要有就应该坚决砍掉。我在设计一个内部配置语言的时候遇到的典型场景是有人提议加入正则表达式字面量因为很多语言都有方便。我反问我们的用户需要处理多少字符串匹配场景如果用标准库函数能不能覆盖结果发现场景极少引入正则字面量会让词法分析器复杂不少还会新增一个转义规则。最后我们选择只提供几个简单的字符串方法配置语言整体维持了极低的认知成本用户 5 分钟就能上手。2.3 一致性比特性数量更能带来简单感很多语言让人觉得难用不是因为它特性多而是因为特性之间不一致。既然有了 A 规则为什么 B 场景下不适用这种处处需要记忆特例的语言看起来没什么语法实际用起来却复杂无比。举个典型例子PHP 早期版本中字符串拼接用点号数组用array()函数而不是字面量不同函数参数顺序也不统一连strpos或strstr谁先传 haystack 都有无数争论。这种不一致让每个操作都需要查文档。相比之下Python 几乎所有操作都遵循对象.方法()的范式有了对象后方法名和参数顺序通常是一致的认知负担就小得多。所以语言设计时最好能定义一套思维模式然后让所有特性都遵循这个模式。像 Smalltalk 和 Ruby 的一切皆对象、一切皆消息Lisp 的代码即数据都是高度一致的语言。它们也许有点自私——学习曲线陡——但真正的力量在于只要你理解了那一个核心思想剩下的大多数特性都是这个思想的推论。一致性还体现在错误处理上。现代语言大多倾向统一错误返回或异常而不是混用错误码、异常、全局错误变量。每次我见到一份 API 有时候抛异常有时候返回错误对象都会希望设计者重新审视一下简单性原则。3. 实操视角评估一门语言时如何判断它是否简单3.1 从新手到专家的认知阶梯我们在团队内做技术选型时常被问到这个语言对新手友好吗。这个问题不应该只看第一节课的体验而要观察一个开发者从新手到熟练的高级使用者整条学习路径是否平滑。有些语言刚上手是简单但越往深处越诡异到处都是反直觉的角落。JavaScript 早期版本就是典型基础语法很简单但涉及this绑定、原型链、类型转换、闭包陷阱时老手也会翻车。后来 TypeScript 在类型层面加上了约束虽然学习成本上升了但从长期来看它把 JavaScript 的隐性复杂度变得显性了很多错误在编译期就能暴露整体反而让使用体验更简单。我做语言评估时会画一张时间-能力曲线。如果语言的前期很简单但中期出现一段断崖式复杂的爬坡就要警惕更好的选择是平缓上升每个阶段都依赖之前的概念不存在突然的规则例外。这是简单性最直观的标准。3.2 从语言规范文档看复杂度把语言规范下载下来看页数和目录结构也能快速判断一个语言的复杂度。Go 语言规范在最精简时只有几十页Rust Reference 是几百页C 标准则厚达几千页。当然rules 大不代表语言一定差但页数越少意味着程序员需要阅读和记忆的规则越少出现认知偏差的概率就越低。我建议对语言设计感兴趣的朋友可以去找几份语言规范的目录来对比。看它有没有大量例外情况、特殊规则、与上一版本的兼容性限制这样的章节。若有说明这门语言已经积累了很多历史包袱简单的初衷被磨掉了。如果你正在设计自己的 DSL这一节还有一条实操建议找两个没有参与设计的人来看你的语法说明文档观察他们能不能顺畅地预测代码行为。如果文档里频繁出现只有……才……、除非……这样的句子那大概率还不够简单。3.3 常用语言的简明对比为了把前面的理论落到地面我做了一张表格方便对比几种主流语言在简单性方面的表现里面有我的主观经验仅供参考语言语法简洁度语义可预测性高级特性一致性我眼中简单的短板C高低低指针与数组的隐式交互、未定义行为多Go高高高泛型和接口的边缘情况略绕Python高中中动态类型的隐式约束绕过、对象模型魔法方法多Rust中高高生命周期标注的抽象负担重Java中中中历史特性叠加内外性子系统并存Lisp极高极高极高思维的抽象层级远超大多数人日常所需这张表想表达的是没有十全十美的简单每种语言的取舍都反映设计者的优先级。比如 Go 选择了工程结构的简单它不在乎表达力是否足够花哨Rust 选择了内存安全的简单即使学习曲线陡峭也寸步不让。选择语言时应当根据项目团队的背景和需求找到自己最能接受的简单。4. 经典语言设计案例拆解它们是怎么样做到简单的4.1 Go在系统级语言里做减法Go 是我非常推崇的简单性范本。它由熟悉 C/C 复杂之痛的团队设计一句少即是多几乎贯穿了整个语言。Go 砍掉的东西包括类继承、构造函数、泛型早期、异常、运算符重载、注解/标注、宏。很多写惯了 Java 或 C 的人刚接触 Go 时会觉得这也不能那也不能但这正是它的精髓把这些功能移除后代码的唯一风格变成了简单直接的组合 接口。没有复杂的对象模型绝大多数类型就是一个结构体加一组函数调用关系清晰可见。在实战中Go 的并发模型也贯彻了简单原则。go func(){}和 channel 两个核心概念几乎就能表达所有并发场景。相比之下Java 的并发工具从synchronized、volatile到ExecutorService、CompletableFuture、锁对象、信号量、原子类……一个比一个复杂。Go 的简单让它成为大部分云原生项目的首选原因正是因为基础设施代码要求高度可维护、可审查。当然Go 的简单也有代价。早期的无泛型设计让容器算法代码非常笨拙到处 interface{}算是一种过度简化的反噬。这告诉我们简单性不是删除一切而是在充分理解问题域后只保留能稳定组合、不产生额外交互成本的特性。4.2 Lisp思想上的极简认知上的不简单Lisp 常被称作最简洁的语言之一它的语法规则少到可以用一张明信片写完所有程序都是列表列表由原子和列表构成操作就是求值和特殊形式。从语言设计者角度看Lisp 的规范可能比现代任何语言都更接近简单。但有意思的是Lisp 号称简单很多人却觉得它难以上手。原因在于它的简单性体现在语法一致而不是符合直觉。你习惯了中缀表达式a b突然变成( a b)就需要记忆重排你习惯了函数之间用不同关键字区分到了 Lisp 里全是(defun ...)、(lambda ...)、(if ...)结构虽然统一但认知上反而不友好。Lisp 给语言设计者的启示是简单性必须考虑人类已有的认知习惯。完全摒弃语法的自由形式在数学上是优美的在工程上却未必是最优的。不少现代语言从 Lisp 中提炼了宏和不可变数据思想但很少有人直接照搬它的全括号语法这不是没有原因的。4.3 Python为可读性而设计的简单Python 的口号是尽量找到一种最好也只有一种明显的方法来做事。它刻意限制了程序员的自由度比如强制缩进、禁止在条件表达式中随意赋值、弱化类型的同时也砍掉了很多隐式魔法。缩进作为语法很多人初看觉得不习惯但它强制所有程序员统一了代码布局风格从语言层面消除了关于格式化的大规模争论。这种把约定变成强制的做法本质上是把复杂度从代码审查阶段转移到了语言设计阶段是一个非常高明的取舍。Python 的类型系统在动态语言里也算比较收敛的。它没有像 JavaScript 那样疯狂的类型转换1 1在 Python 中直接报错而不是懵懵懂懂地拼接/相加。这种在定义不明确的地方果断拒绝的设计让 Python 的多数代码行为都很容易预测。不过 Python 也有随着版本演化逐渐变复杂的趋势。装饰器、元类、上下文管理器、异步语法、类型注解混合在一起时新手的认知负担会突然增大。这也印证了简单是需要持续守护的——一旦语言变得流行各方势力施加的新特性压力就会不断涌来设计团队必须有说不的勇气。4.4 Rust用复杂语法换取语义简单很多人质疑Rust 语法那么庞大说它简单不是在搞笑吗我的观点是Rust 的简单属于另一层——它把程序员在运行时面临的复杂问题全部前置到了编译期让你在代码写完之后运行时的内存行为多数时候都能被预期。在没有垃圾回收的情况下Rust 的所有权和借用规则是一种非常强的约束。虽然学习成本高但它让悬空引用、数据竞争这类 C 中的经典难题变得几乎不可能。换句话说Rust 把复杂性问题从程序员的长期记忆负担转变成编译器的一句话错误提示这里不能这么借用加上 lifetime 就行。在语言演进上Rust 对新特性十分谨慎RFC 审查流程非常严格很多提案因为增加的语言复杂度大于其收益而被拒绝。这个过程本身就是简单性设计原则的体现他们并不拒绝功能但会仔细评估功能与现有模型的交互尽量维持核心语义的一致性。对我们做技术方案的人来说Rust 的经验特别有价值有些复杂是必要的但不能以杂乱的方式存在。设计上宁可多花时间学习一套统一的规则体系也不能让使用者面对几百个零散特例。5. 简单性设计中的常见误区与排查技巧5.1 误区一把简化等同于删除我见过不少语言设计新手一开始雄心勃勃要打造全世界最简单的 DSL结果把所有功能都删没了。写出来的东西确实简单但连表达基本业务逻辑都费劲最后用户不得不用字符串拼接各种 hack。这就不是简单是简陋。真正的简化是找到问题的本质然后只保留最直接的表达方式。比如文件读取你需要的是打开文件 - 读取内容/流 - 关闭文件三个步骤。有些语言设计者非要进一步简化成一行readAll(file)看起来方便但遇到超大文件或需要分批读取时就傻了。保留底层能力的同时提供高层便捷接口才是合理的简单。我在设计 DSL 时会先列出用户必须完成的 10 个核心任务然后针对每个任务设计最低成本的表达方式。如果一个特性让这 10 个任务中任何一个变得更绕我就要重新考虑。5.2 误区二为了易学牺牲表达力导致用户绕路有些语言为了让新手快速上手会过度包装把很多底层逻辑藏起来。这种简单在很小规模的示例中很好用但一旦增长到一定规模程序员就不得不寻找各种技巧来绕过语言的限制最终写出更复杂的代码。典型例子是某些可视化编程语言或低代码平台的表达式拖拽积木确实简单但复杂的业务逻辑只能用嵌套防火墙一样的积木堆来表达维护成本极高。设计语言时一定要记住用户总会在某一天遇到复杂问题。语言应该让简单的问题保持简单同时不能把复杂的问题变得不可能。如果为了让 80% 的简单场景更简单而让 20% 的复杂场景变得极其丑陋这门语言在长期项目中会严重拖后腿。5.3 排查技巧如何发现假简单的语言设计在我做语言评估或代码审查时有一组假简单检测清单隐式行为是否过多比如自动调用函数、自动类型转换、字段自动注入。这些行为让示例代码很整洁但调试时你根本不知道发生了什么。错误信息是否比代码还难懂好的语言设计,应当让错误信息直接指向问题的根源。如果编译器给的提示绕弯子说明语义模型与用户心智模型已经脱节。禁忌规则是否只能靠经验一门简单语言里大多数禁忌应该能在语法或编译期被捕获。如果很多坑要等运行到线上才能发现这种语言的简单是虚假的。大多数代码风格是否都需要设计模式来修补如果一个语言里好的实践必须通过一堆设计模式才能达成那它的原始抽象层级可能就不太对这也是不简单的信号。我曾经在一个内部项目的代码里发现大量业务逻辑被塞进了try-catch和if nil的层层嵌套中原因是选型的语言把所有可能失败的操作都设计成返回特殊值。表面看这种没有异常的模型很简单但当每个函数都要手动检查失败标志时代码看起来简洁真实开发中的上下文却非常复杂。最终我们不得不引入类似 Result 包装和更多的辅助函数来对抗这种假简单。5.4 如果设计者不小心把语言搞复杂了怎么自救对于那些已经在演化的语言项目可能是一个公共库的 API 风格也可能是内部 DSL如何逐步回归简单我实际操作过几条路线第一冻结核心把新特性放入扩展包或独立库不污染语言核心。很多语言都用标准库来承载新玩法让核心语言保持稳定简单。如果某些新特性能用库实现就没有必要进入语法层。第二标记废弃路径。把历史遗留的坏设计标记为 deprecated并逐步在工具链中产生警告。这样可以让新代码沿着简单路径走旧代码还有时间迁移。第三重定义场景边界。有时候一个功能看起来复杂是因为它在试图解决所有场景。拉出来单独建模后每个部分都简单了。比如日志系统最初设计成一个万能 logger配置选项极其复杂拆成结构化日志 文本人类可读日志 指标导出三套简单接口后反而更清晰。6. 设计你自己的语言或 DSL 时我的几条实操建议6.1 先确定用户模型再写语法任何语言设计都有一个前置问题用户心中的模型是什么这里的用户是最终写代码的开发者。如果你在设计外部 DSL比如一个配置语言目标是让运维和产品也能读懂那语法就要贴近自然语言关键字应该来自业务领域而不是计算机术语。如果你在设计嵌入式脚本语言给高级程序员用那模型可以更抽象但必须保持一致。我在设计一个部署配置语言时先画了一张用户思维导图用户关心的是服务名、镜像、端口、健康检查、环境变量。基于这个模型我们定义了service、image、port、healthcheck、env这几个关键字并规定每个块的结构。整个语言只有不到十种结构但已经覆盖了 90% 的部署场景新增用户几乎不需要看教程根据直觉就能写出配置。6.2 用最小表达集约束自己设计过程中我强烈建议为语言定义最小表达集即实现各种功能所必需的最小语言特性组合。任何新特性都要问自己它能否用现有最小表达集组合出来如果不能说明这个新特性填补了语言核心能力的空缺值得考虑如果能那它大概率只是语法糖需要非常谨慎最好不加入——语法糖会让不同的人用不同风格写同一件事情反而伤害一致性。比如有些 DSL 为了省事加入unless关键字作为一种反向 if。但完全可以用if not表达同样的逻辑。除非你发现if not在真实场景中会造成大量阅读障碍否则不要为了好看引入第二个条件关键字。小语言最容易在语法糖上失控几个语法糖叠加后你的 DSL 就变成了小 C。6.3 语法占位符要少错误信息要有上下文设计语言时绝大多数人只关注正确代码长什么样却很少考虑错误代码会给出什么提示。我吃过一次亏设计的 DSL 使用了一个全局变量引用未定义时只报了一个变量不存在用户根本不知道变量名是拼错了还是作用域没传对。后来我们花大力气重写了错误信息模块把当前可用变量列表也附在错误提示里用户的上手时间瞬间缩短很多。简单性设计不只是让正确路径简单还要让错误路径也简单。好的错误信息应当像一位有经验的同事指出问题并给出修复方向而不是丢给你一坨内部栈信息。这在语言设计里的重要程度常常被严重低估。6.4 工具链和文档也是语言的一部分很多时候觉得一门语言复杂不是语言本身而是它的工具链实在折磨人。环境安装、依赖管理、构建配置、调试器配置每一项都能耗费大量精力。当你评估一个语言的简单性时务必把完整的工具链体验也算进去。同样的文档的示例质量直接影响语言的学习成本。一门语言语法很简练但官方文档里全是空洞的术语解释没有贴近现实的示例那它依然算不上简单。好的语言文档应该展示典型场景下如何用最直接的方式完成任务而不是堆砌所有 API 的机械说明。7. 用简单性重塑你的编程观从使用者到设计者的视角切换我最初学编程时总觉得碰到复杂问题就应该用更高级的语言特性。后来才慢慢意识到语言的力量不在于特性多而在于约束得当。如果你常用一门语言花点时间了解一下它的设计哲学和演进史能帮你更好地驾驭它。你会理解哪些写法是顺着语言的设计思路在走哪些是在跟语言打架。我们选语言时不应该只看能否写出来而要看长期维护时一个普通水平的团队能否依然写明白。在这个标准下简单性的权重通常要高于表达力。这也是我在团队内部做技术选型时最常引用的一句话设计语言时要假设使用它的程序员都很忙碌、容易分心、会遗忘、会犯错——然后把容易导致这些错误的可能性从语法和语义里摘除。我至今仍然保持一个小习惯每接触一门新语言先不看语法教程而是直接看它的语言规范目录或者官方 FAQ。如果目录中频繁出现特殊情况下、高级用法、兼容历史行为这样的表述我就会格外警觉。这倒不是要放弃学习它而是提醒自己使用该语言时要多定义约束、多加测试以免被表面的简单欺骗。最近一次我在评估一个新的内部配置语言方案时团队里有个成员提议引入一种类似模板字符串的语法因为他觉得能省到处字符串连接的麻烦。我们一起梳理了必须支持的各种转义、嵌套引号、表达式中能否调用函数、是否允许换行等边界规则后发现这一便利会让词法解析复杂程度直接翻倍。最终我们放弃了这个特性继续用普通字符串 简单拼接结果用户只多敲了几个字符却避免了大量让配置解析器陷入歧义的场景。这就是我理解的尽可能地简单不是少做事而是把力气花在降低后续所有使用者的负担上。语言设计如此写代码也是如此。每当我面临要不要加一个方便的功能的选择时我都会下意识地追问一句这会导致另一个开发者在一个月后的凌晨两点对着编译器报错发呆吗如果答案是可能那就再想想有没有更简单的方案。如果你正准备设计语言或 DSL或正在选择下一门主力语言我最后给出一个实在的建议去读一下 Go 语言设计者关于少即是多的演讲再看看 Lisp 社区的宣言然后在纸上写下你用这门语言最想解决的几个实际问题。如果这门语言能让这些问题用最直接的方式表达出来且不会诱导你做复杂且毫无必要的抽象那就大胆选它。语言设计真正的高手不是能设计出多雄伟的语法大厦的人而是能设计出让你忘了语言本身、专心表达问题的人。
返回列表