编程语言篇
一句话概述:程序设计语言本身也是一个软件,学习它要从模型、接口、实现三个维度理解,而DSL(领域特定语言) 则是将设计做到极致的体现——好的设计应该迈向 DSL。
知识图谱
图注:编程语言篇四大主题——语言的模型(发展史与本质)、接口(语法与程序库)、实现(运行时)、DSL(设计的极致)。
3.1 语言的模型:编程模型的演进
章节概述
程序设计语言之间并没有泾渭分明的界限,开发者唯有通过学习多门语言,才能打破语言的局限,使设计更好地落地。学习程序设计语言,本质上是学习语言提供的编程模型——不同的编程模型会带来不同的思考方式。本节从程序设计语言的发展历程出发,揭示编程模型演进的脉络,并阐明"一切语法都是语法糖"这一核心认知。
打破单一语言的局限
许多开发者在工作中会面临如下困境:
- 面向对象用于组织程序结构效果良好,但项目采用 C 语言开发;
- 项目使用 C++ 开发,函数式编程的优势似乎与之无关;
- 动态语言的特性具有吸引力,但项目采用 Java 开发;
- ……
上述思维方式表明开发者受限于自身熟悉的技术栈,即便接触到更优的方案,也难以有效应用。实际上,程序设计语言之间的界限并非泾渭分明,可根据项目特点选择合适的语言,亦可参考其他语言的优秀特性。Andrew Hunt 和 David Thomas 在《程序员修炼之道》(The Pragmatic Programmer)中提出了一项重要建议:每年至少学习一门新语言。
语言种类繁多,是否需要逐一学习?学习语言究竟是在学什么?程序设计语言本身也是一种软件,同样包含模型、接口和实现三个维度。而学习程序设计语言的核心目的在于掌握其提供的编程模型,例如不同的程序组织方式、不同的控制结构等。因为不同的编程模型会带来不同的思维方式。
程序设计语言发展简史
图灵机:所有语言的底层模型
当今的程序设计语言均为图灵完备。"图灵完备"指的是语言所定义的数据操作规则能够实现图灵机的全部功能(图灵机的概念由阿兰·图灵提出,为计算机可求解问题划定了边界)。因此,图灵机是所有程序设计语言最底层的模型,所有程序设计语言均在此基础上发展而来,包括众所周知的计算机基础——用 0 和 1 进行编码。
汇编语言
当前计算机所能识别的均为 0 和 1 序列,但直接以 0 和 1 编写代码的开发者极少,因为操作效率极低。早在计算机诞生之初,汇编语言便已出现,它可将二进制操作符转换为易于记忆的 ADD、MOV 等指令。
相较于二进制串,汇编语言虽有所改进,但开发者很快发现,用汇编语言编写程序仍然十分困难,因为这要求开发者对计算机硬件有深入的理解。更为棘手的是,即使熟练掌握了某种计算机的汇编语言,一旦更换硬件平台,也必须重新学习对应的汇编指令集。
高级语言的诞生
在此背景下,高级程序设计语言应运而生。
第一门被广泛使用的高级程序设计语言是 Fortran,它为程序设计语言的发展奠定了基础。例如,基本控制结构开始出现,数据开始拥有类型(类型即对内存数据的一种解释方式)。虽然这些特性在当今看来较为基础,但与同时期使用的汇编语言相比,标志着重要进步。
Fortran 对计算机科学的发展起到了巨大的推动作用,人们也逐渐认识到高级程序设计语言对提升开发效率的价值。此后,各类高级程序设计语言相继问世,持续探索如何编写高质量的程序。
C语言:硬件的恰当抽象
早期程序设计语言探索的集大成者即 C语言,它提供了面向计算机最为恰当的抽象层,屏蔽了计算机硬件的大量细节。时至今日,C语言依然拥有广泛的应用群体。
C++:面向对象进入主流
随着高级程序设计语言的发展,技术门槛逐步降低,可开发的程序规模也随之扩大。此时,如何组织程序结构成为新的挑战。有一种语言借助 C 语言的影响力,将面向对象的程序设计风格带入主流视野,即 C++。在相当长的时期内,C++ 成为行业主流选择,既兼容 C 语言,又提供了良好的程序组织方式。
Java:内存管理的突破
尽管各类高级程序设计语言已经屏蔽了大量底层细节,但有一个核心问题始终未能得到妥善解决,并由此引发了诸多衍生问题,即内存管理。业界早已尝试多种内存管理抽象方案,但受限于早期计算机硬件的性能约束,均未成为行业主流方案。
随着计算机硬件性能的大幅提升,此前因性能限制而搁置的技术方案重新焕发活力。该阶段的代表性成果是 Java:一方面支持面向对象编程;另一方面引入了垃圾回收机制——一种自动化的内存管理方案。
Java 的发展历程亦非一帆风顺,因其早期在个人计算平台上的尝试并未取得显著成功。此后转向企业级开发领域,才得以发挥自身优势。由于企业级服务器在性能上优于个人计算机,对 Java 的运行开销具有更高的容忍度,Java 因此获得了持续优化和发展的机会。
函数式编程:从边缘走向主流
当硬件不再是程序设计语言发展的制约因素后,程序设计语言应如何进一步发展?从前述历程可以看出,程序设计语言的发展本质上是一个"逐步远离计算机硬件、向待解决的问题域靠近"的过程。因此,程序设计语言接下来的发展方向在于探索如何更有效地解决问题。
前述内容描述的是程序设计语言发展的主流路径,事实上,还存在一条并行发展的路径,即函数式编程语言,其代表语言为 LISP。
在该路径上,早期研究者多具有学术背景,其研究重点在于解决方案的优雅性,换言之,关注如何解决问题以及如何逐层构建抽象。研究者们还进行了更多探索,垃圾回收机制即源于此路径。然而,受限于当时的硬件性能水平,该路径上的探索在很长一段时间内仅属于小众研究领域。
当硬件性能不再构成瓶颈、问题解决能力变得愈发重要时,函数式编程终于与程序设计语言的主流发展方向汇合。促进函数式编程引起广泛重视的另一硬件因素是多核处理器的出现。
多核处理器的出现本身是 IT 行业应对 CPU 性能增长瓶颈的一项解决方案,但它打破了众多开发者仅利用单核 CPU 编程的传统模式。
为充分利用多核计算优势,业界探索了多种方案,当前所见到的各类并发模型、异步模型等解决方案均从那时起得到了蓬勃发展。函数式编程在该领域的贡献在于利用声明式的表达方式屏蔽了底层硬件差异。而让业界广泛关注函数式编程价值的标志性事件是 MapReduce 的提出。
函数式编程的兴起带动了相关技术社区的发展,例如声明式编程、DSL、元编程等。一些新兴程序设计语言开始融合面向对象与函数式编程两种范式,如 Scala。而 Java 和 C++ 等"成熟语言"则逐步将函数式编程支持纳入语言特性之中。
动态语言:从边缘走上舞台
相较于上述"主流语言",还有一股力量逐渐从边缘走向舞台中央,即动态语言,代表语言包括 Perl、Python、Ruby、PHP 等。此前,这类程序设计语言常被称为"脚本语言",这一命名表明其最初定位是用于快速解决特定问题。因此,在业界认知中,它们显得不够正式。但其简单、轻量的特性有效降低了入门门槛,也吸引了大量使用者。
语言的发展是一个相互学习和参考的过程。此前,动态语言的短板在于不适用于大规模工程开发。近年来,随着动态语言用户群体的扩大,配套工具生态逐渐完善,动态语言项目的规模也逐步增大。与此同时,主流程序设计语言也开始借鉴动态语言的优势,致力于降低代码编写的复杂度,例如 Java 和 C++ 均开始支持类型推演(Type Inference),目的在于减少开发者的编码工作量。
程序设计语言发展脉络
图注:语言发展是一个逐渐远离硬件、靠近问题域的过程,同时各种范式相互借鉴、融合。
编程模型进化树
图注:主流语言的发展谱系——两条主线(命令式→面向对象、函数式)逐步融合,新语言尝试新的编程模型。
一切语法都是语法糖
至此,可以更好地理解前述论断:学习程序设计语言实质上是学习语言提供的编程模型。
以若干程序设计语言为例:
- C语言提供了对汇编指令的直接封装。
- C++ 先是提供了面向对象特性,后续又引入了泛型编程。
- Java 将内存管理从开发者的日常工作中剥离,后续引入的 Annotation 支持声明式编程。
- Ruby 提供了动态类型特性,以及由 Ruby on Rails 引导出的 DSL 风格。
- Scala 和 Clojure 提供了函数式编程能力。
- Rust 提供了全新的内存管理方式,而 Libra 提供的 Move 语言则将其进一步抽象为资源概念。
既然学习新程序设计语言的目的在于掌握新的编程模型,反之亦然:不提供新编程模型的语言不值得刻意学习。若已掌握一两门程序设计语言,学习一门新语言并不困难,因为每种语言提供的新模型数量有限,基本元素相似,差异主要体现在关键字层面。
因此,学习新语言属于增量式学习,思维负担相对有限。一旦对程序设计语言的模型有了深入认知,即可理解一个重要观点:一切语法都是语法糖。
语法糖(Syntactic sugar)是英国计算机科学家彼得·兰丁提出的术语,指那些为方便开发者使用而设计的语法特性,其对语言的功能并无实质性影响。
理解了语法糖的概念后,若要深入理解程序设计语言,一种有效的方法是解析语法糖的实现机制:
- 类型是对内存数据的解释方式。
- class/struct 是将有相关性的数据进行聚合的数据组织方式。
- Groovy、Scala、Kotlin、Clojure 等 JVM 平台上的新语言,提供了不同于 Java 的 JVM 封装方式。
通过前述介绍可以看出,语言的发展并非一蹴而就,而是渐进式演进的过程。一些创新尝试往往在不经意间涌现,且不同语言之间也在相互参考和借鉴。
若能坚持每年学习一门新语言,初期可了解不同的编程模型。当积累达到一定量级后,学习新语言即相当于跟踪程序设计语言的最新发展趋势。当掌握了足够多的编程模型后,即可拓展思路,运用多样化的方式解决问题,甚至将其他语言的优秀特性应用于当前使用的语言中。
"一切语法都是语法糖" 的实例
| 语法 | 底层本质 | 说明 |
|---|---|---|
class / struct | 将有相关性的数据存放到一起 | 数据组织方式 |
| 类型(int / String) | 对内存数据的解释方式 | 类型=内存解释 |
new | 内存分配 + 构造函数调用 | C 中用 malloc + 初始化模拟 |
synchronized | 锁的语法封装 | JDK 5 并发库提供了更细粒度的替代 |
| Lambda | 匿名内部类的语法简化 | 编译器做类型推演 |
| Annotation | 元数据的声明方式 | 程序库在运行时通过反射读取 |
核心要点
- 学习程序设计语言,本质上是学习语言提供的编程模型,不同的编程模型带来不同的思考方式。
- 程序设计语言的发展是一个"逐步远离计算机硬件,向着待解决的问题靠近"的过程。
- 函数式编程从边缘走向主流,动态语言与静态语言相互借鉴,各种范式逐步融合。
- 一切语法都是语法糖——理解语法背后的实现,才能真正掌握语言。
- 不提供新编程模型的语言不值得刻意学习。
- 每年至少学习一门能够提供新编程模型的程序设计语言。
3.2 语言的接口:语法与程序库
章节概述
程序设计语言的接口包含语法(Syntax)和程序库(Library)两部分。语法是开发者最为熟悉的接口,而程序库则是常被忽视但同样重要的接口。本节从程序库的起源出发,揭示语法与程序库之间的相互促进关系,阐明"语言设计就是程序库设计,程序库设计就是语言设计"这一核心论断,并指出编写程序库是提升软件设计能力的有效途径。
消除重复的程序库
进行程序开发时,只要项目规模稍有增大,就会发现相同的模式频繁出现,差异仅体现在部分参数上,此即为代码重复。
最初的重复体现为指令级别的重复,开发者会将相同的指令序列封装在一起,通过传入不同参数加以区分。这就是函数的概念。函数已成为当今主流的编程方式,是几乎所有程序设计语言的基础设施,其起源已鲜为人知。
程序开发的一项主要日常工作即定义各种函数。一旦定义了大量函数,便会发现其中不少函数不仅适用于当前项目,在其他项目中同样适用。此时,作为追求高效的开发者,会将这些跨项目通用的部分抽取出来,组成独立的模块,此即为程序库的来源。
因此,程序库是为消除代码重复而产生的。而消除重复,也是软件设计的初衷。
程序库(Library)是开发者最为熟悉的内容之一。学习一门新语言,首先是学习语法(Syntax),其次是学习程序库(Library),然后是学习运行时(Runtime),至此即已具备该语言的基础能力。后续需要了解的内容包括各种惯用法(Idiom),以及如何将其应用于实际工作场景。
部分程序库的使用频率极高,因而随语言一同发布,成为标准库。例如,开发者熟知的第一个程序"Hello, world"的做法源自《C程序设计语言》,其中使用的 printf 即来自 C 标准库。再例如,Java 开发者无人不知的 JDK 中包含了大量的程序库。
当然,若在实际工作中仅使用标准库,部分代码的编写仍会较为繁琐,因为标准库提供的能力通常较为基础。此时,需要利用更多的第三方程序库,它们提供了更丰富的功能选项,以弥补标准库的不足。
换言之,第三方库会在标准库的基础上进行二次封装,提供新的编程模型或接口,甚至修正标准库的部分缺陷,从而降低开发复杂度。只要拥有足够用户规模的语言,在第三方库生态方面通常都表现良好,能够提供大量第三方库供选择。
正是由于第三方库的兴起,如何管理第三方库成为一个亟待解决的问题。目前这已成为行业共性问题,并形成了标准化的解决方案——包管理器。多种语言均已配备包管理器,如 Java 的 Maven、Node 的 NPM、Ruby 的 RubyGems 等。而像 Rust 这类较新的语言,包管理器甚至已纳入语言发行包的标准组件。
语言设计就是程序库设计
虽然程序库受限于特定的程序设计语言,但其表达的设计思想并不受语言边界限制。
new:从程序库模式到语法
软件开发中最常见的操作之一即为初始化。若采用 C 这类早期的语言,常规做法是分配一块内存,随后对该内存区域赋值:
// C语言中的初始化模式
malloc(sizeof(struct));
// 然后对内存进行赋值此类代码反复出现,形成固定的编程模式。
部分新兴语言直接将内存分配与初始化合并为一项固定的语法,这就是开发者熟知的 new。无论是 C++ 开发者还是 Java 开发者,对 new 均不会感到陌生。
调用 new 时会发生什么?首先,它会申请一块内存空间,然后调用对应类的构造函数。类与构造函数这些概念在 C 语言中并不存在,但这种初始化模式与 C 语言一脉相承。
区别在于,在 C 语言中,该操作通常通过程序库实现;而在 C++ 和 Java 中,它成为了语法的一部分。一旦成为语法,即成为语言本身的组成部分,形成特定的编程模型。
对于使用者而言,该模型表现为一个接口,只要接口行为保持不变,调用方代码无需修改。但对于接口另一侧的实现者而言,即可在此基础上进行特定优化。例如,插入不同的内存分配算法,这正是 C++ 的 allocator 所完成的工作;又如,实现对内存的完全管控,这正是 Java 所采取的方式。Java 之所以能够让开发者忽略内存管理的细节,new 发挥了关键作用。
一个经过验证的编程模式最终成为语言的一部分,而其起点仅为一种常见的用法:一个程序库。
synchronized:从并发概念到语法
再以 Java 中的 synchronized 为例。并发编程是开发者的必修课程。学习并发编程,一方面需要掌握各类概念,如"锁";另一方面还需学习各语言对应的程序库。鉴于此类概念的使用频率极高,Java 直接将其固化为语法,即 synchronized。
成为语法虽具有里程碑意义,但在某些场景下,语法的刚性反而会成为限制。此时,程序库再次发挥作用。
程序库设计就是语言设计
new 的局限与工厂模式
new 虽然解决了部分问题,但与其配合使用的构造函数存在一个固有缺陷:构造函数只能使用类的名称作为标识。
当需要表达多种不同的构造逻辑时,各种解决方案随之出现。部分开发者利用重载(overload)来解决问题,通过不同类型的参数区分不同的构造逻辑。例如,一个参数使用 HashMap,另一个使用 TreeMap。作为新加入项目的开发者,很难意识到这是两种不同的构造逻辑,而这些参数类型与数据结构本身并无关联。
一种更优的解决方案是利用工厂模式,即使用语义更加明确的函数替代构造函数作为对象创建入口。
仍以 Java 为例,ArrayList 是 Java 开发者熟知的数据结构。若要创建一个包含两个元素的 ArrayList,同时还要创建一个初始容量为 10 的 ArrayList。采用 JDK 原生方式:
// 创建有两个元素的数组
ArrayList<String> listWithElements = new ArrayList();
listWithElements.add("foo");
listWithElements.add("bar");// 创建一个初始容量为10的数组
ArrayList<String> listWithCapacity = new ArrayList(10);上述做法虽然可行,但代码的可读性和表意性不佳。Google 的 Guava 程序库针对相同场景提供了另一种实现方式:
ArrayList<String> listWithElements = newArrayList("foo", "bar");
ArrayList<String> listWithCapacity = newArrayListWithCapacity(10);显然,从语义角度而言,Guava 的方案更加清晰。Java 领域的经典著作《Effective Java》(第三版)的首个条款即为"用静态工厂方法代替构造函数",讨论的正是此种实践。
synchronized 的局限与并发库
再看 synchronized。虽然它解决了部分并发问题,但该方案的粒度过粗。开发者只要稍作深入研究,便会感受到 synchronized 的局限性。于是,Java 开发团队在 Java 5 中开启了新一轮编程模型的探索,其成果即为此后的并发编程库。
解决同一问题,既可采用语法层面的方案,也可采用程序库层面的方案,二者在功能上是等价的。
Andrew Koenig 和 Barbara Moo 合著的《C++沉思录》记录了 C++ 早期开发者在设计各项语言特性时的思考过程,这是一本关于编程思想的著作。书中两章的标题颇具深意,分别为**"语言设计就是程序库设计"和"程序库设计就是语言设计"**。
由于语法和程序库旨在解决同一问题,二者之间存在相互促进的关系。通常是先有程序库实践,再有语法固化;若语法无法满足需求,新的程序库便会出现,新一轮的编程模型随即开始孵化。
一切具有生命力的语言都会持续优化自身的语法,部分成熟的程序库会被纳入语言规范,成为正式语法特性。例如,Java 引入 Lambda 以支持函数式编程;C++ 引入类型推演以简化代码编写。
同理,程序库的发展也在推动语言的持续进步,部分语法特性的存在正是为了让程序库表现得更加强大。例如:
- C 语言中的宏,虽然多数开发者用它来定义常量,但只有在程序库设计中才能充分发挥其价值;
- Java 中的 Annotation 虽被广泛使用,但将其应用于系统设计的案例较少,因为其主要使用场景集中于程序库开发;
- Scala 中的隐式转换,若未曾设计过 DSL,很难理解其具体作用。
综上所述,程序设计语言的接口不仅包含语法,还包括程序库。此外,在学习某程序设计语言所提供的模型时,不仅要关注语法本身,还应了解基于语言特性构建的程序库。
因此,对于开发者而言,若希望提升自身的编程水平,学习编写程序库是一条有效的路径。一方面可以锻炼从日常工作中识别重复模式的能力;另一方面可以更深入地理解程序设计语言所提供的能力。
语法与程序库的互相促进
图注:语法和程序库的循环促进关系——成熟的程序库固化为语法,语法的局限性又催生新的程序库。
编写程序库 vs 编写应用
| 维度 | 应用开发 | 程序库开发 |
|---|---|---|
| 关注点 | 业务功能实现 | 通用性、复用性 |
| 接口设计 | 业务语义接口 | 简洁、通用、表达性强 |
| 错误处理 | 业务异常处理 | 考虑所有边界情况 |
| 文档需求 | 相对较低 | 必须完善 |
| 设计能力要求 | 中等 | 高——锻炼软件设计能力 |
核心要点
- 程序库的原始动机是消除重复:函数 → 模块 → 程序库 → 标准库 → 第三方库。
- 经过验证的程序库模式会"转正"成为语法(如
new、synchronized、Lambda)。 - 语法不够灵活时,新的程序库又会探索更好的解决方案。
- 语言设计就是程序库设计,程序库设计就是语言设计——二者在解决同一问题,相互促进、不断演进。
- 编写程序库是提升软件设计能力的发力点。
- 包管理器(Maven、NPM、RubyGems)解决了第三方库管理问题。
3.3 语言的运行时:软件设计的地基
章节概述
程序设计语言的实现即支撑程序运行的部分:运行时(Runtime),也称运行时系统或运行时环境,主要用于实现程序设计语言的执行模型。相较于语法和程序库,开发者在学习语言的过程中对运行时的关注度较低,因为不理解语言的实现细节并不影响程序的编写。然而,运行时是软件设计真正的地基——理解运行时,甚至能够实现程序设计语言本身不支持的设计。本节以 JVM 为例,阐述如何通过"程序如何运行"这条主线理解运行时,以及运行时编程接口如何突破语言限制。
运行时:设计的真正地基
软件设计的地基难道不是程序设计语言本身吗?实则不然,部分基础性设计仅停留在语言层面是不够充分的。
以一个实际项目为例:在 JVM 上运行 Ruby。这种行为显然超出了 Java 语言的能力范围。为实现 Ruby 在 JVM 上的运行,需将 Ruby 代码编译为 Java 字节码,而字节码属于运行时的组成部分。
软件设计的真正地基并非程序设计语言,而是运行时。具备了对运行时的深入理解,甚至能够实现语言本身不支持的设计。 此外,理解运行时有助于成为一名更优秀的开发者,真正做到对所编写的代码了然于胸。
运行时相关知识在很长一段时间内并未得到充分重视,初学编程阶段的相关资料较为匮乏。近年来,该状况已有明显改善,各类程序设计语言运行时的资料日益丰富。尤其在 Java 社区,JVM 相关知识已成为许多开发者技术面试的重要组成部分。JVM 即是一种典型的运行时。
程序如何运行
首先需要明确一点:对于大多数开发者而言,学习运行时的目的并非成为运行时系统的开发者,而是为了更深入地理解自己所编写程序的执行机制。
运行时的知识体系庞大,而**"程序如何运行"**本身就是一条贯穿各知识点的主线。程序能够运行的前提条件是其为一个可执行文件,此处即从可执行文件展开论述。
可执行文件结构
一般而言,可执行程序均具有特定的文件结构,对应到 JVM 上即为类文件(Class File)的结构。
加载机制
可执行程序要执行,需要由加载器将其加载至内存中,这就是 JVM 类加载器的职责。
加载是一个过程,加载的结果是根据程序运行的需求,将已加载的程序放置到相应的内存区域。这就需要了解 JVM 的内存布局,例如程序动态申请的内存位于堆(Heap)上,为支持方法调用需要有栈(Stack),还需要有区域存放已加载的程序,如方法区(Method Area)等。
内存布局
图注:JVM 内存布局的核心区域——堆、栈、方法区、程序计数器。
指令执行
至此,程序完成了加载,做好了运行的准备,但这仅涉及静态内容。接下来需要了解程序在运行过程中的行为。一般而言,执行过程即设置好程序计数器(Program Counter,PC),然后按照指令序列逐条执行。因此,重点在于理解这些指令的具体功能。
在 Java 中,程序会被编译为字节码。对于 Java 而言,字节码相当于汇编语言,其中的指令构成了 Java 程序执行的基础。因此,突破口在于理解指令的执行机制。
事实上,大部分 JVM 指令的理解门槛较低,尤其是在了解了内存布局之后。例如,加法指令即在栈上取出两个操作数,相加后将结果放回栈中。
值得特别说明的是一个看似简单的指令,它是连接内存管理的关键纽带,即 new——创建对象的指令。内存管理机制(即众多开发者熟悉的 GC)是一个庞大的主题,若展开论述,将构成一个完整的知识体系。
具备了指令的理解能力,即对 Java 程序的执行有了基本的认识。剩余的工作可根据具体需求,深入探究被语法和程序库所隐藏的实现细节。例如,synchronized 的实现原理,沿着该线索可延伸至内存模型(Java Memory Model,JMM)。
当然,本节内容的目的并非详细讨论 JVM 的实现细节。无论哪个知识点,实际上均包含大量细节。此处仅以 JVM 为例进行讲解,学习其他语言的运行时可采用类似方法,带着"程序如何运行"这一问题去理解即可。需要注意的是,不同语言的执行模型有所差异,所需了解的内容也不尽相同。例如,理解 C 语言的运行时需要掌握更多计算机硬件的特性,而理解动态语言的运行时则需要对语法树(AST)的结构有一定认识。
具备了对运行时的理解,即可将其他语言的优秀编程模型应用于当前使用的语言中。例如,在使用 C 语言编程时可以实现多态,具体做法是实现虚拟表(vtable),这也是面向对象语言实现多态的一种典型方案。
JVM 运行时全景图
图注:以 JVM 为例的运行时全景——从编译到加载到运行,以及运行时提供的编程接口。
运行时的编程接口
前文已述,运行时是软件设计的地基,那么如何在运行时之上构建设计呢?这依赖于运行时所提供的编程接口。因此,学习运行时除了要理解运行时本身的机制外,还需要掌握运行时提供的编程接口。
反射:运行时类型识别
在 Java 中,最基础的运行时接口即运行时类型识别能力,也就是开发者熟知的 getClass。通过该接口可获取类的信息,部分程序库的实现会利用类自身声明的信息。例如,有些程序库利用 Annotation 进行声明式编程,此类程序库往往在运行过程中以 getClass 为入口进行一系列操作,提取 Annotation 并做相应处理。
动态代理
除上述接口外,还有大量接口以标准库的形式提供,例如动态代理。通过阅读 JDK 文档,即可学会如何运用这些能力。
字节码操控
另有部分接口以规范的形式提供,需要对 JVM 有更深入的理解才能运用自如,例如字节码操作。
前文提到,通过了解指令的执行方式,可以帮助更好地理解运行时机制。具备这些基础后,再来学习字节码,理解的门槛将大幅降低。
若从字节码的角度思考问题,甚至可以创造出 Java 语言层面未提供的能力。例如,部分程序库为 Java 扩展了 AOP(Aspect-Oriented Programming,面向切面编程)能力。如此一来,编程能力的极限不再受限于语言本身,而是取决于字节码的能力边界。
以 Java 7 发布为例,字节码定义了 InvokeDynamic 新指令,当时语言层面并未提供任何对应语法。若有需要,可自行编写字节码使用该指令。JRuby、Jython、Groovy 等 基于 JVM 的语言均可利用该指令优化运行时实现。当然,InvokeDynamic 的诞生初衷即是为了在 JVM 上更好地支持动态语言。
值得注意的是,字节码操控的技术门槛正在逐步降低。最初,字节码操作被视为一项高深的技术,在许多开发者看来,这是仅有 SUN 工程师(当时 Java 归属 SUN 公司)才能胜任的工作。
此后,出现了名为 ASM 的程序库,将字节码操作带入了普通开发者的视野,越来越多的开发者开始具备操作字节码的能力。不过,使用 ASM 仍需理解类文件的结构,操作起来仍有一定复杂度。后来又出现了各种基于 ASM 的改进方案,目前应用较广的是 ByteBuddy。
具备了对字节码的了解,即使在 Java 这种静态类型语言上,也可以实现动态语言的效果。例如,Java 的一些 Mock 框架为何只需声明接口即可执行,原因在于其背后通常会动态生成类。
动态语言的运行时接口
部分动态语言为支持其动态特性,也为开发者提供了运行时接口。例如,Ruby 中著名的 method_missing,许多框架利用该方法实现了特殊效果,即使未定义的方法也能够被执行。Ruby on Rails 中的各类 find_by 方法即可通过该机制实现。
method_missing 实质上是一个回调方法,当运行时在进行方法查找时,若找不到对应方法,则会调用语言层面的该方法。这是运行时与语言协同工作的产物。若能对方法查找机制有更具体的理解,使用时即可更加得心应手,从而实现出色的设计方案。
运行时编程接口:突破语言限制
图注:理解运行时后,语言的语法限制不再构成设计的边界——运行时提供了更大的能力空间。
核心要点
- 做设计的地基不是语言语法,而是运行时(JVM、CLR、V8 等)。
- 学习运行时的主线:可执行文件结构 → 加载机制 → 内存布局 → 指令执行。
- 运行时提供的编程接口使开发者拥有超越语言本身的能力。
- 字节码操控(ASM、ByteBuddy)使 Java 能够实现动态语言的效果。
- 理解运行时,才能真正做到对代码了然于胸。
- 将运行时作为设计的地基,不受限于语言本身。
3.4 DSL:领域特定语言
章节概述
程序设计语言的发展趋势是离计算机本身越来越远,而离待解决的问题越来越近。但通用程序设计语言无论怎样逼近问题域,都不可能无限接近具体问题,因为通用程序设计语言无法预知具体的问题是什么。这为具体问题的解决留下了空间——若将设计做到极致,即可使其成为一门语言,填补这一空间。注意,此处并非比喻,而是指真正成为一门解决特定问题的语言。这种语言即领域特定语言(Domain Specific Language,简称 DSL)。本节阐述 DSL 的概念、分类、实现方式,以及内部 DSL 所体现的"意图与实现分离"这一核心设计思想。
领域特定语言
领域特定语言(Domain Specific Language,简称 DSL)是一种面向特定领域的程序设计语言。其"特定于某个领域"的特性是相对于通用语言而言的,通用语言可跨越多个领域,大多数开发者熟悉的程序设计语言均属于通用语言。
通用语言均为图灵完备的,但 DSL 无须满足图灵完备的要求,只要能够满足特定领域的业务需求,即足以缩短问题与解决方案之间的距离,降低理解门槛。
虽然大多数开发者并不会真正实现一个通用程序设计语言,但实现一个 DSL 是完全可行的。
DSL 并不遥远
提及设计一门语言,许多人会产生畏难情绪。但实际上,在各种场景中可能已经接触过不同的 DSL。开发者最为熟悉的 DSL 即正则表达式——即便是习惯使用正则表达式的开发者也可能未意识到,但它确实是一种 DSL,一种面向文本处理领域的 DSL。
若认为正则表达式较为复杂,还有一种更为简单的 DSL,即配置文件。配置文件可能不被视为一种 DSL,但它确实在实现特定领域的需求,并且可根据需求定制软件的行为。
一个典型的例子是 Nginx。无论将其用作 Web 服务器、反向代理还是负载均衡器,均可通过 Nginx 配置文件实现相应功能。配合 OpenResty,甚至可以完成部分业务功能。
由此可见,DSL 的门槛并不像听起来那样高。
DSL 的核心:模型而非语法
经过前面几节的阐述,应当已知语法仅是一种接口。许多人提及设计 DSL 时,实际上考虑的仅仅是设计一种语法。因此,从软件设计的角度看,DSL 最终呈现出的语法仅是一种接口,其核心在于所包裹的模型。
Martin Fowler 在其著作《领域特定语言》中将该模型称为语义模型(Semantic Model)。不过,Martin Fowler 采用该命名是从语言开发者的角度出发,毕竟"语义"一词只有学习过编译原理的人才易于理解。因此,此处真正的重点是模型本身。
实现一个 DSL 时,DSL 语法的优先级次于模型,模型才是首要考量。 具备了模型之后,所谓的构建 DSL 即相当于设计一个接口,将模型的能力暴露出来。
将 DSL 理解为接口后,接受 DSL 的心理负担将大幅降低。它与开发者熟悉的 REST API 本质上并无差异。
DSL 的分类
既然是接口,其形式便可多样化。常见的 DSL 主要分为两类:外部 DSL 和内部 DSL。Martin Fowler 在其著作中还提到了语言工作台(Language Workbench),但在实际工作中应用较少,暂不作讨论。
外部 DSL 与内部 DSL 的区别在于 DSL 是否采用宿主语言(Host Language)。可以这样理解:假设模型主要使用 Java 实现,若 DSL 使用 Java 语言表达,则为内部 DSL;若 DSL 不使用 Java,例如自定义了一种语法,则为外部 DSL。
外部 DSL
明确了上述概念后,相关问题即可迎刃而解。这也可以解释为何 DSL 让部分人产生畏惧心理——提起 DSL,有些人联想到的是需要自行设计语法的外部 DSL。事实上,即使是外部 DSL,也可选择不自行设计语法,甚至可以借助已有的语法来实现。例如,许多开发者熟悉的 XML 语法。
对于 Java 开发者而言,XML 再熟悉不过。从 Ant 到 Maven,从 Servlet 到 Spring,XML 曾几乎无处不在。若考察一些使用 Ant 作为构建工具的项目,一旦项目规模较大,其 XML 配置文件的复杂程度不亚于普通源代码。
这是因为其本质上就是一种面向构建领域的 DSL,只不过采用的是 XML 语法。正是由于此类 DSL 日趋复杂,一种新的趋势逐渐兴起,即使用全功能语言(也就是真正的程序设计语言)来实现 DSL,这也是 Gradle 这类构建工具逐渐流行的原因——它们以内部 DSL 替代了外部 DSL。
内部 DSL
从复杂度角度分析,自行设计外部 DSL 语法的复杂度高于利用现有语法实现外部 DSL,二者之间的差异在于解析器的开发责任归属。而外部 DSL 的复杂度又高于内部 DSL,因为内部 DSL 省略了解析过程。从实用性角度出发,深入挖掘内部 DSL 的潜力对实际工作具有更大的价值。
内部 DSL 听起来就是一个程序库。这种理解是正确的。前文已述,语言设计就是程序库设计,程序库设计就是语言设计。当一个程序库仅适用于某个特定领域时,它就是一个内部 DSL,该内部 DSL 的语法即为此程序库的使用方式。
DSL 分类与复杂度
图注:DSL 按实现方式分类,内部 DSL 开发成本最低,实用性最强。
代码的表达性:意图与实现的分离
先通过一个示例感受内部 DSL 的风格,该示例来自 Martin Fowler 的《领域特定语言》。若要创建一个 Computer 实例,采用普通风格的代码如下所示:
Processor p = new Processor(2, 2500, Processor.Type.i386);
Disk d1 = new Disk(150, Disk.UNKNOWN_SPEED, null);
Disk d2 = new Disk(75, 7200, Disk.Interface.SATA);
return new Computer(p, d1, d2);而采用内部 DSL 风格编写,则呈现为以下形式:
computer()
.processor()
.cores(2)
.speed(2500)
.i386()
.disk()
.size(150)
.disk()
.size(75)
.speed(7200)
.sata()
.end();若这是一段普通的 Java 代码,看到一连串的方法调用,定会认为这段代码质量极差!但在该场景下,与前述代码相比,这段代码省去了大量变量声明,反而更加清晰。二者的差别何在?
之所以认为这种链式方法调用是可以接受的,一个重要原因是该段代码并非在描述动作,而是在进行声明。动作描述的是"怎么做"(How),而声明式代码表达的是"做什么"(What)。
二者的抽象层次不同,"怎么做"属于实现层面,而"做什么"体现的是意图。将意图与实现分离,是内部 DSL 与普通程序代码的重要区别,同时也是衡量设计质量的重要因素。
内部 DSL:意图与实现的分离
图注:同样的功能,内部 DSL 风格省去变量声明,直接表达意图——代码即是文档。
DSL 的四个关键元素
Martin Fowler 在讨论 DSL 定义时,提出了 DSL 的 4 个关键元素:
| 元素 | 含义 | 关键点 |
|---|---|---|
| 计算机程序设计语言 | 必须具备编程语言的基本能力 | 可执行的 |
| 语言性(Language nature) | 有连贯的表达能力 | 重点:体现意图 |
| 受限的表达性 | 不做通用语言的事 | 限定在特定领域 |
| 针对领域 | 解决特定领域的问题 | 领域聚焦 |
其中,语言性强调的是 DSL 应具备连贯的表达能力。换言之,在设计 DSL 时,核心目标是体现意图。撇开是否要实现一个 DSL 不谈,开发者在编写代码时应关注代码的表达能力,而这恰恰是许多开发者忽视的方面,同时也是优秀开发者与普通开发者拉开差距的关键所在。
普通开发者的关注点仅在于功能的实现方式,而优秀的开发者懂得将不同层次的代码分离,将意图与实现分离,从而使得实现可以被替换。
至此,不难理解学习内部 DSL 的价值。退一步说,并非必须自行设计一个内部 DSL,但学会将意图与实现分离,对日常编码工作同样具有重要价值。
具备该意识后,即可很好地理解程序设计语言的一个重要发展趋势:声明式编程。当前部分程序设计语言的语法即为便于进行声明式编程而设计,典型的例子即 Java 的 Annotation。正是由于它的出现,Spring 原来基于 XML 的外部 DSL 逐步转向了当前常用的内部 DSL,也就是许多开发者熟悉的 Java Config。
虽然此处讨论的是代码编写,但分离意图与实现同样是一项重要的设计原则,若想编写出高质量的代码,必须懂得设计。
核心要点
- 实现 DSL 的核心是先构建好模型,语法只是将模型能力暴露的接口。
- 常见的 DSL 形式:外部 DSL(独立语法/解析器)和内部 DSL(宿主语言表达)。
- 内部 DSL 的关键在于将意图与实现分离开来——表达"做什么"而非"怎么做"。
- 声明式编程是这一思想的重要体现(如 Java Annotation、函数式编程)。
- 即使不设计 DSL,编写具有表达性的代码也能大幅提升代码质量。
- 好的设计要迈向 DSL,可以从编写有表达性的代码起步。
本篇核心概念关系
图注:编程语言的三个维度(模型/接口/实现)与设计的四个关键方向,以及它们之间的推导关系。
与其他知识点的关联
| 本篇知识点 | 关联知识点 | 关联说明 |
|---|---|---|
| 语言的模型 | 04-编程范式篇 | 编程语言提供了实现不同范式的语法基础 |
| 语言的模型 | 02-设计分析篇 | 理解语言本身也要从模型、接口、实现三个维度 |
| 语言的接口 | 04-编程范式篇(面向对象之封装) | 封装是设计好的程序库的基础 |
| 语言的接口 | 05-设计原则篇(接口隔离原则) | 程序库接口设计的关键原则 |
| 语言的接口 | 08-设计实战篇(程序库的设计) | Moco 的设计实战案例 |
| 语言的运行时 | 02-设计分析篇(实现分析) | 软硬结合的根基是对运行时的深刻理解 |
| 语言的运行时 | 09-扩展补充篇(响应式编程) | 响应式编程依赖于运行时的异步调度能力 |
| 语言的运行时 | 05-设计原则篇 | 设计原则在不同运行时环境下的实践差异 |
| DSL | 04-编程范式篇(函数式编程之组合性) | 函数组合是实现内部 DSL 的强大工具 |
| DSL | 07-领域驱动设计篇(通用语言) | DDD 的通用语言与 DSL 一脉相承 |
| DSL | 08-设计实战篇(程序库的设计) | 好的程序库本质上就是内部 DSL |
与其他知识点的关联
- 01-设计认知篇 → 模型与规范:编程语言的模型、接口、运行时正是软件设计中模型与规范思想在语言层面的体现
- 02-设计分析篇:理解语言本身也要从模型、接口、实现三个维度去分析
- 04-编程范式篇:语言决定了可用的编程范式和设计元素
- 05-设计原则篇 → 接口隔离原则:程序库接口设计的核心指导原则
- 07-领域驱动设计篇 → 通用语言:DSL 与 DDD 的通用语言一脉相承
- 08-设计实战篇 → 程序库设计:Moco 的设计是程序库设计的优秀实践案例
- 09-扩展补充篇 → 响应式编程:响应式编程依赖于运行时的异步调度能力