Common Lisp 的迷雾
两(多)个不同的命名空间
在 CL 中,符号扮演了一个特殊的角色,这个角色在其它语言中不存在。
首先,符号扮演了其它语言中“标识符”的作用,它可以用来命名变量和函数;另一方面,符号本身是一种独立的数据结构(对象),就如同数字、字符串一样。也就是符号本身也可以是“值”。例如:T
和 nil
就是两个特殊的符号,它们的值就是它们自己。所有的关键字符号(以冒号开头的符号)都是自求值的,它们不指向内存中的某个值,它们的值就是它自己。
符号背后的事实有些复杂。符号也许只是一个“引用”,它指向内存中的某块数据。CL 的复杂性在于,内存中的这一块数据有可能分成了多个格子,可以同时存放多个值,并且根据上下文需求来访问其中的某个格子。
-
其中一个格子存放“值”(value),和其它语言中的变量一样,就是普通意义上的值。
-
另外一个格子里可能存放着一个函数(function)!!
其它还有一些格子,不过这里暂时不讨论那些东西。
换句话说,同一个符号,它有可能是一个普通变量,也可能同时又是一个函数。那么,解释器(编译器)如何知道在什么时候要去访问内存中的哪个格子呢?
很简单,当符号出现在参数位置上的时候,系统从 value
格子中取值并返回;当符号出现在函数调用位置(紧跟在括号后面)的时候,系统认为这是一个函数调用,于是去function
格子中取出函数并进行调用。
在代码中显式或隐式地进行变量绑定时,值会放在 value
格子中,当 defun
时,函数体(代码)会放在function
格子中。
还是实际操作一下容易理解
;; 隐式地创建一个变量,赋值为32 (setq foo 32) => 32 ;; 在 REPL 中验证一个,foo 的值确实是32 foo => 32 ;; 再定义一个函数 foo, 它总是返回 nil (defun foo () nil) => FOO ;; 现在,在 foo 这个符号后面已经有两个格子了, ;; 分别存了整数 32 和一个函数,互不干扰 foo => 32 (foo) => nil ;; 如果把函数放到 value 的位置上会怎么样? ;; 我们知道 lambda 表达式返回一个函数对象 (setf foo #'(lambda () t)) => #<FUNCTION (LAMBDA ()) {1004E601EB}> foo => #<FUNCTION (LAMBDA ()) {1004E601EB}> (foo) => nil ;; 有趣的来了 (funcall foo) => T
同一个变量,保存了两个函数,调用其中一个返回 nil
,调用另一个返回T
。为什么会这样?
当使用显式的函数调用(紧跟在括号后面)去调用 foo
的时候,系统从 function
中取得函数,并进行调用
当使用funcall
去调用的时候,foo
处在参数位置上,于是,系统去value
格子中取出了另一个函数,传递给 funcall
,于是得到了另一种结果。
(symbol-value 'foo) => #<FUNCTION (LAMBDA ()) {1004E601EB}> foo => #<FUNCTION (LAMBDA ()) {1004E601EB}> (symbol-function 'foo) => #<FUNCTION FOO> #'foo => #<FUNCTION FOO> (function foo) => #<FUNCTION FOO>
我们知道,#'
前缀不过是 function
的语法糖,现在看起来,function
也不过是 symbol-function
的语法糖而以。它们共同的作用,就是从一个符号背后专门放函数的格子里取出东西来。
把这一点搞清楚是有好处的。把函数当作参数去传递时要不要加 #'
前缀?funcall
的第一个参数为什么不能是一个函数名?
我是从 Scheme 入的坑,转到 CL 时四处碰壁。比如高阶函数的使用上,Scheme 直接把一个函数名作用参数传递给另外一个函数,在另外一个函数中可以直接调用。现在搞明白了,正确的姿势是,先用#'
把一个函数的函数体(代码)取出来,再传递给另外一个函数,在被调函数内部,可以使用funcall
直接调用这一坨代码。如果直接给funcall
传递一个函数名,并让它调用会怎么样?系统会认为这个函数名是一个普通变量,并且试图去value
格子里读取它的值,结果找不到就报错。
还有个好玩的事,除了nil
、T
、关键字符号是自求值符号以外,还可以把普通符号也变成自求值的
(setf abc 'abc) => ABC abc => ABC (symbol-value 'abc) => ABC (symbol-value (symbol-value (symbol-value (symbol-value (symbol-value 'abc))))) => ABC
不管使用嵌套多少层的symbol-value
去读取一个自求值符号的值,它永远是它自己。为什么会这样?因为在系统内部,符号是唯一的,两个相同的符号,其实都指向内存中的同一个地址。
通过看文档,发现有几个访问器可以分别访问一个符号背后的几个格子:
symbol-name symbol-value symbol-function symbol-plist symbol-package
也就是说,在一个符号背后,有多达5个格子。
-
name 存放符号的字符串表示
-
value 存放具体的值
-
function 存放函数
-
plist 每个符号都自动地带有一个属性列表,尽管一开始它是空的。也就是说,可以在不需要声明一个变量的情况下,直接给某个符号赋值,过后再访问其中的值
-
package 标识了符号属于哪个包(命名空间)
(symbol-name 'bar)
=> "BAR";; 尝试从一个从未出现过的符号中取出属性值,返回 nil。然而该符号已经悄悄地被创建了
;; 要注意,符号自带的 plist 的 key 也是用符号表示的,而不是冒号开关的关键字符号
(get 'alizarin 'color)
=> NIL
;; 直接赋值
(setf (get 'alizarin 'color) 'red)
;; 然后再读取
(get 'alizarin 'color)
=> RED
按常理,当一个变量未被声明(隐式或显式),内存中是不应该有它的位置的,也就是它存储数据的格子还没有 malloc。真相是,每当 Lisp reader 读取到一个新的符号,就自动在内存中创建了一个新的对象,尽管它的大部分字段还是空的。所以,CL的变量声明与其它语言的变量声明和初始化是不一样的,显得相当另类。
两种作用域
-
词法作用域 没有声明为
(declare (special var))
的局部变量都是词法变量。词法变量的作用域限制在词法范围内。在调用时,它的值不会被新的 env 中的同名变量所覆盖。反过来,如果一个局部变量被声明为special
,那么它的作用域也是动态的(在运行期,从 env 一级一级往上找,直到找到一个同名符号) -
动态作用域 CL 的全局变量几乎都是用
defparameter
和defvar
来声明的。使用这两者声明的变量都是“动态作用域”的(自由变量)。动态作用域的变量会被新 env 中的同名变量所覆盖。这就会造成一种错觉,似乎 CL 的全局变量都是动态作用域的自由变量。其实,如果用setf
或者setq
来隐式地创建全局变量,它的作用域就不是动态的了。
动态变量是把双刃剑,带来方便的同时也会带来难以调试的BUG。需要注意的是,用defparameter
和defvar
声明的全局变量统统都是动态变量。所以,传统上,LISP 程序员会使用前后加星号的方式来命名全局变量,多少能起到点强调作用。
CL 的这种复杂性在我看来是不必要的,CL 在标准化过程中背负了太多的历史包袱,于是就变成了现在这个奇怪(丑陋)的样子。
从某种角度讲,Scheme 是比 Common Lisp 更纯粹的 LISP,但是和 CL 比,scheme 确实缺少了一些必须的语言设施——不单单是缺少库,也包括语法设施(Racket 除外)。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?