Clojure开发者需避免的常见编程错误

92

有哪些 Clojure 开发者常犯的错误,我们如何避免这些错误?

例如,Clojure 的新手可能会认为 contains? 函数与 java.util.Collection#contains 作用相同。然而,contains? 只有在与像 map 和 set 这样的索引集合一起使用时,并且你要查找给定的键时才会类似操作:

(contains? {:a 1 :b 2} :b)
;=> true
(contains? {:a 1 :b 2} 2)
;=> false
(contains? #{:a 1 :b 2} :b)
;=> true

当与具有数值索引的集合(向量、数组)一起使用时,contains? 检查给定元素是否在有效索引范围内(从零开始):

(contains? [1 2 3 4] 4)
;=> false
(contains? [1 2 3 4] 0)
;=> true
如果给定一个列表,contains? 永远不会返回 true。

4
对于那些正在寻找类似于 java.util.Collection#contains 功能的 Clojure 开发者,可以查看 clojure.contrib.seq-utils/includes? 。根据文档:用法:(includes? coll x)。如果 coll 线性包含等于 x(使用 = 进行比较)的元素,则返回 true。 - Robert Campbell
11
您似乎忽略了这些问题是社区维基的事实。 - anon
3
我喜欢Perl问题总是与其他问题不一样 :) - Ether
8
对于正在寻找Clojure中contains函数的开发人员,我建议不要按照rcampbell的建议去做。seq-utils早已被弃用,并且该函数从一开始就没有用。你可以使用Clojure的some函数,或者更好的办法是直接使用contains本身。Clojure集合实现了java.util.Collection(.contains [1 2 3] 2) => true - Rayne
8个回答

71

字面八进制

曾经我在读取一个矩阵时发现它使用前导零来保持正确的行和列。数学上这是正确的,因为前导零显然不会改变基础值。然而,尝试定义一个包含此矩阵的变量将会神秘地失败并显示以下错误信息:

java.lang.NumberFormatException: Invalid number: 08

这让我完全困惑了。原因是Clojure将带有前导零的字面整数值视为八进制,而八进制中没有数字08。
我还应该提到,Clojure通过 0x 前缀支持传统的Java十六进制值。您还可以使用“base + r + value”表示法使用2到36之间的任何基数,例如 2r101010 或 36r16 ,它们都是42进制。
尝试在匿名函数文字中返回字面量
这样做是可行的:
user> (defn foo [key val]
    {key val})
#'user/foo
user> (foo :a 1)
{:a 1}

所以我认为这也会起作用:

(#({%1 %2}) :a 1)

但它失败了:
java.lang.IllegalArgumentException: Wrong number of args passed to: PersistentArrayMap

因为#()阅读器宏会被扩展为

(fn [%1 %2] ({%1 %2}))  

使用括号包裹的字面量地图。由于它是第一个元素,因此被视为函数(实际上是字面量地图),但没有提供必需的参数(如键)。总之,匿名函数文字不会扩展到

(fn [%1 %2] {%1 %2})  ; notice the lack of parenthesis

因此,你不能将任何字面值([], :a, 4, %)作为匿名函数的主体。

评论中提供了两种解决方案。 Brian Carper 建议使用序列实现构造函数(array-map、hash-set、vector),例如:

(#(array-map %1 %2) :a 1)

Dan 演示使用 identity 函数来取消外部括号时:

(#(identity {%1 %2}) :a 1)
Brian的建议实际上引出了我下一个错误...

认为hash-maparray-map决定了不变的具体映射实现

考虑以下内容:

user> (class (hash-map))
clojure.lang.PersistentArrayMap
user> (class (hash-map :a 1))
clojure.lang.PersistentHashMap
user> (class (assoc (apply array-map (range 2000)) :a :1))
clojure.lang.PersistentHashMap

虽然通常你不需要担心Clojure map的具体实现,但你应该知道像assocconj这样增加map的函数可以接受一个PersistentArrayMap并返回一个PersistentHashMap,对于更大的maps,后者的性能更快。


使用函数作为递归点而不是loop来提供初始绑定

当我开始编程时,我写了很多像这样的函数:

; Project Euler #3
(defn p3 
  ([] (p3 775147 600851475143 3))
  ([i n times]
    (if (and (divides? i n) (fast-prime? i times)) i
      (recur (dec i) n times))))

实际上,对于这个特定的函数来说,loop更加简洁和惯用。

; Elapsed time: 387 msecs
(defn p3 [] {:post [(= % 6857)]}
  (loop [i 775147 n 600851475143 times 3]
    (if (and (divides? i n) (fast-prime? i times)) i
      (recur (dec i) n times))))

请注意,我用循环和初始绑定替换了空参数的“默认构造函数”函数体 (p3 775147 600851475143 3)。现在,recur重新绑定循环绑定变量(而不是fn参数),并跳回递归点(循环,而不是fn)。

引用“虚拟”变量

我在谈论您可能在REPL中定义的变量类型-在探索性编程期间-然后无意中在源代码中引用。一切都正常工作,直到您重新加载命名空间(可能通过关闭编辑器),然后发现整个代码中引用了大量未绑定的符号。这也经常发生在您重构时,将变量从一个命名空间移动到另一个命名空间。


列表推导视为命令式for循环

基本上,您正在创建一个基于现有列表的惰性列表,而不是简单地执行受控循环。Clojure的doseq实际上更类似于命令式foreach循环结构。

它们不同的一个例子是能够使用任意谓词过滤它们迭代的元素:

user> (for [n '(1 2 3 4) :when (even? n)] n)
(2 4)

user> (for [n '(4 3 2 1) :while (even? n)] n)
(4)

另一种不同之处在于它们可以操作无限惰性序列:
user> (take 5 (for [x (iterate inc 0) :when (> (* x x) 3)] (* 2 x)))
(4 6 8 10 12)

它们还可以处理多个绑定表达式,首先迭代最右边的表达式,然后向左工作:

user> (for [x '(1 2 3) y '(\a \b \c)] (str x y))
("1a" "1b" "1c" "2a" "2b" "2c" "3a" "3b" "3c")

同时也没有breakcontinue可以提前退出。


结构体的过度使用

我来自一种面向对象的背景,所以当我开始学习Clojure时,我的大脑仍然在思考对象。我发现自己将所有东西都建模为一个结构体,因为它的“成员”组合,无论多么松散,都让我感到舒适。实际上,结构体应该主要被视为一种优化;Clojure会共享键和一些查找信息以节省内存。您可以通过定义accessors来进一步优化它们,以加快键查找过程。

总的来说,与map相比,使用结构体并没有任何好处,除了性能,因此增加的复杂性可能不值得。


使用未经处理的BigDecimal构造函数

我需要很多BigDecimals,并且写了一些丑陋的代码,如下所示:

(let [foo (BigDecimal. "1") bar (BigDecimal. "42.42") baz (BigDecimal. "24.24")]

实际上,Clojure通过在数字后添加M来支持BigDecimal字面量:

(= (BigDecimal. "42.42") 42.42M) ; true

使用精简版可以减少很多臃肿。在评论中,twils提到您还可以使用bigdecbigint函数来更明确地表达,同时保持简洁。
使用Java包命名规则作为命名空间
这并不是一个错误,但它违反了典型Clojure项目的结构和命名惯例。我的第一个重要的Clojure项目有类似以下的命名空间声明 - 以及相应的文件夹结构:
(ns com.14clouds.myapp.repository)

这使得我的完全限定函数引用变得臃肿:

(com.14clouds.myapp.repository/load-by-name "foo")

为了让事情更加复杂,我使用了标准的Maven目录结构:

|-- src/
|   |-- main/
|   |   |-- java/
|   |   |-- clojure/
|   |   |-- resources/
|   |-- test/
...

比“标准”的Clojure结构更复杂:
|-- src/
|-- test/
|-- resources/

这是Leiningen项目和Clojure本身的默认设置。


地图使用 Java 的 equals() 而不是 Clojure 的 = 进行键匹配

最初由 IRC 上的 chouser 报告,使用 Java 的 equals() 导致一些令人费解的结果:

user> (= (int 1) (long 1))
true
user> ({(int 1) :found} (int 1) :not-found)
:found
user> ({(int 1) :found} (long 1) :not-found)
:not-found

由于默认情况下,IntegerLong实例的1打印相同,因此很难检测为什么您的映射没有返回任何值。特别是当您通过一个函数传递键时,该函数可能会返回long,而您并不知道。

应注意,使用Java的equals()而不是Clojure的=对于映射符合java.util.Map接口至关重要。


我正在使用Stuart Halloway的Programming Clojure,Luke VanderHart的Practical Clojure以及无数Clojure黑客在IRC和邮件列表上的帮助来帮助我解决问题。


1
所有的读取宏都有一个普通函数版本。你可以这样做 (# (hash-set %1 %2) :a 1) 或者在这种情况下使用 (hash-set :a 1) - Brian Carper
2
您还可以使用identity函数“删除”额外的括号:(#(identity {%1 %2}) :a 1) - user21037
1
你也可以使用 do(#(do {%1 %2}) :a 1) - Michał Marczyk
@Michał - 我不太喜欢这个解决方案,因为 "do" 意味着正在发生副作用,但实际上在这里并非如此。 - Robert Campbell
你也可以使用:(#(或 {%1 %2}) :a 1) - mikera
显示剩余3条评论

42

忘记强制评估惰性序列

除非明确要求,否则惰性序列不会被评估。你可能期望它会输出一些东西,但实际上并不会。

user=> (defn foo [] (map println [:foo :bar]) nil)
#'user/foo
user=> (foo)
nil

map 没有被评估,而是被悄悄抛弃了,因为它是惰性的。你需要使用 doseqdorundoall 等中的一个来强制对惰性序列进行求值以产生副作用。

user=> (defn foo [] (doseq [x [:foo :bar]] (println x)) nil)
#'user/foo
user=> (foo)
:foo
:bar
nil
user=> (defn foo [] (dorun (map println [:foo :bar])) nil)
#'user/foo
user=> (foo)
:foo
:bar
nil

在REPL中使用裸的map可能看起来可以工作,但这只是因为REPL强制对惰性序列进行求值。这可能会使错误更难以注意到,因为你的代码在REPL中可以工作,但在源文件或函数内部无法工作。

user=> (map println [:foo :bar])
(:foo
:bar
nil nil)

1
+1. 这个问题困扰了我一段时间,但它的影响更加隐蔽:我在 (binding ...) 中评估 (map ...),并且想知道为什么新的绑定值没有应用。 - Alex B

20

我是Clojure的新手。更高级的用户可能会有更有趣的问题。

尝试打印无限懒惰序列。

我知道我正在处理懒惰序列,但出于调试目的,我插入了一些print/prn/pr调用,暂时忘记了我正在打印什么。有趣的是,为什么我的电脑一直卡住?

尝试命令式地编写Clojure代码。

有一些诱惑去创建大量的refatom并编写不断更改它们状态的代码。虽然可以这样做,但这并不是一个好选择。它也可能性能较差,并且很少受益于多核。

尝试100%函数式编程Clojure。

另一方面:某些算法确实需要一些可变状态。不惜一切代价避免使用可变状态可能导致缓慢或笨拙的算法。需要判断和一定经验才能做出决策。

在Java中做太多事情。

因为连接到Java非常容易,所以有时很诱人使用Clojure作为Java的脚本语言包装器。当使用Java库功能时,您肯定需要这样做,但是在Java中维护数据结构或使用Java数据类型(例如集合)没有多少意义,因为Clojure有很好的等效项。


13

循环过程中要注意内存。
如果在循环处理一个可能非常大或无限的惰性序列时同时保留对第一个元素的引用,那么你有可能会耗尽内存。

忘记了没有尾递归优化。
普通的尾调用会消耗栈空间,如果不小心的话就会溢出。Clojure 中有 'recur'trampoline 来处理许多其他语言中使用优化尾调用的情况,但这些技术必须有意地应用。

不完全的惰性序列。
你可以用 'lazy-seq'lazy-cons (或在更高级别的惰性API之上构建)来创建惰性序列,但如果将其包装在 'vec 中或通过某些其他实现序列的函数,则它将不再是惰性序列。这可能会导致堆栈和堆都被耗尽。

将可变的东西放在 refs 中。
从技术上讲你可以这样做,但STM只控制在 ref 本身中的对象引用,而不是所引用的对象及其字段(除非它们是不可变的并且指向其他 refs)。因此,在可能的情况下,最好只在 refs 中使用不可变对象。原子(atoms)也是同样的情况。


4
即将到来的开发分支通过在函数内部的对象成为本地不可达时擦除对这些对象的引用,大大减少了第一项。 - Arthur Ulfeldt

13

已经提到许多事情,我只想再加一点。

Clojure的 if 在处理Java Boolean对象时始终视为true,即使它的值为false。因此,如果您有一个返回Java Boolean值的Java函数,请确保不要直接检查它 (if java-bool "是" "否") 而应该这样写 (if (boolean java-bool) "是" "否")

我曾在Clojure.contrib.sql库中遇到过这个问题,该库将数据库布尔字段作为Java Boolean对象返回。


8
请注意,(if java.lang.Boolean/FALSE (println "foo")) 不会打印出 "foo"。然而,(if (java.lang.Boolean. "false") (println "foo")) 会打印出 "foo",而 (if (boolean (java.lang.Boolean "false")) (println "foo")) 则不会... 确实令人困惑! - Michał Marczyk
在Clojure 1.4.0中似乎如预期般工作:(assert (= :false (if Boolean/FALSE :true :false))) - Jakub Holý
最近我也被这个问题困扰,当我执行(filter :mykey coll)时,其中:mykey的值是布尔值——在Clojure创建的集合中按预期工作,但在反序列化的集合中不起作用,当使用默认的Java序列化进行序列化时会出现此问题。因为这些布尔值被反序列化为new Boolean(),遗憾的是(new Boolean(true) != java.lang.Boolean/TRUE)。 - Hendekagon
1
只需记住Clojure中布尔值的基本规则 - nilfalse为假,而其他所有内容都为真。Java中的Boolean既不是nil也不是false(因为它是一个对象),因此行为是一致的。 - erikprice

9

使用loop ... recur处理序列,而不是使用map。

(defn work [data]
    (do-stuff (first data))
    (recur (rest data)))

对比。

(map do-stuff data)

最新分支的map函数使用了分块序列和许多其他优化。此外,由于这个函数经常运行,Hotspot JIT通常已经优化并准备好运行,无需任何“热身时间”。


1
这两个版本实际上并不相等。你的work函数等同于(doseq [item data] (do-stuff item))。(除了work中的循环永远不会结束这一事实之外。) - kotarak
是的,第一个会强制执行参数的惰性计算。生成的序列将具有相同的值,但它不再是一个惰性序列。 - Arthur Ulfeldt
+1!我写了许多小的递归函数,只发现另一天这些都可以通过使用map和/或reduce进行概括。 - mike3996

5

不同的集合类型在某些操作上有不同的行为:

user=> (conj '(1 2 3) 4)    
(4 1 2 3)                 ;; new element at the front
user=> (conj [1 2 3] 4) 
[1 2 3 4]                 ;; new element at the back

user=> (into '(3 4) (list 5 6 7))
(7 6 5 3 4)
user=> (into [3 4] (list 5 6 7)) 
[3 4 5 6 7]

处理字符串可能会让人感到困惑(我仍然不太理解)。具体而言,字符串并不等同于字符序列,尽管序列函数可以对它们起作用:

user=> (filter #(> (int %) 96) "abcdABCDefghEFGH")
(\a \b \c \d \e \f \g \h)

要取回一个字符串,你需要执行以下操作:
user=> (apply str (filter #(> (int %) 96) "abcdABCDefghEFGH"))
"abcdefgh"

3

太多的括号,特别是在void java方法调用内部,导致NPE:

public void foo() {}

((.foo))

由于内括号的值为nil,导致外部括号出现NPE错误。

public int bar() { return 5; }

((.bar)) 

这会导致更容易调试:

java.lang.Integer cannot be cast to clojure.lang.IFn
  [Thrown class java.lang.ClassCastException]

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接