Racket 编程入门

第 6 部分 · 语言设计与计算模型

include——源码级引入,不是模块

include 把另一个文件的代码原样塞进当前文件,像粘贴,不像导入。它和 require 长得像,却活在完全不同的层面——一个走模块系统,一个只是文本拼接。

你用过 require,知道它怎么从一个模块里借名字出来。你也写过 #lang racket,知道每个文件本身就是一个模块,有边界、有 provide、有独立的命名空间。但你可能翻到过 include——写法 (include "utils.rkt"),看着像 C 里的 #include,又像别的语言里的 import。它和 require 到底是不是一回事?什么时候该用它,什么时候绝不能用?

一句话先放下:

include 不是模块机制,它是源码级的文本拼接。

include 就是把另一个文件粘贴到这里

include 是一个宏。它做的事用一句话讲完:在宏展开期,把指定文件里的代码原样读到当前文件里 (include ...) 所在的位置。

最直观的类比就是粘贴。假设 utils.rkt 里是几行裸定义:

;; utils.rkt —— 没有 #lang,就是一段代码片段
(define (add a b) (+ a b))
(define (sub a b) (- a b))

在另一个文件里写 (include "utils.rkt"),效果等于把上面两行直接贴到这个位置:

#lang racket

(include "utils.rkt")

(add 1 2) ; => 3
(sub 5 3) ; => 2

被包含的文件不需要 #lang,不需要 provide,甚至不需要是一个完整的模块——它就是一段代码。原文件里有几个 define,当前文件里就原样多出几个 define,不多不少。

它发生在程序跑起来之前

require 是运行期的事——模块加载、链接、名字导入,都有一套机制在跑。include 不是。它在宏展开期就把文件读进来拼好了,等程序真正运行的时候,根本没有“加载”这一步。

这一点最容易反直觉。看这段代码:

#lang racket

(if #t
 (include "a.rkt")
 (include "b.rkt"))

很多人会以为:条件是 #t,走 then 分支,所以只读 a.rkt,b.rkt 不会被碰。这是把 include 当成了运行期的函数。实际上宏展开是递归的——if 的两个分支都会被展开,两个 include 作为宏都会在编译期跑完。也就是说,a.rkt 和 b.rkt 在展开阶段就都被读进来拼好了,运行期的 #t 只决定这次求值走哪个分支、返回哪个值,决定不了哪个文件被读。

后果很直接:哪怕 b.rkt 这个文件根本不存在,哪怕 else 分支这辈子都不会执行,编译期照样报错——打不开 b.rkt。

运行期的控制流,管不到展开期的文件读取。

没有 provide,也没有模块边界

require 的世界是有边界的。一个模块要交出名字,得显式 provide;引入方拿到的是导出后的名字,模块内部的辅助定义不会泄漏进来。两个模块各自定义同名的东西,互不干扰。

include 完全反过来。官方文档说得很直白:被包含进来的代码,拿的是 include 这个表达式所在位置的词法上下文。

粘贴进来的代码,和直接写在当前文件里一模一样,没有任何隔离。

于是会有这种事:

;; a.rkt
(define x 10)
;; main.rkt
#lang racket
(include "a.rkt")
(define x 20) ; => identifier already defined: x

require 不会出这个问题,因为模块边界把两个 x 隔开了。include 没有边界,两个 x 落进同一个作用域,必然撞车。同理,同一个文件 (include) 两次,也会因为重复定义而炸掉。include 不建模块,也不缓存——它就是忠实地把文本抄进来,抄两遍就有两份。

include 和 require 不是一回事

把两者摆在一起,差别一目了然:

includerequire
本质宏,文本拼接模块系统
何时生效宏展开期(编译期)运行期(模块加载)
模块边界没有,共享当前作用域有,独立命名空间
名字导出不需要 provide必须 provide
同名冲突直接撞车模块隔离,互不干扰
重复引入重复定义报错模块缓存,只加载一次
被引入的文件一段代码片段(通常无 #lang)一个模块(必须有 #lang 或等价声明)

require 是把别人造好的零件拿过来用,include 是把别人写好的代码抄进自己手里。

路径从哪里算起

include 的路径解析规则值得单独提一句。如果给的是相对路径,默认以 include 表达式所在文件的目录为基准——这正是你直觉上想要的行为,绝大多数情况不用操心。

但有一种场景会出问题:你在宏里生成了一段 include 表达式。这时它的“所在位置”可能指向宏定义所在的文件,而不是使用宏的那个文件。为了在这种时候精确控制路径基准和词法上下文,Racket 提供了 include-at/relative-to:

#lang racket
(require racket/include)

;; 三个参数:context、source、path-spec
;; context 提供词法上下文,source 锚定相对路径的基准。
;; 两者都是语法对象,自身会被展开器丢弃,只取上下文和源位置。
(include-at/relative-to
 (quote-syntax here) ; context —— 词法上下文来自这里
 (quote-syntax here) ; source —— 相对路径从这里算起
 "helpers.rkt") ; path-spec

here 只是个占位标识符,叫什么不重要——宏要的只是它的词法上下文和源位置。日常写代码用不上这个变体,知道它解决的是“宏生成代码时的路径基准”问题就够了。

include 还有个带自定义 reader 的变体 include/reader,用你自己提供的读入器去解析被包含的文件,专门处理非标准语法的文件(比如一份自定义格式的数据)。它更接近语言实现层面的工具,等写 DSL 或编译器时自然会碰到。

它真正的用武之地

既然 require 几乎总是更稳妥的选择,include 存在的意义是什么?在于有一类代码,它天生不是一个独立模块,而是某个模块的一部分,只是物理上拆成了几个文件。

最典型的场景是写解释器、编译器、复杂 DSL。一个语言的实现可能上千行:词法分析、语法分析、AST 构造、求值器各占一段,彼此强耦合、共享大量内部定义、完全不想对外暴露。这时候你把它拆成几个文件,用 include 拼成一个模块:

#lang racket

(include "lexer.rkt")
(include "parser.rkt")
(include "ast.rkt")
(include "eval.rkt")

这些文件不是模块,是源码片段。它们共享同一个作用域,互相之间直接引用彼此的私有定义,对外则统一藏在一个模块边界后面——外部看到的是一个整体,内部却换来了多文件组织的好处。

另一个场景是代码生成。如果有一批 define 是工具自动产出的、严格依附于当前模块、没有独立复用的价值,用 include 引入比硬给它配一套 provide 自然得多。

怎么选:一个判断准则

什么时候用 include,什么时候用 require?问自己一个问题:

这些代码,是同一个模块被拆开存放,还是两个独立模块在合作?

前者用 include——它们本来就是一体的,只是文件太大拆一拆。后者用 require——它们各有边界,靠导出的接口对话。

最后一句留在这里:

能用 require 的地方,别用 include。

只有当你确信手里捏着的是“一个模块的碎片”时,include 才是顺手的工具。理解了这一点,你就再也不会把它和 require 搞混。