第 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 不是一回事
把两者摆在一起,差别一目了然:
include | require | |
|---|---|---|
| 本质 | 宏,文本拼接 | 模块系统 |
| 何时生效 | 宏展开期(编译期) | 运行期(模块加载) |
| 模块边界 | 没有,共享当前作用域 | 有,独立命名空间 |
| 名字导出 | 不需要 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 搞混。