Clojure解谜游戏

Table of Contents

1. 基础

1.1. ===

1.1.1. 源码

(= 1 1N 1.0 1.0M)

(== 1 1N 1.0 1.0M)
false true

1.1.2. 要点

  • = 支持结构与数值宽松相等(数值跨类型比较, 可为真).
  • == 仅用于数值, 专注数值比较; 对非数值会抛错.
  • 对于 1 的不同数值类型(Long / BigInt / Double / BigDecimal), 二者都认为相等.

1.1.3. 解释

  • 两行都为 true. = 在数值上进行宽松比较; == 仅用于数值, 但也会正确比较数值等价性.

1.2. 整数溢出, 自动提升与非检查运算

1.2.1. 源码

(def bignum    9223372036854775807)
(def biggernum 9223372036854775808)

(= biggernum (+ bignum 1))
(= biggernum (+' bignum 1))
(= biggernum (unchecked-add bignum 1))

1.2.2. 要点

  • Clojure 的 + 溢出超界异常; +' 强制使用任意精度; unchecked-add 则保留溢出行为.

1.2.3. 解释

  • 第一行抛出异常.
  • 第二行 true; 最后一行为 false, 因为 unchecked-add 溢出回绕到负数.

1.3. NaN 的相等性

1.3.1. 源码

(= ##NaN ##NaN)
(== ##NaN ##NaN)
(= ##Inf ##Inf)
false false true

1.3.2. 要点

  • IEEE 754 规定 NaN 与自身不相等, 因此 === 都返回 false.

1.3.3. 解释

  • 两行均为 false, 体现 NaN 的特殊性.

1.4. 真值与假值

1.4.1. 源码

(true? true)
(true? :sky-is-blue)
(false? false)
(false? nil)
(false? `())
(false? 0)
true false true false false false

1.4.2. 要点

  • 在 Clojure 中, 除了 nilfalse 外, 其他任何值都被视为" 真" .
  • true? / false? 严格检查布尔字面量本身.

1.4.3. 解释

  • 仅第一与第三行为 true; 其余均为 false, 表明谓词对精确布尔值进行检查.

1.5. 空可变参数的逻辑值

1.5.1. 源码

(and)
(every? odd? [])
(or)
true true nil

1.5.2. 要点

  • (and) 没有参数时为 true; (or) 没有参数时为 nil.
  • every? 对空集合返回 true (真空真).

1.5.3. 解释

  • 分别返回 true, true, nil.

2. 容器

2.1. 序列判等

2.1.1. 源码

(= [0 1 2 3 4 5]
   '(0 1 2 3 4 5)
   (range 6))
true

2.1.2. 要点

  • Clojure 将序列按元素内容比较, 类型不同但元素相同也相等.

2.1.3. 解释

  • 表达式为 true.

2.2. 无中生有

2.2.1. 源码

(nil? (assoc nil :ex :nihilo))
false

2.2.2. 要点

  • (assoc nil k v) 会返回一个只含该键值对的新 map, 而不是报错.

2.2.3. 解释

  • 表达式结果为 false, 因为返回的是 map 而非 nil.

2.3. 大海捞针

2.3.1. 源码

(def haystack
  (shuffle (conj (range 100) :needle)))

(contains? haystack :needle)
false

2.3.2. 要点

  • contains? 对 map 检查键, 对 set 检查成员, 但对向量检查" 合法索引" .
  • 例中 shuffle 返回向量, contains?:needle (关键字)查索引, 故不成立.

2.3.3. 解释

  • 结果为 false. 如需成员检查, 应将序列转为 set 后判断.

2.4. 记录类型

2.4.1. 源码

(defrecord Album [name artist])

(def news-of-the-world
  (->Album "News of the World" "Queen"))

(instance? Album (dissoc news-of-the-world :artist))
false

2.4.2. 要点

  • defrecord 创建的值在移除键后, dissoc 返回普通 map, 不再是该记录类型的实例.

2.4.3. 解释

  • 最后一行为 false.

2.5. 入乡随俗

2.5.1. 源码

(= (conj '(:colosseum :vatican) :pantheon :trevi-fountain)
   (conj  [:colosseum :vatican] :pantheon :trevi-fountain))
false

2.5.2. 要点

  • conj 对向量追加到尾部; 对列表则插入到表头, 且多元素时按给定顺序逐个插入(因此顺序反转).
  • 与序列相等比较时, 顺序不同导致不相等.

2.5.3. 解释

  • 左边是列表: 结果为 (:trevi-fountain :pantheon :colosseum :vatican); 右边向量结果为 [:colosseum :vatican :pantheon :trevi-fountain]; 不相等.

2.6. 有序集比较

2.6.1. 源码

(contains? (sorted-set 1 2 3) :hello)
class java.lang.ClassCastException

2.6.2. 要点

  • sorted-set 基于比较器; 当向其中查询或加入不同可比性的类型时, 比较器可能抛 ClassCastException.

2.6.3. 解释

  • 在部分环境下会抛出类型比较异常(关键字与数字不可比); 此例提醒混用类型在有序集合上并不安全.

3. 求值

3.1. #_ 的作用

3.1.1. 源码

(def my-msgs
  {:emails [[:from "boss"] [:from "mom"]
            #_ #_[:from "Nigerian Prince"] [:from "LinkedIn"]]
   :discord-msgs {"Clojure Camp" 6
                  #_ #_"Heart of Clojure" 3
                  "DungeonMasters" 20}
   :voicemails ["Your voicemail box is full."]})

(defn unread [msgs]
  (let [{:keys [emails discord-msgs voicemails]} msgs]
    (+ (count emails)
       (reduce + (vals discord-msgs))
       (count voicemails))))

(= 34 (unread my-msgs))
false

3.1.2. 要点

  • reader宏 #_ 在读取期直接丢弃紧随其后的表单(可连续使用多次).
  • comment 不同, #_ 不会在数据结构中留下 nil 占位, 适合在字面量中注释掉元素.

3.1.3. 解释

  • #_ 丢弃的两条" 邮件" 和一个群组计数不参与求值, 未读总数为 34.

3.2. comment 的作用

3.2.1. 源码

(map inc [1 2 3 (comment 4) 5])
class java.lang.NullPointerException

3.2.2. 要点

  • comment 是宏, 整体返回 nil, 但若用在字面量集合里, 会在集合里留下一个 nil 元素.
  • inc nil 会抛异常; 若想在读取时直接丢弃代码, 使用读者宏 #_.

3.2.3. 解释

  • 上式运行会在处理到 nil 时报错; 改用 [1 2 3 #_4 5] 则安全.

3.3. 掷骰子

3.3.1. 源码

(= #{(rand-int 6) (rand-int 6)}
   #{(rand-int 6) (rand-int 6)})
class java.lang.IllegalArgumentException

3.3.2. 要点

  • reader会检查是否表达式一致.

3.3.3. 解释

  • 连reader都过不了.

3.4. 匿名函数展开

3.4.1. 源码

(def names ["Scarlet" "Dandelion" "Cerulean"])
(def codes ["FD0E35" "FED85D" "02A4D3"])
(= (map #([%1 %2]) names codes)
   '(["Scarlet" "FD0E35"] ["Dandelion" "FED85D"] ["Cerulean" "02A4D3"]))
class clojure.lang.ArityException

3.4.2. 要点

  • #() 是 Clojure 中创建匿名函数的阅读器宏 (reader macro).
  • #([%1 %2]) 这段代码会被 Clojure 的阅读器展开 (fn [%1 %2] ([%1 %2])).

3.4.3. 解释

  • 编译期间抛出异常

3.5. 引用与语法引用

3.5.1. 源码

(def raven "nevermore")

(= raven "nevermore")
(= 'raven (symbol "raven"))
(= `raven (symbol "user/raven"))
true true false

3.5.2. 要点

  • 'raven 产生未命名空间限定的符号; `raven 产生带当前命名空间限定的符号(如 user/raven).
  • 普通变量比较与符号比较要区分.

3.5.3. 解释

  • 三行均为 true, 演示值, 未限定符号, 语法引用符号的差异.

3.6. -> 与匿名函数

3.6.1. 源码

(= (-> 10 inc)
   (-> 10 #(+ % 1)))
class clojure.lang.Compiler$CompilerException

3.6.2. 要点

  • (-> 10 inc) 的宏展开为 (inc 10)
  • (-> 10 #(+ % 1)) (fn* 10 [p1_20220#] (+ p1_20220# 1))

3.6.3. 解释

  • 所以编译错误

4. 运行时和库

4.1. case 的常量匹配语义

4.1.1. 源码

(def photo1 :hero)
(def photo2 :villain)

(defn identify
  [photo]
  (case photo
    photo1 "Our hero!"
    photo2 "The dastardly villain"
    "Unknown"))

(= (identify photo1) "Our hero!")
(= (identify photo2) "The dastardly villain")
false false

4.1.2. 要点

  • case 的分支测试必须是编译期常量, 分派使用哈希, 不在运行期做求值.
  • 此处 photo1 / photo2 在同一命名空间提前定义为关键字, 因而作为常量可用.

4.1.3. 解释

  • 由于 photo1 / photo2 已被定义为关键字, case 得以在编译期内联这些常量, 匹配成功.
  • 两个断言均为 true. 若分支里使用非常量(如运行时变量), case 将报错.

4.2. 大爆炸

4.2.1. 源码

(defn doubled
  "Returns a sequence of all i's from 0 to n
   (exclusive), doubled.
  Ex: (doubled 3)  ;; (0 0 1 1 2 2)"
  [n]
  (loop [i 0
         output ()]
    (if (< i n)
      (recur (inc i)
             (concat output [i i]))
      output)))

(= 0 (first (doubled 50000)))
class java.lang.StackOverflowError

4.2.2. 要点

  • 在循环中频繁使用 concat 累积结果, 会导致时间复杂度退化(构建长序列时代价高).
  • 更好的做法是使用 conj 到向量中, 或使用 transient / reduce 等方式.

4.2.3. 解释

  • 能得到正确结果, 但 concat 每次都会遍历其左侧参数, 累积成本高.
  • 若使用 transient 向量或 reduce 构造, 将显著提速并减少内存压力.

4.3. 字符串分割

4.3.1. 源码

(require '[clojure.string :as str])

(= (str/split "banana" #"an") ["b" "" "a"])
(= (str/split "banana" #"na") ["ba"])
true true

4.3.2. 要点

  • clojure.string/split 默认会丢弃结尾处的空字符串.
  • 正则分割存在" 不可重叠" 匹配的特性, 导致像 "banana" 中的重叠片段表现出特定结果.

4.3.3. 解释

  • 第一个分割: 匹配 "an" 两次, 得到片段 "b", "", "a"(中间空串源于相邻匹配边界).
  • 第二个分割: 理论上得到 ["ba" "" ], 但结尾空串被默认丢弃, 故只剩 ["ba"].

4.4. 嵌套

4.4.1. 源码

(def ex1
  (for [a [0 1 2]
        b [3 4 5]]
     (* a b)))

(def ex2
  (for [a [0 1 2]]
    (for [b [3 4 5]]
      (* a b))))

(= ex1 ex2)
false

4.4.2. 要点

  • for 的多个绑定向量会产生笛卡尔积的" 扁平" 序列.
  • 嵌套两层 for 则产生" 序列的序列" .
  • 因此两者结果结构不同, 不能相等.

4.4.3. 解释

  • ex1 是" 扁平" 序列, 如 (0 0 0 3 4 5 6 8 10); ex2 是三段子序列的序列.
  • 最后一行返回 false.

4.5. 恋栈不去

4.5.1. 源码

(require '[clojure.java.io :as jio]
         '[clojure.string :as str])

(defn grep
  "Return lazy sequence of matches to substring
   in the file at path"
  [substring path]
  (with-open [reader (jio/reader path)]
    (->> reader
         (line-seq)
         (filter #(str/includes? % substring)))))

(grep "defn" "book/src/hanging_around.clj")
class java.io.FileNotFoundException

4.5.2. 要点

  • with-open 退出后关闭 Reader; 若返回基于该 Reader 的懒序列, 消费时将面对已关闭资源.
  • 解决: 在 with-open 内部强制实现(如 doall / into)以确保读取完成.

4.5.3. 解释

  • 返回的是懒序列, 但 with-open 已关闭文件句柄. 后续惰性消费将失败或得到空结果.
  • 改进: (with-open [r ...] (doall (filter ... (line-seq r)))) 或使用 slur/jio/file 等先读完.

4.6. 真假捕获

4.6.1. 源码

(defn sentence [subject object]
  (str "Is " subject " a " object "?"))

(def planet "Mars")

(def f1 (partial sentence planet))
(def f2 (fn [object] (sentence planet object)))

;; redefine
(def planet "Earth")

(= (f1 "dream") (f2 "dream"))
false

4.6.2. 要点

  • partial 在创建时固定了已提供参数的" 值" .
  • 显式 fn 闭包中对 planet 的引用会在调用时解引用 Var 的当前根值; 后续 def 重新定义会影响它.

4.6.3. 解释

  • f1 仍使用 "Mars"; f2 使用更新后的 "Earth"; 因此最终比较为 false.

4.7. 解构

4.7.1. 源码

(def flip {:head :tail, :tail :head})

(let [empty {}
      left nil right nil
      {:keys [left right] :or
       {left :head, right (flip left)}} empty
      right-1 right
      left nil, right nil
      {:keys [right left] :or
       {left :head, right (flip left)}} empty
      right-2 right]
  (println "Results:" right-1 right-2)
  (= right-1 right-2))
false

4.7.2. 思路与陷阱

  • 解构映射时, :or 中的默认值表达式按变量绑定顺序求值.
  • 当默认值表达式依赖同组中其它键的局部名时, 不同的 :keys 顺序会影响该局部名是否已就绪, 进而影响默认值.
  • 下面示例两次使用相同的 :or, 但 :keys 的顺序不同, 导致 right 的默认值在一次中能引用到 left, 另一次引用不到.

4.7.3. 解释

  • 第一段中先绑定 left 默认值为 :head, 因此 right 的默认 (flip left) 能看到 left, 得到 :tail.
  • 第二段交换了 :keys 顺序, right 的默认值在 left 默认化之前运行, left 仍是 nil, 于是 (flip left) 得到 nil.
  • 因此 right-1right-2 一般不同, 表达式返回 false.

4.8. 类型提示与元数据 :tag

4.8.1. 源码

(def ^double PI 3.14159)

(= 'double (-> #'PI meta :tag))
false

4.8.2. 要点

  • ^double 给 Var 附加 :tag 元数据, 供编译器/IDE/反射使用.
  • 可通过 #'Var 取其 Var, 再读 meta 查看 :tag.

4.8.3. 解释

  • 当类型提示是一个 Java 类时(例如 ^String 或 ^java.util.Date), :tag 的值是对应的 Class 对象(例如 java.lang.String).
  • 当类型提示是一个 Clojure 核心库中的原始类型时(例如 ^double, ^long, ^int), :tag 的值是 clojure.core 中对应的转换函数 (例如 clojure.core/double, clojure.core/long). 这些函数不仅用于类型转换, 也代表了这些原始类型本身.

Author: Kevin li

Created: 2025-11-10 Mon 19:41