书名:Clojure编程乐趣(第2版)
ISBN:978-7-115-44329-8
本书由人民邮电出版社发行数字版。版权所有,侵权必究。
您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。
我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。
如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。
• 著 [美] Michael Fogus Chris Houser
译 艾 广 郑 晔
责任编辑 陈冀康
• 人民邮电出版社出版发行 北京市丰台区成寿寺路11号
邮编 100164 电子邮件 315@ptpress.com.cn
• 读者服务热线:(010)81055410
反盗版热线:(010)81055315
Original English language edition, entitled The Joy of Clojure, Second Edition by Michael Fogus published by Manning Publications Co., 209 Bruce Park Avenue, Greenwich, CT 06830. Copyright ©2014 by Manning Publications Co.
Simplified Chinese-language edition copyright ©2017 by Posts & Telecom Press. All rights reserved.
本书中文简体字版由Manning Publications Co.授权人民邮电出版社独家出版。未经出版者书面许可,不得以任何方式复制或抄袭本书内容。
版权所有,侵权必究。
这既不是一本Clojure初学指南,也不是一本Clojure的编程操作手册。本书通过对Clojure详尽地探究,讲述函数式的程序设计方式,帮助读者理解和体会Clojure编程的乐趣,进而开发出优美的软件。
全书分为6部分共17章。第1部分是基础,包括第1~3章,从Clojure背后的思想开始,介绍了Clojure的基础知识,并带领读者初步尝试Clojure编程。第2部分是第4章和第5章,介绍了Clojure的各种数据类型。第3部分是第6章和第7章,介绍了函数式编程的特性。第4部分是第8~11章,分别介绍了宏、组合数据域代码、Clojure对Java的调用,以及并发编程等较为高级的话题。第5部分为第12章和第13章,探讨了Clojure的性能问题及其带给我们的思考。第6部分为第14~17章,介绍了面向数据编程、性能、思考程序以及Clojure的思考方式。
本书适合想要转向函数式语言或进行并发编程的程序员阅读,对JVM平台编程感兴趣的程序员,想要学习中高级的Java程序以及Clojure的读者,均能够从中获益。
作者通过许多函数式编程和工程开发中的经典例子,带我们纵览Clojure,感觉有时候更像是处于5级热带风暴之中。你会学得很快!
—— Steve Yegge, Google
这本书试图让你成为更好的程序员,而不仅仅是更好的Clojure程序员,如果你对Clojure或函数式编程感兴趣,强烈建议你读这本书!
——Rob Friesel
教你如何使用Clojure,这实在太爽了!爱不释手!
—— Baishampayan Ghose (BG)
无论现在和将来,Clojure社区都会感谢这本书。
——Andrew Oswald
如何使用Clojure以及Clojure的发现之旅。
——Federico Tomassetti
这本书真是不辜负它的名字!每一页渗出由@fogus和@chrishouser对语言和它的社区带来的兴奋。这正是为什么这本书读起来如此愉悦,当有两个信得过的程序员和你分享他们对Clojure之美的激情时,你很难无动于衷。
——Amazon Reader M.K.
Fogus和Houser之于Clojure,就像Irma Rombauer之于烹调。通过超越的基础知识,这本书使得读者能够利用Clojure思维去思考!
——Phil Hagelberg
函数式编程和Lisp的奇妙探险之旅。
——Matt Revelle
在The Joy of Clojure第2版,Michael Fogus和Chris Houser展示了一系列的编程理念,其中包括许多我们多年以来学习的编程语言的一些话题。出现在这本书里的基本编程语言的概念中,我们熟知的有高阶函数、词法范围、闭包、尾递归、相互递归、延续和延续传递风格、懒序列、宏和关系编程。最重要的是,Fogus和Houser教你如何定义自己的小语言。
伟大的语言设计者和首届图灵奖获得者Alan J. Perlis曾说过,“总有一些事情只能用我们自己的语言来很好地表达,而其他的语言不行。”没有现成的编程语言可以精确表达为特定应用程序的概念和抽象。可以设计一门语言解决你具体问题的,就只有你。
创建一门“小语言”来解决特定问题是在软件开发中降低复杂度的有效途径。[1]两个著名的例子是数据库查询语言和电子表格应用程序的公式语言。这些例子都显著地表明它们不包括什么,以及它们包括什么,验证了Perlis另外一句名言:“当应用与语言不相关时,语言的地位就比较低。”由于只包括与问题相关的性能,一门设计良好的小语言本身就是高级语言。
数据库查询语言说明小语言的另一个重要方面:写一个完整的应用程序需要在多个领域解决问题。执行数据库查询的应用程序还将使用其他语言。与一门专用的语言相比,单独的小语言在满足一个普通应用程序的具体需求方面没什么优势。
出于这个原因,小语言协同工作最有效果。实现一个复杂程序的有效方式是将它切成多个针对特定问题的切片,然后对于每个问题切片定义一种语言。如果我们切片是垂直的,其结果是语言的“塔”,自顶向下。无论我们怎么样分割整体问题,我们都可以为每一个子切片应用正确的语言和正确的模式。
与递归一样,定义小语言的艺术鼓励和奖励完全是一厢情愿的思维。你可能会如此思考:“要是有一门语言能表达我的登录系统的密码规则就好了。”一个更复杂的例子,一个真实的故事,开始几年前,当我们想到我们自己,“如果我们有正确的关系语言,就可以实现一个可以向后运行的Lisp解释器[2]。”这是什么意思?
解释器可以被认为是一个函数,它将一个输入表达式,如(+ 5 1)转化为一个值,本例中为6。我们想实现类似关系数据库中的样式的解释器,其中要么是要解释的表达式要么是该表达式的值,或者这二者,可以被当作未知变量。我们可以使用查询[interpret '(+ 5 1) x]正向运行解释器,其中x与6一值有关。更重要的是,我们也可以用(interpret x 6)反向运行解释器,其中x与估值为6的无限个表达式有关,包括(+ 5 1)和[lamda (n) (n * 2) 3]。[谜题:确定查询(interpret x x)的结果。]
实现关系型的解释器是棘手的,但采用专为关系编程设计的小语言可以让这件事变得容易些。我们一厢情愿的想法促使我们建立一个语言塔:关系Lisp解释器,基础是丰富的关系型语言,关系语言的最小集,丰富的函数式语言,函数式语言的最小集[3]。(Lisp解释器接受最小功能的语言,将语言塔转换成一个圈!)考虑到这种方式的威力,许多Lisp的实现,包括Clojure的编译器被构建为语言层也就没什么好奇怪的了。
使用你从Fogus和Houser的The Joy of Clojure学到的内容,你就可以开始构建自己的语言塔,每门语言都有自己的语法形式和求值规则,为您特定的问题领域量身定制。还有什么软件开发方法能比这更有表现力且开心呢?
William E. Byrd and Daniel P. Friedman
The Reasoned Schemer(MIT Press, 2005)作者
[1] Jonn Bentley在他的文章中定义了“小语言”的通俗概念:“Programming Pearls: Little Languages,” Communications of the ACM 29, no. 8 (1986):711-21。
[2] 我们使用的Lisp语言当然指的是一个大家族的语言,包括Scheme、Racket、Common Lisp,Dylan当然还包括Clojure。对于我们来说,一个Lisp必须具备同像性,拥有第一等的函数,并有某种形式的宏(这3个概念都在这本书中描述)。
[3] 对于关系语言,我们的意思是一个纯粹的逻辑程序设计语言;或者,如在本例中,一个纯粹的约束逻辑编程语言。
本书作者选择了一种极具野心且颇为进取的方式教授Clojure。当听到有人进行“疾风式”教学,你会做何感想?感觉就像有人马上就要被吹走一样……我只是说,这不是通常理解的疾风。本书根本没打算成为程序设计的第一本书,即便是第一本Clojure书也不合适。作者假设你是个无畏的家伙,重要的是,你还配备了搜索引擎。浏览书中例子时,手边最好有Google。在这场Clojure旋风之旅中,作者带着我们飞快地领略了函数式编程和工业程序设计的经典基础,偶尔会让人觉得这简直是场五级热带风暴。你会学得飞快!
我们的产业,甚至整个的程序设计社区,都是时尚驱动的,以至于从纽约到巴黎高级服装设计师都局促不安。我们臣服于时尚。时尚决定着学校里教授怎样的程序设计语言,语言雇主招什么样的人,书架上摆什么书。天真的局外人或许以为语言的质量多少会有点影响,至少有那么一点点,但在现实世界里,时尚压倒一切。
所以,突然有一门Lisp方言流行起来,没有人会比我更为惊讶了。Clojure仅仅面世三年[1],却以数十年间前所未见的速度赢得关注。它甚至还没来得及有个“杀手级应用”,就像浏览器将JavaScript推到了闪光灯下,Rails促进了Ruby那样。或者说,也许Clojure的杀手级应用就是JVM本身。所有人对Java语言都忍无可忍,但有一点却可以理解,我们并不打算放弃在Java虚拟机及其能力上的投资:程序库、配置、监控以及所有各种完全有效的理由,都支持我们继续用下去。
对于使用JVM或是.NET的我们而言,Clojure感觉就像一个小奇迹。它的确是一门不可思议的高质量语言,实际上,我已经开始认为它是我见过的最好的程序设计语言了,然而不知怎么它就流行起来了。这简直是个魔法!它重燃了我对这个行业未来生产力整体提升的希望。或许,我们只是想摆脱困境,回到每个项目都像全新启动一样,没有遗留系统,如同Java的往日荣光一般。
在Clojure对生产环境的支持上,还有许多问题悬而未决,特别是相关的工具链。对于一门新语言,这是很正常的,也在预期之中。但是,Clojrue让我们看到了希望,如此优美实用的设计原则,似乎每个人都会为之雀跃。的确如此!我已许久未曾体会到新语言带来的乐趣了直到15年前Java降临。有许多语言觊觎JVM的王座,承诺将Java平台带至前所未有的新境界。时至今日,没有一种语言能将表达性、工业强度、性能同简单的乐趣正确地融合在一起。
在我看来,也许正是Clojure中“乐趣”的部分使之流行起来。
从某种意义上说,我认为所有这些都无可避免。Lisp直接以树形式编写代码的记法,这种理念已经是一次又一次得到了时间的验证。人们尝试过各种疯狂的做法:用XML格式、不透明的二进制,甚至用笨拙的代码生成器编写代码。但这种人造的“拜占庭帝国”总会年久失修,或为自身所累而坍塌崩溃,然而Lisp却历经岁月,依然简单、优雅、纯净。我们需要以一种现代的方式回到这条路上来。Rich Hickey做到了,他用Clojure带我们回来了。
本书或许只是让Clojure有趣起来,对您如此,对我们也如此!
STEVE YEGGE
steve-yegge.blogspot.com
《A Programmer's Rantings》作者[2]
[1] 译注:Clojure诞生于2008年,而本书英文版出版于2011年。
[2] 编者注:该书中文版为《程序员的呐喊》(ISBN 978-115-34909-5),由人民邮电出版社出版。
本书作者要共同感谢Rich Hickey,Clojure之父,带给世人其深思熟虑之作,推动了语言设计进一步发展。没有他的辛勤工作、投入及视野,本书便不复存在。
我们还要感谢年轻的Clojure社区里那些充满智慧的人,包括但不限于Stuart Halloway、David Edgar Liebke、Christophe Grand、Chas Emerick、Meikel Brandmeyer、Brian Carper、Bradford Cross、Sean Devlin、Tom Faulhaber、Stephen Gilardi、Phil Hagelberg、Konrad Hinsen、George Jahad、David Miller、David Nolen、Laurent Petit和Stuart Sierra。之后,我们要特别感谢David Nolen和Sam Aaron,他们用技术震惊了世界。最后,我们要特别感谢Daniel Friedman和William Byrd同意为本书写序,许多年来,他们一直激励着我们。
在本书编写的不同阶段,Manning都会把草稿发出去做评审,我们感谢下列评审者的无价建议:Alejandro Cabrera、Anders Jacob Jørgensen、Cristofer Weber、Heather Campbell、Jasper Lievisse Adriaanse、Patrick Regan、Sam De Backer和Tom Geudens。
还要感谢Manning团队给予的指导和支持,从发行人Marjan Bace开始、副发行人Michael Stephens、我们的开发编辑Nermina Miller以及生产团队Kevin Sullivan、Benjamin Berg、Tiffany Taylor和 Dottie Marsico。还要再次感谢Christophe Grand 和Ernest Friedman-Hill(我们最爱用的语言之一Jess的主要设计者和开发者)分别对第1版和第2版所做的非常专业的技术审校。
我要感谢我美丽的太太Yuki,在写作本书期间,她给予了我无限的耐心。没有她,我恐怕无法坚持下来。我还亏欠Chris Houser许多,我的合作者和朋友,他教会了我许多Clojure的东西,我从未想到可以这么用。我要感谢Larry Albright博士,他把Lisp介绍给我,稍后,Russel E. Kacher博士,他激发了我对学习、好奇心以及沉思的激情。此外,我要感谢Tim Good,同学兼挚友,激励我努力工作并且不遗留任何bug。最后,我要感谢我的儿子Keita和Shota,他们教会我爱的真正含义,还有它并不总是我的。
首先要感谢上帝,造物主。感谢我的父母,感谢你们的爱与支持,你们的探索精神开启了我的奇妙冒险之旅。我的哥哥Bill,感谢你最早把我带入计算机的世界,领悟程序设计的乐趣与挑战。我的妻子Heather,你始终如一的鼓励,贯穿了本书从始至终的创作历程。我的朋友和合作者Michael Fogus,感谢你绝妙的灵感以及令人叹为观止的知识宽度,才有诸位手上的这本书。
这本书是关于编程语言Clojure的。具体而言,这本书是关于如何用Clojure的方式写Clojure代码。更确切地说,这本书是关于有经验的、成功的Clojure程序员是如何编写Clojure代码的,以及语言本身如何影响开发软件的方式。
您可能会问自己:“这些家伙是谁,我为什么要听他们的?”在简单地向一位你一无所知的权威发问之前,让我们先花一些时间来解释这本书是什么,我们是谁,为什么我们写这本书。
我们在Clojure的早期就开始关注它。可以肯定地说,有一段时间Clojure的IRC频道#clojure(Freenode上)只有我们自己伴随着Clojure的设计师——Rich Hickey和其他几个人。我们在探寻的路上发生的故事和一些早期的采用者的故事是类似的。在发现Clojure之前,我们一路上从现代的面向对象语言,如Java和C++,到一些简单(看上去)的语言如JavaScript和Python,然后进入更强大的语言如Scala和Common Lisp。我们是如何发现的Clojure并不重要,重要的是我们在探索一些其他语言没有提供的内容。
Clojure提供了什么其他语言不能提供的呢?简单地说,我们认为,当你理解了Clojure的性质和编写代码的同像性,会发现编程艺术和系统构建的新的视角。因此,Clojure不同于其他语言的地方是它的启蒙作用(可以这么说)。当然它不是唯一的,受到Clojure的影响,很多项目也开始这么做。从Datomic到OM、AVOUT、Pedestal,Clojure的影响是显而易见的。Clojure的方法开始蔓延至其他编程语言,包括(但不限于)Scala、Elixir和Haskell。
这本书是关于Clojure的书,是由每天使用其作为开发语言并了解其特性的两个程序员完成的。我们希望你通过精心阅读这本书,可以了解Clojure的威力和重要性。
你与莎士比亚之间仅有的差别在于习语的数量——而非词汇量的多寡。
——Alan Perlis
本书酝酿之际,我们的第一直觉是将Clojure与其宿主语言Java做一个全方位的比较。经过深入反思,我们得到的结论是,做好了最多算是狡猾,搞不好则是灾难。诚然,一些比较无可避免,但Java与Clojure有着很大的不同,为了说明一个而试图歪曲另一个,对二者都有失公允。因此,我们决定采用一种更好的方式,专注于编写代码本身的“Clojure之道”。
当我们熟悉了一门程序设计语言,这门语言的习惯用法和构造就会定义我们思考以及解决程序设计任务的方式。因此,面对一门全新的语言时,我们很自然地就会在精神上将新语言映射为我们熟悉的旧语言。但是,我们恳求你,请将所有的包袱丢在身后;无论你来自Java、Lisp、Scheme、C#或是Befunge,我们都请你铭记,Clojure有其自身的语言,请遵循它自己的一套惯用法。你会发现,Clojure与你已然熟知的语言之间在概念上有着一些联系,但请千万不要假设类似的内容就是完全一样的。
我们会努力工作,引导你了解Clojure用于构建思维模型的特性和语义,这样才能更有效地使用语言。本书的大多数例子都设计成可以在Clojure的交互式程序设计环境中运行,通常称为读取—求值—打印循环(Read-Eval-Print Loop),或是REPL,这是一个极其强大的环境,用于实验和做快速原型。
当你读完本书,用Clojure之道思考以及解决问题将成为你的另一片舒适区。如果我们成功了,那么,你不仅可以成为一个更好的Clojure程序员,还能够以别样的视角看待程序设计语言的选择——无论是Java、JavaScript、Elixir、Ruby J,还是Python。重新评估一些我们已经认为是理所当然的主题,对于个人成长而言,是不可或缺的。
本书并非Clojure初学指南。虽然我们的确提供了一些入门指导,但我们起步极快,没有在搭建可运行的Clojure环境上花费太多精力。此外,本书讨论的并非实现细节,而是语义细节。这也不是一本Clojure的“cookbook”,取而代之的是,通过对Clojure详尽的探究,为创建优美的软件提供素材。我们常常会解释这些素材如何整合,为什么它们搭配极佳,但这里没有全面的系统秘籍。我们的例子直接处理了手头上的一些内容,有时还会把一些内容留给你去扩展,进一步丰富自己的知识。别指望我们将一门全面的课程装到一本书里,无论对你我,还是对Clojure,这都是不可能的。通常,一本语言的书要花掉一半的篇幅介绍“真实世界”的情况,这与语言本身完全没有关系,希望我们能够规避这个陷阱。我们有一种强烈的感觉,如果能够告诉你语言背后的“为什么”,那你就能做好准备,将这些知识应用于真实世界的问题。简而言之,如果你找的是一本对新手负责的书,告诉你如何迁移既有代码库,连接NoSQL数据库以及探索其他“真实世界”的主题,那我们推荐Amit Rathore的《Clojure in Action》(Manning, 2011)。
总而言之,我们确实提供了一份语言简介,我们认为,如果你是愿意花时间理解Clojure 的人,这本书就是为你准备的。此外,如果已经有了 Lisp 程序设计的背景,那你会觉得许多介绍材料看起来都很熟悉,所以,这本书对你而言是理想的。虽然绝非完美,但对于解决实际的程序设计问题,Clojure拥有一套很好的特性组合,能够放入一致的系统中,解决程序设计中的问题。Clojure鼓励我们思考问题的方式可能不同于我们习惯的方式,需要花些精力才能“得到”。但是,一旦跨过了这个门槛,我们或许能体会到一种愉悦,在本书里,我们就是想帮你到达那里。这是令人兴奋的时刻,我们希望你会同意,Clojure会是带我们驶入未来所不可或缺的一种工具。
我们要带你上路了。也许之前你已然开启了自己的Clojure探索之旅。也许你是个Java或Lisp老手,第一次接触Clojure。也许你来自完全不同的背景。无论如何,我们在对你说。本书自诩为写给冒险者,它需要我们丢开自己的包袱,带着开放的心态了解其中的主题。在很多方面,Clojure会改变我们看待程序设计的方式,在其他一些方面,它会冲刷掉我们预先形成的一些理念。关于软件如何设计与实现,这门语言有很多要说的,本书将逐一触及这些主题。
几乎每一门程序设计语言都有一些被认为是根本性的内容。偶尔,一门语言发明出来会动摇软件产业的根基,驱散一些广为人知的既有的关于“良好软件实践”的概念。那些根本性的语言总能将一些全新的方式引入软件开发,缓和那个时代一些困难的问题,如果不能完全消除的话。任何一份根本性语言的列表都无可避免地会引发某些语言支持者的愤慨,在他们看来,其喜好的语言不应被忽略。但是,我们愿意承担此风险,所以,仅将下列程序设计语言归为此类。
根本性的程序设计语言
年份 |
语言 |
发明者 |
趣味阅读 |
---|---|---|---|
1957 |
Fortran |
John Backus |
John Backus, “The History of Fortran I, II, and III,”IEEE Annals of the History of Computing 20, no. 4 (1998) |
1958 |
Lisp |
John McCarthy |
Richard P. Gabriel和Guy L. Steele Jr., “The Evolution of Lisp” (1992), www.dreamsongs.com/Files/HOPL2-Uncut.pdf |
1959 |
COBOL |
由委员会设计 |
Edsger Dijkstra, “EWD 498: How Do We Tell Truths That Might Hurt?”,出自Selected Writings on Computing: A Personal Perspective (New York: Springer-Verlag, 1982) |
1968 |
Smalltalk |
Alan Kay |
Adele Goldberg, Smalltalk-80: The Language and Its Implementation (Reading, MA: Addison-Wesley, 1983) |
1972 |
C |
Dennis Ritchie |
Brian W. Kernighan和Dennis M. Ritchie, The C Programming Language (Englewood Cliffs, NJ: Prentice Hall, 1988) |
1972 |
Prolog |
Alain Colmerauer |
Ivan Bratko, PROLOG: Programming for Artificial Intelligence (New York: Addison-Wesley, 2000) |
1975 |
Scheme |
Guy Steele和Gerald Sussman |
Guy Steele和Gerald Sussman, the“Lambda Papers,”mng.bz/sU33 |
1983 |
C++ |
Bjarne Stroustrup |
Bjarne Stroustrup, The Design and Evolution of C++ (Reading, MA: Addison-Wesley, 1994) |
1986 |
Erlang |
爱立信公司 |
Joe Armstrong, “A History of Erlang,”Proceedings of the Third ACM SIGPLAN Conference on History of Programming Languages (2007) |
1987 |
Perl |
Larry Wall |
Larry Wall, Tom Christiansen和Jon Orwant, Programming Perl (Cambridge, MA: O’Reilly, 2000) |
1990 |
Haskell |
Simon Peyton Jones |
Miran Lipovacˇa, “Learn You a Haskell for Great Good!”http://learnyouahaskell.com/ |
1995 |
Java |
Sun微系统公司 |
David Bank, “The Java Saga,”Wired 3.12 (1995) |
2007 |
Clojure? |
Rich Hickey |
你在读的这本书 |
无论喜欢与否,少有争论的一点是,所列语言极大地影响了软件构造的方式。Clojure是否应位列其中尚待观察,但是,Clojure确实从诸多根本性的语言中借鉴了许多,其他有影响力的程序设计语言也让它获益良多。
第1章开启了我们的旅程,介绍了Clojure蕴含的一些核心概念。这章完结之时,我们应该可以很好地理解这些概念。沿途之中,我们展示了一些说明性的代码样例,突显了一些概念(还有些不错的图)。第1章所包含的多数内容均可以视为“Clojure哲学”,因此,如果你想知道Clojure受什么启发以及由什么组成,这章就是为你准备的。
第2章快速地介绍了Clojure特定的特性和语法。
第3章讨论了一些不易归类的通用Clojure程序设计的习惯用法。从真值和风格,到打包和nil的考量,第3章就是个大杂烩。所有的主题本身都很重要,从许多方面来看,这些内容是理解大部分Clojure惯用源码的起点。
第4章讨论了标量数据类型,大多数程序员对这个话题相对熟悉,但还有一些重点需要注意,源于Clojure一些有趣的特性,这是寄宿于Java虚拟机的函数式程序设计语言所固有的。阅读本书的Java程序员会关注数字精度(4.1节),Lisp程序员则会关注Lisp-1 vs. Lisp-2(4.4节)。Clojure里还包含了实用的正则表达式,并将其作为一等语法元素(4.5节),程序员们会对此心存感激的。最后,经验丰富的Clojure程序员也许会发现,关于有理数和关键字(分别在4.2节和4.3节)的讨论,对这些貌似无足轻重的类型给出了全新的见解。
无论背景如何,第 4 章都会提供一些关键信息,帮助我们理解 Clojure 那些未受重视的标量类型的本性。
第5章涵盖了Clojure全新的持久化数据结构;任何希望深入了解它们的人都能够从中获得启迪。持久化数据结构位于Clojure程序设计哲学的核心,必须理解方能完全掌握Clojure设计决策的含义。我们只会简要触及这些持久化结构的实现细节,因为相对而言,理解为什么使用以及如何使用这些结构会更重要一些。
第6章会让人们对不变性、持久化和惰性有个大致的了解。我们会探索Clojure在支持并发程序设计中的关键元素:不变性。类似地,我们还会看到,有了不变性,许多与需要协调状态改变的问题都消失殆尽了。我们还会探索Clojure利用惰性降低内存占用以及加速执行时间的方式。最后,我们会谈及不变性和惰性的相互作用。如果你来自那些对修改不加限制且拥有严格求值表达式的语言,初涉之下,第6章或许是一种令人费解的体验。但这种令人费解会带来某种启迪,我们可能以前所未有的视角审视我们最喜欢的程序设计语言。
第7章全面展示了Clojure式的函数式编程。如果有函数式编程的背景,那你会熟悉本章的很多内容,虽然Clojure会呈现出其独特的一些东西。但类似于每一种被授予“函数式”称号的程序设计语言,Clojure的实现给我们提供了一个不同的视角,让我们有机会审视自己之前的经验。如果你完全不熟悉函数式编程的技术,第7章可能是令人费解的。以对象层次结构和命令式程序设计技术为核心的语言里,函数式编程的概念犹如异类。但我们相信,由于Clojure的决策源自函数式范型的编程模型,它应该是个正确的方向,我们希望你也会赞同。
任何规模的应用都可以把 Clojure 当作主要的语言,第 8 章关于宏的讨论会改变我们对于开发软件的想法。作为一种Lisp,Clojure也拥抱了宏,我们会带你经历理解宏的过程,让你意识到,能力越大,责任越大。
在第 9 章,我们会带你领略 Clojure 内建的对“代码和数据”进行组合及关联的机制。从命名空间到多重方法再到类型和协议,我们会逐一解释 Clojure 如何促进大规模应用的设计和实现。
Clojure 与生俱来对程序状态就有着完善的管理,简化了并发程序设计,这些内容会在第 10章看到。Clojure 的状态模型简单而强大,缓和了这种复杂任务所包含的大多数头疼问题,我们会逐个为你展示如何以及为什么使用。此外,我们还会强调一些并非由 Clojure 直接解决的问题,例如,如何识别和降低对 Clojure 引用类型所保护元素的需要。
最后,在第11章总结了Clojure对于进程内并发机制的支持。
Clojure 是一种共生的程序设计语言,这意味着,它要运行于宿主环境之上。目前选择的宿主是Java虚拟机,但未来,Clojure 可能会变成跨宿主平台。无论如何,Clojure都会提供一流的函数和宏,用于与宿主平台的直接交互。在第 12 章,我们会讨论 Clojure 与其宿主互操作的方式,自始至终关注于JVM。在第13章会关注ClojureScript的交互方式。第13章描述了ClojureScript是如何以Clojure的方式被实现的,并且提供了一个播放声音的设计和实现。
本书最后一部分讨论了同样重要的话题:透过Clojure哲学的视角看待我们应用的设计和开发。在第14章,我们将讨论Clojure如何实现面向数据的开发方式并且如何简化实现和测试。在这之后我们会讨论改善单线程应用性能的一些方式。Clojure提供了许多机制改善性能,我们会逐一深入,包括其用法及适用范围,这些都将放在第15章。
第16章是有趣的一章,我们探讨了Clojure生态系统的发展趋势——利用逻辑编程技术扩展函数式编程。本章使用core.logic库探索“后函数式编程”。
作为本书的总结,在第17章,我们强调了在某些偏离开发行为的方面,Clojure改变了我们思考的方式,例如定义自己的应用领域语言、测试、错误处理和调试。
本书的源码都采用直白而实用的方式进行了格式化。文本里内联列出的源码,例如(:lemonade :fugu),都采用等宽字体并加粗。在代码块里列出的代码片段距左边有一些偏移,采用等宽字体并加粗以突出显示:
(def population {::zombies 2700 ::humans 9})
(def per-capita (/ (population ::zombies) (population ::humans)))
(println per-capita "zombies for every human!")
如果源码片段表示一个表达式的结果,那么结果会有个前缀——“;=>”。这种特殊的序列有三重目的。
(def population {::zombies 2700 ::humans 9})
(/ (population ::zombies) (population ::humans))
;=> 300
此外,在REPL里,如果预期的显示不是返回值(例如表达式或打印输出),那么,实际的返回值前面会有个先导的“;”:
(println population)
; {:user/zombies 2700, :user/humans 9}
;=> nil
在上面的例子里,显示为{:user/zombies 2700, :user/humans 9}的map就是一个打印值,而nil表示println函数的返回值。如果表达式后面没有显示返回值,那么我们可以认为就这个例子而言,要么是nil,要么是忽略了。
阅读Clojure代码,如果从左向右阅读,只要注意重要部分的上下文(defn、binding、let等)即可。而由内而外阅读,则要仔细注意每个表达式返回的内容,以传递给紧邻的外部函数。在阅读最内部的表达式时,这要比试图记住整个外部上下文简单许多。
无论是内联,还是代码块,所有格式化过的代码都是为了让键入或粘贴同写进Clojure源码或REPL的完全一样。一般来说,Clojure的提示符user>没有显示,因为它会导致复制/粘贴的失败。最后要说的一点是,我们有时会用省略号表示略去的结果或打印输出。
在许多列表里还有一些代码标记,强调了一些重要概念。在某些情况下,还会有一些用以解释的数字编号链接跟在列表后面。
如果你目前还没装Clojure,那么,我推荐你使用Phil Hagelberg创建的项目自动化工具,位于http://leiningen.org/,其安装指令在http://leiningen.org/#install。
下载并安装完成后,在命令行执行如下指令:
lein repl
您可能会看到输出Leiningen显示安装进度所需的库,但这是第一次运行lein所需的步骤。一旦它完成后,您将看到类似以下内容:
nREPL server started on port 53337 on host 127.0.0.1
REPL-y 0.2.1
Clojure 1.5.1
Docs: (doc function-name-here)
(find-doc "part-of-name-here")
Source: (source function-name-here)
Javadoc: (javadoc java-object-or-class-here)
Exit: Control+D or (exit) or (quit)
user=>
使用由Leiningen提供的Clojure的REPL有一个需要注意的地方。首先,将REPL的lein repl执行是基本的Clojure REPL的增强版本。除了更大的错误显示,该Leiningen REPL提供了一个相当不错的查看代码历史的功能,自动完成括号和括号匹配以及命令的建议。例如,如果你知道你想使用的命令是“update...xxx,”你可以在REPL输入update,按下Tab键,这时你会看到下面的内容:
user=> (update-
update-in update-proxy
user=> (update-
该Leiningen REPL显示该命名空间(这个例子里是user)下所有可用的,并以update为前缀的函数名。
现在Leiningen安装并运行,你可以开始输入代码。可以试试下面的内容:
(+ 1 2 3 4 5)
按下Enter键,REPL会调用+函数,并显示如下结果:
15
user=>
对于测试和开发来说。REPL是一个功能强大的环境。大多数Lisp和Clojure的程序员使用某种形式的REPL来开发他们的代码,无论是直接如下所示或间接通过其他开发工具,例如Emacs、Eclipse、Vim和Light Table等。如果你看到一个开发人员编写Lisp代码,你最好相信REPL是近在咫尺的。
Leiningen的优点在于它提供了创建和管理Clojure的项目依赖。它通过采取项目规范文件,通常命名project.cl;解决该文件中所列的依赖关系;并运行额外任务、如编译、测试,或其他相关的任务。例如,对于该project.clj文件在这本书中的源代码被显示出并在下面的列表解释:
代码1 The Joy of Clojure源代码的project.clj
(defproject second-edition "1.0.0"
:description "Example sources for the second edition of JoC"
:dependencies [[org.clojure/clojure "1.5.1"] ◁——● 每一个项目包含的名称和版本
[org.clojure/clojurescript "0.0-2138"]
[org.clojure/core.unify "0.5.3"]
[org.clojure/core.logic "0.8.5"]] ◁——● 运行时依赖
:source-paths ["src/clj"] ◁——● 代码目录
:aot [joy.gui.DynaFrame] ◁——● 命名空间预编译为JVM字节码
:plugins [[lein-cljsbuild "0.3.2"]] ◁——● lein通过插件扩展
:cljsbuild ei ◁——● n编译需要特殊的参数配置
{:builds
[{:source-paths ["src/cljs"]
:compiler
{:output-to "dev-target/all.js"
:optimizations :whitespace
:pretty-print true}}
{:source-paths ["src/cljs"]
:compiler
{:output-to "prod-target/all.js"
:optimizations :advanced
:externs ["externs.js"]
:pretty-print false}}]})
这本书不会假设你精通lein,但我们会经常提到它。我们建议了解一些lein的知识,特别是如果你打算经常地写Clojure代码,因为它是无处不在的选择。
本书所有的可运行源码都可以从出版商的网站下载,地址是www.manning.com/ TheJoyof Clojure Second Edition。我们也会在https://github.com/joyofclojure/book-source保留一份,并时常更新。
购买本书的同时,你也就可以免费访问Manning Publications的一个私有Web论坛,可以在上面发表评论,咨询技术问题,得到作者和其他用户的帮助。要访问和订阅这个论坛,请到www.manning.com/TheJoyofClojure。注册之后,就可以从这个页面获得论坛访问信息,了解得到相关的帮助,以及论坛的管理规则。
Manning对读者的承诺是,提供一个场所,让读者之间以及读者和作者之间进行一场有意义的对话。作者方面无法承诺参与的量,在AO上面的贡献是志愿的(且无偿)。我们建议你尝试问作者一些有挑战性的问题,以免他们意兴阑珊。
只要本书在售,作者在线论坛和之前讨论的合集就都可以在出版商的网站上访问。
MICHAEL FOGUS是一个Clojure和ClojureScript分布式仿真体验的核心贡献者,擅长机器视觉和系统建设。
CHRIS HOUSER是Clojure和ClojureScript的关键贡献者,提供了许多特性。
想要完全理解 Clojure,就应该品味一番 Paul Graham 的文章《拒绝平庸》,它让人有机会一窥其公司 Viaweb 在 1998 年被雅虎收购之前的内部状态。虽然纵览创业文化很有趣,但这篇文章真正令人难以忘怀的部分是,Viaweb 怎样用 Lisp 程序设计语言赢得竞争优势。一门50多岁的程序设计语言是如何为Viaweb 带来超越其竞争对手的优势呢?它的竞争对手肯定用的是更现代的企业级技术。这里无意重复文章中的确切内容,Graham 确实给出了一个令人信服的例子,充分展现了 Lisp 在促进更加敏捷程序设计环境方面的能力。
事实证明,自从2007年Clojure发布以来,很快获得了工业界的青睐。很多开发商使用Clojure或ClojureScript创造自己的软件系统和产品。软件开发人员已经发现,如Graham在他的文章中所述,使用Lisp语言的喜悦和力量。虽然毫无疑问,目前Clojure的使用比例还不显眼,但是它每个月都在增长。这是Clojure语言令人欣喜的地方。
Clojure是一门 Lisp 方言,通过函数式编程技术,直接支持并发软件开发。它类似于《拒绝平庸》中所描述的Lisp,提供了一个有益于敏捷性的环境。Clojure以一种许多流行的程序设计语言无法复制的方式促进了敏捷性的发展。许多程序设计语言受困于下列事情的全部或大多数:
相比之下,Clojure将能力和实践融合在一起,缩短了开发周期。但是,Clojure的益处并没有止步于其敏捷性,正如一篇文章中的明确断言,《多核已成新热点》(Multicore is the new hot topic)(Mache Creeger in ACM Queue, vol. 3, no. 7)。虽然多核处理器概念本身并不新,但其重要性正日益赢得更多的关注。时至今日,并发和并行程序设计已然无法回避,而凭借不断加快的处理器浪潮赢得更好性能的往日荣光已经一去不回。好吧,它正逐步放缓,直至停止,Clojure恰逢其时的出现,给了我们很大的帮助。
Clojure将函数式编程与宿主共生以一种独特的方式融合在一起,这种宿主共生是对宿主平台的拥抱和直接的支持,这里宿主平台指的是Java虚拟机。此外,Clojure简化有时甚至消除了需要协调的状态改变所包含的复杂性,这也将Clojure定位成一种重要的、勇往直前的语言。最终,所有的软件开发人员都会将处理这些问题视为理所当然,对于Clojure的研习、理解以及最终的运用都是征服这些问题的必经之路。从软件事务性内存,到惰性,再到不变性,本书将引导你理解Clojure这些主题背后的“为什么”,当然,还包括了“怎么做”。
我们愿做你的向导,帮你深入理解Clojure的乐趣,因为我们相信,这种艺术终将成为软件开发新时代的序曲。
即便最宏伟的建筑也必须从坚固而简陋的基础开始。我们也要从浇筑知识基础起步,有了这样的基础,才能对Clojure中那些不甚熟悉的做法产生深刻的理解。这样的基础包括Clojure根本的程序设计哲学、由数据和函数构建起的坚固围墙、REPL以及nil双关,当然,还有其他一些内容。
本章涵盖:
学习一门新的语言往往需要投入很多的精力并且需要深入的思考,只有达到程序员所学语言的预期效果,这样的投入才是公平的。Clojure出自Rich Hickey的手笔,他试图规避使用传统面向对象技术管理可变状态带来的诸多复杂性:既有本质的,也有偶然的。
凭借对程序设计语言深入的研究和严谨的态度以及对实用性的热切追求,Clojure逐渐发展为一门重要的程序设计语言。它正扮演着一个重要角色,体现着程序语言设计的最新发展方向。
在程序设计语言的历史长河中,Clojure只是一个婴儿,但其用法(或是说“最佳实践”或惯用法)却源自有50年历史的Lisp[1]以及有15年历史的Java。自问世以来,社区就表现出了极大的热情并且呈现爆炸式的增长,进而培养出一套独特的体系。
在本章中,我们会讨论一些既有语言的缺陷以及Clojure如何弥补这些缺陷,并且会涉及Clojure的一些设计特性。我们还会看到一些既有语言对Clojure的影响,此外,还会定义一些整本书都会用到的术语。虽然本章只会粗浅地介绍本书的一些内容(topic),但是对于理解如何用Clojure以及为什么要用Clojure是很有帮助的。不过如果你不熟悉Clojure,最好先了解第2章的入门内容。
我们会慢些起步。
Clojure是一门观点鲜明的语言,它并不打算涵盖所有编程范式,也不准备提供清单列出每个重要特性。相反,它只提供以 Clojure 之道解决各种真实问题所需的特性。要从Clojure中获得最大收益,我们就要写出遵循语言自身规则的代码。在本书中,我们会依次介绍语言特性,但我们想说的并不只是一个特性做了些什么,更重要的是,为什么会有这样的特性以及如何利用好这样的特性。
但是,开始之前,我们先来从宏观上了解一下Clojure最重要的哲学基础。图1.1所示列出了Rich Hickey设计Clojure时头脑中一些大致的目标以及为了支持这些目标而内建在语言中的一些更具体的决策。
图1.1 Clojure的大致目标:本图展示了构成Clojure哲学的一些概念以及这些概念之间的交互
如图1.1所示,Clojure的总目标由一些支持目标和功能综合而成,稍后几节,我们会逐一谈及。
复杂问题很难有一个简单的解决方案。但是,如果把事情搞得过于复杂,即便是有经验的程序员也会栽倒,这就是“偶然复杂性”,与其相对的是任务的本质复杂性(Moseley 2006)。Clojure致力于帮助我们解决各种复杂问题,而不引入偶然复杂性,例如,各种数据需求、多并发线程、独立开发的程序库等。它还提供了一些工具,减少了一些初看起来像本质复杂性的内容,Clojure提供了一组简单的抽象和模块,方便用于拿来构建更强大的功能。如此一来,最终的特性集合或许看起来并不简单,尤其在我们对这些特性还不甚熟悉时,但随着通读本书,我们认为,你会逐渐体会到Clojure去除了很多的复杂性。
偶然复杂性有一个例子,就是现代面向对象程序设计语言的一个发展趋势,代码逻辑往往掺杂着各种类定义、继承和类型声明。Clojure通过支持“纯函数”去除了所有这些内容,所谓纯函数就是传入几个实参,然后,只根据这些实参产生一个返回值。Clojure很大一部分就是构建在这样的函数基础上的,绝大多数应用也可以如此,这意味着有更多精力专注于问题本身。
写代码总是要和干扰做斗争,每当语言让我们思考语法、运算符优先级、继承层次结构时,只会让干扰增多。Clojure尽力让一切保持尽可能简单,无须为探索一个想法经历“编译—运行”的循环,无须类型声明,等等。它还提供了一些工具,让我们可以改造语言,使词汇和文法能够更好地适应问题领域,因此,Clojure极具表现力。这种做法影响极大,可以在不牺牲可理解性的前提下,很好地完成一些极其复杂的任务。
之所以能够保持专注,关键的一点在于恪守对动态系统的承诺。即使处于运行态,Clojure仍然允许重新定义程序中的函数、多态、类型和类型层次结构,甚至Java的方法实现。尽管在生产环境中动态定义这些内容会让人感到可怕,但是这为逻辑的实现打开了另一扇窗。我们可以对不熟悉的API进行更多的实验和探索,这是一种乐趣,而这种乐趣却常常为更静态的语言、漫长的编译周期所阻碍。
但是,Clojure并不只有乐趣。乐趣只是一种副产品,更重要的是,它可以让程序员有能力获得超乎想象的高效。
某些程序设计语言生来只为展示学术成果,或是探索某种计算理论。Clojure不在此列。Rich Hickey曾在很多场合说过,Clojure致力应用于有趣且有用的产品。
为达此目标,Clojure努力做到务实 —— 一种用于完成工作的工具。在Clojure里,如果某一设计决策要在实用和聪明、花哨或是纯理论的解决方案进行权衡,胜者往往是那些实用的解决方案。Clojure曾经试图通过封装类库的方式,从而让使用者远离Java,但是这样做会使得第三方Java库的使用变得更加笨拙。所以,Clojure选择了另一条路:不做封装、编译成相同的字节码,能够直接访问Java的类和方法。Clojure字符串就是Java字符串,ClojureScript的字符串就是JavaScript的字符串。Clojure和ClojureScript调用就是Java方法调用。这样做简单、直接、务实。
使用Java虚拟机(JVM)和针对JavaScript的决策本身就是一个务实的做法。例如,JVM是一个惊人的务实平台——成熟、快速、部署广泛。它支持各种硬件和操作系统,拥有数量众多的程序库以及支持工具,由于这个极尽务实的决策,所有这一切都可以为Clojure所用。同样,对于ClojureScript来说,可以很方便地利用JavaScript的优势,例如浏览器端的开发、服务器端的编程、针对移动设备的应用,甚至于数据库脚本。
除了直接的方法调用外,Clojure还有proxy、gen-class、gen-interface(参见第10章)、reify、definterface、deftype和defrecord(参见9.3节),为互操作性提供了许多选择,所有这些都是为了完成工作。务实对Clojure很重要,当然,许多其他语言也同样务实。我们后面会看到Clojure摆脱混乱的一些做法,正是这些地方让它显得与众不同。
下面有一段简单的代码,可能是用Python写的:
# This is Python code
x = [5]
process(x)
x[0] = x[0] + 1
执行这段代码之后,x的值是什么呢?如果假设process没有改变x的内容,那就应该是[6],对吧?但是,怎样才能做这样的假设呢?如果不了解process做了些什么,调用了怎样的函数等,我们根本无法确认。
就算process不会改变x的值,这时,再加入多线程,我们还是会有一大堆顾虑。如果在第一行和第三行之间,另一个线程改变了x会怎么样?还有更糟糕的,如果在第三行做赋值时,某段代码设置了x,那又该如何?你能保证你的平台写变量是原子操作吗?或者,是不是最终的值可能是多个写操作混杂的结果?我们可以抱着获得某种清晰的想法,将这个思维训练无休止地进行下去,但结果是一样的——我们根本无法得到清晰,只会适得其反:混乱。
Clojure为代码的清晰做着努力,提供了一些工具规避几种不同的混乱。就刚才描述的那种情况而言,采用它所提供的不变局部量和持久化集合,便可一并消除单线程和多线程的大部分问题。
当我们所用的语言将不相关的行为合在一个构造里时,我们不难发现,自己已深陷多种泥潭。Clojure通过分离关注点让我们保持警醒,应对这样的情况。一旦事物得到分离,思路就会清晰许多,只在必要时重新组合。从某种程度上说,这样的做法对某些特定问题非常有用。表1.1所示是将某些语言把概念混杂在一起的常规方式,同Clojure类似概念分离的做法进行了对比,本书稍后会对Clojure的做法进行更详尽的解释。
表1.1 Clojure中的分离关注点
组 合 |
分 离 |
出 处 |
---|---|---|
有可变字段的对象 |
将标识和值分开 |
第4章和5.1节 |
类当作方法的命名空间 |
将函数的命名空间同类型的命名空间分开 |
8.2节和8.3节 |
由类组成的继承层次结构 |
将名字的层次结构同数据和函数分开 |
第8章 |
数据和方法从词法上绑定在一起 |
将数据对象与函数分开 |
6.1节、6.2节和第8章 |
方法实现嵌在类继承链里 |
将接口声明与函数实现分开 |
8.2节和8.3节 |
有时,很难在脑子里将这些概念区分开来,但如果能做到的话,就会非常清晰了,为了这种强大和灵活,我们值得努力一试。我们有那么多不同的概念要处理,以一致的方式表现代码和数据就显得很重要了。
Clojure在两个具体的方面提供了一致性:语法和数据结构。
语法一致性指的是,相关的概念在形式上是类似的。有个简洁有力的例子,for和doseq这两个宏之间的语法是一样的。
它们做的事情不尽相同——for返回的是一个惰性seq,而doseq只是为了产生副作用——但二者支持相同的迷你语言(mini-language):嵌套迭代、解构以及:when和:while的保障。两个例子都会返回关键字a或b,与小于5的奇数的任意组合。第一个例子用for表达式实现这样的逻辑:
(for [x [:a :b], y (range 5) :when (odd? y)]
[x y])
;;=> ([:a 1] [:a 3] [:b 1] [:b 3])
第二个例子用doseq实现:
(doseq [x [:a :b], y (range 5) :when (odd? y)]
(prn x y))
; :a 1
; :a 3
; :b 1
; :b 3
;;=> nil
这种相似的价值在于,只要学习一种基本语法即可应对两种情况,必要时,在两种用法间切换也会容易许多。
类似地,数据结构的一致性表现在Clojure持久化集合类型的精心设计上,它为各个类型提供了尽可能相似的接口,并尽可能广泛地去使用这些接口。这种做法实际上是Lisp经典的“代码即数据”哲学的扩展。Clojure数据结构不仅可以持有大量应用的数据,还可以持有应用自身的一些表达式元素。它们可以描述对form的解构,还可以为各种内建函数提供命名选项(named options)。其他面向对象语言可能会鼓励应用定义多个彼此不相容的类,以持有不同类型的应用数据,而Clojure则鼓励使用行为上类似于map的对象。
这样做的好处在于,同样一套处理Clojure数据结构的函数可以用于下列所有情形:大规模数据存储、应用代码和应用数据对象。用into可以构建任意类型,用seq可以获取一个用于遍历的惰性seq,用filter可以选择满足特定条件的元素,等等。一旦习惯了所有这些丰富且随处可用的函数,用Java或C++处理应用中的Person或Address这样的类就会让人觉得处处掣肘。
简单、专注、实用、一致和清晰。
Clojure程序设计语言里几乎所有元素都是为了提振这些目标。编写Clojure代码处理真实问题时,请将“简单、实用、专注”等方面推向极致,将此铭记于心,我们相信你会发现,Clojure就是你到达成功彼岸所需的工具。
一套好的概念可以将大脑从无用功中解放出来,专注于更高级的问题。
——Alfred North Whitehead[2]
去到任何一个开源项目托管站点,搜索“Lisp interpreter”(Lisp解析器)。这个貌似平淡无奇的搜索,可能会带给我们一个堆积如山[3]的结果。事实上,计算机科学的历史中堆积着大量废弃的Lisp实现(Fogus 2009)。诸多初衷良好的Lisp来了又走,一路遭到无数嘲笑,但到了明天,搜索结果依然会无限制增长。既然有如此惨痛的过往,为何还有人愿意将其崭新的程序设计语言构建于Lisp模型之上呢?
Lisp吸引了计算机科学史上最聪明的一群人。但是,仅有权威的争论是不够的,我们不该仅凭此判断Lisp。只有用其编写应用,才可以直接看得到Lisp语言家族的真正价值。Lisp的风格就在于极具表现力、非常实用以及在大多数情况下表现出的美感。最初的Lisp语言是John McCarthy在其惊天动地的论文“Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I”(McCarthy 1960)中定义的,只用区区7个函数和两个特殊form便定义出整个语言:atom、car、cdr、cond、cons、eq、quote、lambda和label。
通过这9个form的组合,McCarthy将整个计算以一种令人窒息的方式呈现出来。计算机程序员总在寻找美,而多数情况下,美会以简单的形式自我呈现出来。7个函数和两个特殊form,美不过如此。
Lisp何以历经50多年而弥新,相较之下,无数语言却成了匆匆过客。个中原因可能极尽复杂,但究其根因,无外乎Lisp自身的语言基因(Tarver 2008)将语言的灵活性推向极致。Lisp新手常常气馁,无处不在的括号和前缀记法,与非Lisp程序设计语言大相径庭。然而,正是这种行为上的规律性,不仅让需要记忆的语法规则减少了,也让宏的编写变得很简单,你可以利用宏向Lisp中添加新的语法,用于解决特定的问题。另一方面还能够根据需求,利用Lisp创建领域特定语言(DSL)。我们会在第8章更详细地了解宏,但为了让你开开胃,这里先简单地看一下。
Clojure看上去并不像现在比较流行的编程语言。当你第一次面对类Lisp的语言时,脑中蹦出的第一个想法就是:括号的位置不对!
如果你熟悉C、Java、JavaScript或C#这类语言,它们的函数和方法调用是这样的:
myFunction(arg1, arg2);
myThing.myMethod(arg1, arg2);
而数值计算往往又是这样的:
// THIS IS NOT CLOJURE CODE
1 + 2 * 3;
//=> 7
但是在Clojure中函数和方法调用是这样的:
(my-function arg1 arg2)
(.myMethod my-thing arg1 arg2)
更令人惊讶的是不同的运算符号,在使用上看上去是一样的,并且顺序是先计算括号内,再计算括号外,如下所示:
(+ 1 (* 2 3))
;;=> 7
我们一般都是类C语言的背景,因此需要慢慢适应Lisp这种运算符前置的用法。Clojure极致的灵活性使得你可以修改它用于适应你个人的风格。例如你不想使用Lisp这种前置的风格,而更希望表达式的计算方式类似于APL(Iverson 1962)那种严格遵循的从右向左的顺序。接下来我们看看Lisp是如何满足你的需求的。
程序1.1 计算顺序为从右向左的函数
(defn r->lfix
([a op b] (op a b))
([a op1 b op2 c] (op1 a (op2 b c)))
([a op1 b op2 c op3 d] (op1 a (op2 b (op3 c d)))))
也许你已经猜到了这个函数是如何运行的。但是为了更清晰,图1.2所示揭示了一些细节。让我们来认识这个函数是否能够得到我们想要的结果:
(r->lfix 1 + 2) 将前缀转为中缀
;;=> 3 使用核心的string函数
(r->lfix 1 + 2 + 3) 处理不安全的字面量
;;=> 6 支持各种语句
图1.2 r->lfix函数打乱了运算符的顺序,把最右面的中缀表达式放到了
最里面的括号中,从而保证了它首先被执行
可见对于只有一种运算符的情况下,确实如我们所预期的一样。而且对于混合了多个运算符的情况下,看上去也是可以正常工作的:
(r->lfix 1 + 2 * 3) 调用适当的转换器
;;=> 7 提供主入口宏
但是如果改变数值和符号的位置会引发潜在的问题。
(r->lfix 10 * 2 + 3)
;;=> 50
显然由于乘法的优先级要高于加法,所以这段表达式的结果应该是23。事实上对于大多数的类C的语言,执行这段代码的结果也会是23。
// 这不是clojure代码
10 * 2 + 3
//=> 23
那好吧,看来我们需要把函数修改为类SmallTalk那种形式,使得运算严格遵守从左到右的顺序。
程序1.2 计算顺序为从左向右的函数
(defn l->rfix
([a op b] (op a b))
([a op1 b op2 c] (op2 c (op1 a b)))
([a op1 b op2 c op3 d] (op3 d (op2 c (op1 a b)))))
这个函数的执行细节在图1.3中做了详细的解释。表达式中的第一个运算符(乘法符号)会首先被计算。
图1.3 l->lfix函数打乱了运算符的顺序,把最左面的中缀表达式放到了
最里面的括号中,从而保证了它首先被执行
现在尝试运行这个函数,看到是否能够得到我们想要的结果。
(l->rfix 10 * 2 + 3)
;;=> 23
看上去不错。那对于其他的表达式呢?
(l->rfix 1 + 2 + 3)
;;=> 6
不过现在你可能已经发现这种解决方式也是愚蠢的。你可以用下面这个表达式轻易揭示它的问题。
(l->rfix 1 + 2 * 3)
;;=> 9
看来只是坚持严格的从左向右或者从右向左的顺序是无法得到令人满意的结果运算符优先级的。不过可以利用其他的方式来描述正确的运算符优先级。其实对于我们的需求,一个简单的map就足够了。
(def order {+ 0 - 0
* 1 / 1})
这个map描述了小时候老师们给我们讲的——乘法和除法的权重要高于加法和减法,所以应该先被计算。为了简单起见,我们来实现一个仅用于3个变量的函数——infix3。
程序1.3 利用运算符权重改变运算顺序的函数
(defn infix3 [a op1 b op2 c]
(if (< (get order op1) (get order op2))
(r->lfix a op1 b op2 c)
(l->rfix a op1 b op2 c)))
可能你已经注意到了,函数执行过程中会在map中查找运算符并进行比较,如果左边的运算符的权重小于右边的,那么就需要改变运算的顺序(从右到左)。一般来说,计算的顺序应该从优先级高的一侧开始。
检查新函数的执行结果,看上去都是按照预期执行的。
(infix3 1 + 2 * 3)
;;=> 7
(infix3 10 * 2 + 3)
;;=> 23
Lisp的语法,尤其是Clojure,并不像许多人认为的需要花费很多的精力去学习。当然我们可以扩充infix3函数,使得其可以处理更多数量的算术运算,而且这种方式也许能够让算术表达式更易读,但是Clojuer采用这样的语法自有其道理。
对我们来说,乘法的优先级高于加法是显而易见的,因为长久以来我们一直是这么被教育的。但是像小于、相等、按位与和取模这几个运算的优先级又该是怎么样的呢?我们很难记住类C语言中所有运算符的优先级。因此只有使用若干括号来控制计算的顺序。在Clojure中,计算顺序始终如一——从内到外,从左到右:
(< (+ 1 (* 2 3))
(* 2 (+ 1 3)))
;;=> true
坦白地说,没有优先级足以说明Lisp语法的伟大。虽然不用关心运算符的优先级是一件非常爽的事情,但事实上在实际工程中并不会有大量涉及运算符优先级的代码。即使忘记了随便翻一本Java书或者在网络上搜索一下就可以了。不过没有优先级引出了更大的问题。在Clojure中,括号只是用于求值的结构(scheme):把大量事物聚合成一个列表,第一个元素代表函数名,其他的作为函数的参数。而在类C或类Java的语言中,括号还提供了其他的语义:
如果你熟悉的语言中括号承载了这么多功能,你也许能够理解Lisp的精明。
之前提到过,Clojure的函数调用看上去都是一样的:
(a-function arg1 arg2)
在Clojure中,算术运算符也是函数。但是在其他一些语言中,例如Java,算术运算符是特殊符号而且只会出现在数值计算表达式中。正因为Clojure中的运算符也是函数,所以函数的使用方式同样适用于运算符,包括使用一系列参数:
(+ 1 2 3 4 5 6 7 8 9 10)
;;=> 55
如果Clojure中所有函数的使用方式都是一样的,那么调用多个参数的函数会变得非常简单。同样的表达式在C或Java中是这样的:
// 这不是Clojure代码
1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10;
//=> 55
坦白的说,你会经常用这种方式来实现功能吗?恐怕用到的次数很少。由于C和Java的运算符只能有左右各一个参数,所以对一组数字做运算需要用到循环结构。
int numbers[] = {1,2,3,4,5,6,7,8,9,10};
int sum = 0;
for (int n : numbers) {
sum += n;
}
在Clojure中,可以用apply函数把一个由数字组成的sequence传给另外一个函数,看上去就像传递给函数多个参数一样。
(def numbers [1 2 3 4 5 6 7 8 9 10])
(apply + numbers)
多么美妙啊。现在你就可以创建一个帮助类,对外提供累加数组中所有元素的函数,但是如果需要提供数组内所有元素做乘法的方法怎么办?按照刚才的使用方式加进去就好了。并不复杂,不是吗?也许还是有点难度的,但是Clojure利用Lisp语法,把对于数值集合的操作视为函数。一个有趣的例子是小于符号,在许多编程语言中是这样的:
// 这不是Clojure代码
0 < 42;
//=> true
Clojure提供了同样的功能:
(< 0 42)
;;=> true
但是Clojure中的<函数可以接收多个参数,因此可以应用于判断序列是否单调递增。示例如下:
(< 0 1 3 9 36 42 108)
;;=> true
如果序列不是单调递增的,那么返回值会是false:
(< 0 1 3 9 36 -1000 42 108)
;;=> false
关键是,这种函数调用的一致性为函数接收参数提供很大的灵活性。
Clojure程序由数据结构组成。例如,Clojure有两种方式解析文本(+1 2):
Clojure依赖于上下文去选择采用哪种方式解析。如果启动了Clojure REPL,输入上面的表达式,会看到结果。
(+ 1 2)
;;=> 3
也许这就是你预期的。但是Clojure的计算模型包含多个过程,求值只是其中之一。图1.4所示展示了REPL是Read-Eval-Print Loop的简写。
图1.4 REPL这个单词暗示了3个不断循环的阶段:读取(read)、计算(eval)和输出(print)
大多数的Lisp语言,在读取、计算、输出3个阶段中间还有其他阶段。图1.5所示展示了真实的Clojure的求值过程。
图1.5 更多的REPL阶段:Clojure有宏展开和编译的过程
新增的展开(expand)和编译(compile)阶段暗示了Lisp中非常有趣的议题——宏。
在第8章,我们会深入地讨论宏的问题,不过在这里先会花上1~2页的篇幅讨论下宏背后的高级思想。Clojure将求值分为几个阶段,使得使用者可以深入每个阶段从而控制求值的过程。回忆前一节提到的(+ 1 2),既可以是一个列表也可以是一个函数;这其中的区别只是上下文。当你看屏幕或者打开这本书的时候,(+1 2)只是个文本。如图1.6所示,在读入之后,Clojure就不再处理括号。取而代之的是处理相应的数据结构
图1.6 读入阶段,读入Clojure程序,生成对应的数据结构
但是对于读者来说,文本字符很容易区别于列表。Lisp(Clojure也不例外)对类List的文本和其包含的数据做镜像(mirror)。通过对语法及其数据所代表的代码做镜像,能发现输入的代码和发送给编辑器的代码在概念上有一些不同。这些不同使得处理Clojure程序变得容易。
“代码即数据”这样的说法最初很难理解。实现一门程序设计语言,代码同数据一般对待,这需要语言本身具有非常强的可塑性。当语言就是以这种本质的数据结构表现时,语言本身就可以操作自己的结构和行为了(Graham 1995)。读到上面这句话,我们脑海中可能会浮现出一条衔尾蛇(Ouroboros),也许这么说不合适,因为Lisp可以比作一个自我舔食的棒棒糖——更正规的说法是同像性(homoiconicity)。要完全掌握Lisp的同像性,需要跨越一个巨大的概念鸿沟,但在本书里,我们尽力帮你理解这个概念,希望你最终能够领会其巨大的威力。
初学Lisp是一番乐趣,如果你能从本书得到同样的体验,那么我们欢迎你——甚至有点嫉妒。
快点回答,函数式编程是什么意思?错!
别太泄气,其实,我们也不知道确切的答案是什么。函数式编程只是诸多定义模糊的计算机术语[4]中的一个。如果找100个程序员问它的定义,我们会得到100个不同的答案。确实,某些答案是类似的,但如同雪花一般,没有两个答案是完全一样的。要进一步搅混水的话,让计算机科学的专家们单独给出定义,我们可能会发现,某些答案甚至是彼此矛盾的。同样,任何一个函数式编程定义的基本结构都可能会不同,这完全取决于回答问题的人喜欢用哪种语言写程序:Haskell、ML、Factor、Unlambda、Ruby、Qi等。随便一个人、一本书或是一门语言怎么就能声称自己是函数式编程的权威呢?然而,正如大多数各具特色的雪花都是由水组成的,各种说法的函数式编程核心都遵循着同样的核心原则。
无论函数式编程定义用的是lambda演算、单子I/O(monadic I/O)、delegate还是java.lang. Runnable,基本的单元可能就是某种形式的过程、函数或是方法——这是根本。函数式编程关心和处理的是函数的应用和组合。再进一步,一门被认为是函数式的语言,它的函数概念一定是一等的。在这门语言里,函数可以存储、可以传递,还可以返回,同语言里的其他数据一样。各种不同的定义远远超出了这一核心概念,但是,谢天谢地,作为起点,这足够了。当然,我们还会进一步阐述一下Clojure风格的函数式编程,包括纯粹性、不变性、递归、惰性和引用透明等主题,不过,这些内容稍后会在第7章讨论。
面向对象程序员和函数式程序员看到问题和解决问题的方式有所不同。面向对象思维模式采用的方式是,把应用领域定义成一组名词(类),函数式思维模式则会把解决方案视为各种动词及其组合(函数)。虽然二者产生的结果可能是一样的,但函数式解决方案会在简洁、可理解、可重用方面更胜一筹。确实如此!希望在本书结束时你也会认同,函数式编程会让程序设计更为优雅。这是一种思维模式的转换,从考虑名词,到思考动词,但这个旅程物有所值。无论如何,我们都相信,Clojure会让你获益良多,反哺到你选择的语言中——唯有打开心胸,方能体会这一点。
优雅同熟悉正交。
——Rich Hickey
Clojure的出现源自一种无奈,很大程度要归因于并发编程的复杂性以及面向对象程序设计在这方面的无能为力。本节将会探索这些缺陷,了解Clojure之所以是函数式而非面向对象的根因。
开始之前,先定义术语[5]。
第一个要定义的重要术语是时间(time)。简单地说,时间是指事件发生的相对时刻。有了时间,同实体关联在一起的属性——无论是静态还是动态,单数的还是组合的——会形成一种共生关系(Whitehead 1929),从逻辑上说,可以认为是其标识(identity)。在任意给定的时间,都可以得到实体属性的快照,这个快照定义了状态(state)。在这种概念里,状态是不可变的,因为状态没有在实体本身内定义成一种可变的内容,只是展现某个给定时刻的属性而已。为了充分理解这些术语,可以想象一下儿童手翻书,如图1.7所示。
图1.7 奔跑者:儿童手翻书,用以解释Clojure状态、时间和标识的概念。书本身表示标识。当我们希望插图有所改变时,就画另一幅图,加到手翻书的末尾。翻动书页的动作,
表示状态随时间改变。停到给定页面,观察特定图片,表示某一时刻奔跑者的状态
有一件事很重要,要特别提一下,按照面向对象程序设计的原则,状态和标识并没有清晰的区分。换句话说,这两个概念合并成一个通常称为可变状态的东西。经典的面向对象模型对对象属性的修改毫无限制,完全不会保留历史状态。Clojure的实现尝试在对象状态和标识(因为其与时间有关)之间画出一条清晰的界限。同样是上面手翻书的例子,采用可变状态模型结果是完全不同的,为了表示与Clojure模型之间的差异,可以参考图1.8。
图1.8 可变的奔跑者:将状态改变建模为可变的,需要准备一些橡皮擦。书只有一页,
状态改变时,我们必须物理擦除,根据修改重绘图片的一部分。采用这样的模型可以看出,
可变性摧毁了时间、状态和标识这些概念,变成了仅有的一个
不变性是Clojure的基石,Clojure实现的绝大部分都是为了高效地支持不变性。通过关注不变性,Clojure完全消除了可变状态(这是一个矛盾修辞法[6])的概念,这说明大多数对象表示的内容其实都是值。从定义上说,值是指对象固定不变的代表值[7]、量级或是时间段等。或许,你会问自己:在Clojure里,这种基于值的编程语义内涵到底是什么呢?
遵循严格的不变性模型,并发之后就变成一个比较简单(虽然还是不那么简单)的问题,这意味着,如果不必顾忌对象状态的改变,我们便可肆无忌惮地共享,而无惧并发修改。我们会在第11章看到,Clojure把值的修改与其引用类型隔离开来。Clojure的引用类型为标识提供了一个间接层,这样一来,如果不总是当前状态的话,标识就可以用于获得一致的状态。
命令式编程是如今占主导地位的编程范式。命令式程序设计语言最纯粹的定义是,这种语言用一系列语句修改程序状态。在编写本书期间(很可能也是未来一段时间内),命令式编程首选的风格就是面向对象的风格。这样的事实本质上没那么糟糕,因为无数成功的软件项目就是用面向对象命令式编程技术构建的。但在并发编程的上下文里,面向对象命令式模型却是自我吞食[8]的。命令式模型允许(甚至鼓励)无限制地修改变量,所以,它并不直接支持并发。如果对修改不加控制,那么任何变量都无法保证包含的值是符合预期的。面向对象程序设计将状态聚合在对象内部,朝着这个方向又迈了一步。虽然加锁机制让单个方法可能是线程安全的,但是,如果不采用更为复杂的加锁机制,并扩大加锁范围,就没有办法在多个方法调用期间保证对象状态的一致性。而Clojure则关注于函数式编程、不变性,注意区分状态、时间和标识。当然,面向对象并没有彻底失去希望。实际上,它在很多方面还是可以促进编程实践的。
有一点应该清楚,我们并不打算流放面向对象程序员。相反,要提升自己的技艺的话,了解面向对象程序设计(OOP)的缺陷是很重要的。在后面几小节里,我们也会接触到OOP一些强大的方面,让我们看看用Clojure如何来做,以及在某些情况下,Clojure给出了怎样的改进。首先,我们来看看Clojure是如何利用protocol特性来处理多态的。
程序1.4 定义Concatenatable多态协议
(defprotocol Concatenatable
(cat [this other]))
协议(protocol)在某种程度上和Java的接口类似,并且对mix-ins做了提炼。Concatenable定义了一个有两个参数的函数cat。函数会把目标对象和其他对象连接起来。但是Concatenatable协议只是描述了执行协议的函数的骨架,也就是说目前还没有实现这个协议。接下来我们讨论关于协议更多的细节,包括把它扩展到新类型和已存在的类型。
多态是这样一种能力,函数或方法根据目标对象类型的不同有着不同的定义。Clojure也有多态,它是通过协议实现的,协议允许在已经存在的类型和类上加一些新的行为。相比于许多语言中的多态,这两种机制都更开放、扩展性更好。
在程序1.4中,我们定义了一个叫作Concatenatable的协议,包含了一个或多个函数(这里只有一个,cat),定义出其提供的函数集。这意味着cat函数对任何满足协议Concatenatable的对象都起作用。之后,我们用这个协议扩展String类,给出一个特定的实现——函数体将一个实参other连接到了字符串this上。
(extend-type String
Concatenatable
(cat [this other]
(.concat this other)))
;;=> nil
(cat "House" " of Leaves")
;;=> "House of Leaves"
我们还可以把协议扩展到java.util.List上。这样cat函数就能应用到另一个类型上。
(extend-type java.util.List
Concatenatable
(cat [this other]
(concat this other)))
(cat [1 2 3] [4 5 6])
;;=> (1 2 3 4 5 6)
至此,这个协议已经扩展到两个不同的类型上,String和java.util.List,所以,无论用哪个类型作为第一个实参,调用cat函数,都会调用到相应的实现。
注意,String在定义协议之前就已经定义过了(对这个例子而言,是由Java本身定义的),而我们依然可以成功地用新协议扩展它。对很多语言而言,这是不可能的。例如,Java需要我们定义好所有的方法名及其分组(称之为接口),然后才能用一个类实现它们,这种限制称为表达式问题。
表达式问题
表达式问题指的是,在不修改已定义代码的前提下,为既有具体类实现一套既有的抽象方法。面向对象语言允许我们在自己控制的具体类里实现既有的抽象方法(接口继承),但是如果具体类不在控制范围内,实现新的或是既有抽象方法的办法就不那么多了。一些动态语言,例如Ruby和JavaScript,为这个问题提供部分的解决方案,我们可以给既有的具体对象中添加方法,这种特性称之为猴子补丁(monkey-patching)。
Clojure的协议可以扩展到任何有意义的类型上,甚至是类型原实现者或者是协议原设计者想都没想过的地方。我们会在第9章更深入地探索Clojure风格的多态,不过,这里还是希望你能对于其运作机理有一个大概的了解。
Clojure可以创建一种特殊的层次结构,提供了一种子类型化的方式。稍后,我们会在9.2节深入研究如何利用这种特殊的层次结构。类似地,Clojure通过其协议机制还提供了一种类似于Java接口的能力。通过定义一套逻辑分组的函数,我们就可以开始定义协议,这是数据类型抽象必须要有的。面向抽象的编程模型是构建大规模应用的关键,我们会在9.3节及随后的章节了解到。
如果 Clojure 不以类来组织,那如何进行抽象呢?想象一下,我们需要一个简单的函数,对于给定的棋盘和坐标,返回给定方格上棋子的一个简单表示。为了让这个实现尽可能简单,我们用一个包含一套字符的vector表示不同颜色的棋子,如程序1.5所示。
程序1.5 用Clojure表示的简单棋盘
(ns joy.chess)
(defn initial-board []
[\r \n \b \q \k \b \n \r
\p \p \p \p \p \p \p \p ◁——● 小写表示黑棋
\- \- \- \- \- \- \- \-
\- \- \- \- \- \- \- \-
\- \- \- \- \- \- \- \-
\- \- \- \- \- \- \- \-
\P \P \P \P \P \P \P \P ◁——● 大写表示白棋
\R \N \B \Q \K \B \N \R])
没有必要把棋盘弄得更复杂;象棋就够难的了。在代码中,这个数据结构直接对应着实际的棋盘,从起始点开始,如图1.9所示。
图1.9 相应的棋盘布局
从图1.4中可以看出,黑棋是小写字符,白棋是大写的, 你可以为棋盘上任意的一个部位提供标准的命名规则——用字母代表列名称,用数字代表行名称。例如a1代表左下角。这种结构可能不是最优的,却是个好的开始。我们暂且忽略实际的实现细节,关注于查询棋盘某块的客户端接口。要强制封装,以免客户端代码陷入棋盘实现细节,这是个绝佳的机会。幸运的是,拥有闭包的语言自动就支持某种形式的封装(Crockford 2008),把函数根据其支持的数据进行分组[9]。
下面这段代码定义了lookup函数,用于返回棋盘上某一块的内容。同时还定义了一些实现lookup功能的辅助函数。用宏defn-将函数封装在命名空间joy.chess的某个层次里。
程序1.6 查询棋盘的某个方格
(def ^:dynamic *file-key* \a)
(def ^:dynamic *rank-key* \0)
(defn- file-component [file]
(- (int file) (int *file-key*))) ◁——● 计算水平投影
(defn- rank-component [rank]
(->> (int *rank-key*)
(- (int rank)) ◁——● 计算垂直投影
(- 8)
(* 8)))
(defn- index [file rank]
(+ (file-component file) (rankcomponent rank))) ◁——● 将1D布局映射为逻辑2D的棋盘
(defn lookup [board pos]
(let [[file rank] pos]
(board (index file rank))))
(lookup (initial-board) "a1")
;;=> \R
探索惯用的源码不难发现,Clojure的命名空间封装是最为普遍的一种封装方式。但是,如果采用词法闭包,则有更多的封装选择:block级封装,如程序1.7所示以及局部封装,二者都能有效地将一些不甚重要的细节聚合在更小的作用域里。
程序1.7 使用block级封装
(letfn [(index [file rank]
(let [f (- (int file) (int \a))
r (* 8 (- 8 (- (int rank) (int \0))))]
(+ f r)))]
(defn lookup2 [board pos]
(let [[file rank] pos]
(board (index file rank)))))
(lookup2 (initial-board) "a1")
;;=> \R
在最明确的作用域内聚合相关数据、函数和宏通常都是个好主意。我们依然可以像之前一样调用lookup,但是,在更大的作用域内,辅助函数是不可见的,在这个例子里,就是命名空间joy.chess里。前面代码的file-component和rank-component函数,*file-key*和*rank-key*值都从命名空间里挪了出来,放到了由letfn宏定义的block级index函数里。随后,我们在这个宏体内定义了lookup函数,这样就限定了暴露给客户端的棋盘API,隐藏了特定函数和form的实现。但是,我们还可以进一步限定封装的作用域,正如程序1.8所示,进一步收缩作用域,使之成为真正的函数局部的上下文。
程序1.8 局部封装
(defn lookup3 [board pos]
(let [[file rank] (map int pos)
[fc rc] (map int [\a \0])
f (- file fc)
r (* 8 (- 8 (- rank rc)))
index (+ f r)]
(board index)))
(lookup3 (initial-board) "a1")
;;=> \R
终于,我们把所有实现相关的细节都放到lookup2本身的函数体里。这就将index函数和所有辅助值的作用域都限制在了相关的地方——lookup2。额外的奖赏是,lookup2简单而紧凑,没有牺牲任何可读性。当然,Clojure避开了大多数面向对象语言浓墨重彩表现的数据隐藏封装的概念。
最后要说一点,面向对象程序设计的另一个不足之处是,函数和数据之间绑定过紧。事实上,Java程序设计语言强迫我们把整个程序完全构建在类层次结构上,所有功能都必须出现在高度受限的“名词王国”(Yegge 2006)所包含的方法中。这一环境如此受限,以致于程序员们只能被迫闭上双眼,否则将无法面对这些组织不当的方法和类带来的尴尬结果。正是因为这种极尽苛刻的以对象为中心的视角,导致了Java代码显得啰唆而复杂(Budd 1995)。Clojure的函数就是数据,然而,对于数据及处理数据的函数而言,并不要求一定要将二者解耦。许多程序员认为是类的东西,实际上就是Clojure用map[10]和记录形式提供的数据表。是对“视万物为对象”的最后一击,在数学家眼里,没有什么东西是对象(Abadi 1996)。相反,数学通过应用函数,构建于一组元素同另一组元素之间的关系基础之上。
我们在本章讨论了很多概念性的东西,但这是必需的,定义了本书余下部分用到的术语。类似地,要进行后续讨论,理解Clojure的基础是很重要的。如果你尚不确定自己是否了解Clojure,那好——我们可以理解,一次消化所有这些内容有点多。随着我们逐渐讲述Clojure的故事,这些东西慢慢就能理解了。如果有函数式编程的背景,之前章节的一些讨论,你很可能会感到很熟悉,或许也会有些令人吃惊的纠结之处。相反,如果你的背景更多地植根于面向对象程序设计,那你会觉得 Clojure 同自己熟悉的东西有着极大的不同。虽然在很多方面着实是这样的,但在后续章节里,你会见到 Clojure 怎样优雅地解决我们日常的问题。同经典面向对象技术相比,Clojure从一个截然不同的角度来解决软件问题,但是,正是面向对象这些基本的长处和缺点激励着Clojure不断前进。
有了这样的概念基础,是时候快速看看Clojure技术的基础和语法了。下面会讲得很快,但也没快到理解后面章节都困难的份上。好吧,打开你的REPL,我们出发了……
[1] 虽然借鉴了Lisp(总的来说)和Java的传统,但对Lisp和Java而言,Clojure在很多方面是一种寻求改变的直接挑战。
[2] 引自Philip J. Davis and Reuben Hersh《The Mathematical Experience》(Birkhäser;1981)。
[3] ……且疯狂的。
[4] 快点回答,组合子(combinator)的定义是什么?云计算呢?企业级呢?SOA呢?Web 2.0呢?真实世界呢?黑客呢?通常,追求有唯一准确定义这件事无异于缘木求鱼。
[5] 这些术语是Rich Hickey在其演讲“Are We There Yet?”(Hickey 2009)里定义和详细阐述的。
[6] 译注:之所以说可变状态是一个矛盾修辞法,是因为在Clojure里,状态就是一个关于不变性的术语。
[7] 某些实体没有代表值——π就是其中之一。但在计算领域,最终处理的都是有限的事物,这是个悬而未决的问题。
[8] 译注:关于自我吞食,参见前面“衔尾蛇”的译注。
[9] 这种形式的封装成为模块模式(the module pattern)。但以JavaScript实现的模块模式也可以提供了某种程度的数据隐藏,同Clojure一样——虽然还有所不及。
[10] 参见5.6节,可以看到这方面更多的讨论。
本章涵盖:
本章只是要快速地浏览一些基本内容——也就是理解本书余下部分所需知道的一些内容。也许现在就开始提供一些类似入门手册的章节有点奇怪,但是我们认为语言的思想比机制更重要。[1]如果你曾用Clojure写过一段时间程序,这就算个回顾,否则,它带来的就是开始编写Clojure代码所需的一切。本章的例子大多不痛不痒,只是为了强调这里的重点而已。在本书后面部分,我们会基于这些内容构建更多的东西,所以,如果你无法一下理解所有特性,别着急——你会理解的。
同Clojure交互,通常就是执行“读取—求值—打印循环”(Read-Eval-Print Loop,REPL)。启动一个新的REPL会话,会出现一个简单的提示符:
user>
user提示符指的是REPL默认的顶层命名空间。Clojure停在这里就是等待输入表达式。有效的Clojure表达式包括数字、符号、关键字、布尔值、字符、函数、函数调用、宏、字符串、字面量map、vector, 队列、records和set。某些表达式,如数字、字符串和关键字,是自我求值的,也就是说,输入后,它们会对自身进行求值,求值的结果就是它自身。Clojure REPL还接受源码注释,注释以分号(;)开头,直到行末。
user> 42 ; 数字对本身求值
;=> 42
user> "The Misfits" ; 字符串也是
;=> "The Misfits"
user> :pyotr ; 关键字同样
;=> :pyotr
至此,我们已经见过了几种标量数据类型,接下来,再逐一地近距离观察一番。
Clojure语言有着丰富的数据类型。类似于大多数程序设计语言,它也提供标量类型,例如整数、字符串以及浮点数,每一种都代表一个数据单元。Clojure提供几种不同的标量数据类型:整数、浮点、有理数、符号(symbol)、关键字、字符串、字符、布尔值以及正则表达式[2]。在本节中,我们将依次介绍这些类型,逐一提供例子。
数字由0~9、小数点(.)以及正负号(+或−)组成,如果采用指数记法,可能还会有个e。除了这些元素外,Clojure的数字还可以写成八进制或是十六进制的形式,还可能有个M或N,分别表示任意精度的小数(decimal)或任意大小的整数:这是数字在Clojure里很重要的一个方面。在许多程序设计语言里,数字精度[3]受限于宿主平台,而Java或C#则由语言规范定义。虽然Clojure可以提供任意精度的数字,但是在默认情况下使用的是宿主语言(Java或JavaScript)的原生类型。对于JVM来说,为了提供安全性,会在数字溢出时抛出异常。
整数由整个的数集构成,既有正数也有负数。任何由可选正负号或数字开头,后面只跟有数字的数,都可以看作是整数,并且当作整数存储,尽管如此有些具体类型的使用需要依赖具体的环境。从理论上说,Clojure的整数可以表示无限大的值,但在实际中,大小受限于可用内存。下面的数都可以认为是整数:
42
+9
-107
991778647261948849222819828311491035886734385827028118707676848307166514
以上的这些数字除了最后一个,都被作为Java的long类型来读入。最后一个对于long类型来说太大了,因此读入的时候被认为是BigInt(打印时以N结尾),下面分别展示了十进制、十六进制、八进制、三十二进制以及二进制字面量,所有的都表示同一个数:
[127 0x7F 0177 32r3V 2r01111111]
;=> [127 127 127 127 127]
基数记法支持的最大基数是36,包括十六进制(16r7F)和八进制(8r177)。当使用比较大的基数(首先可能想到的是十六进制)。需要注意的是在表示高进制数字的时候,ASCII字符用于补充数字0~9。因此由于ASCII只有26个字符,这就限制了能够支持的最大基数只能到36:26个ASCII字符外加0~9共10个数字。在整数前添一个正负号也是合法的。
浮点数是有理数的十进制展开。类似于Clojure的整数实现,浮点数也是任意精度的[4]。浮点数可以采用传统形式,一些数字,然后是小数点,再跟着一些数字。浮点数也可以采用指数形式(科学计数法),有效数位部分跟着指数部分,中间用小写或大写的E分开。下面的数都是有效的浮点数:
1.17
+1.22
-2.
366e7
32e-14
10.7e-3
对于大多数程序设计语言而言,数都是一样的,所以,我们转向其他的,那些Lisp或受Lisp启发语言所特有的标量类型。
作为整数和浮点数的补充,Clojure还提供了有理数类型。对于给定的值,相比于浮点数,有理数提供一种更紧凑、更精确的表示方式。有理数采用了经典的以整数做分子、分母的表示方式,这也是Clojure的表示方式。下面的数字都是有效的有理数:
22/7
-7/22
1028798300297636767687409028872/88829897008789478784
-103/4
关于Clojure的有理数,还有一件事要提一下,如果可以,有理数会被简化,例如有理数100/4会简化成整数25。
Clojure的符号本身是一个对象,通常用于表示另一个值:
(def yucky-pi 22/7)
yucky-pi
;;=> 22/7
对数字或字符串求值,我们会得到完全一样的对象,但对符号求值,得到的却是在当前上下文中符号所指的值。换句话说,符号通常用于引用函数参数、局部变量、全局变量以及Java类等。
关键字与符号很类似,但有一点不同,对关键字求值,会返回它自身。我们在Clojure里更多见到的可能是使用关键字,而非符号。关键字的字面量语法形式如下:
:chumby
:2
:?
:ThisIsTheNameOfaKeyword
虽然关键字以冒号(:)开头,但它只是字面量语法的一部分,并不是名字本身的一部分。我们会在4.3节进一步深入了解关键字的细节。
Clojure的字符串同大多程序设计语言的表现方式是类似的。字符串就是包在一对双引号之间的字符序列,包括换行符,如下所示:
"This is a string"
"This is also a
String"
二者的存储方式和编写方式是一样的,但是在REPL中打印时,多行字符串会包括换行符字面量的转义符,例如"This is also a\n String"。
Clojure字符可以写成用反斜线开头的字面量,存储成Java的Character对象,如下所示:
\a ; 字符小写a
\A ; 字符大写A
\u0042 ; 字符大写B的unicode
\\ ; 反斜线字符\
\u30DE ; unicode片假名字符?
Clojure的标量数据类型就是这些了。在下一节里,我们讨论Clojure的集合数据类型,那才是真正乐趣开始的地方。
我们会在第5章涵盖集合类型更多的细节,但是因为Clojure程序是由各种字面量集合组成,所以了解一下list、vector、map和set至少还是有益的。
list是表处理语言(List Processing language)中的经典集合类型,Clojure也不例外。字面量list写的时候要有括号:
(yankee hotel foxtrot)
对list求值,list的第一项(在这个例子里,就是yankee)会解析成函数、宏或特殊form。如果yankee是一个函数,list余下的项会依次执行,其结果传递给yankee做参数。
form
form是任意要求值的Clojure对象,包括但不限于list、vector、map、数字、关键字和符号。特殊form也是一种form,它有着特殊的语法或者特殊的求值规则,通常,它不是用基本的Clojure form实现的。特殊form的一个例子是.(点)运算符,用于与Java的互操作。
另一方面,如果yankee是个宏或特殊form,list余下的部分并不一定要执行,但会按照宏或运算符的定义进行处理。
list可以包含任意类型的项,包括其他集合。下面是几个例子:
(1 2 3 4)
()
(:fred ethel)
(1 2 (a b c) 4 5)
注意,不同于某些Lisp,Clojure的空list写成(),它不同于nil。
类似于list,vector也存储了一系列值。5.4节描述了二者的几点差别,但就目前而言,只有两点很重要。首先,vector的字面量语法用的是方括号:
[1 2 :a :b :c]
另一个重要的差别是,求值时,vector只是依次对每一项求值。函数或宏调用都不会在vector里执行,尽管如此,如果list出现在vector里,list还是会按照list通常的规则求值。与list类似,vector也是类型异构的,也许你猜得到,空vector[]也不同于nil。
map存有不重复的键值,每个键值对应一个值——类似于一些语言和程序库里称为字典或哈希的东西。实际上,Clojure有几种类型的map,属性各异,但眼下先别考虑这些。map可以用字面量语法来写,在花括号里交替着键值和值。逗号常用于间隔键值对,但在Clojure程序里随处可见的就只是以空格间隔。
{1 "one", 2 "two", 3 "three"}
同vector类似,map字面量的每一项(每个键值和每个值)都会在结果存入map前求值。不同于vector的是,其求值顺序是无法保证的。map里的项,无论是键值还是值都可以是任意类型,空map{}也不同于nil。
set存储有0个或多个不重复的项。set用花括号加一个先导的井号表示。
#{1 2 "three" :four 0x5}
再说一次,空set{}也不同于nil。
基本集合类型就是这些了,第4章会深入介绍每种类型的用法,包括其相对的强项和劣势,除此之外还会介绍队列的使用。
Clojure函数是一等类型,这意味着,其用法与其他值是一样的。函数可以存储在var里,可以放在list和其他集合类型里,可以作为实参传递给其他函数,甚至可以作为其他函数的结果返回。Clojure借鉴了Lisp的函数调用约定,也称为前缀记法:
(vector 1 2 3) ◁——● 把1,2,3作为参数传给vector函数
;;=> [1 2 3] ◁——● 返回vector
相对于C风格语言的中缀记法[5],前缀记法有一个显而易见的优势,每个运算符都可以接受任意数量的操作数,而中缀表达式只能有两个。采用前缀表达式构建代码,还有一点不那么明显的优势,它完全消除了运算符优先级问题。Clojure并不区分运算符和常规的函数调用——所有的Clojure构造、函数、宏和运算符都采用了前缀记法,或全括号(fully parenthesized)记法。这种统一结构为Lisp类语言那匪夷所思的灵活性提供了基础。
通常来说,程序员们都习惯于处理变量和可变性。Clojure中最接近于变量的东西是var。var以符号命名,持有一个单独的值。程序运行时,其值可以改变,但是,最好还是留给程序员手动修改。var的值也可以由一个线程局部值所遮蔽,但这并不会改变原有的值。
在Clojure里,创建var最常见的方式是使用def:
(def x 42)
用def将值42同符号x关联起来,由此创建了一个称之为根绑定[6]的东西
这个小例子表示符号(symbol)x绑定到值42,但是var不一定需要值[7]:
(def y)
y
;=> java.lang.IllegalStateException: Var user/y is unbound.
从理论上说,有了函数和var,就有了实现任何算法所需的全部内容,但某些语言却只给了我们这些“原子”构造。
我们在之前已经展示了如何使用(函数名 参数……)这样的形式来调用函数,现在我们要解释一下如何定义一个函数。在这一节,我们讨论函数定义相关的概念,先从匿名函数开始。
匿名(无名)Clojure函数可以定义成一种特殊form。特殊form是一种Clojure表达式,是语言核心的一部分,但其创建并没有采用函数、类型或是宏的方式。
下面的例子定义了一个函数,获取两个元素,返回由这两个元素组成的set:
(fn [x y] ◁——● 包含参数的vector
(println "Making a set") ◁——● 函数体
#{x y}) ◁——● 返回值表达式
;;=> #<user$eval1027$fn__1028 user$eval1027$fn__1028@e324105>
在Clojure REPL输入这个函数,我们会得到一个看起来很奇怪的结果。这是因为REPL显示的是一个内部名字,这个名字代表一个了由fn创建的函数对象。这还远不到令人满意的程度,假设现在这个函数已经定义好了,我们却没有一种显而易见的方式执行它。回想一下前面的章节,函数调用的形式总是:(函数各参数)。所以我们可以定义了函数之后立刻调用它,像下面这个例子一样:
((fn [x y] ◁——● 定义函数,立刻调用
(println "Making a set")
#{x y})
1 2) ◁——● 把1和2作为参数
;; Making a set ◁——● 打印并返回set
;;=> #{1 2}
尽管匿名函数很有用,但是很多时候为了引用方便,我们更愿意赋予函数名字。接下来,我们来看下Clojure提供了哪几种方式用于定义命名函数(named function)。
def特殊form是一种给一段Clojure数据赋一个符号名的方式,Clojure函数是一等的;它们同数据是等同的,可以赋给var,在集合中存储,也可以当作其他函数的实参,或是从函数中返回。这点不同于某些“函数是函数,数据是数据”的程序设计语言,前者有着许多后者力所不能及的地方。
因此,要把一个名字同之前的函数关联起来,可以使用def这样做:
(def make-set
(fn [x y]
(println "Making a set")
#{x y}))
现在,我们可以用更直观的方式调用它了:
(make-set 1 2)
;; Making a set
;;=> #{1 2}
Clojure还有一种定义函数的方式,使用defn宏。虽然通过名字对函数进行定义以及后续引用肯定是一种更好的方式,但像上面这样去用def还是略显笨拙。相反,对于创建命名函数而言,最简单的defn语法是一种很方便、很简洁的方式,看起来和原来的fn形式很相似,还可以额外地添加一些文档描述:
(defn make-set
"Takes two values and makes a set from them."
[x y]
(println "Making a set")
#{x y})
函数调用同我们之前所见是一样的。
Clojure同样提供了根据参数个数的不同来执行不同的函数体。接下来讨论不同参数数量(arity)这个问题。
另外一种定义函数的方式允许在函数调用的时候利用参数个数的不同,实现函数的重载。让我们把刚才的函数稍加变化,使得其可以接受一个或者两个参数。
(defn make-set
([x] #{x})
([x y] #{x y}))
与前一个form不同的是,现在你可以根据参数的数量定义任意多个参数/函数体。显然一个参数的调用结果是:
(make-set 42)
;;=> #{42}
如你所见,函数调用过程中实参和符号之间都是一一对应的,但是可以有其他方法让函数能够接受多个参数:[8]
(make-set 1 2 3)
;; ArityException Wrong number of args passed...
如上面代码所示,给make-set传递3个参数会发生异常。但是如果想让函数能够接受可变实参要怎么做呢?可以利用&符号或者解构(会在下一章涉及)。参数列表中在&之前的符号依然会遵从一一对应的原则。但是其他的实参会聚合成一个序列(sequence)映射到&后面的符号:
(defn arity2+ [first second & more] ◁——● 定义包含两个及以上参数的函数
(vector first second more))
(arity2+ 1 2) ◁——● 额外的参数是nil
;;=> [1 2 nil]
(arity2+ 1 2 3 4) ◁——● 额外的参数是list
;;=> [1 2 (3 4)]
(arity2+ 1) ◁——● 参数太少,抛出异常
;; ArityException Wrong number of args passed…
当然arity2+需要至少两个参数,所以传入的参数太少会报错。
为了完善关于函数的理解和使用,需要在花一些时间了解利用#()创建匿名函数。
Clojure还为创建匿名函数提供了一种简单的记法,使用#()读取器(reader)特性。简而言之,读取器特性类似于预处理器指令,它表示,某些特定form应该在读取时会由另一个所替代。使用#() form的情况,也是可以用特殊form fn替代。事实上,任何适用#()的地方,同样适用于fn特殊form。
#() form也可以接收实参,只不过实参是通过以%为前缀的特殊符号隐式声明的。
(def make-list0 #(list)) ◁——● 不接受任何参数
(make-list0)
;;=> ()
(def make-list2 #(list %1 %2)) ◁——● 只接受两个参数
(make-list2 1 2)
;;=> (1 2)
(def make-list2+ #(list %1 %2 %&)) ◁——● 接受两个及以上个数的参数
(make-list2+ 1 2 3 4 5)
;;=> (1 2 (3 4 5))
关于上面这些函数的笔记值得关注。首先函数如果只接受一个实参,可以显式地声明为#(list %1)或者隐式地声明为#(list %)。在这里%符号和%1是相同的,但是我们更倾向于有数字的版本。另外需要注意的是make-list2+这个函数中%&用于指向可变实参。
有了Clojure的函数和值绑定能力这个基础,开发人员就可以动手写代码了,但是,Clojure还提供了一些能力:创建局部值绑定、循环的构造以及聚集功能的block。
如果要把一系列表达式或一个表达式的block当作一个整体对待,那就要用到do form了。所有的表达式都会求值,但只有最后一个会返回:
(do
(def x 5)
(def y 4)
(+ x y)
[x y])
;;=> [5 4]
表达式(def x 5)、(def x 4)和(x + y)会按序执行,但是值都会被丢弃掉——只有最后一个表达式[x y]会返回。在do代码块中使用def是很典型的用法。无论何时见到使用do form,你都可以推断其目的是为了定义一个var,打印它或者做一些其他操作。
Clojure并不支持局部变量,但是它却有局部量,只是不能改变而已。用let form就可以创建局部量,定义其作用域,先是一个定义绑定的vector,后面跟着任意数量的表达式,组成执行体。vector里先是一个绑定form(通常是一个符号),这是一个新局部变量的名字。在这个局部变量后面会跟着一个表达式,表达式的值绑定在局部变量上。随后,绑定名和表达式可以继续成对出现,根据需要创建许多局部变量。所有这些都是在let体内有效的:
(let [r 5
pi 3.1415
r-squared (* r r)]
(println "radius is" r)
(* pi r-squared))
;; radius is 5
;;=> 78.53750000000001
let体的部分有时被形容成“隐式的do”,因为其遵循着相同的规则:可以包括任意数量的表达式,所有的都会求值,但只有最后一个会返回。
在上面的例子里,所有的绑定form都是简单的符号,即r、pi和r-squared。也可以用一些更复杂绑定表达式,对返回集合的表达式进行分解。这个特性称为解构(destructuring):参见2.9节,了解更多细节。
但是,它们都是不变的,局部量不能用于累加结果;相反,我们会用高阶函数或是loop/recur form。
在Lisp里,构建循环的经典方式是递归调用,Clojure也是如此。有时,使用递归需要人们以不同于命令语言所鼓励的方式思考问题;但是,在尾部递归,在很多方面类似于结构化的goto,相比于其他类型的递归,它与命令式循环有着更多的共通之处。
Clojure有一种特殊form,称为recur,专用于尾递归。下面这个函数从后向前打印x到1:
(defn print-down-from [x]
(when (pos? x) ◁——● x为正数则执行
(println x) ◁——● 打印当前x的值
(recur (dec x)))) ◁——● x减1后递归调用
这几乎与命令式语言构建while循环的方式如出一辙。一个重要的差别在于,在循环体内,x的值不会在某个地方减少,而是会算出一个新值当作recur的参数,它会立即做两件事:将x重新绑定成新值,将控制权交给print-down-from的顶端。
如果函数有多个实参,recur调用也必须一样传入多个参数,就像按名字调用函数而不是使用recur特殊form。同函数调用一样,recur里的表达式都是先依次执行,然后同时绑定在函数实参上。
上面的例子并不关心自身的返回值;它只是有println的副作用。下面有一个类似的循环,构建一个累加器,返回最终的结果:
(defn sum-down-from [sum x] ◁——● 计数器
(if (pos? x) ◁——● 如果是正数
(recur (+ sum x) (dec x)) ◁——● 递归
sum)) ◁——● 返回sum
首先sum-down-from接受初始的sum和累加的上限作为参数。只有在x大于0的条件下才会递归。当调用递归时,把当前的x值加到sum上,然后x减1,并且把计算后的值作为参数传给下次递归调用。如果x为0,则停止递归,并且返回sum。跳出函数唯一的方式是recur(其实也不是真正的跳出)和sum。因此,当x不再为正时,函数返回sum的值:
(sum-down-from 0 10)
;=> 55
sum-down-from执行过程如图2.1所示。
图2.1 sum-down-from执行过程
注意递归调用的返回值首先是if某个分支的求值。然后依次是函数的返回值。当x为0时的sum值才是原始函数sum-down-from返回值。
你或许已经注意到,之前两个函数用了不同的block:第一个是when,第二个是if。我们会经常见到用一个或另一个当作条件式,但是这算不上是一个显而易见的答案。一般使用when的原因在于:
如果不需要考虑上述两条,那么可以使用if。
有时,我们并不希望循环回函数顶部,而是内部的某个地方。举例来说,就sum-down-from而言,我们或许会倾向于sum的初始值不由调用者提供。想做到这一点,可以用loop form,同let一样,它只是提供了一个recur跳转的目标。用法如下:
(defn sum-down-from [initial-x]
(loop [sum 0, x initial-x] ◁——● 设置递归目标
(if (pos? x)
(recur (+ sum x) (dec x)) ◁——● 调转至递归目标
sum)))
一进入loop form,局部量sum和x就初始化了,这和let的处理是一样的。
recur总会循环回最近的外部loop或fn,在这个例子里,就是到loop。loop局部量重新绑定成recur所给的值。循环和重新绑定会持续进行,直到最终x不为正。整个循环表达式的返回值是sum,同之前的函数一样。
至此,我们已经认识了几个使用recur的例子,不过,我们必须讨论一个重要的限制。recur form只能出现在函数或loop结尾位置。那结尾位置是什么呢?简而言之,出现在表达式结尾位置的form,其值可以作为整个表达式的返回值。考虑下面的函数:
(defn absolute-value [x]
(if (pos? x)
x ; 子句
(- x))) ; 子句
它有一个参数,名为x。如果x是正数,返回x;否则返回x的相反数。
if form就在函数的结尾位置,因为无论它返回什么,整个函数都会返回。“then”子句里的x也是在函数的结尾位置。但是“else”子句的x并不在函数的结尾位置,因为x的值是传递给 - 函数的,而不是直接返回的。else子句(-x)作为一个整体是处于结尾位置上的。
如果在非结尾位置使用recur form,Clojure会在编译时提醒我们:
(fn [x] (recur x) (println x))
; java.lang.UnsupportedOperationException:
; Can only recur from tail position
至此,你已经看过了Clojure怎样提供大多数流行程序设计语言的核心功能,只不过方式有所不同而已。但在下一节,我们会谈及quote form的概念,在许多方面,这是Lisp族程序设计语言所特有的,或许,那些来自经典的命令式和/或面向对象程序设计语言的程序员,会视之为异类。
Clojure有两种quote form:quote和语法quote。二者的语法都很简单,我们可以将其放在程序的某个form前面。要在Clojure程序中包括标量及其组合,而不把它们当作代码求值,这就是主要的做法。但是,想要理解quote form,就要对于表达式如何求值有深刻的理解。
集合求值时,其包含的各项会先求值[9]:
(cons 1 [2 3])
如果在REPL输入这段代码,form会作为一个整体求值为(1 2 3)。在这个特定的例子里,函数cons“构造(construct)”了一个序列,将第一个实参放在第二个实参提供的序列前面。因为form是一个list,每一项都会先求值。求值时,符号可能会解析成局部量、var或是Java的类名。如果是局部量或是var,就返回其值。
cons
;=> #<core$cons__3806 clojure.core$cons__3806@24442c76>
字面标量值会对自身求值——这样的求值只是返回同样的东西:
1
;=> 1
另一种类型的集合vector,其求值也是先对其包含的项求值。这里都是字面标量,没什么可说的。完成之后,继续对vector求值。类似于标量和map,vector也要对自身进行求值:
[2 3]
;=> [2 3]
现在,上面那个list里的所有项都求了值(有一个函数,数字1和vector [2 3]),就可以对整个list求值了。list的求值不同于vector和map:它们会调用函数,或是说触发特殊form,如下所示:
(cons 1 [2 3])
;=> (1 2 3)
无论list头是什么函数(这个例子里是cons),都会用list余下的项作为实参进行调用。
使用特殊form看起来就像调用函数一样——用一个符号作为list的第一项:
(quote age)
每个特殊form都有自己的求值规则。只是quote这个特殊form根本不让其实参求值而已。虽然符号age本身可能会求出var的值,例如9,但当出现在quote form里时,它不会求值:
(def age 9)
(quote age)
;=> age
相反,整个form只会对符号本身求值。无论多么复杂的实参,quote都能起作用:嵌套vector、map,甚至可能是个list,里面包含函数调用、宏调用,更有甚者包含特殊form。整个内容都会返回。
(quote (cons 1 [2 3]))
;=> (cons 1 [2 3])
使用quote form的原因可能有很多,但目前为止,最常见的一个原因是,我们可以用字面量list做数据集合,而Clojure不会尝试调用函数。在本节迄今的例子里,我们都很谨慎地在用vector,因为vector本身不是函数调用。如果采用list,某些天真的尝试就会失败:
(cons 1 (2 3))
; java.lang.ClassCastException:
; java.lang.Integer cannot be cast to clojure.lang.IFn
Clojure告诉我们,整数(这里是数字2)无法用作函数。所以,我们必须阻止form (2 3)当作函数调用——这恰好就是quote做的事情:
(cons 1 (quote (2 3)))
;=> (1 2 3)
在其他Lisp里,这种需求很常见,所以,专门提供一种快捷方式:单引号。虽然用得不多,但Clojure还是提供了。上面的例子可以写成:
(cons 1 '(2 3))
;=> (1 2 3)
看一下,少了一对括号——这种做法在Lisp里总是受欢迎的。记住,quote会影响其所有实参,并不只是顶层的。所以,即便上面的例子可以用'()替代[],但这种做法的结果可能不是我们想要的。
[1 (+ 2 3)] ;=> [1 5]
'(1 (+ 2 3)) ;=> (1 (+ 2 3))
最后要说明的一点是,空list ()已经自我求值过了;所以,它不需要quote。对空list进行quote不是Clojure中的惯用法。
同quote类似,语法quote也会阻止其实参和子form的求值。不同于quote的是,它还有一些额外的特性,很适合构建用作代码的集合。
语法quote的写法就是用一个反引号[10]。
`(1 2 3)
;=> (1 2 3)
它并不像quote那样展开成一个简单的form,而是会展开成支持下面特性的表达式。
符号可以用命名空间加一个斜线开头。下面这些称为全限定的符号。
clojure.core/map
clojure.set/union
i.just.made.this.up/quux
语法quote会自动对其实参里未限定的符号进行限定。
`map
;=> clojure.core/map
`Integer
;=> java.lang.Integer
`(map even? [1 2 3])
;=> (clojure.core/map clojure.core/even? [1 2 3])
如果符号命名的var或类还不存在,语法quote会使用当前的命名空间。
`is-always-right
;=> user/is-always-right
这种行为会在第8章我们专门讨论宏时派上用场。
如你所发现,quote这个特殊form阻止了其实参及其所有子form求值。但有时候,我们需要对其中某些form求值。达成此壮举的方式是使用称为反quote的做法。反quote用于区分需要求值的特定form,只要在语法quote体里,给符号加上前缀~即可。
`(+ 10 (* 3 2))
;=> (clojure.core/+ 10 (clojure.core/* 3 2))
`(+ 10 ~(* 3 2))
;=> (clojure.core/+ 10 6)
刚才发生了什么?后一个form用反quote对子fom (* 3 2)进行求值,当然,就是执行了3和2相乘,然后将结果插入到最外层的语法quote form里。反quote可以用于任何需要求值的表达式。
(let [x 2]
`(1 ~x 3))
;=> (1 2 3)
`(1 ~(2 3))
;; ClassCastException java.lang.Long
;; cannot be cast to clojure.lang.IFn
我们用反quote告诉Clojure,所标记的form需要求值。但是这里标记的form是(2 3),Clojure遇到这样的表达式会怎么办?它会尝试把它当作函数求值!因此,使用反quote要小心,确保要求值的form是我们预期的form。所以,执行上面任务更恰当的做法或许是:
(let [x '(2 3)] `(1 ~x))
;=> (1 (2 3))
这种做法提供了某种间接性,要求值的表达式不再是(2 3),而是x。但这种新做法打破了前面例子里返回(1 2 3)这个list的模式。
Clojure有一个很方便的特性,恰好可以解决之前的问题。这是反quote的一个变体,称为反quote拼接,其运作方式类似于反quote,但稍有不同:
(let [x '(2 3)] `(1 ~@x))
;=> (1 2 3)
请注意~@里的@,它告诉Clojure,不要解开序列x,将它拼装到最终的list里,而不是作为嵌套list插入。
有时,我们需要一个未限定的符号,例如对参数或let的局部名。在语法quote里,最简单的做法是,在符号名后添加一个#,让Clojure生成一个新的未限定符号。
`potion#
;=> potion__211__auto__
有时即便这么做也不够,因为我们想在多个语法quote里引用相同的符号,或是想要得到某个特定的未限定符号,我们将会在8.5.1节里详细讨论。
至此,我们已经涵盖了许多基本特性,正是这些特性让Clojure成为一种独一无二的Lisp。但是,Clojure和ClojureScript的优于与宿主语言及运行时的互操作,即Java和Java虚拟机,或JavaScript运行时。
Clojure与其宿主共生[11],它提供了丰富而强大的特性,而Java则提供了对象模型、程序库和运行时等方面的支持。在本节里,我们简要地看一下怎样用Clojure访问Java类及类成员,怎样创建实例以及访问其成员。Clojure与Java的交互称为interoperablity,简称interop。
Clojure提供了一种强大的机制,访问、创建和改变Java类和实例。常见的一种情况是访问类的静态属性:
java.util.Locale/JAPAN
;=> #<Locale ja_JP>
Clojure的惯用法倾向于采用类似于访问命名空间限定var的语法访问静态类成员:
(Math/sqrt 9)
;=> 3.0
上面调用的是java.lang.Math#sqrt这个静态方法。在默认情况下,java.lang这个包下的所有类都可以直接使用。ClojureScript并不提供类似的方法,因为JavaScript没有这样的特性。
创建Java类实例对Clojure同样常见。new这个特殊form与Java和JavaScript模型非常接近:
(new java.awt.Point 0 1)
;=> #<Point java.awt.Point[x=0,y=1]>
这个例子把0和1作为参数用于创建java.awt.Point的实例。Clojure有趣的一点是其核心的集合类可以用于初始化Java的集合类。现在我们来看一个用Clojure的Map初始化Java Map的例子。
(new java.util.HashMap {"foo" 42 "bar" 9 "baz" "quux"})
;=> {"baz" "quux", "foo" 42, "bar" 9}
第二种也是更简洁的Clojure form才是创建实例惯用的form。
(java.util.HashMap. {"foo" 42 "bar" 9 "baz" "quux"})
;=> {"baz" "quux", "foo" 42, "bar" 9}
正如我们这里所见,类名后面跟着一个点,表示调用构造函数。ClojureScript同样如此,只是当引用核心函数或者访问JavaScript的全局变量的时候,需要显示的加上js命名空间。
(js/Date.)
;=> #inst "2013-02-01T15:10:44.727-00:00"
在Clojure和ClojureScript中还有一些诸如此类的细小差别(尽管相对较少),必要时我们会做出解释。
要访问public变量,需要在属性名前加上.和连字符:
(.-x (java.awt.Point. 10 20))
;=> 10
返回值就是Point实例中x字段的值。
要访问实例属性,只要在属性或方法名前加一个点即可:
要访问实例方法,点form还可以给方法传入额外的实参:
(.divide (java.math.BigDecimal. "42") 2M)
;=> 21M
上面例子调用的是BigDecimal类的#divide方法。注意一点,调用divide方法的时候,把实例作为了其第一个参数。这与Java中的使用方法是相反的。另外使用2M字符常量表明想要使用一个任意精确的数值。
Java实例属性可以通过set!函数设定。
(let [origin (java.awt.Point. 0 0)]
(set! (.-x origin) 15)
(str origin))
;=> "java.awt.Point[x=15,y=0]"
set!的第一个实参是一个实例成员访问的form。
使用Java工作,常见的一种做法是把一些方法串联起来,基于前一个方法调用的返回类型调用后一个,如下所示:
new java.util.Date().toString().endsWith("2014") /* Java code */
使用Clojure的点特殊form,下面的代码是等价的[12]:
(.endsWith (.toString (java.util.Date.)) "2014") ; Clojure code
;=> true
虽然正确,但上面的代码读起来很费劲,如果方法调用链变得更长,理解起来只会更困难。为了解决这种问题,Clojure提供了..宏,这样可以把调用链简化成下面的样子:
(.. (java.util.Date.) toString (endsWith "2014"))
;=> true
上面的..调用同Java等价代码非常接近,而且更易读。请记住,在实际的Clojure代码里,我们可能很少在非宏定义的上下文里见到..。另外,Clojure还提供了->和->>宏,它们与..宏很类似,但也适用于非互操作的情形,因此,这也是大多数场景倾向采纳的方法调用做法。->和->>宏会在第8章进行深度介绍。
使用Java工作时,还有一种很常见的做法,通过调用一组修改器,初始化一个新实例。
// 这是Java,不是Clojure
java.util.HashMap props = new java.util.HashMap();
props.put("HOME", "/home/me"); /*更多代码,对不起。*/
props.put("SRC", "src");
props.put("BIN", "classes");
但这种做法非常啰唆,采用doto宏,可以使之成为流线型,其形式如下:
(doto (java.util.HashMap.)
(.put "HOME" "/home/me")
(.put "SRC" "src")
(.put "BIN" "classes"))
;=> {"HOME" "/home/me", "BIN" "classes", "SRC" "src"}
虽然这样比较Java和Clojure更有用,但我们不能假设,二者编译出的结构是一样的。
Clojure还有reify和deftype宏,这是创建Java接口实现一种可能的方式,但是,我们要把这些话题推迟到第9章介绍。此外,Clojure还有一个名为proxy的宏,可以动态地实现接口和扩展基类。类似地,使用gen-class宏,我们可以静态地生成命名类。第12章会介绍proxy和gen-class的更多细节。
现在,我们简要地谈一下Clojure处理异常的方式。Clojure提供一组类似于Java的form,抛出和捕获运行时的异常:分别叫作throw和catch。虽然throw和catch几乎完全来源于Java和JavaScript,但是它们被视为处理error的标准方式。换句话说,即使没有交互,大多数的Clojure代码也是用throw和catch处理异常。
抛出异常的机制相当直白:
(throw (Exception. "I done throwed"))
;=> java.lang.Exception: I done throwed …
在Clojure里,捕获异常的语法类似于Java。
(defn throw-catch [f]
[(try
(f)
(catch ArithmeticException e "No dividing by zero!")
(catch Exception e (str "You are so bad " (.getMessage e)))
(finally (println "returning… ")))])
(throw-catch #(/ 10 5))
; returning…
;=> [2]
(throw-catch #(/ 10 0))
; returning…
;=> ["No dividing by zero!"]
(throw-catch #(throw (Exception. "Crybaby")))
; returning…
;=> ["You are so bad Crybaby"]
Java和Clojure处理异常的方式的主要差别在于,Clojure不像Java那样要求检查异常ClojureScript的catch,用法看上去差不多,除了需要使用js来捕捉error。
(try
(throw (Error. "I done throwed in CLJS"))
(catch js/Error err "I done catched in CLJS"))
;=> "I done catched in CLJS"
接下来,介绍Clojure的最后一节里,我们会谈到命名空间,如果熟悉Java或Common Lisp,你会觉得很眼熟。
Clojure的命名空间提供一种“将相关函数、宏以及值放在一起”的方式。在本节里,我们会简要地谈一下如何创建命名空间以及如何引用和使用其他命名空间的东西。本小节主要关注Clojure(对比Java)命名空间的功能。关于ClojureScript相关的命名空间的讨论见第13章。
创建新的命名空间,可以使用ns宏:
(ns joy.ch2)
于是,REPL提示符显示为:
joy.ch2=>
根据这个提示符显示,我们正工作在joy.ch2命名空间的上下文中。Clojure还提供了一个var *ns*,其值就是当前的命名空间。任何新建的var都是当前命名空间的成员:
joy.ch2=> (defn hello []
(println "Hello Cleveland!"))
joy.ch2=> (defn report-ns []
(str "The current namespace is " *ns*))
joy.ch2=> (report-ns)
;=> "The current namespace is joy.ch2"
在命名空间里输入符号,Clojure会尝试在当前命名空间里寻找它的值。
joy.ch2=> hello
;=> #<ch2$hello joy.ch2$hello@2af8f5>
命名空间可以随时创建。
(ns joy.another)
joy.another=>
我们会注意到提示符又一次发生了改变,它表示新的上下文是joy.another。使用ns form,就是告诉Clojure创建一个新的并且切换到新建的命名空间。由于joy.another是一个新的命名空间,并且和joy.ch2没有任何关系。所以尝试运行report-ns,我们发现,它不起作用了。
joy.another=> (report-ns)
; java.lang.Exception:
; Unable to resolve symbol: report-ns in this context
这是因为report-ns存在于joy.ch2命名空间里,只有通过其全限定名joy.ch2/report-ns才能访问。但这种做法只对已经隐式的加载过,或者是Clojure core命名空间下的,或者通过显示的使用稍后提到的:require加载的才会起作用。
创建命名空间的做法很直白,但这并不意味着建好了一个命名空间,并且在空间里实现类一些可用的函数,然后就可以被其他的命名空间调用了。事实上如果需要使用其他命名空间下的函数,需要从硬盘中加载。但怎样加载命名空间呢?Clojure提供了一种方便的指令:require来完成这项任务。示例如下:
(ns joy.req
(:require clojure.set))
(clojure.set/intersection #{1 2 3} #{3 4 5}) ◁——● 调用clojure.set命名空间中的函数
;;=> #{3}
这个:require的使用表示,我们要加载clojure.set,但不需要从符号到joy.req命名空间函数的映射。我们还可以使用:as指令为clojure.set创建一个别名:
(ns joy.req-alias
(:require [clojure.set :as s]))
(s/intersection #{1 2 3} #{3 4 5})
;=> #{3}
限定命名空间的form看上去像调用静态类方法一样。但不同之处在于,命名空间符号只能用于限定符,而类符号则可以独立引用:
clojure.set ◁——● 命名空间
; java.lang.ClassNotFoundException: clojure.set
java.lang.Object ◁——● 类符号
;=> java.lang.Object
命名空间将符号映射为限定的var和不限定的var,这种奇特表现最初可能会在类名和静态方法之间引起混淆,但是随着我们的深入,这种差异就会变得很自然了。另外,Clojure惯用的做法倾向于使用my.Class给类命名,用my.ns给命名空间命名,这也有助于消除潜在的混淆。
有时,我们要创建一些映射,将另一个命名空间的var映射到自己的命名空间里,省得调用每个函数或宏都得用全限定命名空间符号。为了创建这些未限定映射,Clojure为:require提供了:refer选项:
(ns joy.use-ex
(:require [clojure.string :refer (capitalize)]))
(map capitalize ["kilgore" "trout"])
;=> ("Kilgore" "Trout")
:refer选项表示只有capitialize函数才会直接映射到joy.use-ex。虽然可以使用:refer :all(或者使用:use)把所有的public var都引入进来,但是并不建议这么做。在Clojure中显示的引入感兴趣的函数是最佳实践,因为这样可以避免创建不必要的名称。过多的不必要的名字会导致冲突的可能性增大,并且使得代码不易读。
(ns joy.yet-another
(:refer joy.ch2))
(report-ns)
;=> "The current namespace is joy.yet-another"
任何引用到的命名空间必须已经加载过,要么是之前定义过的,或Clojure核心命名空间之一,这样会隐式加载,要么通过:require显式加载。应该注意的是,:rename也可以和:use指令共同起作用。
Clojure还直接提供了:refer指令,其执行方式和:require指令的:refer选项差不多。不过它只能对已经加载的类库创建映射。
(ns joy.yet-another
(:refer clojure.set :rename {union onion}))
(onion #{1 2} #{4 5})
;=> #{1 2 4 5}
这里使用:refer将reort-ns的函数映射到joy.ch2的命名空间下,使得函数可以被正常调用。还可以用:rename关键字为函数创建别名。
注意:rename也可以用于:require指令。
要在任意给定命名空间内使用未限定的Java类,就应该通过:import指令来导入,如下所示:
(ns joy.java
(:import [java.util HashMap]
[java.util.concurrent.atomic AtomicLong]))
(HashMap. {"happy?" true})
;=> {"happy?" true}
(AtomicLong. 42)
;=> 42
不过全命名空间的Java类不使用:import也可以正常使用。命名空间创建时,会自动导入java.lang包里的类。我们会在9.1节和10.1节进一步讨论命名空间。
我们称本章为“Clojure疾风式教程”——你已经打通关了!感觉如何?这里只提供了后续章节所需主题的概要,而非全景式的语言教程。如果你尚未完全理解Clojure程序设计语言,也无须着急;当你按照自己的方式读完本书,理解自然会随之而来。
在下一章里,我们会往回退一步,深入某些不易归类的话题,其普遍性也让这些话题值得我们关注一下。下一章简短而甜美,在移步后面更深入的讨论之前,让我们有个喘息之机。
[1] Clojure是一门包含深思熟虑设计理念的语言。在很多情形下,Clojure背后蕴含的理念要远远重要于语言本身。
[2] 这里未提及正则表达式,但翻到4.5节,你会了解正则表达式相关的一切细节。
[3] 提示一下,第4章会进一步描述。
[4] 先提示一下,4.1节会进一步讨论。
[5] 当然,Java只在一些情况下用了中缀记法。语言的余下部分都朝着C风格那种特有的方向堕落。
[6] 根绑定除了提供默认值还有其他的作用。除非在特定线程另外重新绑定,否则在所有线程中的值都是一样的。在默认情况下,所有的线程都从根绑定起步,如果没有线程特定的绑定值,用的就是这个关联值。
[7] 相反,我们可以只做生命,然后将值的绑定推迟到各自的线程中。在第11章,我们会更多地讨论每个线程独立绑定。
[8] 事实上Clojure不允许函数有超过20个参数。现实中很少出现这种情况,当然如果出现了,那么确实是个问题。
[9] 除非是以宏或特殊form名字开头的list,稍后谈及。
[10] 译注:反引号就是Esc下边的那个键。
[11] 我们对Java虚拟机和JavaScript的关注贯穿本书,然而,Clojure也可以寄宿于.NET的CLR、Scheme、Lua、Python和ClojureScript(http://clojurescript.n01se.net/repl/)。
[12] 结果依赖于何时执行这段代码,运行的时候可能返回false。可以把“2013”替换为当前的年份(例如“2012”)。