第 2 部分 · 基础:用 Racket 写点什么
Racket 的数据——原子与复合
Racket 的数据看似五花八门,其实只分两半:原子(数、字符串、布尔、符号、字符)不可再拆,复合(pair 与 list)把原子粘起来。可它们为什么都能塞进同一套括号?
你敲过 (+ 1 2),看到 3。那个 1 和 2,是 Racket 数据里最常见的一种——数。但括号里能放的,远不止数。
上一篇 讲了括号怎么组织结构,这篇换个角度,看括号里到底装了什么。Racket 的数据种类不少,但抓牢一条主线就不乱。
数据要么是原子,要么是原子粘起来的复合物。 先把主线立起来,再往里填具体类型。
先分两类:原子与复合
第一类是原子(atom)。一个数、一段字符串、一个布尔值、一个符号、一个字符——它们各自独立,没法再用 car 拆成更小的 Racket 数据。42 就是 42,拆不碎。
第二类是复合(compound)。把两个东西粘成一个 pair,再把一串 pair 拉成链得到 list。复合数据能拆:car 取左、cdr 取右,一路拆到原子为止。
Racket 给每种数据配了一个问号结尾的谓词(predicate),专门回答“是不是这种数据”。常用的有 number?、string?、boolean?、symbol?、char?、pair?、list?。在交互区敲一个谓词,它返回 #t(是)或 #f(不是)。下面逐个用。
数:1/2 就是 1/2,不是 0.5
Racket 的数分两路:精确(exact)和不精确(inexact)。整数、有理数、复数都可以是精确的;浮点数是不精确的。
42 ; 精确整数
1/2 ; 精确有理数,二分之一
3.14 ; 不精确浮点数
3+4i ; 复数,实部 3 虚部 4
精确数算出来的还是精确数,分数不会悄悄变小数。 这一条最反直觉——除不尽也不丢精度:
(/ 1 2)
;; 1/2 精确相除,结果保留分数
(+ 1/2 1/2)
;; 1 两个分数相加,得到精确整数
(* 1/3 1/3)
;; 1/9 分数相乘,仍是精确分数
整数也没有上限。C 语言里的 int 撑破几十亿就溢出回绕,Racket 的整数是任意精度(bignum),多大都不溢出:
(expt 2 100)
;; 1267650600228229401496703205376 2 的 100 次方,精确、不溢出
可一旦式子里混进浮点,不精确就像墨水滴进清水,整个结果都跟着不精确:
(+ 1 0.5)
;; 1.5 混入 0.5,结果变不精确
(= 1/10 0.1)
;; #f 0.1 在二进制里没法精确表示,不等于 1/10
辨别一个值是不是数,用 number?;想看清是整数还是有理数,用 integer?、rational?、real?、complex?。它们逐层嵌套——所有数都是复数,实数都是有理数:
(number? 42)
;; #t
(number? "42")
;; #f 字符串不是数
(integer? 1/2)
;; #f 1/2 不是整数
(rational? 1/2)
;; #t
(complex? 1/2)
;; #t 有理数也是复数(虚部为零)
字符串:双引号包起来的一段文本
字符串用双引号包起来,里面是一串字符:
(string-append "hello" " " "world")
;; "hello world" 拼接
(string-length "hello")
;; 5 长度
字符串和数是两个世界,string? 只认字符串:
(string? "hello")
;; #t
(string? 42)
;; #f
布尔:只有两个值,但“假”只有一个
布尔只有 #t(真)和 #f(假)两个值,boolean? 认它们:
(boolean? #t)
;; #t
(boolean? #f)
;; #t
(< 3 5)
;; #t 比较的结果就是布尔值
这里藏着一个 Lisp 的老传统,从 Python 或 C 过来的人特别容易踩坑。
在判断条件里,只有 #f 算假,其他所有值——哪怕 0、空串、空列表——全算真。
(if #f 'yes 'no)
;; 'no 只有 #f 走 else
(if 0 'yes 'no)
;; 'yes 0 是真
(if "" 'yes 'no)
;; 'yes 空串也是真
(if '() 'yes 'no)
;; 'yes 空列表也是真
所以别指望用 if 直接判断列表空不空——空列表也是真。判断空列表,得老老实实用 null? 或 empty?。
符号、字符、字符串:长得像的三兄弟
Racket 里有三种东西都对应字母 a,却是完全不同的类型,新手最容易混。摆一起看:
'a ; 符号(symbol),一个名字
#\a ; 字符(character),单个字母
"a" ; 字符串(string),一串字符
符号更像“标签”或“名字”,用单引号开头:'foo、'red、'north。它和字符串的分工是——字符串是给人读的文本,能算长度、能拼接;符号是程序内部用的身份标识。同一个名字在全局只有一个符号,比较起来比字符串还快:
(symbol? 'a)
;; #t
(symbol->string 'foo)
;; "foo" 符号能转成字符串
(eq? 'foo 'foo)
;; #t 同名就是同一个符号(interned)
字符是单个 Unicode 字符,写法是 #\ 加字符本身,如 #\a、#\A(大写 A,和 #\a 是两个不同字符)。空格、换行这类不可见字符有专门写法,如 #\space、#\newline。
(char? #\a)
;; #t
(char->integer #\A)
;; 65 字符对应的 Unicode 码点
三者的谓词互不通用。把字面量、类型、谓词对齐,记住就不混:
| 字面量 | 'a | #\a | "a" |
|---|---|---|---|
| 类型 | 符号 | 字符 | 字符串 |
| 谓词 | symbol? | char? | string? |
pair:两个东西粘成一个
从这一节起进入复合数据。最基础的复合是 pair(也叫 cons 单元):把两个值粘成一个整体,用 cons 造,用 car 取左、cdr 取右:
(cons 1 2)
;; '(1 . 2) 一个 pair,左 1 右 2
(car '(1 . 2))
;; 1 取左
(cdr '(1 . 2))
;; 2 取右
(pair? '(1 . 2))
;; #t
打印结果里那个点 .,是 pair 的标志,表示左右是两个独立的东西。pair? 判断一个值是不是 pair,空列表不算:
(pair? '())
;; #f 空列表不是 pair
list:pair 拉起来的链
pair 只装两个东西。但只要让每个 pair 的右边再接一个 pair,最后接上空列表 '(),就得到一条链——这就是 list。
(cons 1 (cons 2 (cons 3 '())))
;; '(1 2 3) 一串 pair 链
(list 1 2 3)
;; '(1 2 3) 等价的省事写法
list 不是另一种全新数据,它就是一串 pair,尾巴收在空列表上。 正因为形状固定地“右链到底”,打印时不加点,直接列出来。
car 取第一个元素,cdr 取剩下的列表:
(car '(1 2 3))
;; 1
(cdr '(1 2 3))
;; '(2 3)
list? 只认尾巴收在 '() 上的真列表。前面那个 (1 . 2) 虽然是 pair,尾巴没收住,所以不算列表:
(list? '(1 2 3))
;; #t
(list? (cons 1 2))
;; #f 尾巴是 2 不是 '(),叫非真列表
都是 S-表达式
把所有零碎收拢回一句话:Racket 里的数据,不管原子还是复合,都是 S-表达式(S-expression)。
数 42、字符串 "hi"、符号 'foo,是 S-表达式最小的节点;pair 和 list 把这些节点粘起来,组成更大的 S-表达式。你在交互区敲的 (+ 1 2) 之所以能既是代码、又能当数据看,正因为它是符号 + 和数 1、2 组成的一个 list——一个 S-表达式。
上一篇讲了括号这个容器的形状,这篇填上了容器里装的东西。
记住原子与复合这条主线,你就拿到了 Racket 数据的地图。 剩下那些没讲的——向量、哈希表、结构体——都是在这张地图上加新零件。
数、串、布尔、符号、字符是原子;pair 和 list 是原子粘起来的复合物;它们全是 S-表达式。下一篇,看 Racket 怎么把这些数据喂给函数。