The Racket 编程语言 之所以有趣,并非因为它的 syntax。What 使其有趣的是 that 它被设计用来 for 构建语言,而不仅仅是编写程序。

This 文章跳过了 “hello world” 和像猜数字这样的玩具练习(exercises),直接切入 the 使 Racket 与众不同的理念,再加上一些有用的系统脚本编写。

本文范围: This 不是参考手册。它涵盖了一套精选的核心概念,例如 expressions,一等函数,map/filter/fold,模式匹配(pattern matching),宏,契约,以及一些 Linux 脚本编写(Linux scripting)。如需更多细节,请阅读 the Racket 指南 和 the Racket 参考手册。

关于 require 的说明: 下面的一个示例展示了 (require racket/contract)。如果你的文件以 #lang racket(而非较小的 #lang racket/base)开头,则它已被包含,显式的 require 是多余的。不过很多人为了明确文件依赖关系,还是会保留它。

设置 Racket 编程环境

你不需要任何花哨的东西,只需要 Racket 本身。

1. 安装 Racket 语言

从 download.racket-lang.org 下载适用于你平台的安装程序(当前稳定版是 v9.2)。在 Linux 上,你也可以通过包管理器安装,不过官方安装程序通常能让你获得更新的版本:

我们发布了一篇专门的指南,介绍了在 Linux 中安装 Racket 的不同方法。请查看以下链接:

安装完成后,验证 Racket 版本:

racket --version

2. 运行下面代码示例的两种方式

方式 A:作为脚本文件(本文大多数示例采用此方式):

将代码片段保存到一个文件中,文件以示例中显示的 #lang racket 行开头(有些片段是片段,需要粘贴到现有文件中,如果缺少 #lang racket,请自行将其添加为第一行):

#lang racket
(displayln (+ 2 3))

然后在终端中运行它:

racket my-file.rkt

方式 B:在 REPL 中(尝试单个表达式最快的方式):

racket

这会进入一个交互式提示符(>),你可以在其中粘贴本文中的任何单个表达式,并立即看到它的 result:

> (map add1 '(1 2 3))
'(2 3 4)

输入 (exit) 或按 Ctrl+D 退出 REPL。

3. 可选:DrRacket(捆绑的 IDE)

安装程序还包含了 DrRacket,一个带有内置“运行”按钮和代码下方交互面板的图形化编辑器。在处理本文示例(尤其是多行示例)时,这通常是最简单的实验方式。只需打开它,粘贴一个以 #lang racket 开头的代码片段,然后点击“运行”。

让我们 begin。

1. 代码即数据

与 C 或 Python 不同,Racket 源代码本身就是一个数据结构,具体来说,是嵌套的列表。例如,(+ 2 3)

(+ 2 3)

实际上就是一个列表,其第一个元素是符号 +(它指向一个函数),其余的是参数:+、2、3。

因为代码就是数据,你可以通过编程方式构建它并 eval 它。注意,这个示例需要在 REPL 中运行,而不是作为脚本文件。eval 使用当前命名空间,而使用 #lang racket 运行的脚本从一个空命名空间开始,所以这里会引发一个未绑定标识符的 error,而不是返回 30。

(define expr '(+ 10 20))
(eval expr)

示例输出:

30

如果你更愿意将其作为脚本运行,而不是在 REPL 中,请为 eval 提供一个显式的命名空间:

#lang racket
(define expr '(+ 10 20))
(eval expr (make-base-namespace))

将上述代码复制到一个文件中,例如 my-file.rkt,然后使用命令运行它:

racket my-file.rkt

这将打印 30。

这个单一的理念,即代码即数据,是宏得以实现的基础。

你也可以从 DrRacket GUI 运行代码。

2. 一切都是表达式

与 C、C++ 或 Python 不同,几乎每个 Racket 形式都返回一个值。即使对于 if 也是如此。

# Python
if x > 10:
y = 5
else:
y = 20

变成

(define y (if (> x 10) 5 20))

同样地,begin 按顺序运行一系列表达式,并返回最后一个表达式的值。

(define result
(begin
(displayln "Calculating...")
(+ 4 7)))

示例输出:

Calculating...

现在 result 是 11。因为每个表达式都返回一个值,你可以直接组合表达式,而无需为了在某个步骤中保存一个值而创建额外的变量。

3. 函数是一等值

函数可以像其他任何值一样被存储、传递和返回。

(define (square x) (* x x))
(define f square)
(f 8)
64

匿名函数的工作方式相同:

(map (lambda (x) (* x x)) '(1 2 3 4 5))

示例输出:

'(1 4 9 16 25)

你甚至可以将函数放入列表中:

(list add1 sub1 sqrt)
'(#<procedure:add1> #<procedure:sub1> #<procedure:sqrt>)

注意: 每个过程显示的确切文本可能因你的 Racket 版本而异,但它总会显示该列表包含的是函数,而不是数字或文本。

4. 高阶编程:map、filter、fold

与其编写显式的 loop,不如从对集合进行转换的角度来思考。这三个操作可以解决大量问题。

Map - 转换每个元素:

(map string-length '("Linux" "Kernel" "Racket"))

示例输出:

'(5 6 6)

Filter - 保留匹配谓词的元素:

(filter even? '(1 2 3 4 5 6 7 8))

示例输出:

'(2 4 6 8)

Fold - 将元素合并为单个 result:

(foldl + 0 '(1 2 3 4 5))    ; sum      -> 15
(foldl * 1 '(1 2 3 4))      ; product  -> 24
(foldl max 0 '(2 8 5 12 4)) ; maximum  -> 12

5. 递归与尾调用

Racket 确实有 for 和 do 这样的 loop 形式,但许多惯用的 Racket 程序使用 递归 而不是 loop,尤其是在处理列表时。

(define (factorial n)
(if (zero? n)
1
(* n (factorial (sub1 n)))))
(factorial 5)

示例输出:

120

Racket 还保证了正确的尾调用。这意味着,当一个函数在最后一步调用自身时,Racket 会将该调用转换为一个 loop,而不是在调用栈上添加新的一层。因此,下面的函数可以安全地处理数百万个元素而不会耗尽内存。

(define (sum lst)
(define (loop lst acc)
(if (empty? lst)
acc
(loop (rest lst) (+ acc (first lst)))))
(loop lst 0))

6. 命名 Let(读起来像循环的递归)

let loop let 你编写递归迭代,而无需一个单独的顶层函数:

(let loop ([i 0])
(when (< i 5)
(displayln i)
(loop (add1 i))))

示例输出(每次 displayln 调用打印一行):

0
1
2
3
4

看起来像一个 loop,但本质上它是递归。因为对 loop 的调用处于尾位置,Racket 会自动将其转换为一个真正的 loop。

7. 模式匹配

模式匹配是 Racket 最好的特性之一。它来自 racket/match,当你使用 #lang racket 时,它已经被包含在内,所以你不需要添加额外的 require 行。

简单的解构:

(match '(1 2 3)
[(list a b c) (+ a b c)])

示例输出:

6

嵌套结构:

(match '((1 2) (3 4))
[(list (list a b) (list c d)) (+ a b c d)])

示例输出:

10

匹配“命令”形状。这对于解析配置文件或命令行参数非常有用:

(match '(cp file1 file2)
[(list 'cp src dst) (printf "Copy ~a -> ~a\n" src dst)]
[_ "Unknown"])

示例输出:

Copy file1 -> file2

8. 多返回值

Racket 原生支持一个函数返回多个值,而无需将它们打包成元组或列表。

(define (divide x y)
(values (quotient x y) (remainder x y)))
(let-values ([(q r) (divide 20 3)])
(displayln q)
(displayln r))

示例输出:

6
2

9. 列表推导式

Python 的列表推导式看起来像 [x*x for x in nums],其中 nums 是某个数字列表。Racket 的等价物是 for/list:

(for/list ([x '(1 2 3 4)])
(* x x))

示例输出:

'(1 4 9 16)

你可以使用 #:when 添加一个 filter 子句:

(for/list ([i (in-range 20)] #:when (even? i))
(* i i))

示例输出:

'(0 4 16 36 64 100 144 196 256 324)

10. 哈希表

(define h (hash "cpu" 16 "ram" 32))
(hash-ref h "cpu")

示例输出:

16

重要提示:(hash ...) 创建一个不可变的哈希表。hash-set 不会原地更改 h。相反,它会返回一个全新的哈希表:

(define h2 (hash-set h "disk" 1000))

h 保持不变。h2 是新表。如果你想原地更改哈希表,请改用 make-hash 和 hash-set!:

(define mh (make-hash))
(hash-set! mh "disk" 1000)

11. 结构体

结构体是一种轻量级、默认不可变的记录。它是 Racket 对简单类的回答。

(struct person (name age))
(define p (person "Senthil" 40))
(person-name p) ; "Senthil"
(person-age p)  ; 40

12. 契约

契约让你在运行时记录并强制执行函数的期望。它们是独立静态类型系统的实用替代方案,尤其是在模块边界处。

(require racket/contract)
(define/contract (divide a b)
(-> number? (and/c number? (not/c zero?)) number?)
(/ a b))

调用 (divide 5 0) 会在除法发生之前引发一个契约违规。错误消息清楚地显示了哪个参数违反了哪条规则。

示例输出:

divide: contract violation
expected: (not/c zero?)
given: 0
in: an and/c case of
the 2nd argument of
(->
number?
(and/c number? (not/c zero?))
number?)
contract from: (function divide)
blaming: top-level
(assuming the contract is correct)
at: string:1:18
[,bt for context]

13. 宏:Racket 真正的超能力

普通函数在运行之前会评估其参数。

(define (twice x) (+ x x))

宏则不同。它在代码被评估之前转换源代码。参数表达式永远不会被预先求值。

(define-syntax-rule (unless-again test body)
(if (not test) body (void)))
(unless-again (> 2 5) (displayln "Hello"))

示例输出:

Hello

这是另一个经典示例。它运行一个表达式两次,并且该表达式在宏展开之前保持未评估状态。

(define-syntax-rule (twice-run expr)
(begin expr expr))
(twice-run (displayln "Hello"))

示例输出:

Hello
Hello

这就是 Racket 在编程语言圈子中闻名遐迩的原因。宏让你扩展语言本身,而不仅仅是调用用该语言编写的函数。

14. 构建 DSL

想象一个看起来像这样的配置文件:

(server
port 8080
ssl yes
workers 8)

这种语法在 Racket 中默认不存在。你可以自己构建它,使用 define-syntax-rule,或者更通用的 syntax-rules 和 syntax-parse 形式。这是一个小例子:

(define-syntax-rule (repeat n body)
(for ([i n]) body))
(repeat 5 (displayln "Ostechnix"))

示例输出:

Ostechnix
Ostechnix
Ostechnix
Ostechnix
Ostechnix

这读起来像是一个内置的语言特性,因为在 Racket 中,你确实可以添加新的语言特性。很少有主流语言能像这样轻松或令人愉快地做到这一点。

15. 惰性求值与流

Racket 默认是急切的(也称为严格)。这意味着它通常立即计算值。但 Racket 也通过 racket/stream 内置了对惰性的支持。

(require racket/stream)
(define nums (stream 1 2 3 4 5))
(stream-first nums)

示例输出:

1

流可以是无限的,因为它们的元素只有在你实际请求时才会被计算:

(define ones (stream-cons 1 ones))
(stream-ref ones 1000000)

示例输出:

1

只有你实际请求的那部分流才会被计算。

16. Linux 上的系统脚本编写

Racket 也可以作为一种非常强大的 shell 脚本和文本处理语言。

读取文件:

(file->string "/proc/cpuinfo")

或逐行读取:

(with-input-from-file "/proc/meminfo"
(lambda ()
(displayln (read-line))))

运行 shell 命令:

(system "ls -lh")

捕获输出而不是打印它:

(with-output-to-string
(lambda () (system "uname -r")))

一个小的 /proc/meminfo 过滤器:

#lang racket
(define text (file->string "/proc/meminfo"))
(for ([line (string-split text "\n")])
(when (regexp-match? #rx"Mem" line)
(displayln line)))

这会打印完整的匹配行,例如:

MemTotal:       11719360 kB
MemFree:         6641244 kB
MemAvailable:    9034448 kB

(具体数字取决于你的机器。这些函数匹配并打印整行,而不仅仅是标签。)

命令行程序:

#lang racket
(command-line #:args (name)
(printf "Hello ~a\n" name))

像这样运行它:

racket hello.rkt Racket

示例输出:

Hello Racket

一个函数式管道。它读取一个包含数字的文件,删除空行,将文本转换为数字,然后将它们全部相加:

(define total
(foldl +
0
(map string->number
(filter (lambda (s) (not (string=? s "")))
(string-split (file->string "numbers.txt") "\n")))))

一旦 map、filter 和 foldl 变得自然,像这样的管道读起来几乎就像是对任务的简单描述,而不是一组实现步骤。

一个微型 Web 服务器。Racket 附带了一个完整的 Web 应用库:

#lang racket
(require web-server/servlet
web-server/servlet-env)
(define (my-app req)
(response/xexpr '(html (body "Hello from Racket"))))
(serve/servlet my-app #:servlet-regexp #rx"")

一个服务处理程序必须返回一个正确的响应值,而不是一个纯字符串,所以这个版本使用 response/xexpr 包装文本。还需要 #:servlet-regexp #rx"" 选项,以便服务器在下面的普通地址上响应,而不是仅在其默认路径上响应。

将上述代码复制到一个文件中,例如 http.rkt,然后运行它:

$ racket http.rkt
Your Web application is running at http://localhost:8000/servlets/standalone.rkt.
Stop this program at any time to terminate the Web Server.

现在,你会看到 Web 服务器在 http://localhost:8000/ 上运行。一个只有几行代码的可用 Web 服务器。

按 CTRL+C 停止 Web 服务器。

17. 条件(多分支条件判断)

if 只处理两个分支。cond 处理多个分支,真正的 Racket 代码使用它而不是嵌套的 if。

(define (sign n)
(cond
[(> n 0) "positive"]
[(< n 0) "negative"]
[else "zero"]))
(sign -5)

示例输出:

"negative"

每一行按顺序检查。第一个为真的测试获胜,else 捕获所有 else 的情况。

18. 和

let 创建局部变量。所有值都是先计算出来的,所以一个变量不能使用另一个变量。

(let ([x 1] [y 2])
(+ x y))

示例输出:

3

let* 让每个变量使用在其之前定义的变量。

(let* ([x 1] [y (+ x 1)])
(+ x y))

示例输出:

3

在普通的 let 中编写 (y (+ x 1)) 会失败,因为 x 尚未就绪。

19. 向量

列表最适合从前面逐个添加元素。当你需要快速、按索引访问时,向量是最佳选择,并且它们可以原地更改。

(define v (vector 1 2 3))
(vector-ref v 0)

示例输出:

1
(vector-set! v 0 99)
(vector-ref v 0)

示例输出:

99

20. 基本错误处理

error 引发一个异常。with-handlers 捕获它,这样你的程序就可以做出响应,而不是崩溃。

(with-handlers ([exn:fail? (lambda (e) (displayln (exn-message e)))])
(error "Something went wrong"))

示例输出:

Something went wrong

你现在已经看到了塑造 Racket 代码编写方式的核心概念:表达式和一等函数、map/filter/fold、递归、模式匹配、宏、契约,以及足以编写真正工具的 Linux 脚本编写知识。

这些内容不会让你成为专家。但它为你提供了对该语言的工作 map,这样当你接下来阅读 Racket 指南 和 Racket 参考手册 时,它们会更有意义。从这里开始,最好的学习方法是编写小程序,并在过程中查阅资料。

祝你好运!