最近在写编程语言发展史,其中就讨论了一门好的编程语言,应该关注哪些设计要点。根据书稿,整理如下,欢迎大家讨论和指正。
编程语言是现代计算机科学中不可或缺的一部分,人们使用编程语言来实现各种各样的软件和应用程序。但是,编程语言仅仅具备语言的基本元素还远远无法满足开发者的实际需求,好的编程语言需要在很多方面具有出色的设计水平,这包括安全性、性能、灵活性、低心智负担、正交性、易用性等方面,这些方面有的相辅相成,有的则是各有取舍,下面让我们更深入了解一下这些不同方面的含义及其重要性。
安全性和性能
安全性在计算机领域的不同上下文有不同的含义,一种是强调程序被非法反编译和破解的安全性,一种是强调程序自身存在缺陷在运行时出现非预期异常的安全性,本书在提到安全性时默认指第二种安全性的含义。
编程语言的安全性主要体现在多个方面。
类型安全:类型安全是指编程语言在编译或运行时能够检测和防止类型错误的能力。这有助于避免程序员在处理变量和数据结构时犯错误,从而减少潜在的安全漏洞。C语言格式函数printf作为类型安全问题的典型代表最能说明类型安全的重要性。一般强类型的编程语言(如C++)具备较好的类型安全能力,而弱类型的语言(如JavaScript)的类型安全能力较弱,很多类型错误需要在运行时才能发现。
异常处理:一流的异常处理能鼓励程序员考虑程序的异常逻辑,在一定程度上提高了语言的安全性。良好的异常处理机制设计,能让程序员能便捷地编写异常处理逻辑,并和普通程序逻辑明显区分,这有助于确保程序在遇到问题时能够优雅地恢复,而不是崩溃或产生不可预测的行为。更多异常处理的讨论可以参考本章后面的“异常处理”小节。
语言表达:语言表达的安全性包括:语义的清晰度、语法的一致性,防御性地还原程序员意图三个方面。当语义规则过于复杂、灵活、冗余、上下文相关时,语义的清晰性也会降低。高度精确的语义,可以有利于静态分析工具准确地发现潜在问题,空安全就是伴随指针的语义的完善逐渐建立起来的,它经历了由静态分析工具到编译器支持的过程。语法的一致性则可以让开发者更容易熟悉语言的语法特点,更容易理解和掌握语言,甚至可以凭直觉写出符合语法的代码。而防御性地还原程序员意图鼓励开发者采用最小权限的编程实践,例如Rust语言中的声明默认为常量,变量需要添加mut关键字,可以明显提高语言表达的安全性,避免无意的误用发生。
算术运算安全:算术运算安全常常以运算溢出、精度丢失等形式出现,并且隐藏较深,往往在数值较大、精度要求较高的场景中出现问题。一些对性能不敏感的脚本语言(如Ruby)提供了整数类型自动扩展能力,能在算术运算中自动扩充字长提供安全度很高的算术运算安全,Python语言内置了decimal模块也支持高精度的算数运算。但目前大部分语言不提供高精度运算安全的保障,浮点数的精度和溢出问题往往需要借助程序库来实现。
跨平台安全:编程语言的跨平台一致性是保障跨平台安全的核心。具体表现在字长的一致性、算术精度的一致性、基本类型的一致性、字符串编码的一致性、IO的一致性等。虚拟机语言和脚本语言在跨平台安全上更有优势。而面向底层系统编程的语言由于要面向硬件,往往会提供平台相关的抽象(如C/C++的int长度是平台相关的),导致降低了跨平台安全性。一些兼具安全性和底层编程的语言如Rust提供了std::os::raw的库来支持平台相关的编程支持,把一流的平台无关的原始类型保留给了语言核心。
语言生态(工具链的可靠性):一个健康的社区和成熟活跃的生态有助于提高编程语言的安全性。编程语言工具有庞大的用户群体和专业的维护团队,能及时发现和修复语言工具链中的缺陷、漏洞等问题,语言的基础库也更加稳定可靠,这些都为语言的可用性提供保障。大平台主推的语言(如C#)和流行的语言(如Python)往往具有成熟活跃的生态,很多项目在语言选型时都会考虑语言生态情况,可见语言生态的重要性。
内存管理安全:内存管理安全是指编程语言在分配和释放内存时能够防止错误的发生,避免出现重复释放和内存泄漏的问题。内存管理安全问题是手动管理内存语言中(如C/C++)最常见的问题之一。目前有不少编程语言可以提供完全的内存管理安全,主流的方案参考本章的“内存管理”小节。
空安全:用以保障编程过程中处理空值(null或nil)的安全性被称为空安全。空安全常常以空指针崩溃的形式展现出来,这是编程历史上最多的崩溃之一。空安全能有效帮助程序员发现此类错误,并减少指针判空代码,提高了性能和代码简洁度。空安全目前以两种方式提供,第一种以Kotlin、Swift为代表的可空值(Option)实现,该方案需要程序员遵守开发约定否则无法保障完全的空安全。第二种以Rust的所有权模型为代表,在编译期间确保变量的有效性后,才能对其进行访问,提供了完全空安全的保障。
内存访问安全:内存访问安全问题一般容易出现在允许直接操作内存的编程语言中,该类问题以缓冲区溢出、内存数组越界等案例出现,可能导致程序崩溃或被恶意攻击者利用。目前这类问题很难完全规避,往往需要借助静态代码分析和动态内存栅栏进行发现。以Java为代表的语言禁止了指针运算来减少这种问题出现的概率,或者鼓励使用迭代器、索引检查的编程方式进行避免。Rust语言在编译期间保留了内存大小的信息,可以让大部分下标越界作为编译错误提示出来,来保障内存访问安全。另一种内存访问安全,是在内存释放后再次访问的场景出现,这种情景往往出现在手动内存管理的语言里,此处不再展开。
内存竞争安全:支持并发和多线程的编程语言需要解决内存竞争的问题,这包括对线程同步、互斥锁和其他并发控制原语的支持。在没有内存竞争安全的语言中程序员需要小心翼翼地处理数据加锁、加锁顺序、避免死锁或竞争。目前较新的编程语言已经能提供完全的内存竞争安全,主流的做法有两种,第一种以Dart为代表将线程之间的内存空间完全隔离(Isolate机制),线程之间通过事件通信,本质是通过降低灵活性换安全性的方式避免了内存竞争问题;第二种以Rust语言的所有权机制为代表,它确保了一个对象在一个时刻只能由一个线程访问,解决了内存竞争安全问题,本质是通过更严格的语义来避免竞争。
下图展示了编程语言安全性多个方面的总览:
图 编程语言安全性总览
从20世纪70到21世纪20年代,编程语言的安全性经历过三次重大提升。在早期计算机算力不足,很多安全特性需要消耗更多的运行性能,编程语言的安全性没有获得足够的重视。20世纪60年代摩尔定律开始生效,编程语言的安全性伴随计算机性能提升而提升,以结构化编程为代表的理论让编程语言安全性提升到第一层高度,并随着面向对象和面向函数编程范式获得完善。20世纪90年代后,软件系统变得越来越复杂和庞大,这使得安全性成为编程语言的一个关键问题,以Java语言为代表将编程语言把跨平台安全、类型安全、内存分配和内存访问安全集中优化,把语言的安全性推向了第二层高度,并随着模块化的普及获得完善。在21世纪10年代后大量新语言支持的空安全、异步等把编程语言安全性推向了第三层高度,2015年发布的Rust把语言让编程语言的安全性达到了前所未有的高度。
软件行业将继续往复杂化方向发展,大型团队和大型工程项目越来越常见,编程语言提供高质量的安全性能大大提高研发效能,减少调试时间和软件的缺陷,提高项目竞争力,解放程序员的生产力,是其编程语言的核心竞争力之一。下图展示了编程语言在安全方面的三次重大提升。
图 编程语言的安全性经历三次重大提升
IBM曾经希望通过数学方法来找到一种完全正确的编程方式,这种方式被称为“形式化方法”(Formal Methods)。形式化方法是一种基于数学逻辑的方法,它用来证明程序的正确性,从而避免程序中的错误和漏洞。IBM在20世纪80年代开始研究形式化方法,并在20世纪90年代初推出了一个名为“VDM++”(Vienna Development Method)的形式化方法解决方案。VDM++是一种基于面向对象编程的形式化方法,它可以用来描述和验证软件系统的行为和结构。
尽管形式化方法可以提高程序的可靠性和安全性,但它也存在一些限制和挑战。该方法要求程序员具有良好的数学和逻辑能力,并且需要大量的时间和资源来进行验证和证明,这使得它在实际项目中的应用受到了限制。IBM在形式化方法方面进行了多年的研究,但这种方法并未能如愿成为安全的编程方式。我们站在历史的高度可以看到,IBM当年希望通过形式化方式来提高软件开发的安全性是艰难的,那时人们也开始接受这种“不安全性”,甚至在项目管理上寻求缓解该问题的方法,业内陷入了长时间的迷茫时期。直到在编程语言在安全性上找到了新的突破,出现了类似Rust这样的语言,从语言的安全性上极大程度地降低了程序员人为的失误,更低代价地实现“提高程序的可靠性和安全性”,在一定的程度上实现了IBM当初的梦想。
从IBM失败的教训来看,通过投入人力来提高软件质量的做法最后难以获得持续较好的效果。更直接有效的方法依然是将“人”的因素尽可能降低,采取更安全的编程语言,或者提高代码扫描的安全级别,更容易获得立竿见影的效果。
安全性高的编程语言可以有效减少程序的漏洞和错误,减少开发者的调试和修复缺陷的时间。根据MEND.IO发布的报告,C语言程序的漏洞占七种主流编程语言漏洞总数的47%。其中最为普遍的漏洞类型是缓冲区错误,这一漏洞已经成为一个臭名昭著的问题。著名的技术问答社区网站Stack Overflow的名字就是对这一问题的一种讽刺,这个名字意味着当程序员遇到堆栈溢出等问题时可以去该网站寻求帮助。这也说明了C语言程序员需要特别注意缓冲区错误的问题。而这种问题在更安全的语言中几乎消失,因此,提高编程语言的安全性,是软件工业对编程语言赋予的热切期待,也是从编程语言20世纪90年代开始最重要的发展趋势。
而在编程语言早期的发展趋势中,编程语言的性能是人们最关注的方面之一,这是由于计算机硬件性能无法满足现实计算需求导致的。编程语言的性能分为两个方面,分别是编译期性能和运行期性能,编译期性能影响开发者的开发体验,而运行期性能则影响用户在使用程序时的体验。编译期性能和运行期性能的优化是现代编程语言发展的重要方向之一,在这个领域出现了代码优化、提前编译技术(Ahead of Time,AOT)、即时编译技术(Just in Time,JIT)等用于提高编程语言性能的技术。
在很长一段时间里,编程语言的安全性和性能无法兼得,例如引入垃圾回收会导致语言运行时的性能受影响,增加编译检查也会增加编译时间,因此语言设计中需要获得安全性和性能的平衡。但随着编程语言新的理论的发现和发展,出现了一些办法兼得安全性和性能。例如Rust语言就是以安全性和性能都出色而闻名。
编程语言的性能除了和语言自身的设计有关,编译器优化技术的进步也给编程语言性能的提升提供了有力的支持。一般而言,静态程度越高的编程语言在编译优化方面的潜力也越高,关于编译优化的话题,我们在1.7进行进一步讨论。
在运行期性能上,Programming-Language-Benchmarks-Visualization项目根据Benchmarks Game小组的数据,整理出主流编程语言的性能如图:
图 C语言为基准,2023年2月主流语言在Benchmarks的时间排名
虽然C语言在安全漏洞的排名第一,但在其运行的性能也是排名第一。而以安全性闻名的Rust在运行性能上非常接近C,这让Rust成为近期最受关注的语言之一。
需要指出的是,上面的性能排名在一定程度上反映出了语言性能的天花板,但是在实际项目中,编程语言发挥出的性能潜力受多种因素影响。例如,使用C++、C#为主编写的VisualStudio,和使用Java为主编写的JetBrains系列IDE、以及使用JavaScript、TypeScript为主编写的Visual Studio Code,这三种著名的IDE使用的语言之间的性能差异近6倍。然而,这并不代表这些IDE的性能就有巨大差异。一个软件系统的性能与其架构、设计、实现细节以及优化程度息息相关,从根本上说,人的因素大于语言的因素,在大型复杂的项目中,不合理的架构和实现给性能带来的损失可能会远大于语言本身。
灵活性和低心智负担
当语言具有优良的灵活性时,能更方便地帮助开发者把构想变成代码。具体来说,既能在开发者把握方向时不被细节干扰,也能在实现的过程中让开发者参与细节的把控。以灵活性称著的语言有C++、JavaScript,这两种语言分别强调编译期阶段灵活性和运行期阶段灵活性。但过于灵活的语言特性也很容易提高学习门槛,增加误用场景,导致心智负担过重。
编译期灵活的语言具有控制编译器行为的能力,在一些情况下也被称为元编程。以宏编程(Macro Programming)、泛型编程(Generic Programming)两种方案为代表。其中宏编程允许程序员在编译时根据一定的模式或规则来生成重复性的代码。泛型编程允许程序员编写可以适用于多种数据类型的代码,泛型编程通过参数化类型来实现,使得代码可以在不同的数据类型上进行重用。宏编程和泛型编程都能提高代码的灵活性和可重用性,减少代码的重复编写。使用泛型编程思想甚至能实现编译期的排序、搜索等算法,这种技术被称为模板元编程(Template Metaprogramming)。
具有运行期灵活性的语言具有在运行期间修改数据结构和程序逻辑的能力,习惯上统一称为元编程(Metaprogramming)能力。元编程可以通过反射、代码生成、动态加载等方式实现运行时动态地创建、修改和执行代码,从而实现更高级的抽象和灵活性。元编程常见于一些脚本编程语言,Python、Ruby、JavaScript等。甚至还有使用元编程思想命名的编程语言:元语言(Meta Language)。由于运行期的灵活性语言很多操作需要在程序运行时计算取得,在性能上具有一定的劣势。
宏编程、泛型编程和元编程的思想都具有高度的灵活性,同时也是语言特性中较难掌握和运用的特性,是提高程序员心智负担的典型代表,因此在设计高灵活性的特性时需要注意考虑降低使用者的心智负担。
低心智负担除了要求语言有简单清晰的语法,更容易理解学习之外,还强调:语言的设计符合人们已知的现实情况或者业内惯用习惯、语言的使用符合大部分人的直觉、语言特性不容易误用、不容易埋藏隐藏的问题、减少常见错误的出现、有明显的最佳选择来完成常见的任务等。Python、Go、Kotlin等语言是低心智负担方面设计的佼佼者。
需要指出的是语言的灵活性和语言的复杂度关系不大,灵活性主要体现在语言是否能够适应各种不同的编程需求和场景,以及程序员能否以他们喜欢的方式使用这种语言。语言的复杂度则主要体现在语言的语法、特性和规则的数量,和理解使用这些规则特性所带来的复杂性。一种语言可能非常灵活,但并不意味着它就必须复杂,例如函数式编程语言的复杂度很低,但函数式编程的灵活性却是非常强大。反之,一种语言可能非常复杂,但并不意味着它就具有高度的灵活性,例如汇编语言(Assembly)。因此,设计较好的语言可以兼得灵活性和减少复杂度。
俗话说过犹不及,灵活性也需要有个度,灵活性过度的语言容易导致程序员写出难以理解的代码,因此在保持语言的灵活性的同时保持代码的直观和简单,需要语言设计者对语言特性进行反复推敲,仔细权衡,深入优化之后才能实现,很多语言投入了大量精力在此,可见设计编程语言也是一项非常细致的工作。
图 语言设计者反复思考的问题,往往是一些很细致的问题
正交性和易用性
降低语言的复杂度最有效的方法就是提高语言的正交性。正交性主要含义包含两个方面的要求:第一个是语言每个特性解决的问题都是不同方面的,不存在一个问题有两种解决方案;另一个是一种语言特性的实现和使用不会影响其他特性,特性的组合使用不容易引入问题,不同的特性之间是相互独立的。这种正交性减少了语言特性的数量,可以使得程序员更容易理解和使用语言。另一方面较少的特性也对语言的灵活提出了更高的要求,促使语言提高灵活性。例如,在面向对象的语言中,类的继承和类的组合都能实现代码复用,两者之间缺乏正交性,这使得不同程序员在代码复用场景选择有所不同,增加了编码的复杂度。
正交性的哲学思想在于善于找到并解决问题的根本。深入理解问题的核心,寻找根本原因,并通过解决根本问题来简化解决方案,避免在表面层面引入多个复杂且可能彼此冲突的修复措施。通过解决一个根本问题来消除或减少需要解决的表面问题的数量。这样做不仅可以减少系统的复杂性,而且还可以提高效率和可靠性。
语言的正交性在1970年代得到明显的发展,当时很多语言把编译器内置的功能转移到语言库中支持,大大增加了语言核心的正交性。
语言正交性高时,它的语法和特性之间的独立性上就较好,进而影响语言库具有良好的正交性,语言和库的高正交性进一步能影响语言的使用者编写出高正交性的代码。较高正交性的语言往往具有两大特点,一是学习门槛低且具有平滑的语言学习曲线,二是语言生态的一致性更好,做一件事情最佳的方式只有一种,不容易出现生态内部分裂的情况。C语言和go语言就是高正交性的语言,他们的语法和特性比较简单和基础,但具有强大的表达能力。Python也提出“应该只有一种最好的、明显的方法来做一件事情”的理念,强调正交性的价值。
一般情况下语言设计者最初会重视正交性,但是很多语言在各种场景优化和范式支持的诱惑下,不断引入非正交的设计,逐渐地把语言设计得过于复杂,随之正交性就下降了。
但是单纯地追求语言的正交性,容易导致语言的易用性下降。函数式编程在语言设计上是正交性设计的典范,而且函数式编程还有突出的安全性优势,但是函数式编程没有成为最流行的编程方式,其易用性较低是很重要的因素之一。
语言的内核一般更侧重正交性,语言的外围工具则更注重易用性。很多语言通过提供库、框架和工具等方式来降低编码难度来提高语言的易用性。在开源的趋势下,语言设计者们鼓励社区为语言生态贡献高质量易用性好的程序库,这能大大提高语言生态的易用性,提高使用者的开发效率。
总之,在相同水平的编程理论下,编程语言设计者需要根据各自生态和场景的特点,精心选择高度正交的基本语言特性,再辅以易用性设计进行补充,平衡语言的易用性和正交性,才能设计出一门优秀的语言。
我们看一个Unix/Linux和Windows两种操作系统API设计的例子,来说明API设计在正交性和易用性上的取舍。Unix/Linux可以使用fork函数创建进程,fork函数将运行着的程序分成2个(几乎)完全一样的进程,每个进程都启动一个从代码的同一位置开始执行的线程。而Windows只有基本的CreateProcess创建一个全新的进程。考虑这种需求场景:创建一个守护进程,在主进程异常退出时重新运行主进程。如果使用Windows的CreateProcess则需要将主进程的业务信息(如命令行、业务状态等复杂的结构化信息)通过命令行参数传给新创建的守护进程,然后在守护进程中解析命令行获得传入的信息,实现过程过于复杂,因此易用性较低。而如果使用fork函数则简单很多,因为fork函数创建的新进程可以与原进程共享文件描述符、内存映射、信号处理等资源,业务信息不需要通过基于字符流的命令行传递。我们发现,fork函数是注重易用性的设计产物,而CreateProcess是重视正交性的设计产物。虽然fork在一些场景使用起来非常方便,但是Unix/Linux不得不提供vfork、clone等相关函数来实现其他场景的支持,从而增加API的学习难度和概念数量。而Windows操作系统只需要学习使用CreateProcess即可,但是在使用上需要使用者需要进行二次封装才能较易使用。总之,正交性和易用性都重要,单纯地偏向某一边都容易导致设计上的缺陷,正因为如此,设计出一门优秀的编程语言是一件浪漫而又困难的事情。
易用性除了对正交设计的补充外,还包括提高代码的可读性和可写性。编程语言的语法和符号经过精心的设计,是为了提高代码的表现力,将编码者的意图更直观地展现出来,提高语言的可读性和可写性。一般情况下,可写性和可读性是相辅相成,共同进退,但也有一些差异。可读性和可写性都需要和编辑器配合才能获得更好的效果。在现代的集成开发环境下,编辑器往往会将代码展示成更加容易阅读的形式,例如增加函数参数的参数名注解,自动折叠非关键代码等;而可写性还会强调在编码过程中更容易地被编辑器的智能提示识别,提示正确率更高,语言的信息熵更小,这样开发者可以通过几个初始的信息节点逐渐逐层地覆盖所有功能和概念,并且键盘按下的次数更少。
图 在关键字的问题上,没有最简洁,只有更简洁!
除了语言设计中最重视的安全性、灵活性、正交性、高性能、低心智负担、易用性外,现代编程语言设计考虑的问题范围更加宽广和复杂,例如需要在编程范式的选择和优化上,具体业务场景的使用上,找到最符合设计目标的最优平衡点。这种设计的偏重就像调整天平的秤砣,在各自的目标场景中找到最优解。
下图总结了在语言设计时需要考虑和平衡的要素:
图 语言设计时需要考虑和平衡的要素