Clojure-JVM上的函数式编程语言(1) 综述 作者: R. Mark Volkmann

原文地址:http://java.ociweb.com/mark/clojure/article.html

作者:R. Mark Volkmann

译者:RoySong

简介

    这篇文章的目的是给Clojure做一个广泛公正的介绍,以简要的形式对多个特性进行了介绍.可以根据兴趣选看其中的章节.

 

    对本文有任何意见或者建议发送邮件到 [email protected] ,这篇文章的最新版本会在 http://www.ociweb.com/mark/clojure/ 刊载,上面有更新的日期,同样,你也可以在 http://www.ociweb.com/mark/stm/ 看到关于Clojure的软件事务内存特性(STM)的文章。

 

    这篇文章中的代码示例通常采用 “;注释-〉结果”的形式来展示函数调用的结果以及输出,比如:

(+ 1 2) ; showing return value -> 3
(println "Hello") ; return value is nil, showing output -> Hello

 

函数式编程

    函数式编程是一种强调“第一类”(first-class)“纯”函数的编程模式。它是根据λ运算原理演化而来的。

 

    所谓的“纯函数”是指传给同样的参数总是会返回同样结果的函数,而不是那些状态随着时间改变的函数。纯函数的特性使它更加容易理解、调试和测试。它们没有诸如:改变了某个全局状态,执行了各种I/O操作,数据库更新或者文件读写这类型的副作用(side effects)。状态通过保存在栈(stack)中的函数参数来维护,而不是通过保存在堆(heap)的全局变量来控制。这样就允许函数能够被重复执行而不用担心影响到全局状态(这是一个重要的特性,在之后我们讨论事务时会谈到)。它也为编译器提升性能打开了一扇门,以自动组织和实现并发的形式,尽管当前而言自动并发化还未实现。

 

    在实际中,应用程序需要产生某些副作用。 Simon Peyton-Jones,函数式编程语言Haskell的主要贡献者,曾经说过:“到最后,任何程序都会去改变状态。没有副作用的函数是某种类型的黑匣子,你唯一能说的就是匣子越来越烫”。 (http://oscon.blip.tv/file/324976 ) 关键在于限制副作用,清晰的标识它们,避免让它们散落在代码的各个角落。

 

    支持“第一类函数”的编程语言允许采用变量绑定函数,并把它传递给其他函数或者作为其他函数的返回值。这种允许返回值为函数的特性支持某些行为的延迟执行。采用其他函数作为参数的函数被称为“高层函数”(higher-order functions),从某种意义上来说,它们的行为取决于作为参数的其他函数。被传入的参数函数可以被执行任意次数,包括完全不执行。

 

    函数是编程语言中的数据(Data)通常是不变的,这样在多线程并发使用数据时就不用加锁。不能改变的数据自然就没必要加锁。随着多处理器变得越来越普遍,函数式编程能简单支持并发这一点或许会成为最大的优势。

 

 如果上面这些听起来还算有趣,而你准备开始尝试函数式编程,那么,准备接受一个陡峭的学习曲线吧。函数式编程的很多概念并不比面向对象编程更难,它只是完全不同。介于上文提到的优点,我认为花时间去学习函数式编程是值得投入的。

 

 函数是编程语言包括 Clojure , Common Lisp , Erlang , F# , Haskell , ML , OCaml , Scheme 和 Scala 。Clojure 和 Scala是运行在java虚拟机(JVM)上面的。其他能够运行在JVM上面的函数式编程语言还有: Armed Bear Common Lisp (ABCL) , OCaml-Java 和 Kawa (Scheme) 。

 

Clojure 综述

 Clojure是运行在JVM(java5或者更高)上面的一门动态的、函数式编程语言,并且提供和java的互操作性。这门语言的主要目的就是为了让应用能够更便捷地实现多线程并发操作数据。

 

 很明显Clojure同单词“closure”很像。这门语言的作者,Rich Hickey,解释了这个命名产生的方式:“我想要在命名中包含C (C#), L (Lisp) 和 J (Java)。当从closure的双关语中提出Clojure时,面对有效的域名和大片空白的google空间,做出这个决定就很简单了。”

 

    不久之后Clojure同样可以运用于.net平台,ClojureCLR是Clojure基于Microsoft Common Language Runtime的实现,当这篇文章撰写时,它刚刚发布了alpha版本。

 

    在2011年7月,ClojureScript发布了,它能够将Clojure代码编译为JavaScript。参见 https://github.com/clojure/clojurescript 。

 

    Clojure是一门基于 Eclipse Public License v 1.0 (EPL)协议的开源发布语言。EPL是一个非常宽松的协议,详情参见http://www.eclipse.org/legal/eplfaq.php 。

 

    运行在JVM上面意味着可移植性、稳定性、好的性能以及安全。这同样提供了对java已有库的访问权限,比如文件I/O,多线程,GUI,web应用等等。

 

    在Clojure中的每个操作都是函数、宏或者特殊form。几乎所有的函数和宏都是以Clojure源代码的形式来实现。而函数和宏的区别将在下文详细解释。特殊form由Clojure的编译器识别,并且不能以Clojure源代码的形式实现。有很小一部分特殊form和新的form是不能实现的,它们包括:catch , def , do , dot ('.'), finally , fn , if , let , loop , monitor-enter , monitor-exit , new , quote , recur , set! , throw , try 和 var 。

 

    Clojure提供了很多函数来对“序列”(sequences,集合的逻辑视图)进行便捷操作。很多东西都可以被视为序列,包括Java collections, Clojure-specific collections, strings, streams, directory structures 和XML trees。Clojure能够采用高效的方式从已有的集合中创建新集合的实例,因为这些集合都是持久化数据结构( persistent data structures )。

 

 Clojure提供三种方式来安全共享可变的数据,这些可变数据意味着对不变数据的可变引用。通过 Software Transactional Memory (STM)机制,采用 Refs 可以提供对多层共享数据的同步访问。 Atoms 和 Agents 都提供了对单层共享数据的同步访问,它们将在“引用类型”("Reference Types ")这一节被详细阐述。

 

    Clojure是一种 Lisp 方言。然而,它和老Lisp有着诸多不同。比如:老Lisp中采用car函数来获取list中的第一个元素,而Clojure中采用first函数,如同Common Lisp一样。Clojure和老的Lisp不同处列表参见http://clojure.org/lisps 。

 

    Lisp的语法由于对前置操作符和成对括号的使用让很多人喜欢,也让很多人痛恨。如果你属于后者,那么请注意以下事实:很多文本编辑器和IDE都能够高亮成对的括号,所以你没必要去计算括号的层数以保证它们总是闭合的。Clojure的函数调用并不像java那样杂乱无章。比如,某个java方法调用如下:

methodName(arg1, arg2, arg3);

    而对应的Clojure函数调用看起来是这个样子的:

(function-name arg1 arg2 arg3)

     括号跑到了函数名前面,参数间的逗号都没有了,语句最后的分号也没有了。语法上这被称为一个“form”,事实上,Lisp中的任何东西都包含form。注意,Clojure中的命名约定是采用小写单词并以“-”分割,不像java的驼峰命名法。

 

    在Clojure中定义函数同样非常简约。Clojure的println 函数会在每个参数的对应输出后面加上空白,为了避免这一点,我们将所有的参数传给str函数,然后将这个函数的返回值传给println

// Java
public void hello(String name) {
    System.out.println("Hello, " + name);
}

; Clojure
(defn hello [name]
  (println "Hello," name));add more space

(defn hello [name]
  (println (str "hello," name)));use str function

    Clojure广泛应用了“延迟求值”(lazy evaluation),这个机制允许函数仅当需要它的返回值时才进行调用。“延迟序列”(Lazy sequences)是直到实际使用时,才会计算集合的结果。这样就能支持无穷集合的有效创建。

 

    Clojure代码的处理分三个阶段:读取时、编译时和运行时。在读取时,读取器会阅读源代码并将它转换为某个数据结构,大部分是一个list包含许多list,其中每个list又包含很多list这种形式。在编译时,这个数据结构会被编译为java字节码。在运行时,字节码被执行。函数仅在运行时被调用。宏和特殊结构在调用上看起来跟函数一样,但实际上在编译时它们就已经被展开为新的Clojure代码了。

 

    是否Clojure代码很难以理解?想象一下当你阅读java代码时,遇到某些语法元素,比如:声明、for循环和匿名类,你不得不停下来为它们的含义而迷惑。当然,对于有志于成为职业java开发者的人来说,以上这些含义都是明确无疑的。同样地,对于能有效阅读和理解Clojure代码的人来说,Clojure的语法部分也是明确无疑的。包含对let , apply , map , filter , reduce以及匿名函数的舒适运用的例子将在接下来为大家展示。

 

入门指南

    Clojure是一门相对较新的语言。它经过数年的工作后在2007年10月16号首次发布,Clojure主文件的下载被称为  "Clojure proper" 或者 "core". 它可以从 http://clojure.org/downloads 下载获得,或者也可以采用 Leiningen 来获得。最新的代码版本可以从Git repository中获得.

 

    "Clojure Contrib " 是Clojure的组件库.库中的组件大多是成熟、通用的,甚至某些最终会被包含到Clojure的系统中去。然而,库中同样存在不成熟、不通用,也不适合包含到Clojure中去的组件。这是一个鱼龙混杂的仓库。有关这个仓库的详细文档,请参见http://richhickey.github.com/clojure-contrib/index.html 。

 

    有三种方式能够为Clojure发布获得一个.jar文件。第一,下载一个预编译的.jar文件。第二,一个采用Maven来构建的源代码。Maven能够从 http://maven.apache.org/ 获得。执行的命令是"mvn package ".第三,一个采用Ant来构建的源代码。Ant可以从 http://ant.apache.org/ 获得。Ant的执行命令是 "ant -Dclojure.jar={path } "。

 

    为了获得最新的构建版本,假设你已经安装了 Git 和 Ant ,执行如下的脚本来检索和构建Clojure本身和组件库:

git clone git://github.com/richhickey/clojure.git
cd clojure
ant clean jar
cd ..
git clone git://github.com/richhickey/clojure-contrib.git
cd clojure-contrib
ant -Dclojure.jar=../clojure/clojure.jar clean jar

    然后,创建一个脚本来启动Read/Eval/Print Loop (REPL)和运行Clojure程序。这个通常被命名为“clj”。REPL的使用稍后将会解释。windows系统下下面一行脚本语句就可以满足刚刚两个要求(在UNIX, Linux and Mac OS X下面,将%1 改为$1):

java -jar path/clojure.jar %1 

    这条脚本语句假设在环境变量path中能够找到java的目录。如下所述可以让这条脚本语句更有用:

  • 添加经常使用的jar包比如 "Clojure Contrib "和数据库驱动到 classpath (-cp )
  • 添加可编辑、自动完成和交互会话命令行特性,采用 rlwrap (supports vi keystrokes) 或者 JLine
  • 采用开始脚本来设置特殊符号(比如*print-length* 和*print-level* ),引入常用但不在java.lang包中的java类,加载常用但不在 clojure.core命名空间中的Clojure函数,和定义常用的自定义函数。

    采用脚本来启动REPL将会在接下来的章节中讨论到。运行Clojure源文件,通常以.clj后缀,的脚本如下:

clj source-file-path

    更多的细节, 参见 http://clojure.org/getting_started 和 http://clojure.org/repl_and_main 。同样地, Stephen Gilardi 在 http://github.com/richhickey/clojure-contrib/raw/master/launchers/bash/clj-env-dir 提供了一个现成的脚本。

 

    为了发挥多处理器的最大优势,可能需要采用 "java -server ... "这种方式来启动Clojure。

 

    传递给Clojure程序的命令行参数是有效的,参数会预定义绑定到*command-line-args*上面。

 

Clojure语法

    Lisp方言拥有非常简洁,某些地方可以称作优美的语法。数据和代码拥有同样的结构和表现形式,list的嵌套能够在内存中很自然的如同树状呈现。

 

    (a b c)的含义是一个名叫a的函数接收了b和c两个参数,然后被调用。

如果要把它作为数据处理而不是函数调用,则需要在前面加上引号。

'(a b c) 或者 (quote (a b c))代表一个list,包含a、b和c。除了某些

特殊的情况,一般语法就是这样的。而不同的情况的数目取决于采用哪种方言。

 

    特殊的情形可以以某些语法糖的形式被察觉。拥有更多的语法糖,代码就会变得更短,代码的读者就需要学习和记忆更多。这是一种微妙的平衡。大部分的语法糖都拥有同名可替代的函数。我把决定权留给你自己,去采用或多或少的语法糖。

 

    下面的列表简单地描述了Clojure代码中会遇到的特殊情况。这些都会在接下来的章节中会被详细描述,所以,现在你不用完全弄明白它们。

 

 

目的 语法糖 函数
注释 ; text
for line comments
(comment text ) macro
for block comments
character literal (采用Java char 类型) \char \tab
\newline \space
\uunicode-hex-value
(char ascii-code )
(char \uunicode )
字符串 (采用 Java String 对象) "text " (str char1 char2 ...)
连接字符以及其他多种类型的值来创建一个字符串

关键字;一个被保留的字符串;同样名字的关键字指向同样的对象;通常被用于map的key

:name (keyword "name ")
在当前命名空间中生效的关键字 ::name none
正则表达式 #"pattern "
引用规则不同于函数form
(re-pattern pattern )

视为空白符;有时会用在集合中来增加可读性

, (一个逗号) N/A
list - a linked list '(items )
doesn't evaluate items
(list items )
evaluates items
vector - similar to an array [items ] (vector items )
set #{items }
创建了一个 hash set
(hash-set items )
(sorted-set items )
map {key-value-pairs }
创建了一个 hash map
(hash-map key-value-pairs )
(sorted-map key-value-pairs )

为符号或者集合添加元数据

add metadata to a symbol or collection

#^{key-value-pairs } object
processed at read-time
(with-meta object metadata-map )
processed at run-time

从符号或者集合上获取映射的元数据

get metadata map from a symbol or collection

^object (meta object )

获取某函数的参数数量

gather a variable number of arguments
in a function parameter list

& name N/A
conventional name given to
function parameters that aren't used
_ (an underscore) N/A

初始化一个java类;

注意类名后面还有个句点

(class-name . args ) (new class-name args )
调用一个java方法 (. class-or-instance method-name args ) or
(.method-name class-or-instance args )
none

调用数个java方法,将每个方法返回的结果作为下一个方法的第一个参数;

每个方法在括号中拥有一个额外的参数;
注意开头的双句点

(.. class-or-object (method1 args ) (method2 args ) ...) none
创建一个匿名函数 #(single-expression )
use % (same as %1 ), %1 , %2 and so on for arguments
(fn [arg-names ] expressions )
dereference a Ref, Atom or Agent @ref (deref ref )
get Var object instead of
the value of a symbol (var-quote)
#'name (var name )
syntax quote (used in macros) ` none
unquote (used in macros) ~value (unquote value )
unquote splicing (used in macros) ~@value none
auto-gensym (used in macros to generate a unique symbol name) prefix #

(gensym prefix ?)

 

    Lisp方言采用前缀符号,而不同于一般的编程语言中,把符号比如+或者*放到中间。

举个例子,在java中,可能会这么写:a + b + c;而在Lisp的方言中,写法变为(+ a b c)。

这种写法的一个好处是,不用重复操作符就可以指定任意数量的参数,

而不像其他语言的基础操作符一样受限于两个操作数。

 

    Lisp代码比其他语言要多很多括号的原因是它使用圆括号如同java使用大括号一般。举个例子,java的方法声明是用大括号包起来的,而Lisp函数定义中的表达式是用圆括号包起来的。

 

    比较以下java和clojure的代码片段,作用都是定义一个简单函数并调用它。两者的输出都是"edray" 和 "orangeay".

// This is Java code.
public class PigLatin {

    public static String pigLatin(String word) {
        char firstLetter = word.charAt(0);
        if ("aeiou".indexOf(firstLetter) != -1) return word + "ay";
        return word.substring(1) + firstLetter + "ay";
    }

    public static void main(String args[]) {
        System.out.println(pigLatin("red"));
        System.out.println(pigLatin("orange"));
    }
}
; This is Clojure code.
; When a set is used as a function, it returns a boolean
; that indicates whether the argument is in the set.
(def vowel? (set "aeiou"))

(defn pig-latin [word] ; defines a function
  ; word is expected to be a string
  ; which can be treated like a sequence of characters.
  (let [first-letter (first word)] ; assigns a local binding
    (if (vowel? first-letter)
      (str word "ay") ; then part of if
      (str (subs word 1) first-letter "ay")))) ; else part of if

(println (pig-latin "red"))
(println (pig-latin "orange"))

 

    Clojure支持所有的公用数据类型比如说boolean(字面值是true和false),integers, decimals, characters (参见上面表格中的 "character literal") 和strings。它同样支持包含分子和分母的分数,并且能够在计算中不丧失精度。

 

    符号(symbol)被用在命名某些东西上,这些名称的作用域是在某个命名空间中--或是被指定的命名空间,或是默认的命名空间。使用符号实际上是使用的符号所指向的值,如果要获取符号本身,必须采用‘号。

 

    关键字(keyword)以冒号开头,被作为一个独一无二的标示符使用。例子包括map中的key,或者枚举的值(比如::red , :green:blue )。

 

    在Clojure中,以及其他的编程语言中,都有可能写出难以理解的代码。遵循少量的指导方针会带来巨大的差异。编写短小的、目标明确的函数使得它们便于阅读、测试和重用。经常采用“导出方法”(extract method)重构模式。深层嵌套的函数调用让人很难理解。如果有可能的话,限制嵌套的使用,通常采用let来将某个复杂的表达式变为数个不那么复杂的表达式。将某个匿名函数传给某个有名字的函数是常见的。然而,要避免将一个匿名函数传给另一个匿名函数,因为这样的代码是非常难以阅读的。

 

REPL

    REPL意即读取(read)、求值(eval)、打印(print)的循环(loop)。这是Lisp方言中的一个标准工具,它允许用户在其中输入表达式,然后使输入内容被读取和计算,最后打印出得到返回的结果。这是一个对测试和增进了解程序非常有用的工具。

 

    在命令行中运行之前我们建立的脚本,叫做“clj”那个,就可以启动REPL。然后就会出现"user=> "提示符,"=> "之前的部分代表当前的命名空间。在提示符后输入的内容将被求值,并打印结果在屏幕上。下面是个REPL输入输出的简单例子:

user=> (def n 2)
#'user/n
user=> (* n 3)
6

 

    第一个参数def是个特殊的form,它不会被求值,而是采用它的字面值作为名字。

这个表达式的输出显示了在“user”命名空间中定义了一个名叫“n”的符号。

 

    要查看某个函数、宏或者命名空间的文档信息,使用(doc name)。如果查看的是个宏,单词 "Macro"会迅速出现在一条在参数列表下面的线上。被查看的元素必须要提前加载(参见 require 函数)到当前的命名空间来。举个例子:

(require 'clojure.contrib.str-utils)
(doc clojure.contrib.str-utils/str-join) ; ->
; -------------------------
; clojure.contrib.str-utils/str-join
; ([separator sequence])
;   Returns a string of all elements in 'sequence', separated by
;   'separator'.  Like Perl's 'join'.

 

    要在所有的函数或者宏中去查找某些名字或者文档中包含某个字符串的文档,采用(find-doc "text")。

 

    要查看某个函数或者宏的源代码,采用(source name)。source是一个定义在 clojure.contrib.repl-utils命名空间中的宏,这个命名空间会在REPL中自动加载。

 

    要从文件中加载并运行form,采用(load-file "file-path")。通常这些文件采用 .clj扩展名。

 

    在window系统下要离开REPL环境,按下ctrl+z然后回车或者直接ctrl+c都可以。在其它系统(包括UNIX, Linux 和 Mac OS X)下要离开REPL,按下ctrl+d就可以了。

 

绑定(bindings)

    Clojure并不支持变量。与之替代的是绑定(bindings),跟变量很像,但在指定了值之后并不打算去改变。绑定包含全局绑定(global bindings)、线程本地绑定(thread-local bindings)、函数内本地绑定(local binding)或者某些form内的本地绑定。

 

    特殊form def 会创建一个全局绑定并给予一个“顶级值”( root value),在所有的线程中全局绑定都会是这个值,除非重新指定了一个线程本地绑定。 def 同样可以用来改变某个已存在绑定的顶级值。然而,这种做法是会被鄙视的,因为它破坏了数据的不可变性。

 

    函数的参数就是函数的本地绑定。

    特殊form let会创建一个针对某个form的本地绑定。它的第一个参数是一个包含“名字/表达式”对的vector,

这些表达式会依次被求值,然后将执行的结果赋予它左侧的名字。这些绑定能够被参数vector后面的表达式所使用。

它们同样有可能被多次指定以改变它们的值。let剩下的参数由一系列采用本地绑定的表达式所组成。

在let作用域内调用的其他函数是无法感知到let的本地绑定的。

 

   binding 宏类似于 let,它能暂时创建一个线程本地绑定覆盖掉 已存在的全局绑定。这个线程本地绑定的值可以在form内部以及内部调用的函数所感知到。当离开 binding form的范围,全局绑定就会恢复之前定义的值。

 

    从Clojure1.3开始,仅仅声明为动态变量(dynamic vars)的绑定可以这么做了。下面的例子会演示如何声明动态变量。

 

    let和binding的另一个区别在于,let顺序地指定绑定的值,后面绑定的值可以基于前面绑定来赋予,而binding则是并发指定绑定的值。

 

    能够采用binding来绑定新线程本地值的符号有其约定的命名规则,这些特殊的符号前后都用*号包围。在这篇文章中出现的例子包括:*command-line-args* , *agent* , *err* , *flush-on-newline* , *in* , *load-tests* , *ns* , *out* , *print-length* , *print-level**stack-trace-depth*。采用这些绑定的函数会被其值的改变所影响。举个例子,改变*out*的值会影响 println 函数的输出目标。

 

    下面的例子展示了 def , letbinding的用法:

(def ^:dynamic v 1) ; v is a global binding

(defn f1 []
  (println "f1: v =" v)) ; global binding

(defn f2 []
  (println "f2: before let v =" v) ; global binding
  (let [v 2] ; creates local binding v that shadows global one
    (println "f2: in let, v =" v) ; local binding
    (f1))
  (println "f2: after let v =" v)) ; global binding

(defn f3 []
  (println "f3: before binding v =" v) ; global binding
  (binding [v 3] ; same global binding with new, temporary value
    (println "f3: in binding, v =" v) ; global binding
    (f1))
  (println "f3: after binding v =" v)) ; global binding

(defn f4 []
 (def v 4)) ; changes the value of the global binding

(f2)
(f3)
(f4)
(println "after calling f4, v =" v)

 

    上面例子的输出如下:

f2: before let v = 1
f2: in let, v = 2
f1: v = 1 (let DID NOT change value of global binding)
f2: after let v = 1
f3: before binding v = 1
f3: in binding, v = 3
f1: v = 3 (binding DID change value of global binding)
f3: after binding v = 1 (value of global binding reverted back)
after calling f4, v = 4

你可能感兴趣的:(翻译,Clojure)