D程序设计语言

978-7-115-31419-2
作者: 【美】Andrei Alexandrescu
译者: 张雪平谭丽娜
编辑: 杨海玲

图书目录:

详情

这是一本权威的、全面介绍D语言的书籍。书中不仅介绍了D语言的各个方面,还提供了一个涉及大量优秀实践与习惯用语的纲要,以此帮助读者使用D语言编码和进行通用编码。书中通过惯用示例,对所有语言特性都进行了深入的解释;对每一项主要特性的基本原理都进行了说明,并对这些特性的最佳用途提出了建议;对错误处理、契约编程和并发性等问题进行了讨论;包含大量的图表,为使用D语言解决日常问题提供了快速参考。

图书摘要

版权信息

书名:D程序设计语言

ISBN:978-7-115-31419-2

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

• 著    [美] Andrei Alexandrescu

  译    张雪平 谭丽娜

  责任编辑 杨海玲

• 人民邮电出版社出版发行  北京市丰台区成寿寺路11号

  邮编 100164  电子邮件 315@ptpress.com.cn

  网址 http://www.ptpress.com.cn

• 读者服务热线:(010)81055410

  反盗版热线:(010)81055315


D语言是一种通用的系统和应用编程语言,它保持了生成高效代码以及直接访问操作系统API和硬件的能力。这是一本介绍D语言的权威书籍,全面、系统、专业地讲解了D语言相关的内容。书中涵盖了D语言的方方面面,不但介绍了语言的表达式、语句、类型、函数、契约、模块、类和结构等基本内容,还通过常见示例,深入解释了所有语言特性,讲解了如何将语言特性进行组合以支持重要的编程范型,阐述了每一项主要特性的基本原理,对它们的最佳用途给出了建议,并对跨领域的问题(如错误处理、契约编程和并发性)进行了讨论。此外,书中还通过大量的图表,为使用D语言解决日常问题提供了快速参考。其细致入微的讲解让人几乎觉得有些“啰嗦”,但正是这些“啰嗦”内容更能加深人们对D语言程序设计的理解,进而拓展到对一般意义上的编程语言程序设计的理解。

本书不仅适合于D语言初学者,对于想学习编译器实现的研究人员、想拓展学生的编程语言知识面的教师以及想深入了解编程语言设计的人,也能提供很好的帮助和指导。


不管从哪个方面来讲,C++都获得了巨大的成功,但即使是其最狂热的拥护者也不可否认它是一只复杂难懂的“猛兽”。这种复杂性也影响到了被广泛使用的C++后继者Java和C#的设计。这两种语言都在尽量避免C++的复杂性,采用的方法是,通过一个更易使用的程序包提供其大部分的功能。

降低复杂性可以采用的基本形式有两种。一种形式是,去掉“复杂的”语言特性。例如,通过垃圾回收(garbage collection)的方式可以避免C++中对手动管理内存的需要。在模板的“成本/效益”测试被认定为失败时,这些语言的初始版本都选择了排除C++中与泛型支持相关的所有内容。

降低复杂性的另一种形式是,使用相似但要求更少的结构方式取代“复杂的”C++功能。C++的多重继承可以改变成使用接口进行扩展的单继承。Java和C#的当前版本都支持模板化的泛型,但它们都比C++的模板更简单。

这些后继语言并不只是要求通过降低复杂性来完成C++所能完成的任务,而是被寄予了更多的期望。它们都定义了虚拟机,增加了对运行时反射(runtime reflection)的支持,并提供了大量的开发库,这些库可以让许多程序员将关注点从创建新代码转移到组合现有的组件方面上来。最终结果就是它们被看做是基于C语言的“生产力语言”(productivity language)。如果想快速创建软件,而它几乎等同于去组合现有的组件(很多软件都属于此类),那么与C++语言相比,Java和C#都是不错的选择。

但C++不是生产力语言,它是一种系统编程语言。它旨在媲美C语言在以下几个方面的能力:硬件通信(如驱动程序和嵌入式系统方面),在不经任何调整的情况下可以直接处理基于C语言的库和数据结构(如遗留系统方面),用尽运行硬件上的所有性能。这里不是想嘲讽Java和C#语言底层虚拟机的关键性能组件都是使用 C++编写的,高性能虚拟机的实现就应该是系统语言干的事,不适合生产力语言。

D语言的目标是想在系统编程领域成为C++的继任者。像Java和C#语言一样,D语言也想要避免C++的复杂性。为此,D语言使用了一些相同的技术,其中包括加入垃圾回收功能,摒弃手动内存管理[1];采用单继承和多接口,舍弃多继承。但是,D语言走的是一条属于自已的路。

先是从标识出C++的功能性漏洞并修补它们开始。当前的C++未提供对Unicode的支持,而其后继版本(C++11)也只提供了有限的支持。D语言从一开始就支持Unicode。当前的C++和C++11都不支持模块(module)、契约编程(contract programming)、单元测试(unit testing)或“安全”(safe)子集(其中不可能出现内存错误)。D语言提供了上述所有功能,但未对生成高质量本地代码的能力产生影响。

针对C++强大与复杂兼有的特点,D语言瞄准的目标是:在保持功能强大的同时降低复杂性。C++中的模板元编程人员已证明,编译时计算是一项很重要的技术,但必须要先克服句法的困难才能将它付诸实践。D语言提供了类似的功能,但没有词法方面的烦恼。在当前的C++里,即使知道如何编写函数,你也不一定了解如何才能编写出与之对应的能在编译期间进行计算的C++函数。在D语言里,如果知道如何使用编写函数,你就能准确地知道如何编写其编译时版本,因为两种情形下的代码是一样的。

最有意思的地方是,在基于线程的并发性方面,D语言与其由C++派生出的兄弟语言之间使用的方法是截然不同的。D语言认识到对共享数据不正确的同步访问(数据竞争)就是一个陷阱——容易陷进去且很难爬出来。因此,它从一开始就约定:默认情况下,数据不会被跨线程共享。正如D语言的设计者指出的那样,在现代硬件深度缓存层次的环境里,内存并不是真正跨内核或处理器共享的。既然这样,那为什么还要默认给开发者提供的一种抽象呢?这种抽象不仅仅是一种错觉那么简单,它是一种错误的观念——众所周知,是它滋生了难以调试的错误。

所有这些内容让D语言在C语言的传统设计空间里特别引人注意,这一点已足以成为你阅读这本书的理由。而本书的作者是Andrei Alexandrescu,阅读理由变得愈加充分。作为D语言的共同设计者以及绝大部分D语言库的实现者,没人能比Andrei更了解D语言。他能对D编程语言进行描述,这是很自然的事,他也能解释D语言为什么是这个样子。有些特性出于某种原因存在于D语言里,而有些应该有的也正缺失的特性出于某种原因却未包含在D语言里。要阐明其中的原因,Andrei是不可替代的人选。

这种解释独特而富有魅力。其中有些内容似乎有点跑题(但事实上它们只是通往终点的一个中间站),Andrei对此作了保证:“我知道你在反问自己:编译时计算真的有必要吗?很有必要!请容我慢慢道来。”针对连接器诊断不够直观的根本性问题,Andrei说到“如果你忘记了--main,也不用着急。连接器会使用其本地语言——编码后的克林贡语[2],流畅、详尽地[3]提醒你。”即使是对其他出版物的引用,Alexandrescu也都有提及。他不是简单地像“Wadler的著作Proofs are programs”这个样子进行提及,实际提及的样式非常详实,如“Wadler的迷人著作Proofs are programs”;关于Friedl的著作Mastering regular expressions不是简单推荐,而是“热情推荐”。

一本关于编程语言的书自然满篇都会带有样例代码,而这些样例代码同时也说明Andrei是一个注重实践的作者。这里有一个他编写的搜索函数原型:

bool find(int[] haystack, int needle);

本书描述的是一门很有意思的编程语言,是由一位经验丰富的作者编写而成的。我保证你会发现这本书值得一读。

Scott Meyers

2010年1月

[1] 事实上,它是可选的。作为名符其实的系统编程语言,如果你真的想要手动管理内存,D语言也不阻拦你。

[2] 英文原词Klingon,它指的是一种人造语言,它主要的特点是使用“宾谓主结构”(OVS)语序,而这种语序在很多情形下正好与英文语序相反。——译者注

[3] 英文原词baroquely,它是baroque一词的变形。baroque是一个英语外来词,一般情形下它指代的是17世纪风行于欧洲的一种艺术风格——巴洛克。而在非正式场合,它的含义与elaborate(详尽的)很接近。因此,这里将baroquely译作“详尽地”。——译者注


记得很久以前,我读过一本科幻小说,其中有这么一行:科学家在可能拓展其研究领域知识的时候,就应该勇往直前。简单地讲,它揭示了想要成为一名科学家所应具备的潜质。值得一提的是,在物理科学家Richard Feynman的影像资料和著作中,无处不体现着发现的乐趣,而且他对科学的热情也极具感染力且非常迷人。

尽管我不是科学家,但我明白他们的动机。我的动机跟工程师的动机一样——享受创造的乐趣,从无到有把某样东西建造出来。有一本我很喜欢的书The Wright Brothers as Engineers,作者是Wald,书中记录了莱特兄弟在克服一个又一个飞行难题之后逐步前进的过程,同时还记录了他们如何应用这些知识来创建飞行器的过程。

我早期的兴趣与Brinley所著的Rocket Manual for Amateurs开篇中所概述的内容一样:“燃烧与爆炸的事物总是让人激动和着迷”。后来又变成想去构建一些能跑得更快和飞得更高的事物。

不过构建功能强大的机器耗资巨大,于是我发现了计算机。它最神奇的地方在于可以让你轻松地去构建某些事物,又不需要昂贵的制造车间和机械工厂,甚至连一把螺丝刀也不需要。只需要一台花费不多的计算机,你就可以创造世界。

就这样,我开始在电脑里建造虚拟世界。第一个作品便是游戏Empire——世纪之战。由于当时电脑的处理能力实在太弱,这个游戏根本就没办法正常运行,因此我又开始对如何优化程序的性能产生了兴趣。此兴趣让我开始对可以生成代码的编译器进行研究,自然而然便发展到了“我能写出比它更好的编译器”这样一种自负。因迷恋C语言,我迫切希望实现一个C语言编译器。这个也不是太难,大概只用了几年的空余时间。后来我又发现了Bjarne Stroustrup的C++语言,于是我认为再花上几个月的时间应该能够将那些扩展添加到我的C语言编译器里。

十多年后,我仍然在为此努力。在实现这个编译器的过程中,我对C++语言的每一个细节变得非常熟悉。为一个庞大的用户群提供支持意味着能体验到其他人对此种语言的大量的认知:起作用的是什么,而什么是没用的。我不会不假思索地去改进设计。在1999年,我决定将这一想法付诸实践。开始时它叫做Mars编程语言,而我的同事最初是开玩笑地称其为D语言,不过这个名称后来被延用下来,D语言由此诞生。

在撰写本文时,D语言已有十个年头,而且已演变到了第二代,有时也叫D2。在这段时期里,D语言已从由一个人敲打着键盘开发它,发展到由一个全球性的开发者社区来解决它方方面面的问题,以及为库和工具的生态系统提供支持。

D语言自身(本书的焦点所在)已从卑微的初级阶段成长为一门功能非常强大的语言,它擅长从多种角度解决编程问题。据我所知,D语言前所未有地将多种强大的编程范型(programming paradigm)巧妙地集成在一起:命令式(imperative)、面向对象(object-oriented)、函数式(functional)以及元式(meta)。

乍一看,这门语言肯定不简单。的确,D语言不是一门简单的语言。但我认为这是一种错误地看待一门语言的方式。更恰当的方式应该是:这门语言的编程解决方案看起来的样子像什么?D语言程序是复杂愚钝还是简洁优雅呢?

我有一位同事,他有着丰富的企业环境开发经验。他注意到IDE(Integrated Development Environment,集成开发环境)是一个相当重要的编程工具,因为只需单击一下鼠标就会有数百行的样板代码生成。对D语言来说,IDE不是不可或缺的,因为D语言并不依赖基于向导式的样板代码。通过自省(introspection)和生成功能,它让自己不再依赖于使用样板。程序员不必看到样板,负责程序内在复杂性的是语言,而非IDE。

例如,有人想要用更简单一些的语言(对范型没有特别的支持)来进行OOP(面向对象编程)设计。这也是完全可能的,但这种做法糟糕透顶,而且毫无价值。当有一门更为复杂的语言直接支持OOP时,编写OOP程序就会变得简洁优雅。语言越复杂,用户代码就越简单。这才是值得去做的事情。

必须有一种可以支持多范型的语言,才能支持以简洁优雅的方式为各种类型的任务编写用户代码。正确编写的代码在页面上看起来应该是赏心悦目的,说来也奇怪,漂亮的代码往往就是正确的代码。我不确定两者之间存在什么样的联系,但确实如此。与好看的飞机常常都飞得很好是一样的道理。因此,让算法可以以一种漂亮的方式来进行表达,这样的语言特性肯定会是件好事情。

然而,编写代码时的简洁优雅并不是衡量一门程序语言好坏的唯一标准。当前,程序的规模在无止境地迅速增长。随着这种变化,依赖于惯例和编程方面的专长以确保代码的正确性变得越来越不实际;相反,依赖于使用机器进行检查的保证正变得越来越有价值。为此,D语言提供了多种策略,让程序员可以使用它们来实现这类保证。这些策略包括契约(contract)、内存安全(memory safety)、各种函数属性、不变性(immutability)、劫持保护(hijack protection)、作用域守护(scope guard)、纯洁性(purity)、单元测试(unit test)、线程数据隔离(thread data isolation)。

我们并没有忽视性能!尽管有预言说性能不再那么重要,尽管现在计算机的运行速度比我编写第一个编译器时快了上千倍,但对更快速程序的需求却从未改变。D语言是一门系统编程语言。这预示着什么呢?从某种意义上讲,它意味着使用D语言除了能编写设备驱动程序和应用程序外,还可以用来编写操作系统。在更为技术的层面,它意味着D语言程序可以访问机器的所有功能,即可以使用指针、进行指针别名和指针运算、绕过类型系统,甚至可以使用汇编语言编写代码。没有什么是D语言程序员无法访问的。例如,D语言的垃圾回收器实现就完全是使用D语言编写的。

请等等!这怎么可能?一门语言怎么能同时提供可靠的保证和随意的指针操作呢?答案就是这种保证是基于语言所使用的概念。例如,函数属性和类型构造函数可用于实施编译时的保证,而契约和不变量(invariant)则专门用于运行时实施的保证。

D语言的大部分特性都曾以这样或那样的形式出现在其他语言里。任何特定的单个特性都无法造就一门语言,但它们组合后所产生的作用却能超过单个部分所起作用的总和,D语言的组合造就了一种令人满意的语言,它以优雅和直接的方式来解决各式各样的编程问题。

Andrei Alexandrescu因其不拘一格的编程思想而闻名,这些思想已成为新的主流(请参考他的重要著作Modern C++ Design)。Andrei于2006年参与到D编程语言的设计团队。他拥有坚实的编程理论基础,以及无尽创新性的针对编程设计问题的解决方案。D2的成型主要归功于他。本书在许多方面都跟随着 D 语言一起在演变。在这本关于 D 语言的著作中,你会欣喜地发现这样一件事件,即其中包含的是很多关于设计选择的原因,而非枯燥乏味的事实陈述。了解一门语言为什么会是这个样子能让人更容易和更快速地理解它,并让程序可以运行得更快。

使用D语言来解决大量基础性的编程问题,Andrei不断地采用这种方式以求阐明其中的原由。因此,他不仅展示了D语言是如何工作的,而且还说明了它为什么会工作以及如何使用它。

正如我已把D语言融入我的生活一样,希望你也能在D语言编程的过程中获得乐趣。在Andrei的这本书里,每一页都令人兴奋不已,相信你一定会受益无穷。

Walter Bright

2010年1月


没错,本书介绍的是D语言——这是一种集成了多种优秀特性(如内置字符串支持、基于垃圾回收机制的内存自动管理模式、自动类型推断等)且拥有系统级编程能力的语言。它支持多种编程范型、可以实现跨平台编程,同时无需依赖任何虚拟机。

尽管我一直都知道C/C++语言在软件开发领域有着广泛的影响力和优秀的表现,但对其手动管理内存的模式与编译速度一直耿耿于怀,且一直惊叹于Pascal语言的编译速度。直到2006年前后,我开始接触D语言,便立即被其优美的语法和一闪而过的编译速度所吸引。当时,D语言仍未发布稳定版本,各种形式的参考资料都比较少,比较权威的还是包含在DMD(D语言参考编译器)发行版里的参考文档。于是我试着通过翻译这些参考文档来加深对D语言的了解和学习,同时也希望这些翻译的资料能为国内更多D语言爱好者和追求高效编程的朋友提供帮助。随着D语言1.0稳定版本的发布以及后来D语言2.0的出现,这些参考文档的翻译也尽可能保持了同步更新。到2011年,我基本完成了把D语言2.0版本里所有参考文档的中文翻译。

对广大D语言初学者来说,DMD里的参考文档具有重要的参考价值,但这些资料主要还是侧重于语法方面的讲述,而且必要的实例都比较精简,对缺乏一定编程基础的人来说,理解这些资料仍然需要花费很大的力气。本书的出现将弥补这一缺憾,它全面、系统、专业地讲解了D语言相关的内容,其细致入微的讲解让人几乎觉得有些“啰嗦”。但正是这些“啰嗦”的内容更能加深人们对D语言程序设计的理解,进而拓展到对一般意义上的编程语言程序设计的理解。

本书的英文版在发行之后,受到了众多D语言爱好者的追捧。希望本书中文版的翻译出版能激发大家学习D语言的热情,进一步推动D语言在国内的发展和应用。

本书由我和谭丽娜共同翻译完成。能参与本书中文版的翻译是我的荣幸,这里要特别感谢人民邮电出版社对国内D语言发展的关注,感谢信息技术分社杨海玲女士等人给予的无私帮助,也感谢所有为此付出努力的朋友们。限于译者能力,翻译内容如有不当之处,敬请大家批评指正,对可能造成的误解深表歉意!

张雪平

2013年2月于上海


设计编程语言追求的是简单而又不失功能的强大,在获得成功的同时也会享受其中的优美。

在各种相互矛盾的需求中权衡取舍是一项极具挑战性的任务——它要求语言的设计者尽可能精通各种理论原则,并且拥有实实在在的语言实现经验。设计编程语言其实就是一项彻头彻尾的软件工程。

D语言是这样一种语言,它会坚定地朝着自己设定的目标发展,它会提供对计算资源系统级访问的能力,也会提供高性能的处理能力,以及保持与C语言的派生语言相类似的语法特性。在确保目标不变的情况下,D语言有时会遵从传统,实现一些其他语言所拥有的功能;有时又会打破传统,提供全新的、创造性的解决方案。也就是说,D语言会重新审视其表面声称的那些约束。例如,将大程序进行细分或将整个程序使用定义良好且内存安全的D语言子集来编写,尽管这样会损失少量进行系统级访问的能力,但却大大提高了程序的可调试能力。

如果下面列出的几点对你非常重要的话,那么你可能就会对D语言产生兴趣。

本书假定你是一名程序员,也就是说,你应该知道如何使用自己熟悉的语言来完成一些常规的编程任务。本书不会假定或者特别推荐你一定要具备某种特定语言的知识。但如果你熟悉的语言是由Algol发展来的(如C、C++、Java或者C#),那么你可能会稍微有一点优势,因为从一开始,它们的语法是很相似的,甚至想找出一个不一样的语言(语法相似但语义不同)都很难。具体来讲,如果你粘贴一小段C语言代码到D语言文件里,它要么可以用相同的语义进行编译,要么根本就不用编译。

一本介绍语言的书,如果没有对各种特性背后的动因提供深入的剖析,也没有对使用这些特性来完成具体任务的最有效方式进行说明,那么它将是枯燥乏味的,同时也是不完整的。对于所有那些不太起眼的功能,本书也讨论了它们背后所蕴藏的机理;同时还对一些看起来很好的设计,最终却未被选中的原因进行了反复的说明。有些设计选择可能会大大加重语言实现的负担,也可能会对那些有着更好理由留在原地的特性置之不理,还可能会掩盖很多在简短示例里无法见到的责任,或者掩盖那些因不够强大而无法自我承载的责任。更为重要的是,语言设计者跟其他所有人一样常会犯错,因此可能有些好的设计已经存在,但就是未被发现。

第1章概要地介绍D语言的各主要部分。这一章并没有涉及所有细节。不过你还是能从中建立起对D语言的初步印象,并使用它来编写一些小程序。第2章和第3章是不可或缺的语言参考章节,其中分别讲解了表达式和语句。我尝试着将它们与传统语言进行融合,在有差异的地方,则进行了突出显示。你可以依次阅读这些章节,也可以在需要的时候回头查阅。在这些章的末尾都备有一张“速查表”——用简洁、直观的词汇进行表达,以帮助快速记忆。

第4章讲述的是内建数组、关联数组以及字符串。数组可以理解为带有安全开关的指针,它是D语言实现内存安全的工具,也是大家享受D语言乐趣的工具。字符串是由经UTF编码后的Unicode字符构成的数组。对Unicode的支持遍及整个D语言和标准库,这为字符串处理的正确性和效率提供了保障。

在阅读前4章之后,你便可以使用D语言提供的抽象概念编写一些简短的脚本式程序。后续的章节介绍的是抽象概念的基础内容。第5章以整合的方式对函数进行了描述,其中包括编译时参数化函数(模板函数)以及编译期间求值的函数。这些概念在正常情况下应该被归到高级章节里,但D语言让它们变得简单了,完全可以提前介绍。

第6章讨论基于类的面向对象设计。编译时参数化类再一次以一种高度集成、有机组合的方式出现在这里。第7章继续介绍一些类型,其中引人注目的是结构(struct)。结构是构建高效抽象概念的工具,它常与类(class)配合,一起使用。

接下来的4章讲述的是相对独立且有些特殊的功能。第8章涉及的是类型修饰符。修饰符能够提供很强的保证,这种保证在单线程和多线程应用程序里都能发挥作用。第9章谈及的是异常模型。第10章介绍D语言在“契约编程”方面的强大功能,并特意将它与第9章区分开来,其目的在于想要消除一种常见的误解:错误处理与契约编程实际上是一回事。其实不然,第10章会解释其中的原因。

第11章针对组件式大型程序的构建提供了部分信息和建议,并对D语言标准库做了简单梳理。第12章介绍的是操作符重载,如果没有它,许多像复数那样的抽象概念就会受到严重影响。最后,第13章讨论了D语言针对并发问题的原始解决方法。

听起来有些俗气,但D语言就是一项充满爱的工作。Walter Bright是一名C和C++编译器作者。在20世纪90年代的某一天,他决定不再把继续维护编译器作为自己的事业。因此,在觉得“时机成熟”的时候,他便着手去定义一门新的语言。我们当中有很多人都梦想着在某个时候能去定义出讨人喜欢的语言。幸运的是,Walter已经拥有了一大堆基础工具,其中包括后台代码生成器、连接器以及丰富的构建语言处理器的宝贵经验。尤其是那些经验,让Walter在设计时能够高瞻远瞩、洞悉全局。在某种神奇自然法则的作用下,语言功能设计不足的问题在复杂编译器的实现过程中便会暴露出来。在设计新语言时,Walter也尝试过系统性地克服这类弊端。

在D语言的初期,它在思想上与C++非常相似,尽管Walter最开始想将它命名为Mars,但社区还是因此简单地称其为D语言。基于后面会提到的原因,我们已将这门语言称作为D1。多年来,Walter一直都在开发D1。他靠着澎湃的激情和不懈的努力吸引了一群追随者。到2006年的时候,D1已成长为一门强大的语言,在技术上已能跟C++和Java那样的语言进行正面的竞争。不过,当时的形势已经很明朗:D1不可能成为主流语言,因为它还没有足以令人神往的特性来弥补与其他语言的差距。于是,当时Walter做了一个大胆的决定:他认为D1是通往理想国度的第一个版本,应该把D1置于维护模式,然后去着手修订这门语言的第二轮设计,同时不再考虑向后兼容的问题。目前,D1的用户能继续获得错漏[2]修复,但D1不会再加入新特性;而D2将变成最新的语言定义,以后我便把它称作D语言。

这个决定获得了成功。第一版的设计让大家明白哪些该做哪些需要避免。同时,也没有急着去对新语言进行宣传——新用户可以继续使用稳定且被积极维护的D1。因此,兼容性和截止时间的压力都没有成为主要问题,同时也有了足够的时间进行仔细的分析和设计新的版本,并且可以完完全全地进行正确的决定。为进一步推动设计,Walter还召集了像Bartosz Milewski和我这样的合伙人一起参与设计。在一次只有我们三人参加的、用时很长而且气氛活跃的聚会上(地点就在华盛顿州柯克兰市的一家咖啡店里),许多与D语言直接相关的重要特性,如不变性(immutability)、泛型编程(generic programming)、并发性(concurrency)、函数式编程(functional programming)、安全性(safety)等,都被一一确定下来。

最终,D语言没有辱没“更优C++”的名号,并成为了一门强大的多用途语言,它可以同时承担系统级的、企业级的以及脚本语言的工作。这里仍然留有一个问题,那就是D语言的进步与革新主要是在背后进行的,因此关于使用D语言进行编程的方式并没有太多的文档进行记录。

大家正阅读的这本书就是想要填补这个空白。希望大家也像我喜欢写这本书一样,喜欢阅读它。

D语言有许许多多的贡献者,在这里无法一一列举。其中有很多人在新闻组digitalmars.D里的表现都特别突出。新闻组就像是一块共鸣板,用于审视我们提出的设计,同时也激发了许多的想法和改进方法。

Walter在社区也受益不少。在定义编译器dmd的参考实现方面,有两名贡献者的表现极为出众,他们是Sean Kelly和Don Clugston。Sean重新编写和改进了核心运行库(其中包括垃圾回收器),并且编写了大部分的D语言并发库实现代码。他很擅长自己的工作,如果你的并发代码出现问题,那么很可能是你自己的原因造成。Don是一位数学领域的专家,尤其是在浮点数方面。他让D语言的数值原语(numeric primitive)成为最棒的实现之一,而且他还将D语言的更新换代能力提升到极限。在参考编译器dmd的源代码对外开放之时,Don就开始不停地往里面添加代码,并成为了dmd的第二大贡献者。Sean和Don两人都发起过改进D语言定义的提议并将其完成。另外还漏掉一点,他们都是非常优秀的“黑客”,很喜欢与人交流,无论是面对面的还是在线的,都包括在内。很难想象如果没有他们,D语言会发展到何处去。

最后,我要向审读本书的人表示诚挚的谢意,感谢他们给予的理解。他们完成的是一项艰苦却又费力不讨好的工作。没有他们,本书就不会有现在的样子(如果大家不喜欢它,还请多多谅解——你可以想象一下它过去应该有多么糟糕)。因此,请允许我向这些人表示我的谢意,他们就是:Alejandro Aragón、Bill Baxter、Kevin Bealer、Travis Boucher、Mike Casinghino、Àlvaro Castro Castilla、Richard Chang、Don Clugston、Stephan Dilly、Karim Filali、Michel Fortin、David B. Held、Michiel Helvensteijn、Bernard Helyer、Jason House、Sam Hu、Thomas Hume、Graham St.Jack、Robert Jacques、Christian Kamm、Daniel Keep、Mark Kegel、Sean Kelly、Max Khesin、Simen Kjaeraas、Cody Koeninger、Denis Koroskin、Lars Kyllingstad、Igor Lesik、Eugene Letuchy、Pelle Mansson、Miura Masahiro、Tim Matthews、Scott Meyers、Bartosz Milewski、Fawzi Mohamed、Ellery Newcomer、Eric Niebler、Mike Parker、Derek Parnell、Jeremie Pelletier、Pablo Ripolles、Brad Roberts、Michael Rynn、Foy Savas、Christof Schardt、Steve Schveighoffer、Benjamin Shropshire、David Simcha、Tomasz Stachowiak、Robert Stewart、Knut Erik Teigen、Cristian Vlăsceanu、Leor Zolman。

Andrei Alexandrescu

2010年5月2日,星期日

[1] 这是一个航空专业术语,作者是想借用它来形容D语言表达力的强大。——译者注

[2] bug原意为臭虫、错误、漏洞。本书统一将其译作“错漏”。——译者注


大家都知道该从哪里开始,那我们就不废话了,请看下面的代码:

import std.stdio; 
void main() { 
    writeln("Hello, world!"); 
}

如果你对其他语言也有所了解,那么是否会觉得这个特别简单呢?甚至可能还会有一点失望:D语言走的并不是脚本语言的路线,也不像它们那样支持语句至上的原则。语句至上原则会引入很多全局变量。随着程序规模的变大,这些全局变量会迅速变成一种累赘;而D语言提供的是一种在main之外执行代码的能力,而且提供的方式更结构化。如果大家对精度的要求很严格,那么请放心:void mainint main其实是等效的。如果执行过程顺利结束,它们都会向操作系统返回“成功”(代码为0)标识。

我们先不着急往前推进。传统的“Hello, world!”程序不是用来讨论一门语言所拥有的功能的,而是让大家可以从一开始就使用这种语言来编写并运行程序。如果大家没有合适的能提供透明构建功能的IDE,那么选用命令行的方法也不会太难。将上述代码录入到一个文件,如hello.d,运行控制台shell,然后输入下面的命令:

$ dmd hello.d 
$ ./hello 
Hello, world!
$ _

其中,$表示的是命令提示符(在Windows系统中,它是“C:\Path\To\Dir>”;在OSX、Linux和Cygwin这种类Unix的系统中,它是“/path/to/dir %”)。只需大家应用一点点操作系统技巧,就能让程序自动编译和运行起来。在Windows系统里,大家或许会想到将shell命令run与程序rdmd.exe联系起来。对于类Unix系统,由于它们支持使用“shebang符号”(井号加叹号)方式来启动脚本,而且D语言也支持这种语法,因此将下面一行添加到程序hello.d的最开始位置便能直接运行它。

#!/usr/bin/rdmd

在完成如上修改后,只需要在命令提示符处简单地输入下面的内容(chmod命令只需要执行一次):

$ chmod u+x hello.d
$ ./hello.d 
Hello, world! 
$ _

在所有操作系统里,程序rdmd都表现得非常智能。它能够缓存已生成的可执行文件,因此重新编译只会针对被更改过的程序,并不需要每次运行它时也编译一次。最终结果就是整个编译过程非常迅速,从而形成一个快速的“编辑-运行”周期——这一点对于简短的脚本和大型程序都同样有效。

应用程序自身是从下面指令开始的:

import std.stdio;

该指令会指示编译器去搜寻一个名叫std.stdio的模块,并解析出它所提供的符号。import相当于C和C++语言里的#include预处理指令, 但在语义上却更接近于Python语言的import——不会产生文本包含,获取到的仅仅是一张符号表。重复导入同一文件并不会产生新的导入。

遵照C语言所创立的受人敬重的传统,D语言程序也是由一组遍布于多个文件的声明所构成的。这些声明还可能会引入其他的内容,如类型、函数和数据等。我们的第一个程序就定义了一个不带任何参数的主函数——main,该函数返回的是“空内容”,即void。当main函数被调用时,它又会去调用writeln函数(当然,此函数已事先在std.stdio模块里进行了定义),同时还会向该函数传递一个字符串常量。后缀ln表示writeln函数会在输出文本里添加一个换行符。

下面的内容是对D语言世界的一个快速浏览。为说明D语言的基本概念,会引入一些小段的示例程序。其重点是想让大家尽快建立起对D语言的感性认识,而不是要进行刻板的定义。后面的章节则会对语言中的各个部分进行更详细的说明。

外国人有多高?大家是否有过这样的好奇呢?现在我们来编写一个简单的程序,它实现的是用“英尺+英寸”和“厘米”两种单位显示一系列常见身高值。

/* 
  将一系列以英尺和英寸两种单位表示的身高值转换成以厘米为单位的值 
*/ 
import std.stdio; 

void main() { 
    // 不太可能变化的值 
    immutable inchesPerFoot = 12; 
    immutable cmPerInch = 2.54; 

    // 循环输出 
    foreach (feet; 5 .. 7) { 
        foreach (inches; 0 .. inchesPerFoot) { 
            writeln(feet, "'", inches, "''\t", 
                 (feet * inchesPerFoot + inches) * cmPerInch); 
        } 
    } 
}  

执行此程序,会输出一个列表,它含有两个整齐的列:

5'0''     152.4 
5'1''     154.94 
5'2''     157.48 
...
6'10''    208.28 
6'11''    210.82

foreach (feet; 5 .. 7) { ... }是一个迭代语句,它定义了一个整型变量feet,并依次将其设置为56。请注意,不包括7,这是一个右开区间。

与Java、C++和C#一样,D语言也支持“/*多行注释*/”和“//单行注释”两种注释风格(另外还支持文档注释,这个会在后面说明)。这里还有一个很有意思的细节,即我们的小程序引入数据的方式。首先,它定义了两个常量:

immutable inchesPerFoot = 12;
immutable cmPerInch = 2.54;

这些带有关键字immutable的常量绝对不会被更改。跟变量一样,常量也可以不必提供明确的类型说明,编译器会根据该符号的初始值推断出实际的类型。因此,文字12其实是告知编译器:inchesPerFoot是一个整型变量(在D语言里使用int来表示);同样地,文字2.54会让cmPerInch变成一个浮点常量(类型为double)。继续往前,我们还会注意到,feetinches的定义也充满了同样的魔法,因为它们表面上是变量,却没有明确的类型修饰。与下面的做法相比,那样做并没有让程序变得不安全;恰好相反,它让程序变得更加简洁:

immutable int inchesPerFoot = 12; 
immutable double cmPerInch = 2.54; 
...
foreach (int feet; 5 .. 7) { 
    ...
}

只有当根据上下文能够进行无歧义的类型推导时,编译器才允许忽略类型声明。刚才我们提到了类型,那就先暂停一下,一起来看看可以使用的数字类型有哪些。

按大小递增的顺序,有符号整数类型包括byteshortintlong,它们对应的大小分别为8、16、32和64位。其中每一种类型还对应有一个同样大小的无符号类型,即ubyteushortuintulong。(在C语言里并没有“无符号”修改符。)浮点类型包括float(32位IEEE 754单精度数)、double(64位IEEE 754)和real(它与机器的浮点寄存器允许的范围一样大,但不会小于64位;例如,在Intel机器上,real即为所谓的IEEE 754扩展双精度79位格式)。

现在回到稳健的整数领域,像42那样的字面量可以赋给任何数字类型,只不过编译器会检查目标类型所允许的范围大小,看它能否容纳下该值。下面的声明与省略掉byte的声明效果一样:

immutable byte inchesPerFoot = 12;

这是因为对于12来说,8位与32位类型都可以用来表示。默认情况下,如果目标类型需要由数字进行推导(如这里的示例),那么整型常量的类型就是int,而浮点常量的类型则为double

运用这些类型,再借助算术操作符和函数,就能够在D语言里构建出大量的表达式。操作符及其优先级都可以从其他与D语言同族的语言里找到,如+*/%用于基本的算术运算,==!=<><=>=用于比较运算,fun(argument1, argument2)用于函数调用等。

再回到那个英寸转换为厘米的程序,这里有两个关于调用writeln函数的细节需要注意。一个细节是writeln接受了5个参数(它与“Hello, world!”程序里的那个函数完全不一样)。这跟其他语言的I/O操作非常相似,如Pascal语言的writeln、C语言的printf或者C++语言的tout,D语言的writeln函数能够接受个数可变的参数,即它是一个“可变参数函数”(variadic function)。在D语言里,用户也可以定义自己的可变参数函数(这一点跟Pascal语言有所不同),而且这些函数还总是类型安全的(这一点跟C语言又有所不同)。另一个细节是对writeln的调用将格式信息与要被格式化的数据混合在一起,而且很难看。人们总是希望能将数据与呈现分离开来,因此这里也可以使用格式化的输出函数writefln

writefln("%s'%s''\t%s", feet, inches, 
    (feet * inchesPerFoot + inches) * cmPerInch);

调整后的调用输出了完全一样的结果,前后的差别在于writefln的第一个参数完整地对格式进行了描述。与C语言的printf一样,%会引入一个格式说明符,例如:%d代表的是整数,%f代表的是浮点数,而%s代表的是字符串。

如果大家使用过printf,那么可能会有一种亲切感,不过还有一个特别小的细节需要注意一下:我们要输出的是整数和双精度数,可它们怎么都使用了格式说明符%s来进行格式描述,难道它不只是用于字符串描述吗?答案很简单。D语言的可变参数功能让writefln可以访问到传入参数的实际类型,其中包含两层意思:(1)%s的含义会被扩展为“任何参数的默认字符串表示”;(2)如果参数的实际类型与该说明符不匹配,则会给出一个明确的错误,而不会试图使用怪异的说明符去对printf调用进行错误的格式化(关于调用printf时使用不可信格式字符串所导致的潜在安全漏洞,这里就不多说了)。

跟同族的语言一样,在D语言里,后面紧跟有一个分号的任何表达式就是一条语句,如“Hello world!”程序里的writeln调用,它后面就带有一个;。语句的作用是要简化表达式的计算。

D语言也是“花括号块作用域”一族的成员,即可以通过“{”和“}”将多条语句括起来形成一组语句——这是很有必要的,例如需要在一个foreach循环里完成多件事情。如果恰好只有一条语句,则可以完全省略花括号。事实上,那个执行身高转换的双重循环可以改写成下面的样子:

foreach (feet; 5 .. 7)
   foreach (inches; 0 .. inchesPerFoot)
      writefln("%s'%s''\t%s", feet, inches, 
        (feet * inchesPerFoot + inches) * cmPerInch);

对于单条语句,省略花括号的好处在于可以缩短代码,而坏处就是会让编辑变得比较费事(在代码维护阶段,随着语句的调整,通常都需要添加或删除花括号)。人们习惯利用缩进和花括号来实现整齐分隔。事实上,只要你保持一致,这种事情也并不是那么重要。举个例子,因为排版的关系,本书所使用的样式(即使对于单条语句也使用了括号来将其围住:正括号位于开始行,反括号则独立占一行)就与作者平日里代码的样式有很大不同。如果不会因这样做而变成一个“狼人”的话,那就继续吧。

Python语言采用了一种与众不同的样式——形式遵从结构(form follows structure),它通过缩进的方式恰如其分地来表示块结构,空白符也要起作用。这对于其他语言的程序员来说有些奇特,但Python程序员一直坚持这一点。D语言在通常情况下会将空白符完全忽略掉,不过也有进行特别地设计,以便更好地解析它们(如解析时不需要去理解这些符号的意义)。因此,一个好的项目应该实现一个简单的处理程序,以便在D语言里也支持使用Python语言的缩进样式,进而不用去忍受编译、运行和调试程序过程中出现的种种不便。

if语句的一般形式如下:

if (<表达式>) <语句1> else <语句2> 

有个名叫“构造定理”(见参考文献[10])的理论结论,它证明我们只使用复合语句就能够实现任何算法:使用if进行测试,而循环可以使用forforeach。当然任何实际语言提供的内容远不止这些,D语言也不例外,一起继续往下看吧。

我们先跳过必不可少的main函数定义,一起来看看在D语言里如何定义其他的函数。函数定义遵从的模式也可以从其他与Algol类似的语言里找到:首先是返回类型,接着是函数名称,最后是形式参数(formal parameter)[1]——它是一个使用圆括号括起来的以逗号隔开的列表。例如,想要定义一个名叫pow的函数,它接受一个double和一个int类型,并返回一个double,则可以写成这样:

double pow(double base, int exponent) { 
      ...
} 

每一个函数参数(在上面的示例中为baseexponent)都附带有一个类型,除此之外,还可以额外添加一个存储类别(storage class),用以声明函数被调用时参数传递到函数内部的方式。默认情形下,实参是通过值的方式进行传递的。如果在某个形参的类型里使用了存储类别ref,那么该形参就会直接绑定到输入实参上;这样,对形参的更改便会立即直接反映到与之对应的外部实参上。举例:

import std.stdio; 

void fun(ref uint x, double y) { 
   x = 42; 
   y = 3.14; 
} 
void main() { 
   uint a = 1; 
   double b = 2; 
   fun(a, b); 
   writeln(a, " ", b); 
} 

上面的程序会输出42 2。这是因为x类型为ref uint,意味着对x的赋值其实是赋给了a。而同时,对y的赋值并没有对b产生影响,这是因为函数funy处理成了一个私有副本。

在本段介绍的最后要讨论一下装饰类型是inout。简单地讲,函数里的in用于保证参数只会进行查看,而不会被更改。在函数参数里使用out的效果近似于ref,不同之处在于参数会在进入到函数时被初始化成默认值。每一个类型T都会定义一个初始值,其形式为T.init。用户自定义类型可以定义自己的init

关于函数还有很多值得说的。你可以将函数传递给其他函数,可以将它们彼此嵌套在一起,可以让函数保存自己的本地环境(完备的语法闭包),可以创建并随意地操作匿名函数(λ函数)以及其他一些有用的小功能。这些会在后面依次进行讨论。

数组与关联数组(后者也就是大家常说的散列表或散列)可能是计算史上最常用到的复合数据结构,其次要算是Lisp的列表。大量的程序需要的无非就是某种类型的数组和关联数组,因此现在一起来看看D语言是如何实现它们的。

例如,按照下面的要求编写一个简单的程序:

读入一段由使用空白符隔开的单词所组成文本,并为不同的单词关联一个唯一数字。最后,以“标识 单词”的形式输出各行。

这段小脚本在进行文本处理时相当有用:一旦建立好词汇表,就只需要去操作那些数字(代价更小),从而避开对所有单词的操作。有一种构建此类词汇表的方法就是:在一个将单词映射成整数的词典里,按照单词出现的顺序进行累计。当添加新的映射时,我们只需要确保该整数是唯一的就行(比较可靠的方法就是只使用词典的当前长度,最后标识就是0,1,2,…)。现在一起来看看使用D语言会怎么做。

import std.stdio, std.string, std.algorithm;

void main() {
   uint[string] dictionary;
   foreach (line; stdin.byLine()) {
      // 将句子分成单词组,并将每一个单词添加到词汇表
      foreach (word; splitter(strip(line))) {
         if (word in dictionary) continue; // 什么也不做
         auto newID = dictionary.length;
         dictionary[word.idup] = newID;
         writeln(newID, '\t', word);
      }
   }
}

在D语言里,假如关联数组(散列表)实现的功能是将类型K映射到类型V,则其类型需被标识成V[K]。因此,类型为uint[string]的变量dictionary会将字符串映射到无符号整数——正是我们所需要的,可用于存储“单词-标识”映射关系。如果关键字word能在关联数组词典中查找到,则表达式word in dictionary的结果将是一个非零值。最后,通过dictionary[word] = newlD来完成新单词的插入。

尽管在上面的脚本中未做明确说明,但类型string实际上就是一个字符数组。一般情况下,类型为T的动态大小数组可以表示成T[],并且存在多种分配方式,如:

int[] a = new int[20]; // 20个整数,初始值为0
int[] b = [ 1, 2, 3 ]; // 包含元素1、2和3的数组

与C语言数组不同,D语言的数组知道自己的长度,例如arr.length访问的就是数组arr的长度。对arr.length赋值会导致数组重新分配。数组访问是带边界检查。如果有代码不怕承担缓冲区溢出的风险,也可以将数组的指针提取出来(方法是使用arr.ptr),然后使用无边界检查的指针运算。另外,如果的确还想要“硅片”提供的所有内容,那么就使用编译器选项来禁用边界检查。这样就为通往安全性铺平了道路:默认情况下,代码是安全的;然而也可以通过付出更多的努力来获得微量的提速。

下面展示的是如何使用已经熟悉的foreach语句的新形式实现数组的迭代操作:

int[] arr = new int[20]; 
foreach (elem; arr) { 
   /* ... 使用 elem ...*/ 
}

上面的循环将elem依次绑定到了数组arr的每一个元素。给elem进行赋值并不会影响到arr里的元素。想要更改数组,只需要使用关键字ref即可:

// 将arr的所有元素置0 
foreach (ref elem; arr) { 
   elem = 0; 
}  

现在我们已了解foreach是如何处理数组的,接下来再进一步,看一个更有用的操作。如果在进行迭代时,还需要数组元素的索引值,依然可以使用foreach

int[] months = new int[12]; 
foreach (i, ref e; months) { 
    e = i + 1; 
}

上面的代码会创建一个包含1, 2, …, 12的数组。该循环与下面稍显冗长的代码具有同样的效果。下面的代码是使用foreach来迭代一段数字:

foreach (i; 0 .. months.length) { 
   months[i] = i + 1; 
} 

D语言也提供有静态大小的数组,其表示形式类似于int[5]。除了少量有特殊需求的程序需要它以外,应该首选动态大小的数组,这是因为多数情形下事先并不知道数组的确切大小。

数组具有浅复制语义,即将一个数组变量复制到另一个数组时,并不会复制整个数组,仅仅是针对同一个底层存储产生一个新的视图。如果想得到副本,只需要使用数组的dup特性即可。

int[] a = new int[100]; 
int[] b = a; 
// ++x 会递增x的值
++b[10];     // b[10]现在为1,而a[10]也一样 
b = a.dup;   // 将 a 整个复制到 b 
++b[10];     // b[10]现在为2,而a[10]仍为1 

数组分割(array slicing)是一项强大的功能,它允许引用数组的某部分,而无需进行数组数据的复制。举例来说,现编写一个函数binarySearch来实现二分搜索算法:给定一个有序数组和一个值,binarySearch要快速地返回一个布尔值,以表明该值是否存在于此数组当中。D语言的标准库提供了一个函数以一种更通用的方式实现这个功能,并且会返回更多的信息,而不仅仅是一个布尔值。不过实现它需要了解更多的D语言功能。把我们的目标提高那么一点,让编写出的binarySearch不仅可用于整数数组,还能用于其他任何类型的数组,只需要该类型支持使用“<”进行比较操作。实现它其实也不会花费太多时间,泛型版的binarySearch函数如下:

import std.array;

bool binarySearch(T)(T[] input, T value) {
   while (!input.empty) {
      auto i = input.length / 2;
      auto mid = input[i];
      if (mid > value) input = input[0 .. i];
      else if (mid < value) input = input[i + 1 .. $];
      else return true;
   }
   return false;
}

unittest {
   assert(binarySearch([ 1, 3, 6, 7, 9, 15 ], 6));
   assert(!binarySearch([ 1, 3, 6, 7, 9, 15 ], 5));
}

在函数binarySearch的签名[2]处有一个记号(T),它会引入一个类型参数T。类型参数可以在函数的常规参数列表里使用。当binarySearch被调用时,它会根据接收到的实参对T进行推导。如果想显式指定T(如想要达到双重检查的目的),则可以编写出类似下面的内容:

assert(binarySearch!(int)([ 1, 3, 6, 7, 9, 15], 6)); 

它显露出来的信息是一个泛型函数需要使用两对括号参数来进行调用。首先使用!(...)括起来的编译时参数,接着使用(...)括起来的运行时参数。将分属两个范畴的内容混合在一起的做法是经过深思熟虑的,不过实验表明这种统一形式带来的麻烦比它消除的还要多。

如果你熟悉Java、C#或C++里类似的功能,自然会注意到D语言与这些使用尖括号<>来指定编译时参数的语言有着明显的差异。这是一个人为的决定,其目的在于想尽量避免由C++语言的经验所显露的高额代价,如解析难度的增加、大量的特殊规则和随意决断以及用户难辨的晦涩语法[3]。此难题的根源在于<>事实上同时也是比较操作符[4]。如果把它们当作分隔符,当允许表达式插入这些分隔符内部时,很容易产生歧义。由此导致这些分隔符很难进行配对。Java和C#之所以没这种问题,原因在于它们不允许“<”和“>”内部出现表达式。不过这种做法让它们为获得某些可能存在的好处,而限制了自己以后的扩展能力。将传统的一元操作符“!”扩展成二元操作符来使用,同时使用传统的圆括号(这个保证你的配对总是正确的),D语言通过这种方法便能支持将表达式当做编译时参数来使用,并且由此简化人和编译器的工作。

binarySearch的实现里,另外一个有意思的细节是使用auto来实现类型的推断:imid的类型都是根据它们的初始化表达式推断出来的。

为保持良好的编程习惯,binarySearch还附带有一个单元测试。单元测试就是一块语句,其前缀是关键字unittest(一个文件可以根据需要包含多个单元测试,你明白这意味着什么——几乎多到满额)。将参数标志–unittest传递给编译器,便可以实现在进入main函数之前运行单元测试。虽然unittest看起来像是一个小功能,不过它让插入小测试的操作变得很容易(如果达不到这一点,则让人有些尴尬),从而帮你实现良好的编程风格。当然,如果你是处于高端的思想家,喜欢先看到单元测试,再看到具体实现,那么也可以很轻松地实现将单元测试挪到binarySearch的前面。在D语言里,处于模块级的符号,其语义绝不会依赖于与其他符号的相对顺序。

分割表达式input[a .. b]会返回input的一个分片,其范围从索引值a直至索引值b(但不包括它)。如果有a == b,那么将会生成一个空的分片;而如果有a > b,则会抛出一个异常。一次分割并不会引起动态内存分配,它仅仅是该数组里某一部分的一个别名。在索引表达式或分割表达式里,$代表的是正被访问的那个数组的长度。例如,input[0 .. $]input代表的其实是一个内容。

虽然binarySearch看起来移动了大量的数组,但根本就不会有数组重新分配。所有input的分片都与原始的那个input共享同一个内存空间。与传统使用索引值的实现相比,此实现的运行效率绝不可能更低,但它却更容易理解,因为它操作的状态更少。说到状态,现在我们一起来编写一个binarySearch的递归实现,它完全没有对input重新赋值:

import std.array

bool binarySearch(T)(T[] input, T value) {
   if (input.empty) return false;
   auto i = input.length / 2;
   auto mid = input[i];
   if (mid > value) return binarySearch(input[0 .. i], value);
   if (mid < value) return binarySearch(input[i + 1..$], value);
   return true;
}

与相应的迭代实现相比,此递归实现毫无疑问更简洁。它也一样地很高效,这是因为有一项流行的编译器技术尾部调用消除(tail call elimination),可以很容易优化递归调用。关于尾部调用消除技术,扼要地讲就是,如果某个函数的return语句仅仅是使用了不同的参数来调用其自身,那么编译器会修改这些参数,并产生一个跳转到函数开始处的操作。

现在来编写另一个有用的程序:统计文本里的不同单词数。想知道话剧《哈姆雷特》里哪一个单词的使用次数最多吗?马上就能实现。

下面的程序使用了一个关联数组将string类型映射到uint类型,而整个结构与前面的构建词汇表示例很接近。在添加一个简单的输出循环之后,一个有用的频率统计程序便完成:

import std.stdio, std.string;

void main() {
   // 计算统计
   uint[string] freqs;
   foreach (line; stdin.byLine()) {
      foreach (word; split(strip(line))) {
         ++freqs[word.idup];
      }
   }
   // 输出统计
   foreach (key, value; freqs) {
      writefln("%6u\t%s", value, key);
   }
}

一切就绪,现在上网访问链接http://erdani.com/tdpl/hamlet.txt,下载文件hamlet.txt,然后运行此程序便可得到如下输出:

          1    outface
          1    come?
          1    blanket,
          1    operant
          1    reckon
          2    liest
          1    Unhand
          1    dear,
          1    parley.
          1    share.
          ...

很遗憾,这里的输出并不是有序的,而且无论哪一个单词排在最前面都很难保证它恰好是频率最高的。这一点都不奇怪,为了让实现的基本操作执行起来尽可能快,关联数组允许在内部以任意顺序存储数据。

为将输出排序,使得最高频率的单词排在第一位,只需要将此程序的输出利用通道传送到sort-nr(按数字反向排序)即可,不过这是一种“欺骗”方式。为将排序操作集成到程序里,我们需要将最后一个循环替换成下面的代码:

// 输出统计
string[] words = freqs.keys;
sort!((a, b) { return freqs[a] > freqs[b]; })(words);
foreach (word; words) {
    writefln("%6u\t%s", freqs[word], word);
}

特性.keys产生的是关联数组freps的所有键值,在这里它就是一个字符串数组。此数组是重新分配的,这样做的原因在于我们需要调整字符串的顺序。现在来看看下面这段代码:

sort!((a, b) { return freqs[a] > freqs[b]; })(words);

它正好符合我们已提及的模式:

sort!(<编译时参数>)(<运行时参数>);

去掉一层圆括号!(...)之后,会得到与下面内容一样的一组符号——看起来像是一个不完整的函数,忘了提供参数类型、返回类型以及函数的名称:

(a, b) { return freqs[a] > freqs[b]; }

它就是“λ函数”—— 一种简短的匿名函数,通常用来传递给其他函数。λ函数在很多地方都能派上用场,D语言尽了最大的努力来剔除那些定义λ函数时不必要的语法包袱。参数类型跟返回类型一样都可以进行推断,这一点极具意义,因为λ函数的主体就明明白白地放置在那里,作者、读者以及编译器都可以看到,因而不会出现误解,也不会对模块化原则产生破坏。

关于在本示例里定义的λ函数,其中还有一个小细节需要说明一下。该λ函数访问了属于main函数局部(即是说,它不是全局的或静态的)的变量freps。这项特性让D语言更像是Lisp语言,而非C语言,并且使λ函数的功能更为强大。尽管传统意义上这种功能伴随有运行时成本(需要间接的函数调用),D语言却保证了不会出现间接调用(从而完全有了实现内联功能的机会)。

调整后的程序输出结果如下:

 929 the 
 680 and 
 625 of 
 608 to 
 523 I 
 453 a 
 444 my 
 382 in 
 361 you 
 358 Ham.
 ...

正如所期望的,最常使用的单词也是频率最高的,不过“Ham.”除外。这并不表明剧中的主人公有强烈的烹饪爱好,它仅仅是所有哈姆雷特台词行的前缀。很显然,通篇他有358次需要进行说明,比其他任何人都多。如果向下浏览这个列表,就会发现下一个说话最多的人是国王,却只占了116行,还不到哈姆雷特的三分之一。而Ophelia则有些沉默寡言,仅占58行。

既然我们已经得到了《哈姆雷特》,现在进一步对文本进行分析。例如,我们想收集一些关于所有剧中人物的信息,如他们总共说了多少个单词,以及他们的词汇量有多丰富。为实现这一点,需要将几个数据项目关联到每一个人物上去[5]。想要将这些信息组合到一起,可以定义一个类似下面那样的数据结构:

struct PersonaData {
   uint totalWordsSpoken;
   uint[string] wordCount;
}

在D语言里,可以使用结构(struct),也可以使用类(class)。它们有许多共同之处,但也存在一些差异:结构是值类型,而类表示的是动态多态性,并且它只能通过引用进行访问。这种方式让人有点困惑,与分割相关的错漏以及像“// 别这样!不能进行继承!”这样的注释都不存在。当设计一个类型时,首先需要弄清楚它是一个单值还是一个多态引用。C++语言支持定义“性别模糊”的类型,不过它们的用途很少、易出错且让人生厌,因此在设计时要尽量避免使用它们。

在这里,我们只想要收集一些数据,并没有过多的想法,因此使用结构会是一个不错的选择。现在来定义一个可以将人物名字映射到PersonaData值的关联数组:

PersonaData[string] info;

所有要做的事就是根据hamlet.txt来填充info。这需要费些功夫,因为一个人物的段落可能占用了好几行,因此我们需要进行一些简单的处理来将多个物理行合并成段落。为弄明白怎么做,我们来看一下hamlet.txt里的一小段内容,原样排列如下(为看起更清晣一些,对顶头的空格符进行了标识):

␣␣Pol. Marry, I will teach you! Think yourself a baby
␣␣␣␣That you have ta'en these tenders for true pay,
␣␣␣␣Which are not sterling. Tender yourself more dearly,
␣␣␣␣Or (not to crack the wind of the poor phrase,
␣␣␣␣Running it thus) you'll tender me a fool.  
␣␣Oph. My lord, he hath importun'd me with love
␣␣␣␣In honourable fashion.
␣␣Pol. Ay, fashion you may call it. Go to, go to!

Polonius关于“走吧[6]”的热情到底是不是致使他死亡的原因呢?即使到今天,其结果也仍然只是个猜测。我们不去深究他的死因,只关心一下每一个人物的台词行是什么样子:开始恰好是两个空格,紧跟着是人物名,然后是一个句点和一个空格,最后是行的实际内容。如果一个逻辑行扩展到了多个物理行,那么连续部分恰好是以四个空格作为引导。通过正则式引擎(在std.regex模块里可以找到)便可以完成这种简单的模式匹配,但我们要学习使用数组,因此这里通过人工实现匹配。我们只借助了布尔函数a.startsWith(b),它定义在std.algorithm模块里,能够识别出a是不是由b引导。

main函数会读取所有输入行,将它们连接成逻辑段落(不符合模式的所有内容都忽略掉),将整个段落传递给累计函数,然后输出期望的信息。

import std.algorithm, std.conv, std.ascii, std.regex,
    std.range, std.stdio, std.string;

struct PersonaData {
   uint totalWordsSpoken;
   uint[string] wordCount;
}

void main() {
   // 累计剧中人物的信息
   PersonaData[string] info;
   // 填充info
   string currentParagraph;
   foreach (line; stdin.byLine()) {
      if (line.startsWith("    ")
            && line.length > 4
            && isAlpha(line[4])) {
         // 人物在一个连续行里
         currentParagraph ~= line[3 .. $];
      } else if (line.startsWith("  ")
            && line.length > 2
            && isAlpha(line[2])) {
         // 人物说话
         if(!currtnt Paragraph.empty)
         addParagraph(currentParagraph, info);
         currentParagraph = to!string(line[2 .. $]);
      }
   }
   // 完成之后输出收集到信息
   printResults(info);
}

在了解数组是如何发挥作用之后,此段代码也就不言自明了。请把重点集中到!string(line[2..$])上面去。为什么需要它,我们还遗漏了什么吗?

foreach循环会从标准输入设备stdin里读取内容,并将文本行顺序存放到变量line里。由于每读入一行就重新分配一个缓冲区是很浪费的操作,因此每次执行循环时byline都会再次利用line的内容。line自身的类型为char[] ——字符数组。

检查处理了每一行,然后丢掉它,一切都很顺畅。不过如果有代码想存储行内容,那么它最好能创建一个副本。很显然,currentParagraph就是用于存储文本的,因此需要进行复制。于是有了to!string,它能将任何表达式转换成一个字符串。字符串(string)类型自身是不可能被改写的,to会负责复制为获得这个保证所需要的所有事情。

现在,如果我们不使用to!string,这段代码依然可以编译,但结果就可能会变样,这种错漏也是很难被发现的。让程序的某一部分去修改存放于程序其他部分的数据是很难进行跟踪的,因为这是一个非局部的操作(在一个大程序里,一个人要忽略多少个to调用呢?)。还好它不属于这种情况,linecurrentParagraph的类型反映出了它们各自的功能:line的类型为char[],即字符数组,可以在任何时候改写它的内容;而currentParagraph的类型为string,它也是一个字符数组,但无法单个进行修改。(好奇一下:string的完整名称为immutable(char)[],准确的含义是“不可变字符的连续区域”。在第4章会讨论字符串)。它们引用的都是同一个内存内容,因为line会改变currentParagraph的值。因此编译器会拒绝编译这种错误的代码,并需要一个副本。使用to!string来进行转换,大家都高兴。

另一方面,四处复制字符串时,并不需要复制底层的数据——它们可以全部引用同一块内存,因为已非常明确它们是绝不会被改写的,这也就使得字符串的复制同时拥有了安全和效率。更进一步,字符串在进行跨线程共享时也不会有任何问题,同样的原因,绝对不会有竞态出现。不变性(immutability)其实真的很强大。如果有时需要精确地修改某个单字符,则可以临时操作char[]

上面定义的PersonaData非常简单,不过结构不仅能定义数据,也可以定义其他的实体,像private区块、成员函数、单元测试、操作符、构造函数和析构函数。默认情况下,结构的每一个数据成员都会被初始化成自己的默认值,整数为0,浮点数为NaN(不是一个数字,Not a Number)[7];数组和其他间接访问类型为null。现在来实现addParagraph,它会完全分割文本行,然后放置到关联数组里。

main函数处理的行中有这么一种形式"Ham. To be, or not to be- that is the question."。我们需要从中找到第一句点“.”,以便能在具体行里分辨出人物的名字来。使用find函数可以实现这个功能。haystack.find(needle)返回的是haystack中第一次出现needle后的右边部分(如果没有出现,find函数会返回一个空的字符串)。此外,在收集词汇时,我们还要做一点清理工作。首先,我们必须把句子转换成小写的,这样不论单词是否大写都会被统计到同一个词汇元素上。这个调用tolower函数即可轻松实现。其次,我们必须消除一些干扰:标点符号算是一个,如“him.”和“him”就该统计为两个不同的单词。为实现词汇表的清理,需要向split函数传递一个附加参数,它的内容是一个用于消除所有杂质的正则表达式:regex("[ \t,.;:?]+")。利用此参数,split函数就会将[]之间提及的一系列字符当做单词分隔符。也就是说,我们只需要下面这样一小段代码便可以完成很多事情:

void addParagraph(string line, ref PersonaData[string] info) {
   // 找出人物和句子
   line = strip(line);
   auto sentence = std.algorithm.find(line, ". ");
   if (sentence.empty) {
      return;
   }
   auto persona = line[0 .. $ - sentence.length];
   sentence = toLower(strip(sentence[2 .. $]));
   // 得到对话里的单词
   auto words = std.regex.split(sentence, regex("[ \t,.;:?]+"));
   // 插入或更新信息
   if (!(persona in info)) {
      // 此人物第一次开口说话
      info[persona] = PersonaData();
   }
   info[persona].totalWordsSpoken += words.length;
   foreach (word; words) ++info[persona].wordCount[word];
}

addParagraph函数的主体是由更新关联数组的操作组成的。如果有人还未说话,则代码会往关联数组中插入一个空的、默认构造的PersonaData对象。因为默认构造的uint为零,而默认构造的关联数组为空,因此刚插入的对象已可以用于接收那些有用的信息。

最后,实现printResults函数来输出每一位人物的小结信息:

void printResults(PersonaData[string] info) {
   foreach (persona, data; info) {
      writefln("%20s %6u %6u", persona, data.totalWordsSpoken,
         data.wordCount.length);
   }
}

准备好测试了吗?保存并运行它吧!

       Queen     1104     500
         Ros      738     338
         For       55      45
        Fort      138     102
   Gentlemen        4       3
       Other      105      75
        Guil      349     176
         Mar      423     231
        Capt       92      66
        Lord       70      49
        Both       44      24
         Oph      998     401
       Ghost      683     350
         All       20      17
      Player       16      14
        Laer     1507     606
         Pol     2626     870
      Priest       92      66
         Hor     2129     763
        King     4153    1251
  Cor., Volt       11      11
   Both [Mar        8       8
         Osr      379     179
        Mess      110      79
      Sailor       42      36
     Servant       11      10
  Ambassador       41      34
        Fran       64      47
       Clown      665     298
        Gent      101      77
         Ham    11901    2822
         Ber      220     135
        Volt      150     112
         Rey       80      37

现在变得更有意义了。毫无悬念,我们的朋友“Ham”以绝对的优势摘得了桂冠。Voltemand(Volt)的角色比较有意思:他的话不多,但即使只有那么几句话,他也尽其所能展现出了丰富的词汇量,几乎未重复过一个字。将表达多些的王后与Ophelia进行一下对比可以发现:王后讲的单词比Ophelia多出了10%,但她多出的词汇量却不到25%。

在输出结果里有一些干扰(如"Both [ Mar"),这个对于勤奋的程序员来说很容易修复,但对那些重要的统计没什么影响。不过,修复最后这个小瑕疵也算是一种不错的指导性练习(推荐大家自行完成)。

面向对象特性对于大型程序很重要,因此当通过小示例介绍它们时显得有点滑稽。再加上出版要求不要过度使用表现形状、动物以及雇员等内容的示例,因此我们面临着不小的难题。此外还有一件事情,那就是小示例容易掩盖多态对象创建的问题,而这一点是非常重要的。这个差点把作者给难倒了!还好现实生活中有这么一个可以用上的例子,它以问题的形式存在,而且相对较小,但还没有一个令人满意的过程解决方案。下面我们要讨论的代码改写自一个很有用的awk小脚本,该脚本的成长早已超出了由其设计设置的各种隐含限制。我们一起来实现一个面向对象的解决方案,它同样小巧、完整和优雅。

假设要编写一个名叫stats的小统计程序,它同时带有一个简单的接口:stats可以接收统计函数作为命令行参数进行计算,它通过标准输入以空白分隔列表的形式收集要操作的数据,并在每一行输出统计结果。这里有一个会话样本:

$ echo 3 5 1.3 4 10 4.5 1 5 |  stats Min Max Average 
1 
10 
4.225 
$ _

随便哪个快速实现的脚本都可以完成这样的任务,不过随着统计函数个数的增长,实现起来就没那么快了。因此,我们需要一个更好的解决方案。我们暂时先从一些最简单的统计函数(minimummaximumaverage)着手,然后再想出一个扩展的设计,允许往后实现更复杂的统计函数。

有一种简单的方法,即只循环一下输入并计算出所有需要的统计值。这不是一个可扩展的设计,因为每当我们添加一个新的统计函数时,都必须对已有的代码“动手术”。如果只想要完成命令行里要求的计算,那么这个修改是很重要的。理想情形下,我们会将每一个统计函数限定在一个连续的代码块里。这样,当给程序添加新功能时,只需要简单地添加新的代码即可——正好符合“开-闭”原则[39]。

这种方法需要找出所有或者绝大部分统计函数所具有的共同点,其目的在于可以从一个地方、以一种统一的方式来操作它们。先做些说明,MinMax每次都输入一个数字,在输入完成之后立即得出一个结果。最后的结果就只有一个数字。另外,Average必须在事后进行处理(将累计的总和除以输入的个数)。还有,每一个算法都要维护好自己的状态。当不同的计算都服从某个统一的接口,并需要保留状态时,理所当然需要让它们成为对象,并定义一个形式接口,以便控制这些对象中的任何一个或全部。

interface Stat {
   void accumulate(double x);
   void postprocess();
   double result();
}

一个接口以一组函数的形式来定义所要求的行为。当然,所有声明实现该接口的类型都必须定义它们的声明所指定的所有函数。说到实现,首先一起来看看如何定义Min以便符合Stat的要求:

class Min : Stat {
   private double min = double.max;
   void accumulate(double x) {
      if (x < min) {
         min = x;
      }
   }
   void postprocess() {} // 什么也不做
   double result() {
      return min;
   }
}

Min是一个类——一种自定义类型,它将面向对象的很多内容引入到了D语言里。很明显,Min是通过“class Min : Stat”这样的语法实现Stat的,并且使用了完全一样的参数和返回类型来定义Stat的3个函数(否则,编译器不会认可Min)。Min仅保留一个私有成员变量min,它代表的是当前的最小值,accumulate会将其更新。min的初始值为最大可用数,这样第一个输入数据就会替换它。

在定义更多统计函数之前,我们先为stats程序编写一个测试驱动,它要读取命令行参数,创建合适的对象用于完成计算(例如,当在命令行传递Min时,进行最小值的计算),并通过接口Stat使用该对象。

import std.exception, std.stdio;

void main(string[] args) {
   Stat[] stats;
   foreach (arg; args[1 .. $]) {
      auto newStat = cast(Stat) Object.factory("stats." ~ arg);
      enforce(newStat, "Invalid statistics function: " ~ arg);
      stats ~= newStat;
   }
   for (double x; readf(" %s ", &x) == 1; ) {
      foreach (s; stats) {
         s.accumulate(x);
      }
   }
   foreach (s; stats) {
      s.postprocess();
      writeln(s.result());
   }
}

此程序做了不少工作,不过也就一句话的事儿。首先,main的声明标识与我们之前见到的有所不同——它会接收一个字符串数组。D语言运行时支持通过命令行参数初始化该数组。第一个循环会利用args初始化stats数组。在D语言里,第一个参数固定为程序自已的名字(跟其他语言一样),我们利用分割args[1 .. $]跳过第一个参数。现在来看下面这条语句:

auto newStat = cast(Stat) Object.factory("stats." ~ arg);

它有点儿长,不过套用一句情景剧台词——“我可以解释”。首先,当用做二元操作符时,的作用是连接字符串,因此,如果命令行参数为Min,则该串连接的结果就是"stats.Min",它会传递给函数Object.factoryObject是所有类对象的根,它定义的静态方法factory会接收一个字符串,查询一个在编译期间建立的小数据库,魔法般地创建一个对象(其类型名与传入字符串一致),并返回它。如果该类不存在,Object.factory会返回null。为保证调用成功,所有需要做的就是在同一文件的某一个地方定义一个名叫Min的类。对于很多有用的应用程序来说,根据类型名来创建对象是一项很重要的功能——事实上,有一些动态语言也因此将它作为一项核心功能。语言想要拥有更多静态的用于类型化的方法,则需要依赖于运行时支持(如D语言或者Java语言),或者需要通过程序员设计一个手工注册和发现的机制。

为什么是stats.Min而不是只有Min呢?D语言是高度模块化的,因此它没有可以让任何人放置内容的全局命名空间。每一个符号都在一个命名模块里,而且在默认情形下,模块的名称就是源文件的主名。因此,假定我们的文件叫stats.d,那么D语言便会认定在该文件里定义的每一个名字都属于模块stats

还有一个疑点需要解决。刚获得的Min对象的静态类型其实并不是Min。这听起来让人有些晕,但这是事实。你可以通过调用Object.factory("whatever")来创建任何对象,因此返回的类型应该是可能有的所有对象类型的公共部分,即Object。为获得新创建对象上正确的处理操作,我们必须将其转换成一个Stat对象,即使用众所周知的“强制类型转换”操作。在D语言里,表达式cast(T) expr会将表达式expr强制转换成类型T。涉及类和接口的强制转换操作都需要进行检查,因此我们的代码相当简单。

回顾一下,我们注意到,在main的前5行里已完成了大量坚实的工作。这是最难的部分,因为代码的其余部分是水到渠成的。第二个循环每次会读取一个数(readf负责此事),然后调用所有统计对象的accumulate。函数readf会返回按照指定格式成功读入的条目的数量。在这里,格式为" %s ",它表示的是一个由任意数量空格围起来的条目(其类型由所读入元素的类型决定,在这个示例里,x的类型为double)。最后,程序输出所有的结果。

Max的实现跟Min的实现一样简单,几乎所有内容都一样,只是在accumulate里有一点细微的调整。不管什么时候,当一个新的任务看起来与旧的任务差不多时,头脑里就应该浮现保持“有趣”但不“单调”的想法。重复任务就有了进行重用的机会,而能更好地挖掘出各类相似性的语言,其品质就应该是更胜一筹。需要我们解决的部分是MinMax之间特定的相似性(希望其他统计函数也能够有)。正如我们所想的,它们似乎都属于这样一类统计函数——它们都是递增式地构建结果,并且只需要一个数字来表征结果。那就将这类统计函数叫做“递增函数”吧。

class IncrementalStat : Stat {
   protected double _result;
   abstract void accumulate(double x);
   void postprocess() {}
   double result() {
      return _result;
   }
}

抽象类(abstract class)可以看成是一个部分承诺:它实现了某些方法,但不是全部。也正因如此,导致它无法单独工作。具体化一个抽象类的方式就是继承它并完成其实现。IncrementalStat会负责Stat的样板代码,但会将accumulate留给其派生类去实现。新的Min类便成为下面这个样子:

class Min : IncrementalStat { 
   this() { 
      _result = double.max; 
   } 
   void accumulate(double x) { 
      if (x < _result) { 
         _result = x; 
      } 
   } 
} 

Min也以特殊函数this()的形式定义了一个构造函数,用于初始化各自的结果。与构造函数情形一致,最终代码在各种事务状态的初始化方面节省了很多工作,特别是有这么一个事实存在:其他许多统计函数(如求和、偏差、平均、标准差)都遵从类似的模式。一起来看看求平均的实现,因为它更适合更多概念的介绍:

class Average : IncrementalStat { 
   private uint items = 0; 
   this() { 
      _result = 0; 
   } 
   void accumulate(double x) { 
      _result += x; 
      ++items; 
   } 
   override void postprocess() { 
      if (items) { 
         _result /= items; 
      } 
   }
}

首先,Average又引入了一个成员变量items,通过= 0语法(仅仅是借用了初始化的语法,其实它涉及的数量很多;还有正如在1.5节中讨论过的,整数类型的初始值为0),它被初始化成0。其次,Average定义了一个构造函数,它会将result设置为0;这是因为,与最小值或最大值有所不同,当个数为零时,平均值被定义为0。先将result初始化成NaN,然后又只是将它改写成0,虽然这个操作看起来显得有些多此一举,但它对所有优化器来说,将所谓的“无用赋值”(dead assignment)优化掉只是唾手可得的事。最后,Average改写了IncrementalStat里已定义过的postprocess函数。在D语言里,默认情况下你可以改写(继承后进行重新定义)所有类的成员函数,不过必须要指定override,这样才能避免发生各式各样的意外(如因为排版或基类型的更改而无法进行改写,或错误地进行了改写)。如果某个成员函数被设定为final,那么它就会禁止派生类改写该函数,这有效地阻断了动态方法的查找机制。

先运行一个简单的实验:

import std.stdio;

struct MyStruct {
   int data;
}
class MyClass {
   int data;
}

void main() {
   // 测试MyStruct对象
   MyStruct s1;
   MyStruct s2 = s1;
   ++s2.data;
   writeln(s1.data); // 输出 0
   // 测试MyClass对象
   MyClass c1 = new MyClass;
   MyClass c2 = c1;
   ++c2.data;
   writeln(c1.data); // 输出 1
}

看起来操作MyStruct对象与操作MyClass对象似乎有很大的不同。两种情况下我们都创建了一个变量来复制另一个变量的内容,之后修改那个副本(回想一下,++是一个一元操作符,它会递增其参数)。实验表明,在复制之后,clc2都引用了同一个底层存储内容,而相反的是,sls2是各自独立的。

MyStruct的行为服从“值语义”(value semantics):每一个变量都恰好引用一个值,将一个变量赋予另一个变量时,表示的是将该变量的状态复制为另个变量的状态。复制源不会发生变化,并且两个变量会继续独立地演变。MyClass的行为服从“引用语义”(reference semantics):值需要显式创建(在这里是通过调用new MyClass来实现的),将一个类变量简单地赋予另一个变量时,表示的是两个变量都引用同一个值。

值语义容易处理,易于推理,且支持小规模数据的高效实现。而另一方面,对于某个值,在没有对它进行复制的情况下,如果不通过某些特殊方法便想去引用它,那么有些重要的程序则难以实现。值语义不适合的情形有:组成自引用类型(列表和树),像包含有其父级窗体信息的子窗体这种互斥引用结构。严格的语言都会实现某些引用语义,争辩的焦点集中在默认值。C语言的值语义比较特殊,它支持通过指针的方式显式转成引用。C++语言除了指针之外,也定义了引用类型。有意思的是,纯的函数式语言并没有对使用引用还是使用值传语义进行区分,这是因为用户代码无法分辨出这种差异。由于纯的函数式语言不支持突变(mutation),因此你无法得知它们只是某个值的副本,还是其引用——它被冻结了,因此你无法通过更改它来验证其值是否已被共享。相反,纯的面向对象语言历来针对的都是突变密集的问题,并且明确地使用了引用语义。此外,它还进行了一些扩展——允许超大规模的灵活性,如动态调整全局系统的常量。最后,另外有一些语言则采用了将两者混合的方法,通过多级承诺支持值和引用两种类型。

D语言系统性地采用了混合方法。定义引用类型可以使用类(class)。定义值类型或混合类型,则可以使用结构(struct)。关于它们的详细内容分别会在第6章和第7章进行描述。它们的构造函数都具备专门针对这种基本设计选择的设施。例如,结构不支持动态继承和多态性(像前面的stats程序里所展示的那种情况),因为这类行为与值语义是不兼容的。对象的动态多态性需要引用语义,而且试图想将其混在一起的做法都会导致很多可怕的意外。例如,在C++语言里有一项普遍关注的危险——分割,即当不经意将一个对象当成一个值来使用时,其多态能力会突然被剥离。在D语言里,这种现象绝不会发生。

最后还有一个想法就是结构理所当然就成为实现更灵活设计的选择。通过定义结构,你可以表现出任何想要的语义,如热复制(eager-copy)值、惰性复制(lazy copying)或者介于两者之间的任何内容。惰性复制有时也叫写时复制(copy-on-write)或引用计数(reference counting)。你也可以通过使用类对象或者结构对象里的指针来定义引用语义。在另一方面,有一些操作可能要求能对一些相当高级的技术进行理解,而使用类提供的简单性和统一性则可以顺利跨越这个障碍。

由于本章是介绍性的,因此有一些概念和示例掩盖了某些细节,并且假设大家熟悉其他部分。当然,有经验的程序员可以轻易地找到方法来完成和改进这些示例。

不考虑特殊情况,本章包含的内容适合于所有人。如果你属于实践型和一丝不苟的代码编写者,可能已用赏识的眼光看到了数组和关联数组的简洁性。在各类大小项目中,这两个概念都各自改变了日复一日的代码简化操作。如果你喜欢面向对象,那么接口和类无疑很适合你,同时建议将D语言所具有的良好向上的扩展性应用到大型项目中。如果需要将D语言用于简短的脚本中,那么本章也展示了一些操作文件的简短脚本,它们都很容易编写和运行。

通常,概述的内容都比较长。不过,关注基础内容,并且确保简单的事情依然简单是很有意义的。

[1] 本书会固定使用“形参”(parameter)来暗指在函数内部接受到的和使用的值,而在谈论函数调用期间从外部传递到函数的值时,会使用“实参”(argument)。

[2] 函数签名(function signature)是由函数的原型构成,它所提供的是关于函数的一般性信息,如函数名、参数、函数所处的作用域以及其他一些信息,具体请参考 http://www.cs.unm.edu/~storm/C++/Programming Terms/FunctionSignatures.html。——译者注

[3] 如果你认识一个拥有超人能力的C++编码员,请问问他像object.templatefun<arg>()这样的语法是干什么用的,然后你就能看到氪气石(译注:超人故事里的神奇矿物)发挥作用了。

[4] 再“撒点儿盐”,<<>>也都是运算符。

[5] 很抱歉使用了“persona”一词。使用通俗的“character”一词的问题在于它可能造成与字符类型char之间的混淆。(在英文中persona和character都可以指人物,但后者容易跟字符类型char混淆,在中文里没有问题。——译者注)

[6] 即goto,这里作者借用了Polonius台词里的话。——译者注

[7] NaN对于浮点数来说并是一个好的默认初始值,但没办法,整数没有类似这样的初始值。


如果你曾用C、C++、Java或C#编写过程序,那么在理解D语言的基本类型和表达式时将会有一种轻车熟路的感觉——还可能觉得改进不少。在许多编程任务里,操作基本类型的值算得上是家常便饭,语言提供的与个人喜好存在交互的内容会伴随你很久很久,让你的生活充满欢乐或痛苦。世上没有完美的方法,许多迫切想要得到的方法往往都充满矛盾,还会受到主观因素的影响。因此,对于一门语言,想要找到一种让所有人都满意的解决方案是不太可能的。类型系统太严格会将责任置于错误的地方,因为就连最简单的惯用语法,程序员也必须要与编译器纠缠半天才能使其接受它;太宽松的话,又会让程序员陷入可验证性、效率或二者兼有的麻烦中。

在静态类型的编译型语言家族里,D语言的基本类型系统在其成员资格所界定的范围内创造了多个小奇迹。类型推断(type inference)、值域传播(value range propagation)、各类操作符重载决断以及精心设计的类型自动转换网,它们联合一起让D语言的类型系统成为一个考虑周全的辅助工具,通常它只会在真正有必要的时候,才会引发讨论和关注。

这些基本类型可以被划分成如下几类。

表2-1简要描述了各类基本类型,其中包含了它们的大小以及默认初始值。在D语言里,如果只是定义而没进行初始化,那么所有变量都会被默认值初始化。默认值的访问方式为<类型>.init,例如,int.init即为0。

表2-1 D语言基本类型

名称

描述

默认初始值(<类型>.init)

void

无值

n/a

bool

布尔值

false

byte

有符号8位

0

ubyte

无符号8位

0

short

有符号16位

0

ushort

无符号16位

0

int

有符号32位

0

uint

无符号32位

0

long

有符号64位

0

ulong

无符号64位

0

float

32位浮点数

float.nan

double

64位浮点数

double.nan

real

硬件最大值

real.nan

char

无符号8位,UTF-8

0xFF

wchar

无符号16位,UTF-16

0xFFFF

dchar

无符号32位,UTF-32

0x0000FFFF

符号(symbol)指的是一个大小写敏感的字符串,它由字母或下划线开头,后面紧跟任意数量的字母、下划线或数字。此规则的唯一例外是以两个下划线开头的符号,它们已被D语言的具体实现所预留。符号的开头只允许有一个下划线,它们常被用作成员变量。

关于D语言符号有一个细节很有意思,即对国际化的支持:在上面的定义中,字母不仅仅表示了A到Z以及a到z这样一些罗马字母表里的字符,同时还表示了C99标准[33]里的统一字符。

例如,abcα5、_、Γ_1、_AbCAb9C以及_9x都是有效的符号,但9abc、_ _以及_ _abc都不是。

如果符号的前缀是一个点,如.likeThis,那么便会在模块作用域里去查找它,而非在当前的词法嵌套域里查找。作为前缀的点操作符拥有与普通符号一样的优先级。

表2-2里列出的符号是D语言预留的关键字,任何情形下用户代码都不能定义它们。

表2-2 D语言关键字

abstract long static
alias else struct
align enum macro super
asm export mixin switch
assert extern module synchronized
auto
false new template
body final nothrow this
bool finally null throw
break float true
byte for out try
foreach override typeid
case function typeof
cast package
catch goto pragma ubyte
char private uint
class if protected ulong
const immutable public union
continue import pure unittest
in ushort
dchar inout real
debug int ref version
default interface return void
delegate invariant
deprecated is scope wchar
do shared while
double lazy short with

有些符号被认定为基本表达式。在方法定义的内部,特殊符号this指代的是当前对象,super限定为静态和动态查找当前对象的基对象,这一点在第6章会进行讨论。符号$只在索引表达式或分割表达式里有效,它会计算出正被索引的数组的长度。符号null指代的是一个空的对象、数组或指针。

基本表达式typeid(T)返回的是关于类型T的信息(更多信息请查阅具体编译器的实现文档)。

布尔字面量(Boolean literal)有truefalse

D语言支持十进制、十六进制以及二进制整数字面量。十进制常量就是一串数字,其后缀允许是LUuLULuUL或者uL等内容。十进制字面量的类型推断规则如下。

例如:

auto
   a = 42,             // a的类型为int
   b = 42u,            // b的类型为uint
   c = 42UL,           // c的类型为ulong
   d = 4000000000,     // long;无法放入int
   e = 4000000000u,    // uint;它能放入uint
   f = 5000000000u;    // ulong;无法放入uint

在一个数里可以随意插入下划线(只是不要插入到第一位,以免最终创建成一个标识符)。下划线有助于清晣地书写很大的数:

auto targetSalary = 15_000_000;

十六进制整数字面量的书写方法是前缀0x0X之后紧跟09afAF或者_构成的字母串。首位为0,后面紧跟0~7构成的串或空串,组成的是八进制字面量。最后,构建二进制字面量的方法是在0b0B后面紧跟由0、1和下划线构成的串。所有这些字面量与十进制常量类似,都可以带上后缀。

图2-1胜过千言万语,它简洁明了地定义了整数字面量的语法。查看这个自动机的规则是:(1)每条边会将与之对应的标签当作输入;(2)自动机会尽可能多地接收输入[1]。当停留在终点状态(即双重圆圈)时,即表明一个数字解析成功。

图2-1 理解D语言的整数字面量。本自动机会尝试一步一步连续地执行下去(同时处理掉与所遇边相对应的输入),直到其必须结束为止。当停留在终点状态(双重圆圈)时,即表明一个数字解析成功。s代表的是后缀,取值可以是:U|u|L|UL|uL|Lu|LU

浮点数字面量可以是十进制或十六进制。在仅仅是定义整数字面量方面,十进制浮点数字面量比较容易定义:一个十进制浮点数字面量由一个十进制数字面量构成,它也可以在任何位置包含一个点“.”,后面可以紧跟一个指数和(或)后缀。指数为eEe+E+eE-之后,后面再紧跟一个无符号十进制数字面量。后缀可以是fF或者L。显然,在“.”、“e/E”和“f/F”之间至少需要有一项存在,以防止浮点数在字面上没了小数点而成为一个整数字面量。后缀f/F如果存在,则字面量的类型会强制为float;而L会将其强制为real;否则,字面量的类型为double

十六进制浮点常量看起来可能有些奇怪,但它们在准确书写常量时却非常有用。在内部,浮点值都是以基数为2的形式存储的,因此一个实数以基数10的形式显示时,它其实是进行了一个基数的转换;而由于10并不是2的次方,因此该转换只能是近似的。与此相反,十六进制的记数法让你能够记录下恰好与它们表现出的值一致的浮点数。关于浮点数是如何存储的完整论述已超出本书的范围,不过可以保证D语言的实现都是使用的IEEE 754格式,更多详尽的参数资料请上网搜索(如查找“IEEE 754 浮点数格式”)。

一个十六进制浮点常量构成是:前缀0x0X之后紧跟一串可在任何位置包含一个小数点的十六进制数字。对于指数,它是由pPp+P+p-P-当中的任何一个做引导,后面紧跟多个十进制(可不是十六进制哦!)数字。只有所谓的尾数(在指数之前的小数部分)才是以十六进制形式进行表达的;指数自己是一个十进制整数。十六进制浮点常量的指数在最终的数里是表示成2的指数(而非像十进制里的情况表示成10的指数)。最后,还可选择后缀fFL来补齐该常量[2]。一起来看看相关的示例。

auto
   a = 1.0,                          // a的类型为double
   b = .345E2f,                      // b为34.5,其类型为float
   c = 10f,                          // 因为后缀,所以c的类型为float
   d = 10.,                          // d的类型为double
   e = 0x1.fffffffffffffp1023,       // e为最大的double类型值
   f = 0XFp1F;                       // f为30.0,其类型为float

图2-2简明描述了D语言的浮点数字面量。执行该自动机的规则与整数常量自动机的一样:在字面量里读取字符时会进行变换,而且尝试的是最长路径。这里展示的自动机可以分辨出好几种不常见的点,例如0x.p10xp1尽管有些奇怪,但都是0的合法表示方法,但像0e1.e10x0.0这样结构都是不允许的。

图2-2 理解浮点数字面量

字符字面量指的是使用单引号引起来的单个字符,如'a'。实际的引号符号必须使用反斜线进行转义,如'\"。实际上,与其他语言一样,D语言也定义了好几个转义序列,在表2-3里全部列出来。除了包含标准控制字符外,D语言还定义了组成Unicode字符的方法,即通过使用'\u03C9'\u后面紧跟4个十六进制数字)、'\U0000211C'\U后面紧跟8个十六进制数字)或'\&copy;'(以\&作为开头,并以;作为结尾的命名实体)这样的记号来实现。第一个是ω的Unicode码,第二个是R的美观写法F:\paiban\电子书\16-11-31419\31419-电子书-减60页-田 38.jpg,而最后一个是让人惧怕的版权符号©。需要一个完整列表时,可以上网搜索“Unicode表”。

在了解了如何表示字符之后,再了解字符串字面量就不会有什么问题。D语言在操作字符串方面相当厉害,其部分原因就在于强大的字符串字面量表示方式。与其他字符串操作友好的语言一样,D语言也对引号字符串(在其内部可以应用表2-3里列出的转义序列)和所见即所得(What You See Is What You Get,WYSIWYG)字符串(编译器只是进行简单地解析,并不会去解读转义序列)进行了区别对待。所见即所得风格对于表示要求包含很多的转义符号的字符串相当有用,这里有两个臭名昭著的例子,它们分别是正则表达式和Windows路径名称。

表2-3 D语言转义序列

序列

类型

描述

\'

char

单引号(存在歧义时)

\"

char

双引号(存在歧义时)

\\

char

反斜线

\a

char

响铃(ASCII 7)

\b

char

退格(ASCII 10)

\f

char

换页(ASCII 14)

\n

char

换行(ASCII 12)

\r

char

回车(ASCII 15)

\t

char

水平制表(ASCII 9)

\v

char

垂直制表(ASCII 11)

\‹13个八进制数字

char

八进制形式的UTF-8字符(必须3778

\x‹2个十六进制数字

char

十六进制形式的UTF-8字符

\u‹4个十六进制数字

wchar

十六进制形式的UTF-16字符

\U‹8个十六进制数字

dchar

十六进制形式的UTF-32字符

\&‹命名字符实体›;

dchar

符号化的Unicode字符

引号字符串指的是由双引号引起来的字符序列,如"like this"。所有列在表2-3里的转义序列在引号字符串内都具有意义。

当各种类型的字符串并排在一起时会被自动拼接:

auto crlf = "\r\n";
auto a = "This is a string with \"quotes\" in it, and also
a newline, actually two" "\n";

在上面的代码里,also之后的换行是刻意的:字符串字面量可以嵌入换行符(源代码里实际的换行符,而非反斜线之后跟一个n),并照原样进行存储。

2.2.5.1 所见即所得、十六进制以及导入的字符串字面量

所见即所得字符串可以用r作为开始,以“"”作为结尾(如r"like this"),也可以在开始和结尾部分都使用反引号(如'like that')。所有字符(除了各自的终止符以外)都可以出现在所见即所得字符串里,并会以表面值形式进行存储。即是说,无法在双引号所见即所得字符串里表示双引号本身。这不是什么大问题,因为可以使用各种语法来拼接字面量字符串。例如:

auto a =  r"String with a \ and a " `"` " inside."; 

出于实用的目的,也可以考虑通过使用序列<"`"`">来对处于<r"string">里的双引号进行编码,以及将'string'里的反引号编码成像<`"`"`>这个样子。祝大家的引号数得开心。

D语言还定义了第三种字面量字符串:十六进制字符串。它是一个由十六进制数字和空白符(会被忽略掉)组成的字符串,界定符有“x"”和“"”。在定义原始数据时十六进制字符串比较有用,除了十六进制数字以外,编译器并不会将其中的内容解释成Unicode字符或其他什么东西。在十六进制字符串内部的空格会被忽略掉。

auto
   a = x"0A",          // 等同于"\x0A"
   b = x"00 F BCD 32"; // 等同于"\x00\xFB\xCD\x32"

如果此时在你的“黑客”头脑里已浮现出了将二进制资源嵌入D语言程序的想法,那么当你听说还有一种更强大的定义字符串的方式时,一定会很高兴。这种方式就是通过文件来定义!

auto x = import("resource.bin"); 

在编译期间,x会被初始化成文件resource.bin的实际内容(这一点与C语言的#include方法不同,上面的操作是将文件包含为数据,而非代码)。出于安全的考虑,它只认可相对路径和通过编译器开关控制的搜索路径。参考实现dmd是利用-J标志来控制字符串包含路径的。

源自import操作的字符串不会要求检查UTF-8正确性。它是为支持导入二进制资源而故意做的设计。

2.2.5.2 字面量字符串的类型

字面量字符串的类型是什么呢?先来运行一个简单的测试程序:

import std.stdio;
void main() {
   writeln(typeid(typeof("Hello, world!")));
}

内建的操作符typeof能获得一个表达式的类型,而typeid能将该类型变成一个可输出的字符串。这个小程序的输出为:

immutable(char)[]

它表明字面量字符串是一个由不可变字符构成的数组。其实,在这段示例代码里使用的类型string是一个较长类型immutable(char)[]的简记符号。下面从三个方面深入细节地看一下字符串字面量类型:不变性、长度和基字符类型。

不变性。字面量字符串存放于“只读”内存。它的意思并不是说它们存储在不可删除的内存芯片里或者存储在被操作系统保护的内存里,而是说语言保证不会去改写这个字符串的内存。关键字immutable体现了这种保证,在编译期间,它不允许有任何可能会修改不可变数据内容的操作:

auto a = "Nobody can change me";
a[0] = 'X'; // 出错!不能修改immutable字符串!

关键字immutable是一个类型修饰符(第8章会讨论修饰符),它可对放置于其右边的任何类型进行操作,同时遵从括号规则。假设有immutable(char)[] str,那么在str里的每一个字符都是不可变的,但str却可以引用不同的字符串:

immutable(char)[] str = "One"; 
str[0] = 'X';                 // 出错!不能对immutable(char)进行赋值!
str = "Two";                  // 正确,对str进行了重新绑定

另一方面,如果不存在括号,那么immutable会修饰整个数组:

immutable char[] str = "One"; 
str[0] = 'X';                 // 出错!
str = "Two";                  // 出错!

不变性有很多优点,换言之,即immutable为允许任意跨模块和线程数据共享(详见第13章)提供了很多保证。由于字符串的字符是不可更改的,因此绝不会出现竞态(contention),从而保证共享是安全和有效的。

长度。在编译期间,字面量字符串的长度(如"Hello, world!"的长度为13)很显然是已知的。给每一个字符串指定一个最精确的类型似乎也很自然,例如"Hello, world!"就可以标识为类型char[13],即一个恰好拥有13个字符的数组。但是,Pascal语言的应用经验表明,静态大小的数组使用起来非常不方便。因此,在D语言里字面量的类型没有包含长度信息。不过,如果真的想要固定大小的字符串,也可以通过显式指定长度来创建这样的字符串:

immutable(char)[13] a = "Hello, world!";
char[13] b = "Hello, world!";

对任意类型T,大小固定的数组类型T[N]可隐式转换到动态大小的类型为T[]的数组。在此过程中不会丢失任何信息,这是因为动态大小的数组也记住了它们的长度:

import std.stdio;

void main() {
   immutable(char)[3] a = "Hi!";
   immutable(char)[] b = a;
   writeln(a.length, " ", b.length); // 输出"3 3"
}

基字符类型。最后一点,字符串字面量允许将charwchardchar作为它们的基字符类型。当然,没有必要使用冗长的类型名称immutable(char)[]immutable(wchar)[]immutable(dchar)[],它们都分别对应有方便使用的别名,即stringwstringdstring。如果字面量字符串至少包含了一个4字节的dchar字符,那么它的类型即为dstring;否则,如果该字符串至少包含了一个2字节的wchar字符,则它的类型即为wstring;否则,该字符串的类型为string。如果字符串期望的类型与推断的类型不一致,那么字面量也会像示例里演示的那样安静地接受:

wstring x = "Hello, wide world!";       // UTF-16编码
dstring y = "Hello, even wider world!"; // UTF-32编码

若想要改写字符串推断结果,则可以在字符串字面量后面添加后缀cwd(类似于字符字面量的后缀),从而分别将其类型强制为stringwstringdstring

字符串属于特殊的数组,它们具有自己的字面语法。现在该如何表示其他类型(如intdouble)的数组字面量呢?数组字面量可表示成一个使用方括号括起来的、以逗号分隔的值序列:

auto somePrimes = [ 2u, 3, 5, 7, 11, 13 ];
auto someDoubles = [ 1.5, 3, 4.5 ];

数组的大小可以根据按逗号进行分隔的列表的长度计算出来。与字符串字面量有所不同,数组字面量是可变的,因此在初始化之后可以对它们进行更改:

auto constants = [ 2.71, 3.14, 6.023e22 ];
constants[0] = 2.21953167;  // "moving sofa"常量值
auto salutations = [ "hi", "hello", "yo" ];
salutations[2] = "Ave Caesar";

请注意,可以对salutation里某个位置上的元素进行重新分配,但不能更换存储在该位置上的字符串的内容。这正好是期望的结果,因为数组中的成员并不会影响对字符串所能执行的操作。

数组元素的类型是由数组中的所有元素共同决定的,其计算方式是通过条件操作符“?:”(在2.3.16节会讲到)来完成的。对于拥有多个元素的字面量lit,编译器会应用表达式true? lit[0] : lit[1],并将该表达式的类型存储为类型L。然后,在lit中,对于从lit[2]直到最后一个元素里的每一个元素,编译器都会计算true? L.init : lit[i]的类型,并将该类型存回到L里。最后的L即为数组元素的类型。

与真实的情况相比,这个听起来复杂了很多。简单来说,就是数组元素的类型是通过波兰式的民主共识建立起来的——找到一个类型,让所有元素都能隐式地转换到它。例如,[1, 2, 2.2]的类型为double,而[1, 2, 3u]的类型为uint,原因就是intuint在执行?:运算时,结果为uint

关联数组字面量的定义语法如下:

auto famousNamedConstants =
  [ "pi" : 3.14, "e" : 2.71, "moving sofa" : 2.22 ];

在关联数组字面量里的每一个槽都拥有“键 : 值”这样的形式。关联数组字面量的键类型计算方法为,在概念上所有键放置到一个数组,然后使用上面讨论过的方法来计算该数组的类型。值类型的计算方法与之非常相似。一旦键类型K和值类型V都被计算出来,则该字面量的类型即为V[K]。例如,famousNamedConstants的类型为double[string]

在有些语言里,每一个函数的名字都是在其定义的位置选择的。后期对该函数的调用都使用它的名字。另外有一些语言允许在使用它们的那个地方定义匿名函数(即众所周知的λ函数)。这种特性在强大的高阶函数(即用作参数并/或返回其他函数的函数)惯用法(idom)[3]方面非常有用。只要是希望提供函数名的地方,D语言的函数字面量都支持就地定义匿名函数。

本章唯一关注的重点在于要展示如何定义函数字面量,同时还会展示一些有意义的用例。关于这一不凡特性所具有的强大用途的说明,在第5章再进行详细讲解,请忍耐一下。下面是函数字面量的基本语法:

auto f = function double(int x) { return x / 10.; };
auto a = f(5);
assert(a == 0.5);

函数字面量的定义遵从常规函数定义的语法,唯一不同之处在于,在定义的前面需要添加关键字function,而不需要名称。上面的代码并没有过多使用匿名,因为匿名函数会立即绑定到符号f。符号f的类型为“指向函数的指针,该函数会接收一个int并返回一个double”。该类型自身被拼写为double function(int)(请注意,关键字function被换到了返回类型的后面),因此f的等效定义为:

double function(int) f = function double(int x) { return x / 10.; };

functiondouble之间的交换看起来有些奇怪,事实上它让大家都变得非常好过,这是因为它把函数字面量与其类型进行了区分。这样的内容是很容易记住的:在字面量里,function先出现;而在函数的类型里,function代替了函数的名称。

为简化函数字面量的定义,你也可以省略返回类型,而编译器会帮你推断它,这是因为它直接提供了函数体:

auto f = function(int x) { return x / 10.; };

上面的函数字面量只使用了自己的参数x,因此其含义只通过查看函数字面量的函数体便可以判断出来,而无需依赖使用它的环境。不过,如果函数字面量需要使用存在于调用点的数据,而且该数据不是通过参数形式传递的,那该怎么办呢?此时就必须将function替换成delegate

int c = 2;
auto f = delegate double(int x) { return c * x / 10.; };
auto a = f(5);
assert(a == 1);
c = 3;
auto b = f(5);
assert(b == 1.5);

F的类型现在变成了double delegate(int)。所有针对function(函数)的类型推断都可以不加更改地应用到delegate(委托)。这时大家是否会问:既然delegate能完成所有function做的工作(当然,delegate也可以使用所在环境里的变量,但这不是它的主要负责内容),那为什么一开始还要为function费心呢?难道我们就不能只使用delegate吗?答案很简单:效率。很明显delegate会访问更多的信息,因此按照某些不可抗拒的自然法则,它必须为此付出代价。其实,function的大小就是指针的大小,而delegate的大小是它的两倍(一个指针用于函数,另一个则用于环境)。

接下来的几个小节将以优先级递减的方式详细描述D语言的操作符。这个顺序也对应于在一个更大块里进行分组和对小的子表达式进行计算的自然顺序。

有两组与操作符紧密相关的概念:左值(lvalue)对右值(rvalue),以及数值转换规则。下面两小节介绍必要的定义。

许多操作符只在它们左边满足特定条件的时候才能工作。例如,不需要复杂的理由就能辨别出来像5 = 10这样的赋值肯定是无效的。想要成功赋值,左边的操作数必须是一个左值。现在来准确定义一下左值(顺带右值一起,它们是互补的)。从历史的角度看,这两个术语实际源于像a = b这种赋值表达式里的值的位置:a处于左边,因此它就是左值;而b处在右边,因此它是一个右值。

通过纯枚举的方式进行定义,左值包含的内容有:

所有左值都可以当成右值使用。而右值还包含了上面未明确提及的内容,如字面量、枚举值(由enum类型引用,请参考7.3节)以及像x + 5这类表达式的结果。请注意,成为左值是允许赋值的必要但不充分条件:还必须满足其他的几项语义检查,如访问权限(参考第6章)和互斥权限(参考第8章)。

我们已初步接触过隐式转换的话题,现在来做个全面解析。只要涉及数值转换,就要记住下面几条简单的规则。

(1)如果一个表达式在C语言里能进行编译,则在D语言里也能被编译。其类型在这两种语言里是一样的(注意:并不是所有C语言的表达式都一定会被D语言所接受)。

(2)整数值不会隐式转换成一个更窄的值。

(3)浮点值不会隐式转换成整数值。

(4)所有数值(整数或浮点数)都可以隐式地转换成任何浮点值。

规则1让事情比不这样做的情况稍显复杂些,不过为了能让大家轻松地将整个函数复制、粘贴到D语言程序里,D语言尽量与C和C++语言的相关内容保持了重叠。如果出于安全或可移植性的原因,D语言突然拒绝编译某些部分也是很正常的。不过如果在编译了2000行的加密包之后,运行结果却不相同,那么对于遇到这种倒霉事儿的人来说生活就没那么轻松了。不过,规则2相比C和C++语言加强了很多。因此当进行代码移植时,间歇性的诊断会指出代码不够精细的地方,并提示你插入恰当的检查和显式的强制类型转换。

图2-3展示了全部数值类型的转换规则。在转换过程中,会选择最短路径。当两条路径拥有相同的长度时,转换的结果是相同的。不管转换经历的步数有多长,转换过程也只会当成一步来转换,并且在这些转换当中不存在优先级或者顺序——要么一个类型能转换成另一个,要么不能转。

图2-3 隐式数值转换。只要在此图中从源类型到目标类型存在一条有向路径,那么该源类型便能够自动转换成目标类型。转换时会选择最短路径,并且不管实际路径有多长也只会当成一步来转换。如果值域传播经验证是有效的,则反方向的转换也是可以的。

值域传播

按照上面描述的规则,一个普通的数字(如42),可以明确地认为是int类型的。现在来看看下面同样普通的初始化操作:

ubyte x = 42;

遵从类型检查的必然规律,42首先会被识别为int。该int然后又赋给了x,这一过程引发一个强制转换。允许这种无条件的强制转换是相当危险的(存在大量的int实际上都无法容纳在ubyte里面)。另一方面,对明显正确的代码要求进行类型强制转换是完全无法让人感到满意的。

D语言以一种智能的方法破解了这一难题,这个方法的灵感源自名叫“值域传播”(value range propagation)的编译器优化技术,即在表达式里的每一个值都有一个由最小可能值和最大可能值构成的范围与之关联。在编译期间会跟踪这些界限。当某个值被赋给一个更窄的类型时,只有值的范围适合目标类型,编译器才会认可该赋值操作。对于42这样的常量,很显然最小可能值和最大可能值都是42,因此赋值操作进行得很顺利。

当然,普通的情况处理起来要容易一些,但是值域传播还可以在更多有意义的环境里进行正确性检查。假设有一个函数,它可以从一个int类型数里提取出最低有效字节位和最高有效字节:

void fun(int val) {
   ubyte lsByte = val & 0xFF;
   ubyte hsByte = val >>> 24;
   ...
}

不管输入值val是什么,这段代码都是正确的。第一个表达式是对值进行掩码操作以清除所有的高位,而第二个表达式进行的是移位操作,目的是让val的最高有效位移到最低有效位上,而其他部分为0。

实际上,编译器之所以能正确地输入fun是因为:在第一种情况它会计算val & OxFF的范围,不管val的值是多少,其结果都在0~255;而在第二种情况,它会计算val >>> 24的范围,结果还是在0~255。如果试验一下结果值不一定能容纳于ubyte类型(如val & Ox1FFval >>> 23)的操作,那么编译器就不会接受这样的代码。

值域传播能“理解”所有的算术和逻辑。例如,一个uint除以100 000以后总是能容纳在ushort里,并且在复杂表达式(比如在掩码操作之后再执行除法)里也一样。例如:

void fun(int val) {
   ubyte x = (val & 0xF0F0) / 300;
   ...
}

在上面的示例里,操作符“&”会将界限设置成0~0xF0F0(十进制为61 680),而除法则会将界限设置为0~205。在该范围之内的任何数字都能容纳于ubyte

使用值域传播来辨别窄转换的正确与否是一种不完美且保守的机制。主要原因在于,值域跟踪的范围还是局限在一个表达式内部,而没有涉及多个连续的表达式。例如:

void fun(int x) {
   if (x >= 0 && x < 42) {
      ubyte y = x;  // 出错!不能将int强制转换为ubyte! 
      ... 
   }
}

显然,这个初始化是正确的,但编译器无法识别它。它有可能会使编译器的实现变得更加复杂,而且大大降低编译速度。因此,编译器这才决定降低每一个表达式的值域传播的灵敏度。在实现此特性之后的经验表明:这种保守的估计能够消除程序里出现的绝大部分不恰当的强制类型转换错误。最后对于那些属于误判的错误,可以使用cast表达式(请参考2.3.6.7节)来解决。

下面的几个小节都介绍能应用于数字类型的操作符。对于数值操作符所产生的值,其类型是通过几条新规则推算出来的。在能被想到的规则中,它们算不上是最好的,但却非常简单、一致和有系统性。

一元操作符所产生的类型与操作数类型一样,但取反操作符“!”(在2.3.6.6节有定义)例外,它总会产生bool类型。对于二元操作符,结果类型的计算遵从下面的规则:

所有隐式转换都会选择图2-3所描绘的最短路径。这个细节很重要,请看下面的内容:

ushort x = 60_000;
assert(x / 10 == 6000);

在除法运算里,10的类型为int,而根据上面的规则,x在执行操作之前会隐式转换成int类型。图2-3显示了好几条可能的路径,其中有直接的转换ushortint以及稍微长一点(有一个跳转)的转换ushortshortint。第二条不符合要求,因为将60000转换成short会生成−5536,而它进一步提升至int会导致assert失败。选择转换图里的最短路径能保证存储值更准确。

基本表达式是进行计算的原子操作。我们已见识过了符号(请参考 2.1 节)、布尔字面量truefalse(请参考2.2.1节)、整数字面量(请参考2.2.2节)、浮点数字面量(请参考2.2.3节)、字符字面量(请参考2.2.4节)、字符串字面量(请参考2.2.5节)、数组字面量(请参考2.2.6节)以及函数字面量(请参考2.2.7节)。跟字面量null一样,它们都是基本表达式。下面各小节描述的是其他一些基本子表达式,其中包括assert表达式、mixin表达式、is表达式和括号表达式。

2.3.4.1 assert表达式

有很多表达式和语句,包括assert(断言)自身,都使用了“非零值”(nonzero)的概念。这些值可以为:(a)数字或字符类型,此时,非零值是有确切含义的,(b)布尔类型(非零值表示的是true),或者(c)数组、引用以及指针类型(非零值表示的是非null)。

表达式assert(expr)会计算expr的值。如果expr为非零值,那么不会产生任何效果;否则,assert表达式会抛出一个类型为AssertError的异常。像assert(expr, message)这样的形式,则会将message(必须可转换成字符串类型)作为包含在AssertError对象内的错误信息的一部分(如果expr为非零值,则message不会被计算)。在所有情况下,assert自身的类型都为void

当你要构建一个完全高效的程序时,D语言编译器提供了一个开关(对于参考实现dmd,它是−release),它可用于忽略所有的assert表达式。因此,assert应该被当作一种调试工具,而不是用于测试那些特殊条件的方法,这些条件即使失败也可能是情有可原的。基于同样的理由,将带有副作用的表达式放置在assert表达式内部,而如果程序的行为会依赖这些副作用,则这种做法是错误的。关于构建发行版本的更多细节,请参考第11章。

有些情况是按特殊的方式进行处理的:assert(false)assert(0)或者静态情形下已知assert操作为零值。断言功能总是被启用的(与构建标志无关),并且会调用可让进程突然停止运行的HLT机器代码指令。这个中断会提示操作系统生成一个内核转储(core dump)或者在有问题的那一行启动调试器。

先为逻辑OR表达式(请参考2.3.15节)作一个铺垫,总是会对表达式进行计算并对其结果进行断言的短语为is(expr) || assert(false)

第10章会深入讨论assert和其他能保证程序正确性的机制。

2.3.4.2 mixin表达式

如果将表达式比喻成各式各样的螺丝刀,那么mixin(混入)就像是一把带有可更换刀头的电动螺丝刀,像是可调节的离合器,或像是一个内置有无线摄像头和语音识别功能的脑部手术适配器。它实在太强大了!

简单地说,mixin表达式允许你将一个字符串变成可执行代码。它的语法为mixin(expr),其中expr必须为一个编译时已知的字符串。这条限制规则让mixin没有了动态脚本的能力,例如,从终端读取并解释字符串。的确如此,D语言不是解释性语言,编译器也不会让标准运行时支持这一项功能。也有一个好消息,即D语言在编译期间会运行一个功能稳定的解释器,你可以根据实际需要用它来构造字符串。

在编译期间,有能力操作字符串,并将它们转变成代码,这便有可能创建所谓的特定领域的嵌入式语言(其粉丝亲切地称其为DSEL)。使用D语言来实现DSEL通常会完成这样一些操作:以字符串字面量的形式接收DSEL语句,在编译期间进行处理;以字符串的形式创建相应的D语言代码,并使用mixin将这个字符串转变成D语言。这个听起来有点不切实际,但DSEL是真实存在的。这里就有几个很不错的使用DSEL的实例:SQL命令、正则表达式以及语法规范(如yacc)。事实上,如果以前使用过printf,那么你也就使用过DSEL。printf所使用的格式指示符实际上就是一种小语言,它专门用于描述文本数据的输出格式。

在不使用任何附加工具(语法解析器、连接器、代码生成器……)的情形下,D语言允许你创建任何DSEL。也就是说,标准库里的函数bitfields(在模块std.bitmanip里)能接受位域定义,并且会生成最优的D语言代码来读取和写入它们,即使D语言本身并不支持位域也是可以的。

2.3.4.3 is表达式

is表达式能回答关于类型的质询(“Widget这样的类型存在吗?”或者“Widget是从Gadget继承来的吗?”),而且它是D语言强大的编译时自省功能的重要组成部分。所有is表达式的计算都是在编译期间完成的,并且会以一个布尔常量的形式返回质询答案。is表达式有多种形式,如下所示。

(1)is(Type)is(Type Symbol)两种形式会检查类型是否存在。该类型可能是无效的,很多时候还是不存在的。例如:

bool
   a = is(int[]),      // 真,int[]是一个有效类型
   b = is(int[5]),     // 真,int[5]也是有效的
   c = is(int[-3]),    // 假,数组的大小无效
   d = is(Blah);       // 假,(如果Blah未定义)

在任何情况下,即使语义无效,类型 Type 在语法上也必须要正确。例如,is([]x[])在编译时会出错,因为它不是一个false常量。换句话说,可以只对语法上看起来像类型的内容进行查询。

如果存在符号Symbol,则在结果为true的情况下,它会成为类型Type的一个别名。如果Type很长并且复杂,那么这种形式就特别有用了。还没介绍过的static if语句能够区分出真假情况。第3章会详细地讨论static if,但基本的过程很简单——static if会计算一个编译时表达式,然后编译由表达式为true时所控制的语句。

static if (is(Widget[100][100] ManyWidgets)) {
   ManyWidgets lotsOfWidgets;
   ...
}

(2)is(Type1 == Type2)is(Type1 Symbol == Type2)这两种形式在Type1Type2相同时会得到true值。(它们可能因为使用了别名而拥有不同的名字。)

alias uint UInt;
assert(is(uint == UInt));

如果存在符号Symbol,则在结果为true时,它会变成Type1的别名。

(3)is(Type1 : Type2)is(Type1 Symbol : Type2)这两种形式在Typel等同于Type2或者可以隐式转换到Type2时,会得到true值。例如:

bool
    a = is(int[5] : int[]),      // 真,int[5]可转换成int[]
    b = is(int[5] == int[]),     // 假,它们是相同的类型
    c = is(uint : long),         // 真
    d = is(ulong : long);        // 真

同样地,如果存在符号Symbol,则在结果为true时,它会变成Type1的别名。

(4)is(Type == Kind)is(Type Symbol == Kind)这两种形式会检查Type的类别。类别为下列关键字之一:structunionclassinterfaceenumfunctiondelegatesuper。如果Type属于其中的某一个种类,则表达式结果为true。如果存在Symbol,则它会根据具体的类别进行定义,具体如表2-4所示。

表2-4 is(Type Symbol == Kind)形式中Symbol的各类绑定

类别

Symbol别名

struct

类型

union

类型

class

类型

interface

类型

super

基类(参见第6章)

enum

枚举的基类型(参见第7章)

function

函数类型

delegate

委托的函数类型

return

由函数、委托和函数指针返回的类型

2.3.4.4 括号表达式

括号可以改变惯用的优先级顺序:对于所有表达式‹expr›来说,(‹expr›)都是一个基本表达式。

2.3.5.1 成员访问

成员访问操作符“a.b”会访问到对象或类型a里的名字为b的成员。如果a是一个复杂的值或类型,那么它也可以用括号括起来。b也可以是new表达式(参见第6章)。

2.3.5.2 增值和减值

所有数字和指针类型都支持后增值操作符(lval++)和后减值操作符(lval--),其语义与C和C++语言里的同名操作符类似:对lval进行增值和减值的计算,不过在修改之前,会先产生一个它的副本。lval必须是一个左值。(与前增值和前减值操作符相关的内容,请参考2.3.6.3节。)

2.3.5.3 函数调用

熟悉的函数调用操作符fun()会调用函数fun。而fun(<逗号分隔的列表>)会向fun传递一个参数列表。在fun被调用之前,所有参数都是按自左向右的顺序进行计算的。函数的类型必须与参数列表里值的类型和数目一致。如果函数定义时带上了@property属性,那么通过单独指定函数的名称就可以不带任何参数调用该函数。通常fun就是所定义函数的名称,但它也是一个函数字面量(参见2.2.7节)或者一个能产生函数或委托指针的表达式。第5章会对函数进行详细描述。

2.3.5.4 索引

表达式arr[i]会访问数组或关联数组arr的第i个(从0开始)元素。如果该数组不是关联的,那么i必须为整数类型。否则,i必须能够转换成arr的键类型。如果索引表达式处于赋值操作符的左边(如arr[i] = e),并且arr是一个关联数组,那么如果有一个元素不存在,此表达式会在数组里插入它。对于其他情况,如果i没有引用到arr里已有的元素,那么此表达式会抛出一个RangeErrer对象。如果arr是指针类型,而i为整数类型,则表达式arr[i]也同样能工作。指针引用不会有边界检查。有些构建模式(发行不安全的构建,参见4.1.2节)可能还会针对非关联数组一起禁用边界检查。

2.3.5.5 数组分割

如果arr是一个线性(非关联)数组,那么表达式arr[i .. j]会返回一个子数组,它会引用到arr内部从索引值ij(不包括)这段范围所确定的“窗口”。边界ij必须能转换成整数类型。表达式arr[]会获得arr的一个分割,它与arr自身一样大。实际的数据不会被复制,因此通过分隔操作符返回的数组,也会修改arr的内容。例如:

int[] a = new int[5];             // 创建一个拥有5个整数元素的数组
int[] b = a[3 .. 5];              // b引用了a的最后面两个元素
b[0] = 1;
b[1] = 3;
assert(a == [ 0, 0, 0, 1, 3 ]);   // a被修改了

如果i>jj>a.length,那么该操作符会抛出一个RangeError对象。如果i==j,那么它会返回一个空数组。如果arr是指针类型,那么表达式arr[i .. j]也能工作,并且会返回一个数组,而这个数据映射出的是从arr + iarr + j(不包括在内)这部分内存区域。如果i>j,那么此索引操作会抛出一个RangeError对象;此外还有,对指针的范围分割不会进行边界检查。同样地,有些构建模式(发行不安全的构建,参见4.1.2节)可能会针对分割操作禁用边界检查。

2.3.5.6 创建嵌套类

形如a.new T的表达式(a是一个类类型)会创建一个类型为T的对象,而T的定义被嵌套在a的定义里。如果觉得有些混乱,那是因为类里嵌套有类,此外连new表达式都也未被定义。关于new表达式的定义在2.3.6.1节介绍,但关于类和嵌套类的定义则需要等到第6章(参见6.11节)。本段只是占个位,出于完整性目的而被插入到这里。

2.3.6.1 new表达式

new表达式具有如下形式:

new (‹地址)可选 类型new (‹地址)可选 类型(<参数列表>可选)
new (‹地址)可选 类型[<参数列表>]
new (‹地址)可选 匿名类

先暂时忽略掉可选项“(‹地址›)”。前两种形式为“new T”和“new T(‹参数列表›可选)”,它们会动态分配一个类型为T的对象。第二种形式可以选择传递某些参数给T的构造函数。(“new T”和“new T()”是完全等同的,都会创建一个默认初始化的对象。)我们还未见到带有构造函数的类型,对它的讨论被推迟到了第6章的类(参见6.6节)以及第7章的其他自定义类型(参见7.1.3节)。匿名类分配(最后一种形式)也被推迟到了第6章(参见6.11.3节)。

这里,我们先把注意力集中到大家已熟悉的数组分配操作上。表达式new T[n]会分配一块连续的内存(其空间大到足以一个挨一个地容纳下n个对象),并使用T.init来填满这些槽位,然后以T[]值的形式返回一个针对它们的句柄。例如:

auto arr = new int[4];
assert(arr.length == 4);
assert(arr == [ 0, 0, 0, 0 ]); // 默认初始化了

使用稍微有些差异的语法也可以获得同样的结果:

auto arr = new int[](4);

这次,表达式被解释成new T(4),其中Tint[]。而最终结果为一个通过arr(其类型为int[])进行操作的包含有4个元素的数组。

第二种形式其实比第一种包含了更多的内容。如果要分配一个由数组构成的数组,则可以在括号里提供多个参数。它们会被当做维度的初始值,按行排序。例如,这里有一个实例,讲的是如何分配一个由4个数组构成的数组,而其中每一个数组拥有8个元素:

auto matrix = new int[][](4, 8);
assert(matrix.length == 4);
assert(matrix[0].length == 8);

上面代码片段里的第一行隐藏了很多细节:

auto matrix = new int[][](4);
foreach (ref row; matrix) {
    row = new int[](8);
}

所有分配的内存都是在垃圾回收堆(garbage-collected heap)上进行的。那些不再使用的或者应用程序无法访问的内存都会被自动回收。编译器参考实现dmd的运行时支持,在模块core.gc里提供了几个操控垃圾回收内存的基本工具,其中包括调整已分配内存块的大小以及手动释放内存等。手动管理内存存在一定的风险,除非真的有必要,否则应尽量不要使用。

在关键字new之后传入的可选地址addr会引入一个名叫“定点新建”(placement new)的结构。在此种情形下,new(addr)T的语义有所不同:它不会为新的对象分配内存空间,而只是在指定的地址addr适时地创建一个对象。这是一个很底层的特性,并不会出现在普通代码里。例如,如果想要通过malloc在C语言的堆里分配内存,则可以使用它,然后使用它来存储D语言的值。

2.3.6.2 取地址和解引用

由于指针是后面要讨论的主题,因此,我们这里只是顺便提一下“取地址”和“解引用”(dereference)这一对操作符。对于类型为Tlval,表达式&lval会获得lval(正如这个名字所暗示的,lval必须是一个左值)的地址,并返回一个类型为T*的指针。

操作符*p可以对指针进行解引用,解出方式为:抵消掉&的作用,使得*&lval等同于lval。关于指针,第7章会有更详细的讨论,因为到那个时候,你应该可以在不依赖指针(它虽然卓越超群,但却属于很底层、很危险的特性)的前提下编写出很多优秀的D语言代码。

2.3.6.3 前增值与前减值

表达式++lval--lval会分别对lval执行增值和减值的操作,并将lval刚才更改过的值作为结果提供。lval必须为数字或指针类型。

2.3.6.4 按位求反

表达式~a会对a里的每一位进行切换(反转)操作,而其类型与a相同。a必须为整数类型。

2.3.6.5 一元加与一元减

表达式+val并不会有什么明显的动作——它的存在仅仅是因为完备性的需要。表达式-vat会计算0 - val的值,并且只适应于数字类型。

关于一元减有一个令人惊讶的行为:当应用到一个无符号值上时,它仍然会产生一个无符号值(参照2.3.3节中进过的规则)。例如,-55u4_294_967_241,即uint.max - 55 + 1

在现实中,无符号值也不是真正的自然数。在D语言和其他许多语言中,两者的补运算不可避免地具有自己的简单溢出规则,也是不可抹灭的事实。对于任何整数值val,有一种关于-val的思考方法将它当成简短形式~val + 1。换言之,即对val里的每一个二进制位求反,然后将结果加1。这样不会造成与val的符号有关的问题。

2.3.6.6 取反

表达式!val的类型为bool。如果val为非零值(参见2.3.4.1节关于非零值的定义),则会得到false;否则,为true

2.3.6.7 cast表达式

cast 操作符就像一位法力强大且存有好心的灯神,急于要去拯救光明。与神话里的灯神真的很像,cast(强制类型转换)很“淘气”、很不听话,而且擅长运用言词拙劣的“愿望”——要以很机械的方式才能满足它们,而且经常会造成灾难性的后果。

即是说,当静态类型系统不够灵活,以致无法满足你的应用时,强制类型转换会变得很有用。强制类型转换的语法为cast(Type) a。有多种强制转换,现按安全性等级降序罗列如下。

请特别注意所有这些不会进行检查的强制类型转换,尤其是最后三点,它们可能会影响类型系统的完整性。

幂表达式的形式为base ^^ exp,它会计算出baseexp次方值。baseexp都必须是数字类型。它所提供的功能与C语言和D语言标准库(请参考关于模块std.math的文档)里的库函数pow(base , exp)是一样的。不过,有些数值应用程序会从这种简化语法的版本中获益。

0的0次方为1,而0的任何非0次方为0。

乘法表达式有:相乘(a * b)、相除(a / b)以及求模(a % b)。它们都只针对数字类型。

在整数运算a / b或者a % b中,如果b为0,那么会有一个硬件中断抛出。如果除法产生一个小数,那么它总是会朝0的方向截断(例如,7 / 3为2,而−7 / 3为−2)。定义了表达式a % b之后,就会有a == (a / b) * b + a % b,因此7 % 3为1,而−7 / 3为−1。

针对浮点数,D语言还定义了求模运算,其定义包含了很多内容。在a % b里,当ab至少有一个是浮点值时,其结果为满足下列条件的最大(绝对值)浮点数r

如果无法找到这样的数,那么a % b会产生一个特殊值NaN(Not A Number,不是一个数)。

加法表达式有:相加a + b、相减a - b以及连接a ~ b

加法和减法运算只针对数字类型。结果类型的确定方法在2.3.3节有描述。

连接运算要求ab至少有一个为数组类型,数组包含元素的类型为T。其他值也必须为一个T类型的数组或者一个可隐式转换为T类型的值。其结果是一个由ab并列组成的新数组。

在D语言里有3种移位运算,它们都接受两个整数值:a << ba >> ba>>>b。在所有情形里,b都必须为一个无符号类型。如果都是有符号值,则必须将b强制类型转换成一个无符号类型(尽可能保证b >= 0,移位一个负的量会得到不确定的值)。a << b会将a向左(即往a的最高有效位方法)移动b个二进制位;而a>>b则会将a向右移动b个二进制位。如果a为负值,则移位操作会保留其符号。

a>>>b是一个无符号的移位操作,a的符号没有意义。即是说,0可能会被移到a的最高符号位,也确实是这样。下面举例说明针对有符号数的移位操作有哪些令人惊讶的效果。

int a = -1;               // 即0xFFFF_FFFF
int b = a << 1;
assert(b == -2);          // 0xFFFF_FFFE
int c = a >> 1;
assert(c == -1);          // 0xFFFF_FFFF
int d = a >>> 1;
assert(d == +2147483647); // 0x7FFF_FFFF

如果b是一个静态已知的值,则在编译期间,不允许在移动一定数量的位后,其值超过a的类型所允许的范围;如果b是一个运行时值,则它会在a里放置一个与编译器实现相关的值。

int  a = 50;
uint b = 35;
a << 33;                  // 编译时错误
auto c = a << b;          // 实现所定义的结果
auto d = a >> b;          // 实现所定义的结果

在所有情况下,结果类型的确定都会根据2.3.3节所描述的规则。

以前,移位操作的流行用法是代价很低的整数乘以2(a<<1)或除以2(a>>1),或者更一般地,乘以和除以2的各种次方。这种技术已经过时,现在只需写成a * k或者a / k即可。只要k是一个编译时已知的量,那么编译器会生成带移位操作的优化代码,同时不用担心符号是否正确的问题。时代变了!

如果key的类型为Kmap是一个类型为V[K]的关联数组,那么表达式key in map会产生一个类型为V*V类型指针)的值。如果该关联数组包含有<key, val>这样的一对值,那么该指针会指向val;否则,该指针为null

对于反向负值测试,当然可以写成!(key in map),也可以使用更简洁的形式key !in map——它与key in map拥有相同的优先级。

为什么要使用指针,而不让a in b产生一个布尔值?其目的在于提高效率。人们时常会想要查询关联数组里的索引值,如果映射后的元素已经存在,则可以直接使用它。可能出现的情况如下:

double[string] table;
...
if ("hello" in table) {
   ++table["hello"];
} else {
   table["hello"] = 1;
}

上面这段代码的问题在于,它在成功的那个分支上执行了两次查询。如果使用返回指针的方法,那么这段代码可以写成更有效率的版本,如:

double[string] table;
...
auto p = "hello" in table;
if (p) {
   ++*p;
} else {
   table["hello"] = 1;
}

2.3.12.1 相等比较

a == b,其类型为bool,具有如下语义:首先,如果两个操作数的类型不相同,那么执行隐式转换,使它们具有相同的类型;然后,两个操作数按照下面的要求进行相等性比较。

a != b形式可用于测试不等性。

表达式a is b比较的是别名。如果ab都实际引用了同一个对象,则返回true

目前我们对类的了解不多,不过可以使用带数组的示例来帮助理解:

import std.stdio;

int main(){
   auto a = "some thing";
   auto b = a; // a 和 b 都引用了同一个数组
   a is b && writeln("Yep, they're the same thing really");
   auto c = "some (other) thing";
   a is c || writeln("Indeed... not the same");
}

因为ab实际都绑定到同一个数组,而c绑定的是不同的数组,所以上面的代码会输入两条信息。一般情况下,两个数组的内容可能会相等(即a == ctrue),但它们却指向了不同的内存区域,因此测试a is c会失败。当然,如果a is ctrue,很显然a == c也会为true,除非你的RAM芯片是以非常非常便宜的价格购买的。

不等性操作符a !is b可以改写成!(a is b)

2.3.12.2 顺序比较

D语言定义的表达式有a < ba <= ba > ba >= b,类型为bool,一般的含义分别为小于、小于等于、大于和大于等于。当进行数字比较时,它们当中某一个的类型必须要能隐式转换成另一个的类型。对于浮点操作数,-00被认为是相等的,因此-0 < 0得到false,而0 <= -0会得到true。无论什么时候,只要它们当中有一个操作数为NaN(看起来有些荒谬),那么全部的顺序比较操作符都会返回false

一如既往,当很顺利地处理合法的浮点数时,NaN总是会把事情弄得一团糟。所有比较操作符只要接收到NaN值,便会抛出一个浮点数异常。在常见的编程语言术语里,它不是一个真正的异常,而是一个可以进行显式地检查的硬件级的状态。D语言通过模块std.c.fenv提供了一个针对浮点硬件的接口。

2.3.12.3 非结合性

所有D语言的比较操作符都有一个重要的特征,即它们都是不可结合的。任何链式的比较操作(如a <= b < c),都是非法的。

有一种简单的定义比较操作符的方法,即让它们的结果为布尔类型。布尔值自身能够进行比较,这个可能会造成令人遗憾的状况:a <= b < c并不具有少数数学家所期望的含义,我们大家都在努力摆脱这种情况。如果真的采用此方法,那么这个表达式就可以解析成(a <= b) < c,即“将由a<=b得到的布尔结果与c进行比较”。例如,3 <= 4 < 2的结果就会是true!这绝不是你想要的语义。

还有一种可能的解决方案,它可以支持a <= b < c并赋予它更直观的数学含义,即a <= b && b < c,并让b只计算一次。像Python和Perl 6这样的语言已支持这种语义,它们支持像a < b == c > d < e这样的随意比较链。不过,D语言没有去完全继承。如果让D语言支持C语言表达式,但却使用稍微有些不同的语义(可以说是朝着正确的方向),那么引起的混乱会远多于带来的方便,因此D语言断然地拒绝了这个构想。

表达式a | ba ^ ba & b可分别用于进行或(OR)、异或(XOR)和与(AND)的按位运算。即使结果完全可能通过一边判定出来,两边也都会进行计算(不会缩短操作)。

ab都必须是整数类型。结果的类型需要根据2.3.3节来确定。

按照上面的内容,表达式a && b的语义依赖于b的类型一点都不会让人觉得惊讶。

void型表达式的右边使用&&能够缩短if语句:

string line;
...
line == "#\n" && writeln("Got a # successfully");

表达式a || b的语义依赖于b的类型。

第二点对意外情况的处理比较有用:

string line;
 ...
line.length > 0 || line = "\n";

条件操作符是一个if-then-else表达式,其语法为a ? b : c。你可能对它已很熟悉了。如果a为非零值,则条件表达式会计算b并产生结果;否则条件表达式会计算c并产生结果。编译器付出了很大的努力才找到关于bc的最紧凑的公共类型,这便是条件表达式的类型。此类型(假定为T)是通过一个很简单的算法(如下例所示)计算得到的:

(1)如果bc的类型相同,则T也为该类型;

(2)如果ab都是整数,则首先将所有小于32位的类型提升至int类型,接着让T选择更大的那一个类型(如果大小受限,则优先选择无符号类型);

(3)如果有一个为整数,而另一个为浮点类型,则T为浮点类型;

(4)如果两者都为浮点类型,则T为两者之间更大的那一个;

(5)如果这些类型有一个公共的超类型(如基类),那么T便是该超类型(在第6章会再回到这个话题);

(6)接着尝试将b隐式转换至c的类型以及将c隐式转换到b的类型;如果恰好有一个成功了,则T即为成功转换后的目标类型;

(7)否则,表达式出错。

此外还有,如果bc都拥有相同的类型,而且都是左值,那么其结果也是一个左值,并可以写成这样:

int x = 5, y = 5;
bool which = true;
(which ? x : y) += 5;
assert(x == 10);

有很多泛型编程惯用法都使用了条件操作符来访问两个值的共同类型。

赋值表达式的形式有a = b或者a ω= b,这里的ω代表的是这样一些操作符号:^^*/+<<>>>>>|^&以及“编程语言书籍里强制使用的希腊字母”。前面几节已单独介绍过了这些操作符。

a ω= b的语义等同于a = a ω b,其显著的不同点在于a只会计算一次。想象一下,ab可能是很复杂的表达式,如array[i * 5 + j] *= sqrt(x)

ω的优先级可以不管,而ω=的优先级与=是一样的,刚好在上面讨论过的条件操作符之下,而比下面将要讨论的逗号操作符的优先级稍高一点。同样,ω的结合性也可以忽略,所有的ω=操作符(还有=)都可以自右向左结合在一起。如a /= b = c -= da /= (b = (c -= d))是完全一样的。

逗号分隔的表达式会依次进行计算。整个表达式的结果为最右边那个表达式的结果。例如:

int a = 5;
int b = 10;
int c = (a = b, b = 7, 8);

在执行上面的代码片段之后,abc的值分别为10、7和8。

这里总结的是D语言丰富的表达式构建方式。表2-5归纳了D语言的全部操作符,需要的时候都可以随时到这里来查阅。

表2-5 按优先级递减排列的表达式

表达式

描述

符号

符号(参见2.1节)

.‹符号

访问模块作用域里的符号(忽略其他所有作用域)(参见2.1节)

this

在一个方法内部的当前对象(参见2.1节)

super

通过基子对象来指导符号查找和动态方法查找(参见2.1节)

$

当前数组的大小(在索引或分割表达式内有效)(参见2.1节)

null

空引用(用于数组或指针)(参见2.1节)

typeid(T)

获得与T相关联的TypeInfo对象(参见2.1节)

true

布尔真值(参见2.2.1节)

false

布尔假值(参见2.2.1节)

数字

数字字面量(参见2.2.2节)

字符

字符字面量(参见2.2.4节)

字符串

字符串字面量(参见2.2.5节)

数组

数组字面量(参见2.2.6节)

函数

函数字面量(参见2.2.7节)

assert(a)

在调试模式下,如果a为一个非零值,则中断程序;在发行模式下,什么也不做(参见2.3.4.1节)

assert(a, b)

同上,另外会将b作为出错信息的一部分(参见2.3.4.1节)

mixin(a)

mixin表达式(参见2.3.4.2节)

‹IsExpr›

is表达式(参见2.3.4.3节)

(a)

括号表达式(参见2.3.4.4节)

a.b

成员访问(参见2.3.5.1节)

a++

增值(参见2.3.5.2节)

a--

减值(参见2.3.5.2节)

a(‹csl可选›)

函数调用操作符(‹csl可选›为可选的以逗号分隔的参数列表)(参见2.3.5.3节)

a[‹csl›]

索引操作符(‹csl›为以逗号分隔的参数列表)(参见2.3.5.4节)

a[]

分割整个集合(参见2.3.5.5节)

a[b .. c]

分割(参见2.3.5.5节)

a.‹new表达式›

创建嵌套的实例(参见2.3.5.6节)

&a

取地址(参见2.3.6.2节)

++a

增值(参见2.3.6.3节)

--a

减值(参见2.3.6.3节)

*a

解引用(参见2.3.6.2节)

-a

一元减(参见2.3.6.5节)

+a

一元加(参见2.3.6.5节)

!a

取反(参见2.3.6.6节)

~a

按位求补(参见2.3.6.4节)

(T).a

静态成员访问

cast(T)a

强制类型转换表达式

‹new表达式›

对象创建(参见2.3.6.1节)

a ^^ b

求幂(参见2.3.7节)

a * b

乘(参见2.3.8节)

a / b

除(参见2.3.8节)

a % b

求模(参见2.3.8节)

a + b

加(参见2.3.9节)

a - b

减(参见2.3.9节)

a ~ b

连接(参见2.3.9节)

a << b

左移(参见2.3.10节)

a >> b

右移(参见2.3.10节)

a >>> b

无符号右移(不管a的类型和值是什么,结果的最高符号位都为零)(参见2.3.10节)

a in b

关联数组的隶属关系测试(参见2.3.11节)

a == b

相等性测试。这组里的所有操作符都不可结合,例如:a == b == c便是非法的(参见2.3.12.1节)

a != b

不等性测试(参见2.3.12.1节)

a is b

一致性测试(仅当ab都引用到同一对象时才为真)(参见2.3.12.1节)

a !is b

等同于!(a is b)

a < b

小于(参见2.3.12.2节)

a <= b

小于等于(参见2.3.12.2节)

a > b

大于(参见2.3.12.2节)

a >= b

大于等于(参见2.3.12.2节)

a | b

按位或(参见2.3.13节)

a ^ b

按位异或(参见2.3.13节)

a & b

按位与(参见2.3.13节)

a && b

逻辑与(b可以为void类型)(参见2.3.14节)

a || b

逻辑或(b可以为void类型)(参见2.3.15节)

a ? b : c

条件操作符。如果a为非零值,则为b,否则为c(参见2.3.16节)

a = b

赋值。在本组里的所有赋值操作符都可以自右向左绑定一定,例如,a *= b += c等同于a *= (b += c)(参见2.3.17节)

a += b

就地加;在此组里的所有“计算并赋值”操作符a ω= b按这样的顺序进行计算:(1)计算a(它必须是一个左值);(2)计算b;(2)计算al = al ω b,这里的al是计算a之后得到的左值

a -= b

就地减

a *= b

就地乘

a /= b

就地除

a %= b

就地除

a &= b

就地按位与

a |= b

就地按位或

a ^= b

就地按位异或

a ~= b

就地连接(将b添加到a

a <<= b

就地左移

a >>= b

就地右移

a >>>= b

就地无符号右移

a , b

此表达式会自左向右进行计算,其结果为最右边那个表达式计算出的值(参见2.3.18节)

[1] 从理论上来讲,图2-1和图2-2都是“确定有限自动机”(DFA)。

[2] 不错,该语法是有些奇怪,不过D语言只是复制了一C99的语法,并没有自己特意去发明一种新的记号。

[3] 就软件开发阶段而言,模式可分为分析模式(andysis pattern)、架构模式(architeeture pattern)、设试模式(dseign pattern)和惯用法(idiom)。其中,惯用法指的是某种编程语言所特有的低级别模式,如C++语言所支持的多继承表达方法,并不是所有语言都支持的。——译者注

[4] IEEE 754 浮点数对于零有两种不同的位模式,分别定义为-0和+0。它们引起了一些小烦恼,如这里指出的特殊情况,但也加快了很多计算。在D语言里,应尽量少用-0.0字面量,但它可能会以计算结果的形式偷偷地产生,此类计算主要带有负值并逐渐地逼近零。


相关图书

Rust游戏开发实战
Rust游戏开发实战
JUnit实战(第3版)
JUnit实战(第3版)
Kafka实战
Kafka实战
Rust实战
Rust实战
PyQt编程快速上手
PyQt编程快速上手
Elasticsearch数据搜索与分析实战
Elasticsearch数据搜索与分析实战

相关文章

相关课程