迷宫寻址中深度优先搜索的递归和非递归算法比较
巧若拙(欢迎转载,但请注明出处:http://blog.csdn.net/qiaoruozhuo)
本文只探究迷宫寻址中深度优先搜索的递归和非递归算法比较,其他相关代码详见《迷宫问题(巧若拙)》http://blog.csdn.net/qiaoruozhuo/article/details/41020745
深度优先搜索的递归算法是很容易实现的,只需设置一个驱动函数,然后递归调用子函数就可以了。
代码如下:
int DeepSearchWay()//寻找路径:深度搜索
{
CopyMiGong();
if (c_map[begin[0]][begin[1]] == OPEN && Search(begin[0], begin[1]))
{
c_map[begin[0]][begin[1]] = ROAD;
return true;
}
return false;
}
int Search(int x, int y)//深度搜索递归子函数
{
int i;
c_map[x][y] = PASSED;
if (IsEnd(x, y)) //找到出口
{
c_map[x][y] = ROAD;
return true;
}
for (i=0; i<4; i++)//判断当前路径点四周是否可通过
{
if (c_map[x+zx[ i]][y+zy[i ]] == OPEN && Search(x+zx[i ], y+zy[i ]))
{
c_map[x][y] = ROAD;
return true;
}
}
return false;
}
深度优先搜索的非递归算法需要人工设置一个栈,把搜索过的点存储到栈中,走不通的点就退栈,找到出口就退出函数。
代码如下:
int DeepSearchWay_2()//寻找路径:深度搜索
{
int x, y;
int top = 0; //栈顶指针
int sum = 0;//累积搜索过的点数量
CopyMiGong();
way[0].x = begin[0];
way[0].y = begin[1];
way[0].pre = 0;
c_map[way[0].x][way[0].y] = PASSED; //该点已走过
while (top >= 0)
{
if (way[top].pre < 4)
{
x = way[top].x + zx[way[top].pre];
y = way[top].y + zy[way[top].pre];
if (c_map[x][y] == OPEN)//如果某个方向可通过,将该点纳入栈
{
sum++;
top++;
way[top].x = x;
way[top].y = y;
way[top].pre = 0;
c_map[x][y] = PASSED;
if (IsEnd(x, y)) //找到出口
{
PutStack(top); //把栈路径显示到迷宫中
printf("\n深度优先搜索可行路径,总共搜索过%d个点\n", sum);
return true;
}
}
else //否则换个方向
way[top].pre++;
}
else
{
top--;
}
}
return false;
}
void PutStack(int top) //把栈路径显示到迷宫中
{
CopyMiGong();
while (top >= 0)
{
c_map[way[top].x][way[top].y] = ROAD;
top--;
}
}
深度优先搜索最短路径,除了存储当前遍历结点的栈以外,需要额外设置一个栈存储最短路径。为了避免重复搜索某顶点,我为各个顶点设置了路径长度,只有当前路径长度小于原来的路径长度时,才搜索该顶点。
找到出口后并不退出函数,若当前路径长度小于最小路径长度,则更新最小路径长度,否则直接退栈进入上一点,继续搜索。
直到所有可能的路径被搜索完毕,输出最短路径。
代码如下:
int DeepSearchWay_3()//寻找路径:深度搜索(最短路径)
{
int x, y, i, j;
int top = 0; //栈顶指针
int pathLen[M+2][N+2] = {0};
struct stype shortWay[M*N];
int flag = false; //标记是否能到达终点
int sum = 0;//累积搜索过的点数量
for (x=0; x<M+2; x++) //设置各点初始路径长度均为最大值
for (y=0; y<N+2; y++)
pathLen[x][y] = MAXLEN;
CopyMiGong();
minLen = MAXLEN; //最短路径
way[0].x = begin[0];
way[0].y = begin[1];
way[0].pre = 0;
pathLen[begin[0]][begin[1]] = 0;
while (top >= 0)
{
if (way[top].pre < 4)
{
x = way[top].x + zx[way[top].pre];
y = way[top].y + zy[way[top].pre];
if (c_map[x][y] == OPEN && pathLen[x][y] > top+1)//如果某个方向可通过,且为最短路径,将该点纳入栈
{
sum++;
top++;
way[top].x = x;
way[top].y = y;
way[top].pre = 0;
pathLen[x][y] = top;
if (IsEnd(x, y)) //找到出口
{
if (top < minLen)
{
minLen = top;
for (i=0; i<=top; i++)
{
shortWay[i ] = way[i ];
}
}
flag = true;
top--;
}
}
else //否则换个方向
way[top].pre++;
}
else
{
top--;
}
}
if (flag)
{
for (i=0; i<=minLen; i++)
{
way[i ] = shortWay[i ];
}
PutStack(minLen); //把栈路径显示到迷宫中
}
printf("\n深度优先搜索最短路径,总共搜索过%d个点\n", sum);
return flag;
}
拓扑排序之变量序列
巧若拙(欢迎转载,但请注明出处:http://blog.csdn.net/qiaoruozhuo)
题目描述:
假设有n个变量(1<=n<=26,变量名用单个小写字母表示),还有m个二元组(u,v),分别表示变量u小于v。那么,所有变量从小到大排列起来应该是什么样子的呢?
例如有4个变量a,b,c,d,若以知a<b,c<b,d<c,则这4个变量的排序可能是a<d<c<b。尽管还有可能其他的可能,你只需找出其中的一个即可。
输入:
输入为一个字符串,其中包含N+N个字符,依次表示N个关系式(1<=N<=100000),例如序列"abcbdc"表示a<b,c<b,d<c.
输出:
给出一个字符串,其中存储了一个符合要求的变量序列,例如,字符串"adcb"表示a<d<c<b。
算法分析:
这是典型的拓扑排序问题。先简单科普一下,所谓拓扑排序,是指将一个有向无环图G中所有顶点排成一个线性序列,使得图中任意一对顶点u和v,若<u,v> ∈E(G),则u在线性序列中出现在v之前。通常,这样的线性序列称为满足拓扑次序(TopoiSicai Order)的序列,简称拓扑序列。
我们先将变量作为顶点输入到图数据结构中。表示图的数据结构有多种,由于本题对应的是稀疏图,应该以边为主要研究对象,所以可以把数据结构设置为邻接表或边表集。
我们先来看邻接表数据结构:
typedef char VertexType; //顶点类型由用户自定义
typedef int EdgeType; //边上的权值类型由用户自定义
typedef struct EdgeNode{ //边表结点
int adjvex; //邻接点域,存储该顶点对应的下标
// EdgeType weight; //权值,对于非网图可以不需要
struct EdgeNode *next; //链域,指向下一个邻接点
} EdgeNode;
typedef struct VertexNode{ //顶点表结点
VertexType data; //顶点域,存储顶点信息
int in; //存储顶点入度的数量
EdgeNode *firstEdge; //边表头指针
} VertexNode;
由于变量名用单个小写字母表示,我们可以设置一个大小为26的数组存储顶点;又由于26个字母不见得都会出现,故我们设置一个全局变量int book[MAXM] = {0}; 用来标记某字母是否出现。
首先创建一个图,读入顶点和边信息。代码如下:
/*
函数名称:CreateGraph
函数功能:把顶点和边信息读入到表示图的邻接表中
输入变量:char *data:存储了N个关系式的字符串
VertexNode *GL : 顶点表数组
输出变量:表示图的顶点表数组
返回值:int :顶点数量
*/
int CreateGraph(char *data, VertexNode *GL)
{
int i, u, v;
int count = 0;//记录顶点数量
EdgeNode *e;
for (i=0; i<MAXM; i++)//初始化图
{
GL[i ].data = i + 'a';
GL[i ].in = 0;
GL[i ].firstEdge = NULL;
book[i ] = 0;
}
for (i=0; data[i ]!='\0'; i+=2)//每次读取两个变量
{
u = data[ i] - 'a'; //字母转换为数字,'a'对应0,'b'对应1,以此类推
v = data[i+1] - 'a';
book[u ] = book[v] = 1;
e = (EdgeNode*)malloc(sizeof(EdgeNode)); //采用头插法插入边表结点
if (!e)
{
puts("Error");
exit(1);
}
e->adjvex = v;
e->next = GL[u ].firstEdge;
GL[u ].firstEdge = e;
GL[v].in++;
}
for (i=0; i<MAXM; i++)//计算顶点数量
{
if (book[i ] != 0)
count++;
}
return count;
}
拓扑排序算法其实非常简单,只需要搜索入度为0的弧尾顶点,然后将其对应的弧头顶点入度减1,如果该弧头顶点入度也变成了0,就将其存储到栈(或队列)中。搜索的方法有深度优先和广度优先两种。代码分别如下:
/*
函数名称:TopoLogicalSort_DFS
函数功能:拓扑排序,采用深度优先搜索获取拓扑序列
输入变量:char *topo:用来存储拓扑序列的字符串
VertexNode *GL : 顶点表数组
int n:顶点个数
输出变量:用来存储拓扑序列的字符串
返回值:int :拓扑排序成功返回真,若存在环则返回假
*/
int TopoLogicalSort_DFS(char *topo, VertexNode *GL, int n)
{
int i, u, v, top;
int count = 0; //用于统计输出顶点的个数
EdgeNode *e;
int Stack[MAXM];
for (top=i=0; i<MAXM; i++)//将入度为0的顶点入栈
{
if (book[i ] != 0 && GL[i ].in == 0)
{
Stack[top++] = i;
}
}
while (top > 0)//采用深度优先搜索获取拓扑序列
{
u = Stack[--top];
topo[count++] = u + 'a';
for (e=GL[u ].firstEdge; e!=NULL; e=e->next)//将u的邻接点入度减1,并将入度为0的顶点入栈
{
v = e->adjvex;
if (--GL[v].in == 0)
Stack[top++] = v;
}
}
topo[count] = '\0';
return (count == n);//如果count小于顶点数,说明存在环
}
/*
函数名称:TopoLogicalSort_BFS
函数功能:拓扑排序,采用广度优先搜索获取拓扑序列
输入变量:char *topo:用来存储拓扑序列的字符串
VertexNode *GL : 顶点表数组
int n:顶点个数
输出变量:用来存储拓扑序列的字符串
返回值:int :拓扑排序成功返回真,若存在环则返回假
*/
int TopoLogicalSort_BFS(char *topo, VertexNode *GL, int n)
{
int i, u, v, front, rear;
EdgeNode *e;
front = rear = 0;
for (i=0; i<MAXM; i++)//将入度为0的顶点入栈
{
if (book[i ] != 0 && GL[i ].in == 0)
{
topo[rear++] = i + 'a';
}
}
while (front < rear)//采用广度优先搜索获取拓扑序列
{
u = topo[front++] - 'a';
for (e=GL[u ].firstEdge; e!=NULL; e=e->next)//将u的邻接点入度减1,并将入度为0的顶点入栈
{
v = e->adjvex;
if (--GL[v].in == 0)
topo[rear++] = v + 'a';
}
}
topo[rear] = '\0';
return (rear == n);//如果count小于顶点数,说明存在环
}
我们也可以用边表集来表示图,数据结构如下:
typedef struct Edge{ //边集数组
int u, v; //弧尾和弧头
int next; //指向同一个弧尾的下一条边
// EdgeType weight; //权值,对于非网图可以不需要
} EdgeLib;
为了表示顶点信息,我们还需要设置两个数组:int In[MAXM], first[MAXM]; //分别存储顶点的入度和第一条边信息。
边表集实现拓扑排序的算法和邻接表非常相似,也是先读入图的顶点和边信息,然后进行拓扑排序。代码如下:
/*
函数名称:CreateGraph_2
函数功能:把顶点和边信息读入到表示图的边表集中
输入变量:char *data:存储了N个关系式的字符串
int In[]:存储了顶点的入度信息
int first[]:指向以该顶点为弧尾的第一条边
EdgeLib edge[]:存储了边信息的边表集
输出变量:表示图的边表集数组
返回值:int :顶点数量
*/
int CreateGraph_2(char *data, int In[], int first[], EdgeLib edge[])//创建一个图
{
int i, j;
int count = 0;//记录顶点数量
for (i=0; i<MAXM; i++)//初始化图
{
first[ i ] = -1;
book[ i ] = 0;
In[ i ] = 0;
}
for (j=i=0; data[ i ]!='\0'; i+=2,j++)//每次读取两个变量
{
edge[j].u = data[i ] - 'a'; //字母转换为数字,'a'对应0,'b'对应1,以此类推
edge[j].v = data[i+1] - 'a';
book[edge[j].u] = book[edge[j].v] = 1;
edge[j].next = first[edge[j].u];
first[edge[j].u] = j;
In[edge[j].v]++;
}
for (i=0; i<MAXM; i++)//计算顶点数量
{
if (book[i ] != 0)
count++;
}
return count;
}
/*
函数名称:TopoLogicalSort
函数功能:拓扑排序,采用广度优先搜索获取拓扑序列
输入变量:char *topo:用来存储拓扑序列的字符串
EdgeLib edge[]:存储了边信息的边表集
int In[]:存储了顶点的入度信息
int first[]:指向以该顶点为弧尾的第一条边
int n:顶点个数
输出变量:用来存储拓扑序列的字符串
返回值:int :拓扑排序成功返回真,若存在环则返回假
*/
int TopoLogicalSort(char *topo, EdgeLib edge[], int In[], int first[], int n)
{
int i, u, front, rear;
front = rear = 0;
for (i=0; i<MAXM; i++)//将入度为0的顶点入栈
{
if (book[i ] != 0 && In[i ] == 0)
{
topo[rear++] = i + 'a';
}
}
while (front < rear)//采用广度优先搜索获取拓扑序列
{
u = topo[front++] - 'a';
for (i=first[u ]; i!=-1; i=edge[i ].next)
{
if (--In[edge[i ].v] == 0)
topo[rear++] = edge[i ].v + 'a';
}
}
topo[rear] = '\0';
return (rear == n);//如果count小于顶点数,说明存在环
}
这里只给出了相关函数,完整的测试代码请到巧若拙的博客(http://blog.csdn.net/qiaoruozhuo)查看。
yum -y install make zlib zlib-devel gcc-c++ libtool openssl openssl-devel wget vim
apt install -y zlib1g-dev build-essential libpcre3 libpcre3-dev openssl libssl-dev wget vim
wget http://downloads.sourceforge.net/projec … .35.tar.gz
tar zxvf pcre-8.35.tar.gz
mv pcre-8.35 /usr/local/pcre
cd /usr/local/pcre
./configure
make && make install
wget http://nginx.org/download/nginx-1.22.0.tar.gz
tar zxvf nginx-1.22.0.tar.gz
cd nginx-1.22.0/
./configure --prefix=/usr/local/nginx --with-http_stub_status_module --with-http_ssl_module --with-pcre=/usr/local/pcre
make && make install
vim /etc/init.d/nginx
chmod a+x /etc/init.d/nginx
chkconfig --add nginx
chkconfig nginx on
debian
vim /lib/systemd/system/nginx.service
[Unit]
Description=nginx
After=network.target
[Service]
Type=forking
ExecStart=/usr/local/nginx/sbin/nginx
ExecReload=/usr/local/nginx/sbin/nginx -s reload
ExecStop=/usr/local/nginx/sbin/nginx -s quit
PrivateTmp=true
[Install]
WantedBy=multi-user.target
设置nginx开机自启动(centos7.x)
第一步:进入到/lib/systemd/system/目录
[root@localhost ~]# cd /lib/systemd/system/
第二步:创建nginx.service文件,并编辑
# vim nginx.service
内容如下:
[Unit]
Description=nginx service
After=network.target
[Service]
Type=forking
ExecStart=/usr/local/nginx/sbin/nginx
ExecReload=/usr/local/nginx/sbin/nginx -s reload
ExecStop=/usr/local/nginx/sbin/nginx -s quit
PrivateTmp=true
[Install]
WantedBy=multi-user.target
[Unit]:服务的说明
Description:描述服务
After:描述服务类别
[Service]服务运行参数的设置
Type=forking是后台运行的形式
ExecStart为服务的具体运行命令
ExecReload为重启命令
ExecStop为停止命令
PrivateTmp=True表示给服务分配独立的临时空间
注意:[Service]的启动、重启、停止命令全部要求使用绝对路径
[Install]运行级别下服务安装的相关设置,可设置为多用户,即系统运行级别为3
保存退出。
第三步:加入开机自启动
# systemctl enable nginx
如果不想开机自启动了,可以使用下面的命令取消开机自启动
# systemctl disable nginx
第四步:服务的启动/停止/刷新配置文件/查看状态
# systemctl start nginx.service 启动nginx服务
# systemctl stop nginx.service 停止服务
# systemctl restart nginx.service 重新启动服务
# systemctl list-units --type=service 查看所有已启动的服务
# systemctl status nginx.service 查看服务当前状态
# systemctl enable nginx.service 设置开机自启动
# systemctl disable nginx.service 停止开机自启动
一个常见的错误
Warning: nginx.service changed on disk. Run 'systemctl daemon-reload' to reload units.
直接按照提示执行命令systemctl daemon-reload 即可。
# systemctl daemon-reload
#!/bin/bash
# nginx Startup script for the Nginx HTTP Server
# it is v.0.0.2 version.
# chkconfig: - 85 15
# description: Nginx is a high-performance web and proxy server.
# It has a lot of features, but it's not for everyone.
# processname: nginx
# pidfile: /var/run/nginx.pid
# config: /usr/local/nginx/conf/nginx.conf
nginxd=/usr/local/nginx/sbin/nginx
nginx_config=/usr/local/nginx/conf/nginx.conf
nginx_pid=/var/run/nginx.pid
RETVAL=0
prog="nginx"
# Source function library.
. /etc/rc.d/init.d/functions
# Source networking configuration.
. /etc/sysconfig/network
# Check that networking is up.
[ ${NETWORKING} = "no" ] && exit 0
[ -x $nginxd ] || exit 0
# Start nginx daemons functions.
start() {
if [ -e $nginx_pid ];then
echo "nginx already running...."
exit 1
fi
echo -n $"Starting $prog: "
daemon $nginxd -c ${nginx_config}
RETVAL=$?
echo
[ $RETVAL = 0 ] && touch /var/lock/subsys/nginx
return $RETVAL
}
# Stop nginx daemons functions.
stop() {
echo -n $"Stopping $prog: "
killproc $nginxd
RETVAL=$?
echo
[ $RETVAL = 0 ] && rm -f /var/lock/subsys/nginx /var/run/nginx.pid
}
# reload nginx service functions.
reload() {
echo -n $"Reloading $prog: "
#kill -HUP `cat ${nginx_pid}`
killproc $nginxd -HUP
RETVAL=$?
echo
}
# See how we were called.
case "$1" in
start)
start
;;
stop)
stop
;;
reload)
reload
;;
restart)
stop
start
;;
status)
status $prog
RETVAL=$?
;;
*)
echo $"Usage: $prog {start|stop|restart|reload|status|help}"
exit 1
esac
exit $RETVAL
2022-12-01-systemd-usrmerge
Title /usr merge for systemd users
Author Mike Gilbert <[email protected]>
Posted 2022-12-01
Revision 2
In the latter half of 2023, systemd will drop support for split-usr/unmerged-usr systems [1]. All Gentoo systems running systemd will need to be migrated to merged-usr.
在2023年下半年,systemd将会放弃对于split-usr/unmerged-usr系统的支持[1]。所有使用systemd的Gentoo系统需要切换到merged-usr。
Migrating to merged-usr will move all data from /bin, /sbin, and /lib into the /usr/bin and /usr/lib directories. The directories in / are replaced with symlinks.
切换到merged-usr会把所有/bin、/sbin和/lib目录下的文件移动到/usr/bin和/usr/lib下。这些/里的目录会被符号链接代替。
To facilitate this, a new set of sub-profiles has been created, and a script is available to perform the actual migration.
我们为此创建了一套新的子profiles和一个用于执行真正迁移任务的脚本。
To migrate a system to merged-usr, follow this procedure:
请通过以下步骤将系统迁移成merged-usr。
1. Ensure your system backups are up to date.
1. 确保你的系统备份了。
2. Install sys-apps/merge-usr.
2. 下载sys-apps/merge-usr。
3. Run the merge-usr script. The --dryrun option may be used to check for error conditions before running the script for real.
3. 运行脚本merge-usr。--dryrun选项可以用来在实际执行前检查可能的错误情况。
4. Switch to a merged-usr profile.
4. 切换到merged-usr profile。
eg. eselect profile set default/linux/amd64/17.1/systemd/merged-usr
比如,运行命令:eselect profile set default/linux/amd64/17.1/systemd/merged-usr
5. Run emerge with the --newuse or --changed-use option to rebuild any packages that have a "split-usr" USE flag.
5. 使用--newuse或--changed-use选项运行emerge,以重建所有具有"split-usr"这个USE标记的软件包。
eg. emerge -uDN @world
比如,运行命令:emerge -uDN @world
For new installs, new "mergedusr" stage3 tarballs are being produced for commonly used profiles.
对于新的下载,已为常用的profiles提供了新的stage3压缩包"mergedusr"。
摘取自骏马金龙的第4章ext文件系统机制原理剖析
在写文件(Linux中一切皆文件)时需要为其分配一个inode号。
其实,在格式化创建文件系统后,所有的inode号都已计算好(创建文件系统时会为每个块组计算好该块组拥有哪些inode号),因此产生了问题:要为文件分配哪一个inode号呢?又如何知道某一个inode号是否已经被分配了呢?
既然是"是否被占用"的问题,使用位图是最佳方案,像bmap记录block的占用情况一样。标识inode号是否被分配的位图称为inodemap简称为imap。这时要为一个文件分配inode号只需扫描imap即可知道哪一个inode号是空闲的。
这样理解更容易些,类似bmap块位图一样,inode号是预先规划好的。inode号分配后,文件删除也会释放inode号。分配和释放的inode号,像是在一个地图上挖掉一块,用完再补回来一样。
imap存在着和bmap和inode table一样需要解决的问题:如果文件系统比较大,imap本身就会很大,每次存储文件都要进行扫描,会导致效率不够高。同样,优化的方式是将文件系统占用的block划分成块组,每个块组有自己的imap范围。
目录
如何读写硬盘
读写操作
硬盘控制器端口及作用
硬盘中断
硬盘分区信息的获取
如何读写文件
TASK_HD
如何读写硬盘
读写操作
第一次看到linux 0.12关于读写硬盘几行代码时候,感觉很费解。
复制代码
do_hd = intr_addr;
outb_p(hd_info[drive].ctl,HD_CMD);
port=HD_DATA;
outb_p(nsect,++port);
outb_p(sect,++port);
outb_p(cyl,++port);
outb_p(cyl>>8,++port);
outb_p(0xA0|(drive<<4)|head,++port);
outb(cmd,++port)
复制代码
我还是不明白怎么这样就可以读写硬盘了。但是代码到此就结束了。
一直好奇程序是如何控制硬件的,这些指令就是一个个电信号在cpu中流动,怎么就能把硬盘中的数据拿到内存中呢?
正好在同一个学期开设了《计算机组成原理》和《微机原理与接口技术》这两门课程,那个时候才了解到端口的意思,了解到cpu寻址、数据传输的流程。
往端口写了数据和指令,剩下的我们只能相信硬件制造商的设计和生产能力了,然后默默等待硬件的回应。我记得当时自己疑惑了一段时间,苦于没有人来提醒这一点,可能会的人感觉这根本不是问题吧。
硬盘控制器端口及作用
Linux 0.12当时操作的硬盘是CHS寻址模式,起始扇区编号是1。对于《实现》来说,用bochs自带的工具bximage命令生成的虚拟硬盘是LBA寻址模式的,起始扇区编号是0。CHS模式和LBA模式的端口号和操作方式都一样,只是有一些端口代表的意义不一样了,来看一下LBA寻址模式的端口作用,借用书中的表9.1。
表1 LBA寻址模式的硬盘端口及其作用
I/O端口 读时 写时
primary secondary
1F0H 170H Data
1F1H 171H Error Features
1F2H 172H Sector count
1F3H 173H LBA low
1F4H 174H LBA mid
1F5H 175H LBA high
1F6H 176H Device
1F7H 177H Status Command
3F6H 376H Alternate status Device control
其中Device寄存器比较特殊,它用来指明寻址模式。来看一下格式。
表2 Device寄存器各个bit为的意义
Bit位 值 意义
7 1
6 L 0表示CHS模式,1表示LBA模式
5 1
4 DRV 0表示主盘,1表示从盘
3 HS3
如果是L=0,CHS模式,那么这四位的值表示磁头号
如果L=1,LBA模式,那么这四位的值表示LBA的24到27位
2 HS2
1 HS1
0 HS0
从上面的代码可以很清楚的看到如何读写硬盘,往相应的端口写上我们要读多少个扇区,读哪个扇区,哪个柱面,哪个磁头,哪个硬盘,然后告诉硬盘我们的需求cmd,读或者写。
另外,CHS模式下,硬盘扇区编号从1开始编号。LBA模式下,从0开始编号。
硬盘中断
我们怎么知道硬盘的工作做完了没有呢?只能等待硬盘产生中断信号,通过8259A告诉cpu,这个中断信号是哪个硬件产生的。
在书中,用的是微内核,所有的进程都给TASK_HD(硬盘驱动)发送读写硬盘的命令,而不是自己调用硬盘驱动中的读写函数。所以中断产生后,仅仅需要通知TASK_HD这个进程,TASK_HD会把硬盘准备好的数据读到发出读请求进程指定的内存位置。
硬盘分区信息的获取
前面说了如何向硬盘发送命令,让它读写哪些扇区,但是这些参数都是我们提前计算好的。如何计算这些参数?我们又是如何知道该读写那个扇区呢?
之所以把分区信息的介绍放到读写文件这一小节中,是因为我觉得分区信息和文件关联很大。我们要读写文件,才需要知道分区信息,如果我们不需要按照文件形式来读写硬盘,那么知不知道分区信息就无所谓啦,凭我们的大脑记住要读取的数据在第几个分区,到时候直接汇编操作寄存器就好啦。
那为什么要分区呢?似乎不分区把所有的数据都杂糅在一起,电脑也可以正常运行啊。我百度了一下,大概是由于为了把操作系统和数据分开吧。试想,如果所有的东西和操作系统共处一个空间,那么操作系统崩溃了,这个空间的所有数据的记录索引在重新安装操作系统后都会失效,尽管数据本身依然很正常,但是由于记录索引丢失,我们却没法找到他们。如果分区了,那么最多操作系统的所在分区的数据拿不到了,其他分区数据的记录索引还在。
如何获取分区信息?
在硬盘的0号扇区(MBR扇区)偏移0x1BE处保存的有一张硬盘主分区表。只有四个表项,也就是说一个硬盘只能记录四个主分区,据说是因为当初IBM认为一个PC上装4个操作系统(只有主分区上能安装操作系统)就够用了。如果想要更多的分区,那么需要在格式化的时候指明一个(只能有一个主分区记录能用于扩展分区)表项用作扩展分区,扩展分区并不能直接使用,在这个扩展分区里面我们还要划分出逻辑分区,每一个逻辑分区的起始扇区记录的分区表只能使用两个表项。
对于操作系统而言,每个分区都被当做一个独立的设备对待。
那么书中如何记录分区信息呢?看一下保存数据的结构体:
复制代码
struct part_info {
u32 base; /* # of start sector (NOT byte offset, but SECTOR) */
u32 size; /* how many sectors in this partition */
};
/* main drive struct, one entry per drive */
struct hd_info
{
int open_cnt;
struct part_info primary[NR_PRIM_PER_DRIVE];//计算后NR_PRIM_PER_DRIVE = 5
struct part_info logical[NR_SUB_PER_DRIVE];// 计算后NR_SUB_PER_DRIVE = 64
};
复制代码
书中根设备编号是0x322,可以知道子设备号是0x22,一开始很困惑,这么大的子设备号,难道要分0x22个分区?或者说系统怎么就知道0x22表示的是根分区呢?
还得再看一段代码
logidx = (p->DEVICE - MINOR_hd1a) % NR_SUB_PER_DRIVE;
sect_nr += p->DEVICE < MAX_PRIM ?
hd_info[drive].primary[p->DEVICE].base :
hd_info[drive].logical[logidx].base;
先将设备号减去第一个逻辑设备的编号得到设备号在logical数组的下标。当然,可能这个设备号不是逻辑设备,而是主分区。没关系,下一步判断p->DEVICE 是不是小于MAX_PRIM,如果小于,说明是主分区,直接用p->DEVICE在primary数组中取值就可以了。
原来是这样,你想怎么样编号就怎么样编号,只要你自己能找到映射关系就可以了。
获取信息的步骤:
device = 0,style = P_PRIMARY
调用获取分区信息函数
如果style == P_ EXTENDED执行第10步
读取设备device的起始扇区,提取0x1BE处的4个表项到part_tbl
令i=0
判断第i个分区表项part_tbl[ i ]
如果是主分区,记录起始扇区sect_start和扇区数目setcs到相应的primary[i+1]。
如果是扩展分区,记录起始扇区sect_start和扇区数目setcs到相应的primary[i+1],令device += i+1,style = P_ EXTENDED跳到第2步
如果i>=4,结束;否则i++,执行第6步
扩展分区的起始扇区ext_start_sect = primary[device].base(这个值在第8步中已经计算出来了),求出该扩展分区的第一个逻辑分区的编号,nr_1st_sub = (device-1) * NR_SUB_PER_PART,计算该扩展分区第0个逻辑分区的起始地址s= ext_start_sect
令i=0(由于是递归调用,此处i的值并不影响第5步的i)
读取第nr_1st_sub+i个逻辑分区的起始扇区s,提取0x1BE处的2个表项(逻辑分区只使用分区表的两个表项)到part_tbl
记录逻辑分区的信息到logical[nr_1st_sub+i]
s = ext_start_sect + part_tbl[1].start_sect
如果i>=16,本次递归结束,返回到第8步;否则i++,执行第12步
感觉文字叙述理解起来可能比较模糊,但是比代码实现起来还是省事一些,像读分区起始扇区,一句话带过,知道怎么做就可以了,如果用代码描述,可能还要牵扯到其他知识点。
如何读写文件
其实对于硬盘驱动而言,没有文件这个概念,只有扇区。硬盘驱动能接受的参数就是要读写的起始扇区,读写扇区个数。文件这个概念由上层的文件系统来处理。
这个时候,我们会想起来inode结构体中有两个记录是i_dev和i_start_sect,这两个元素把上层文件系统和硬盘关联起来了。当我们要读某某个文件的时候,文件系统告诉硬盘驱动读目录区,把文件的inode号找到,再读indoe到内存中,这个时候就有了文件在哪个分区i_dev,数据存放在第i_start_sect号扇区,及之后一共的x800个扇区中,这个i_start_sect的值是相对于分区i_dev为起始偏移的。
知道i_dev和i_start_sect之后,硬盘驱动可以做什么呢?首先将以i_dev分区为起始偏移的i_start_sect转化为相对于整个硬盘。怎么转化呢?上面获取分区信息的时候,每个分区的起始扇区都被记录,我们找到i_dev的起始扇区,加上i_start_sect就是相对于整个硬盘的了。
这样就把文件读进来了。至于读文件哪一段的内容,其实还是上层的文件系统来记录处理的,还记得file结构体中有一个元素是pos,这个值就是用来标明要读写的内容在文件中的偏移。将pos/SECT_SIZE再加上上面计算的文件相对于整个硬盘的偏移,就是要读写的某一段数据了。
所谓块设备的名称也许就是这样由来的吧,一次最少处理的数据是一个扇区(当然,不同的硬盘给出的接口肯定不一样)。
TASK_HD
这样一来,TASK_HD的任务就是很简单了啊,接收TASK_FS发送的读写请求,将针对于i_dev设备的i_start_sect转化为相对于整个硬盘的扇区号,再加上pos/SECT_SIZE,然后读写这个扇区交给TASK_FS就什么都不管了。进入下一个循环。
目录
如果没有文件系统
如何读写文件
提炼上述过程中我们需要知道的信息
文件系统的实现
需要在硬盘上保存的信息
代码上实现的逻辑
设备号
分区信息
file结构体
inode保存的信息
如果有文件系统
读写接口
读写流程
TASK_FS
如果没有文件系统
如果我们不在硬盘本身建立文件系统,我们直接面对硬盘的扇区。
如何读写文件
先看看对于操作普通文件来说,意味着什么。
我们要拿着一个小本本,上面记着,文件名,文件所在扇区以及文件大小。每次要读写文件,我们要人工查询这个账本,知道我们要的文件在哪里。如果文件A所在的扇区M已经写满了,随后的一个扇区M+1被文件B占用了,我们还想接着写文件A,怎么办呢?只能从其他地方找一个空闲扇区N,然后在账本上把N记录到文件A占用的扇区项中。
我们如何知道硬盘上还有哪些空间可以用呢?难道每次都从前往后把扇区使用情况计算一遍吗吗?可能还需要另起一个账本记录扇区使用情况,删除文件,我们把对应的扇区标记为空闲,如果创建文件,把对应的扇区标记为不能使用。
对于操作系统而言呢?我觉得,没有文件系统就不会有操作系统,这样的操作系统充其量就是一个硬盘驱动。为什么?可以设想一下创建文件的过程:
用户告诉这样的操作系统,说要创建一个文件A
计算机输出,请你自己记录好文件名,并告诉我要在哪个扇区创建。并且记录好这个文件你占用了哪些扇区
我不能忍受。。。
提炼上述过程中我们需要的信息
将变化的放在一起,将不变的放在一起。统一才有美感。
dir_entry
对于文件使用情况的账本而言,看起来要表述一个文件在硬盘上的信息,我们需要知道它占用了哪些扇区,它的名字,文件大小这样的信息。那么这些信息应该放在哪里呢?当然可以随机存放,但是存放完了,计算机如何在下次使用的时候找到这个文件呢?还是需要一份记录来索引这些信息,还不如把这些文件信息按照统一格式存放在一起,这就是目录结构(dir_entry)的由来。按照树状目录能得到任何文件信息。
sector_map
对于硬盘使用情况的账本而言,要记录好哪些扇区空闲,哪些已经被使用了。这就是sector_map的由来。
super_block
那么这些账本本身是存在于硬盘的某些地方,还需要一个总账本来记录这些管理块的信息,这个总账本就是super_block。
inode
那么inode的由来呢?为什么文件名和inode分开存放呢?
试想一下,如果文件名和文件的属性信息一起存放的话,一个文件目录项会占用很大的空间,一个扇区也许只能存几个文件的信息,而系统在查找文件的时候,可能要读很多次扇区才能找到需要的文件,这样大大影响系统的效率。毕竟我们在找文件的时候,不需要文件的信息,不需要知道文件大小、所在扇区等等信息全部与查找无关,为什么要这些信息来影响我们的速度呢?我们只要文件名来判断这是不是我们要找的文件。所以将文件的其余信息剥离出来概括为inode结构体。
inode_array
inode单独列出来了,存放在哪里呢?如何通过dir_entry找到inode呢?当然可以存放于任何扇区上,只不过dir_entry可能要加上inode所在扇区和在扇区中的偏移两个字段了,随之而来问题就是存放inode的扇区只能用来存放其他inode而不能用来存放文件数据了,因为我们给文件分配空间是按照扇区为单位的,难道一个扇区分给文件时候,还要记上一笔在偏移offset处是inode占用的,读写的时候请跳过,这样的逻辑恐怕没人会去用代码实现它吧。另外,由于“存放inode的扇区只能用来存放其他inode而不能用来存放文件数据”这样的原因,设计者就折中了一下,把indoe占用的扇区都提到一个单独空间,以后所有的inode都放到这个空间里,这个空间就是inode_array。
当然也会出现问题,可能inode_array满了,而硬盘空间还要很大剩余;或者硬盘空间嘛呢,inode_array还有很多剩余。这是很极端的情况,总要有不尽人意的地方,那就把这个不足最小化吧。
在存放inode的时候,怎么知道inode_array中哪个下标可以用呢?这就是又需要一份记录,来记录inode_array中哪些是空闲的,哪些是已经使用,这个记录就是inode_map。而inode在inode_array中的下标就是inode_num。dir_entry中记录了这个inode_num就可以在inode_array中找到对应的文件信息了。这个过程衔接的太美妙了。
文件系统的实现
文件系统需要的结构体大概都知道了,剩下的仅仅是需要规划处具体的结构体了。我们来看看。
需要在硬盘上保存的信息
超级块
Inode-map
Sector-map
Inode-array
上面几个结构体作者在书中都列举出来了,都是很好理解的。我不啰嗦再搬运过来了。
代码上实现的逻辑
设备号
正是在作者的讲解下,我算是真正的了解到设备号的意义。以前总是看书上说主设备号代表设备归属于哪个驱动,子设备号真正表明是哪个具体的设备。我虽然能顺着设备号找到驱动,能从驱动中看到子设备号对流程的分用作用,但是感觉总是欠缺点什么。我就好奇为什么linux 0.12中将0x300就能代表第一块硬盘,难道不能是0x400吗?为什么0代表整个硬盘,1代表第一个分区?分区编号要按照物理分区顺序吗?如果是0x400会产生什么影响呢?
跟着作者一起学着规划硬盘空间,才渐渐明白,这些编号可以随意编,跟硬盘上的分区顺序不存在某种必然的联系,只是最后落实到保存硬盘信息的结构体上的时候,不会出现偏差就可以了。
对于操作系统而言,每个分区都被当做一个独立的设备对待。看看书中所描述的硬盘信息结构体。
复制代码
struct part_info {
u32 base; /* # of start sector (NOT byte offset, but SECTOR) */
u32 size; /* how many sectors in this partition */
};
/* main drive struct, one entry per drive */
struct hd_info
{
int open_cnt;
struct part_info primary[NR_PRIM_PER_DRIVE];//计算后NR_PRIM_PER_DRIVE = 5
struct part_info logical[NR_SUB_PER_DRIVE];// 计算后NR_SUB_PER_DRIVE = 64
};
复制代码
由结构体可以看出来,硬盘上存在的每个分区都会被记录下来。
书中根设备编号是0x322,可以知道子设备号是0x22,一开始很困惑,这么大的子设备号,难道要分0x22个分区?或者说系统怎么就知道0x22表示的是根分区呢?
还得再看一段代码:
logidx = (p->DEVICE - MINOR_hd1a) % NR_SUB_PER_DRIVE;
sect_nr += p->DEVICE < MAX_PRIM ?
hd_info[drive].primary[p->DEVICE].base :
hd_info[drive].logical[logidx].base;
先将设备号减去第一个逻辑设备的编号得到设备号在logical数组的下标。当然,可能这个设备号不是逻辑设备,而是主分区。没关系,下一步判断p->DEVICE 是不是小于MAX_PRIM,如果小于,说明是主分区,直接用p->DEVICE在primary数组中取值就可以了。
原来是这样,你想怎么样编号就怎么样编号,只要你自己能找到映射关系就可以了。
分区信息
硬盘的管理结构体已经设计好了,那么如何获取硬盘的分区信息呢?见硬盘驱动那篇总结。
文件描述符
内存中的文件如何和硬盘中的文件联系起来?当我们打开一个文件后,后续的操作,如何来标示我们操作的是一个文件而不是一段莫名其妙的内存呢?
首先,我们会想到将inode读到内存就好了,我们就知道文件的所有信息了。那文件名呢?好像文件名除了查找匹配能贡献一份力量,其它地方用不着啊,难道也一些读进来吗?仅仅是做个标识而已,用一个数不是更好、更简单吗?这就是文件描述符的作用。那文件描述符放在哪里呢?由于每个进程打开的文件不同,打开同一个文件的次序不同,那么文件描述符一般情况下也就不能作为进程共享的资源了(当然,域套接字是可以的,内核社区的人员一次又一次地刷新人们的理解力)。既然如此,文件描述符最好是进程私有的了,就只能放在进程表(也就是进程控制块)里面了。此外,机器资源有限,总不能让一个进程无限制的打开文件,最好大家都没内存了,只能歇菜了。所以,一个进程打开的文件数是有限制的,目前我们只给20个就好了。
file结构体
好像有了文件描述符就可以直接和inode关联起来了,没必要中间再加一层file结构体啊。我想是因为要以比较节约的方式共享文件吧,节约什么呢,除了内存还能有谁能让那些设计师精益求精呢?
我们当然可以在每个进程控制块里面分配20个存放文件信息的结构体,存放读写偏移指针、打开的权限、inode指针等等信息。但是能保证进程会长时间打开20个文件吗?如果不能保证,那不就浪费了。如果以后允许打开100个文件呢?难道进程控制块也要随之而增大吗?
关于共享文件,父子进程通过一个放在描述符数组里面的指针共享一个file结构体,而不用在单独维护一个file时候还要考虑同步。设想这样一个情形:父子进程都对一个文件进行写操作,父进程写了10个字符,按照需求该子进程接着写10个字符了,如果是父子进程单独维护file结构体,那么实际上只有子进程写了10个字符,父进程写的10个字符被覆盖了。如果共享呢?file中的pos每次操作对于两个进程而言都是同步的(当然这个例子不太严谨,它本身就存在同步问题,但是仅仅用来说明一点问题还是可以的)。
inode保存的信息
为什么不用inode本身当做系统或者进程操作文件的接口呢?这个问题比较好考虑。多个进程操作同一个文件,那读写指针的值肯定不一样,读写方式也不一样,其实这些不一样的地方提炼出来就是file结构体啦,file结构体的内容也不是随意产生的。
将变化的放在一起,将不变的放在一起。
如果有文件系统
再接下来考虑一下如果有文件系统能给我们带来什么好处呢?
读写接口
不过,首先还是要实现读写接口的,就套用linux惯用的读写接口就好了。
int read(int fd, void *buf, int count);
只不过linux是通过中断调用来和内核交互,咱们是通过给TASK_FS发送消息并同步等待来实现的。
读写流程
那么如果一个用户进程A请求读写一个文件X,那么A会向TASK_FS进程发送消息,告诉FS文件名和读写模式。
功能完备的文件系统还要考虑很多因素,诸如做下判断,看看文件路径是相对于当前目录还是根目录。我们比较简单,全部按照根目录实现,而且不支持多级目录,所有文件都放在根目录中。
TASK_FS会给TASK_HD发消息,把目录区读给我。然后逐一比较有没有相同的文件名,假设有同名的,根据dir_entry中记录的inode_num算一下文件indoe所在的扇区是多少,然后再给TASK_HD发消息,把inode所在扇区读进来,文件具体的信息就有了。
后续操作这个文件,TASK_HD根据进程控制块中的信息来计算和决定该怎么操作文件数据。比如说根据文件描述找到file结构体,里面有读写指针知道下一步要操作的位置是哪里,通过file结构体找到inode,这样就知道文件数据在哪个扇区了。
通过上面简单的叙述,也可以窥见现代文件系统问什么加入了dentry这个成员,目录项在查找的时候也是经常用到的,还不如缓存在内存中,加快读写速度。
TASK_FS
TASK_FS在微内核的设计中,被设计为一个进程了,它不断地循环读取其它进程发给它的读写请求,但是一次只能处理一个请求,如果这个请求没有完成,那么其它进程只能挂接在TASK_FS的等待队列上等待了。不过没关系,过早的优化是万恶之源。
## Gentoo BTRFS with Optimizations and Musl Libc
This is the Gentoo Btrfs with Optimizations and Musl Libc. or GentooBOML (GBOML) :penguin:
| :warning: Disclaimer |
| :---------------------------------------------------------------------------------------------------------------------------------- |
| Please keep in mind that this might not work on your machine, this guide is specified for amd64 CPUs and EFI machines. |
## Why?
This is (going to be) a painful journy to achive maximum performance on gentoo with many unusual choices. This all began when i started having issues with replacing and matching different parts of linux in a virtual machine. I struggled with making proper alterations to common parts like glibc and gcc, so i'm making this guide to document the progress of GentooBOML.
## What Does This Contain?
1. Btrfs! an alternative file system to EXT4 which offers many features such as: Autodefrag, Compression, Subvolumes, Self-Healing and most importantly, Great Snapshotting of data instantaneously. It has got many developments over the years, and now 1 year in, i've never had a problem with using Btrfs, although many still consider it unstable, many linux distros have made the bold switch to the future file system BTRFS.
2. Musl Libc! although not a common choice, Musl has proven to be a great standard C library with great simplicity and a small code base, it can do as much as glibc. * This comes to the indivisuals' choice, if you want a stable system then prefer not to use musl libc. It can break many packages!
3. LTO! lto is very controversial in the gentoo community with many opting to just stick with use flags and masking, but lto can offer a great performace boost on many machines, for me i've used lto for many months now and never had a system breaking problem, mostly small breakages that need an hour of fixing for a month use, but be warned, you'll have to debug a lot on your own!
## Let's Start with the BTRFS basics
Btrfs can produce snapshots of the system data, and you can rollback anytime you want, to take proper advantage of these features, btrfs subvolumes must be properly setup.
For making snapshots, subvolumes are a way to exclude variable files from being snapshotted, this prevents data loss and removes big snapshots.
Here I follow the standard subvolumes model provided by [OpenSUSE Wiki](https://aya1.eu.org:443/https/en.opensu … Subvolumes)
The following directories should be excluded from snapshots:
1. /home/ **Avoided if home is on a different partition.**
2. /root/ **Default root user directory.**
3. /opt/ **For 3rd party software installations.**
4. /srv/ **For data of WEB and FTP servers.**
5. /usr/local **Containing local temperory files and caches.**
6. /var/ **Containing variable files.**
7. /.snapshots/ **This is a custom directory made specifically for snapshots. ❗ You don't want to take a snapshot of a snapshot ❗**
8. /tmp/ **For temporary files.**
Now that you know how btrfs works. Let's continue to the early setup phase:
## Phase 1: Disk Preparation
First of all. Pepare your partitions. I prefer the following setup:
|Partition | Filesystem | Partition Type | Size |
|----------|--------------|------------------|--------|
|/dev/sda1 | vFat | EFI Partition | 512M |
|/dev/sda2 | Btrfs | Home Partition | any% |
|/dev/sda3 | Btrfs | Linux Partition | 60G |
**Home Partition is optional but preferable.** For more information about this setup see the [FAQ](#faq)
After making the partitions make sure to format them correctly:
```bash
mkfs --type vfat /dev/{efipart} # Replace {efipart} with your EFI part.
mkfs --type btrfs /dev/{homepart} # You can also have ext4 or vfat // Replace {homepart} with your home partition if you have one.
mkfs --type btrfs /dev/{linuxpart} # Replace {linuxpart} with your linux partition.
```
Now that you have setup disks, you're ready for Phase 2. Please notice that from now on i'll be using the setup above, you must change it according to your setup...
## Phase 2: Mouting and Setup
Mount the linux partition first
```bash
mount /dev/sda3 /mnt
cd /mnt
```
Let's make sure that the subvolumes are created properly, this is done bt creating subvolumes using the standard @ format:
```bash
btrfs subvolume create @
btrfs subvolume create @root
btrfs subvolume create @opt
btrfs subvolume create @srv
btrfs subvolume create @local
btrfs subvolume create @var
btrfs subvolume create @tmp
btrfs subvolume create @.snapshots
btrfs subvolume create @home # Not needed if you use a seperate partition //
```
**@ is the main parent subvolume, it's like the / which is the parent directory.**
Now unmount the partition and make sure to be outside the /mnt directiry
```bash
cd /
umount -r /mnt
```
Now mount the linux partition firstly to create the specified directories:
```bash
mount -o defaults,noatime,compress=zstd,commit=120,autodefrag,subvol=@ /dev/sda3 /mnt
```
Make sure to create the following directories:
```bash
mkdir -p /mnt/{home,srv,root,tmp,opt,usr/local,var,.snapshots,efi,boot}
```
Great! Now mount the rest of the subvolumes:
```bash
mount -o defaults,noatime,compress=zstd,commit=120,autodefrag,subvol=@local /dev/sda3 /mnt/usr/local
mount -o defaults,noatime,compress=zstd,commit=120,autodefrag,subvol=@root /dev/sda3 /mnt/root
mount -o defaults,noatime,compress=zstd,commit=120,autodefrag,subvol=@opt /dev/sda3 /mnt/opt
mount -o defaults,noatime,compress=zstd,commit=120,autodefrag,subvol=@srv /dev/sda3 /mnt/srv
mount -o defaults,noatime,compress=zstd,commit=120,autodefrag,subvol=@var /dev/sda3 /mnt/var
mount -o defaults,noatime,compress=zstd,commit=120,autodefrag,subvol=@tmp /dev/sda3 /mnt/tmp
mount -o defaults,noatime,compress=zstd,commit=120,autodefrag /dev/sda2 /mnt/home
mount /dev/sda1 /mnt/efi
```
**I mounted the EFI Partition on /efi because it's better to seperate the /boot for kernels and /efi for grub and efi configurations.**
Download the latest Musl Libc in the /mnt directory. This can change so make sure to "wget" the latest version from [Gentoo Downloads](https://aya1.eu.org:443/https/www.gentoo.org/downloads/)
```bash
cd /mnt
wget https://aya1.eu.org:443/https/gentoo.os … 42Z.tar.xz
```
Now all we need is to untar the tar
```bash
tar xpvf stage3-*.tar.xz --xattrs-include='*.*' --numeric-owner
```
Now this is the end of [Phase 2](#phase-2-mouting-and-setup). We can go to Phase 3.
## Phase 3
***TO BE CONTINUED... Phase 3 and 4 are under experimentation. Bulletproof methodes are not possible, although some startigies can be applied for a usable system. soon enough i'll be able to make a gentoo system that works with musl libc and clang. I hope i can get these phases finished before summer. or if i can't then next year when Musl Libc gentoo is stable. Please be patient...***
***Testing for Phase 3 and 4 failed. Project halted until further notice
***
## FAQ
**1. Why put the EFI partition first?**
Because the EFI partition is a common partition between different linux distros, so it doesn't need to change according to the distro.
**2. Why put the Home partition second?**
Because the Home partition starts at a certain section in the hard disk or SSD, which when increasing or decreasing the partition size from the begining, it needs to move the partition data, increasing/decreasing the size at the ends doesn't need to move the data. So, by having it between the EFI partition and Linux partition, it fixes the issue.
**3. What is the minimum size for the partitions?**
EFI can be up to 512 Megabyes, Home partition can be whatever size you want, and the Linux partition needs to be at least 50-60 Gigabytes.
section .data
hello: db 'Hello World!/n',10 ;’Hello World!’,加换行符
helloLen: equ $-hello ;’Hello World!’字符串长度
section .text
global _start
_start:
move ax,4 ;4:sys_write系统调用号
mov ebx,1 ;1:标准输出文件描述符
mov ecx,hello ;放hello字符串的首地址
mov edx,helloLen ;hello字符串长度
int 80h ;软中断,陷入内核
move ax,1 ;sys_exit系统调用号
mov ebx,0 ;返回值,0表示没有错误.exit(0)
int 80h ;这里有必要解释下,int 80h实际上是执行一个中断,叫做软中断,int 80h执行之后,中断会返回到原来发生中断的那条指令的下一条指令的地址开始取指,可以阅读我的另一篇关于ARM流水线的文章, 所以,mov ax,1这条指令之后的又需要再次产生一个软中断陷入内核来执行exit操作。即需要再调用一次int 80h,你只需要记住,每执行一个系统调用,都需要跟一条int 80h 来陷入内核执行。
英文名称:Register
寄存器定义
寄存器是中央处理器内的组成部份。寄存器是有限存贮容量的高速存贮部件,它们可用来暂存指令、数据和位址。在中央处理器的控制部件中,包含的寄存器有指令寄存器(IR)和程序计数器(PC)。在中央处理器的算术及逻辑部件中,包含的寄存器有累加器(ACC)。
寄存器是内存阶层中的最顶端,也是系统获得操作资料的最快速途径。寄存器通常都是以他们可以保存的位元数量来估量,举例来说,一个 “8 位元寄存器”或 “32 位元寄存器”。寄存器现在都以寄存器档案的方式来实作,但是他们也可能使用单独的正反器、高速的核心内存、薄膜内存以及在数种机器上的其他方式来实作出来。
寄存器通常都用来意指由一个指令之输出或输入可以直接索引到的暂存器群组。更适当的是称他们为 “架构寄存器”。
例如,x86 指令及定义八个 32 位元寄存器的集合,但一个实作 x86 指令集的 CPU 可以包含比八个更多的寄存器。
寄存器是CPU内部的元件,寄存器拥有非常高的读写速度,所以在寄存器之间的数据传送非常快。
[编辑本段]
寄存器用途
1.可将寄存器内的数据执行算术及逻辑运算;
2.存于寄存器内的地址可用来指向内存的某个位置,即寻址;
3.可以用来读写数据到电脑的周边设备。
[编辑本段]
数据寄存器
8086 有14个16位寄存器,这14个寄存器按其用途可分为(1)通用寄存器、(2)指令指针、(3)标志寄存器和(4)段寄存器等4类。
(1)通用寄存器有8个, 又可以分成2组,一组是数据寄存器(4个),另一组是指针寄存器及变址寄存器(4个).
数据寄存器分为:
AH&AL=AX(accumulator):累加寄存器,常用于运算;在乘除等指令中指定用来存放操作数,另外,所有的I/O指令都使用这一寄存器与外界设备传送数据.
BH&BL=BX(base):基址寄存器,常用于地址索引;
CH&CL=CX(count):计数寄存器,常用于计数;常用于保存计算值,如在移位指令,循环(loop)和串处理指令中用作隐含的计数器.
DH&DL=DX(data):数据寄存器,常用于数据传递。
他们的特点是,这4个16位的寄存器可以分为高8位: AH, BH, CH, DH.以及低八位:AL,BL,CL,DL。这2组8位寄存器可以分别寻址,并单独使用。
另一组是指针寄存器和变址寄存器,包括:
SP(Stack Pointer):堆栈指针,与SS配合使用,可指向目前的堆栈位置;
BP(Base Pointer):基址指针寄存器,可用作SS的一个相对基址位置;
SI(Source Index):源变址寄存器可用来存放相对于DS段之源变址指针;
DI(Destination Index):目的变址寄存器,可用来存放相对于 ES 段之目的变址指针。
这4个16位寄存器只能按16位进行存取操作,主要用来形成操作数的地址,用于堆栈操作和变址运算中计算操作数的有效地址。
(2) 指令指针IP(Instruction Pointer)
指令指针IP是一个16位专用寄存器,它指向当前需要取出的指令字节,当BIU从内存中取出一个指令字节后,IP就自动加1,指向下一个指令字节。注意,IP指向的是指令地址 的段内地址偏移量,又称偏移地址(Offset Address)或有效地址(EA,Effective Address)。
(3)标志寄存器FR(Flag Register)
8086有一个18位的标志寄存器FR,在FR中有意义的有9位,其中6位是状态位,3位是控制位。
OF: 溢出标志位OF用于反映有符号数加减运算所得结果是否溢出。如果运算结果超过当前运算位数所能表示的范围,则称为溢出,OF的值被置为1,否则,OF的值被清为0。
DF:方向标志DF位用来决定在串操作指令执行时有关指针寄存器发生调整的方向。
IF:中断允许标志IF位用来决定CPU是否响应CPU外部的可屏蔽中断发出的中断请求。但不管该标志为何值,CPU都必须响应CPU外部的不可屏蔽中断所发出的中断请求,以及CPU内部产生的中断请求。具体规定如下:
(1)、当IF=1时,CPU可以响应CPU外部的可屏蔽中断发出的中断请求;
(2)、当IF=0时,CPU不响应CPU外部的可屏蔽中断发出的中断请求。
TF:跟踪标志TF。该标志可用于程序调试。TF标志没有专门的指令来设置或清楚。
(1)如果TF=1,则CPU处于单步执行指令的工作方式,此时每执行完一条指令,就显示CPU内各个寄存器的当前值及CPU将要执行的下一条指令。
(2)如果TF=0,则处于连续工作模式。
SF:符号标志SF用来反映运算结果的符号位,它与运算结果的最高位相同。在微机系统中,有符号数采用补码表示法,所以,SF也就反映运算结果的正负号。运算结果为正数时,SF的值为0,否则其值为1。
ZF: 零标志ZF用来反映运算结果是否为0。如果运算结果为0,则其值为1,否则其值为0。在判断运算结果是否为0时,可使用此标志位。
AF:下列情况下,辅助进位标志AF的值被置为1,否则其值为0:
(1)、在字操作时,发生低字节向高字节进位或借位时;
(2)、在字节操作时,发生低4位向高4位进位或借位时。
PF:奇偶标志PF用于反映运算结果中“1”的个数的奇偶性。如果“1”的个数为偶数,则PF的值为1,否则其值为0。
CF:进位标志CF主要用来反映运算是否产生进位或借位。如果运算结果的最高位产生了一个进位或借位,那么,其值为1,否则其值为0。)
4)段寄存器(Segment Register)
为了运用所有的内存空间,8086设定了四个段寄存器,专门用来保存段地址:
CS(Code Segment):代码段寄存器;
DS(Data Segment):数据段寄存器;
SS(Stack Segment):堆栈段寄存器;
ES(Extra Segment):附加段寄存器。
当一个程序要执行时,就要决定程序代码、数据和堆栈各要用到内存的哪些位置,通过设定段寄存器 CS,DS,SS 来指向这些起始位置。通常是将DS固定,而根据需要修改CS。所以,程序可以在可寻址空间小于64K的情况下被写成任意大小。 所以,程序和其数据组合起来的大小,限制在DS 所指的64K内,这就是COM文件不得大于64K的原因。8086以内存做为战场,用寄存器做为军事基地,以加速工作。
以上是8086寄存器的整体概况, 自80386开始,PC进入32bit时代,其寻址方式,寄存器大小,功能等都发生了变化。
=============================以下是80386的寄存器的一些资料======================================
寄存器都是32-bits宽。
A、通用寄存器
下面介绍通用寄存器及其习惯用法。顾名思义,通用寄存器是那些你可以根据自己的意愿使用的寄存器,修改他们的值通常不会对计算机的运行造成很大的影响。通用寄存器最多的用途是计算。
EAX:通用寄存器。相对其他寄存器,在进行运算方面比较常用。在保护模式中,也可以作为内存偏移指针(此时,DS作为段 寄存器或选择器)
EBX:通用寄存器。通常作为内存偏移指针使用(相对于EAX、ECX、EDX),DS是默认的段寄存器或选择器。在保护模式中,同样可以起这个作用。
ECX:通用寄存器。通常用于特定指令的计数。在保护模式中,也可以作为内存偏移指针(此时,DS作为 寄存器或段选择器)。
EDX:通用寄存器。在某些运算中作为EAX的溢出寄存器(例如乘、除)。在保护模式中,也可以作为内存偏移指针(此时,DS作为段 寄存器或选择器)。
同AX分为AH&AL一样,上述寄存器包括对应的16-bit分组和8-bit分组。
B、用作内存指针的特殊寄存器
ESI:通常在内存操作指令中作为“源地址指针”使用。当然,ESI可以被装入任意的数值,但通常没有人把它当作通用寄存器来用。DS是默认段寄存器或选择器。
EDI:通常在内存操作指令中作为“目的地址指针”使用。当然,EDI也可以被装入任意的数值,但通常没有人把它当作通用寄存器来用。DS是默认段寄存器或选择器。
EBP:这也是一个作为指针的寄存器。通常,它被高级语言编译器用以建造‘堆栈帧'来保存函数或过程的局部变量,不过,还是那句话,你可以在其中保存你希望的任何数据。SS是它的默认段寄存器或选择器。
注意,这三个寄存器没有对应的8-bit分组。换言之,你可以通过SI、DI、BP作为别名访问他们的低16位,却没有办法直接访问他们的低8位。
C、段选择器:
实模式下的段寄存器到保护模式下摇身一变就成了选择器。不同的是,实模式下的“段寄存器”是16-bit的,而保护模式下的选择器是32-bit的。
CS 代码段,或代码选择器。同IP寄存器(稍后介绍)一同指向当前正在执行的那个地址。处理器执行时从这个寄存器指向的段(实模式)或内存(保护模式)中获取指令。除了跳转或其他分支指令之外,你无法修改这个寄存器的内容。
DS 数据段,或数据选择器。这个寄存器的低16 bit连同ESI一同指向的指令将要处理的内存。同时,所有的内存操作指令 默认情况下都用它指定操作段(实模式)或内存(作为选择器,在保护模式。这个寄存器可以被装入任意数值,然而在这么做的时候需要小心一些。方法是,首先把数据送给AX,然后再把它从AX传送给DS(当然,也可以通过堆栈来做).
ES 附加段,或附加选择器。这个寄存器的低16 bit连同EDI一同指向的指令将要处理的内存。同样的,这个寄存器可以被装入任意数值,方法和DS类似。
FS F段或F选择器(推测F可能是Free?)。可以用这个寄存器作为默认段寄存器或选择器的一个替代品。它可以被装入任何数值,方法和DS类似。
GS G段或G选择器(G的意义和F一样,没有在Intel的文档中解释)。它和FS几乎完全一样。
SS 堆栈段或堆栈选择器。这个寄存器的低16 bit连同ESP一同指向下一次堆栈操作(push和pop)所要使用的堆栈地址。这个寄存器也可以被装入任意数值,你可以通过入栈和出栈操作来给他赋值,不过由于堆栈对于很多操作有很重要的意义,因此,不正确的修改有可能造成对堆栈的破坏。
* 注意 一定不要在初学汇编的阶段把这些寄存器弄混。他们非常重要,而一旦你掌握了他们,你就可以对他们做任意的操作了。段寄存器,或选择器,在没有指定的情况下都是使用默认的那个。这句话在现在看来可能有点稀里糊涂,不过你很快就会在后面知道如何去做。
指令指针寄存器:
EIP 这个寄存器非常的重要。这是一个32位宽的寄存器 ,同CS一同指向即将执行的那条指令的地址。不能够直接修改这个寄存器的值,修改它的唯一方法是跳转或分支指令。(CS是默认的段或选择器)
上面是最基本的寄存器。下面是一些其他的寄存器,你甚至可能没有听说过它们。(都是32位宽):
CR0, CR2, CR3(控制寄存器)。举一个例子,CR0的作用是切换实模式和保护模式。
还有其他一些寄存器,D0, D1, D2, D3, D6和D7(调试寄存器)。他们可以作为调试器的硬件支持来设置条件断点。
TR3, TR4, TR5, TR6 和 TR? 寄存器(测试寄存器)用于某些条件测试。
[编辑本段]
寄存器分类
数据寄存器 - 用来储存整数数字(参考以下的浮点寄存器)。在某些简单/旧的 CPU,特别的数据寄存器是累加器,作为数学计算之用。
地址寄存器 - 持有存储器地址,以及用来访问存储器。在某些简单/旧的CPU里,特别的地址寄存器是索引寄存器(可能出现一个或多个)。
通用目的寄存器 (GPRs) - 可以保存数据或地址两者,也就是说他们是结合 数据/地址 寄存器的功用。
浮点寄存器 (FPRs) - 用来储存浮点数字。
常数寄存器 - 用来持有只读的数值(例如 0、1、圆周率等等)。
向量寄存器 - 用来储存由向量处理器运行SIMD(Single Instruction, Multiple Data)指令所得到的数据。
特殊目的寄存器 - 储存CPU内部的数据,像是程序计数器(或称为指令指针),堆栈寄存器,以及状态寄存器(或称微处理器状态字组)。
指令寄存器(instruction register) - 储存现在正在被运行的指令
索引寄存器(index register) - 是在程序运行实用来更改运算对象地址之用。
在某些架构下,模式指示寄存器(也称为“机器指示寄存器”)储存和设置跟处理器自己有关的数据。由于他们的意图目的是附加到特定处理器的设计,因此他们并不被预期会成微处理器世代之间保留的标准。
有关从 随机存取存储器 提取信息的寄存器与CPU(位于不同芯片的储存寄存器集合)
存储器缓冲寄存器(Memory buffer register)
存储器数据寄存器(Memory data register)
存储器地址寄存器(Memory address register)
存储器型态范围寄存器(Memory Type Range Registers)
首先介绍一下我自己:本人是一个极客,爱好研究OS底层技术!觉得只有掌握OS底层技术才算真正掌握IT技术!
当初在网上看到Gentoo方面的介绍,兴致勃勃安装,结果以失败告终!后面在某年国庆终于编译到图形界面,卡在火狐安装上,后来一直没有整了!
进了几个Gentoo群,发现都不活跃,后来去了电报上,才发现电报才是最活跃!
个人觉得技术是慢慢积累的,感觉新手没有好的交流平台,相信很多英文很多人都比较吃力!
所以我就开了这个中文社区,当然不代表本人精通Gentoo,只是为了方便新手入门和学习,也方便有相同爱好的朋友加入!
网站至少会开放十年!
同时对以下网友特别鸣谢:
TMD 免费吧管理团队成员(网站:http://bbs.mn)
Viper 中国黑客第一军团负责人(公司网站:http://www.cnini.com)
齐雷鸣 益友和同事(工作室:https://www.iegum.com/)
Batsum SQL爱好者(个人博客:u.batsum.com)
雾雨凝霜 一个只会吹水的Linux爱好者
杰克 跨境自由职业者(个人网站:https://qhjack.gitee.io)
semes
项目地址,欢迎 star:https://github.com/leitbogioro/Tools
萌咖的一键重装脚本近期经过更新,已经支持 Debian 11/Ubuntu 20.04 等新系统,加入了对 Oracle ARM 机型的支持,现对该脚本进行了一些优化,以增强重装脚本的适用性和实用性。
脚本特色:
全自动无人值守安装;
支持各主流VPS商家;
重装前可预先指定 ssh 密码、端口、固件、镜像源等参数,执行重装命令时,如果未指定密码、端口。重装后的系统默认用户:root,默认端口:22,默认密码:LeitboGi0ro,首次 ssh 机器后请立即修改密码;
preseed 过程针对 Debian 做了大量优化,预置常用组件,永久更改 DNS 为 CloudFlare、Google(需进系统后手动安装 resolvconf:echo "N" | apt install resolvconf -y ),vim 支持鼠标终端复制,不同文件类型不同彩色显示,ssh 连接欢迎页面显示系统占用、IP 信息,软件数更新提示;
双栈(同时拥有 ipv6 和 ipv4 地址)机型默认优先配置 ipv4 网络,开机后请手动配置 ipv6 网络,针对纯 ipv6 机型的支持正在开发中;
对于 Debian 系统,安装时附带的固件源为国外,国内 VPS 连接速度很慢,长时间连接无速度往往会下载失败,可指定 --cdimage 'cn',将源切换到国内中科大的,以提高下载速度;
安装时避免进入低内存模式(Debian 特有)后需要进行手动配置,导致无法自动化部署安装的内存量检测阈值,256M 以上机型即使安装时进入低内存模式,也可以自动化进行,这点对内存少于 1GB 的机型尤为重要。已在搬瓦工 512M 机型做过测试,萌咖原版脚本重装 Debian 11 时,会跳出低内存模式手动配置,自动化安装过程无法继续,首先必须手动选择需要加载的硬件驱动,项目多且复杂,不同机器的硬件各有差别,选择稍有错误,就会导致驱动安装不全,最后系统安装失败,本脚本可保证小内存 VPS 低内存模式自动化安装过程顺利进行,低于 768M 小内存机型安装前执行脚本时,不要附带“-firmware”或“-firmware --cdimage”参数,否则重启后无法进入低内存模式安装界面,导致安装失败。
由于 Ubuntu 22.04 官方移除了对“initrd.img”和“vmlinuz”两个网络引导安装文件的支持,导致目前并无很方便重装 Ubuntu 22.04 的方法,Ubuntu 母公司 Canonical 强推的 Cloudinit 自动部署方式对机器要求极高,必须有虚拟化支持,这是很多已经在母机上被虚拟化后的 VPS 所不具备的。目前仅甲骨文机器 CPU 仍支持虚拟化,所以市面上所有号称能重装成 Ubuntu 22.04 的一键脚本都是假的,无法完成安装,切勿相信。鉴于 Canonical 经常喜欢做焚烧自家亲妈的行为,未来不会对后续 Ubuntu 系统重装做任何支持。
CentOS 8 已被官方放弃,9 以后的 stream 版,成为一项供 Redhat Linux 测试 bug 的上游服务,不再具备 7 及以前版本可完备替代 Redhat Linux 的稳定成熟特性,后续也不再对 CentOS 进行支持。
下载:
wget --no-check-certificate -qO InstallNET.sh 'https://raw.githubusercontent.com/leitb … tallNET.sh' && chmod a+x InstallNET.sh
完整版可指定参数案例:
bash InstallNET.sh -d/u/c(系统种类,debian/ubuntu/centos) 11(系统版本) -v 64(系统位数,32 或 64 或 arm64) -port "ssh 登陆端口" -pwd "ssh 登录密码" -a(自动安装)/m(在 VNC 里手动安装) -mirror "系统镜像源,系统安装完成后的默认软件安装包源也是这个" -firmware(带固件) --cdimage "cn"(此项仅适用于在国内要重装成 Debian 的 VPS)
快速上手:
Debian 8
bash InstallNET.sh -d 8 -v 64 -a
Debian 9
bash InstallNET.sh -d 9 -v 64 -a
Debian 10
bash InstallNET.sh -d 10 -v 64 -a
Debian 11 (选择带固件安装时,推荐国内机器安装源的命令)
清华:
bash InstallNET.sh -d 11 -v 64 -a -mirror "https://mirrors.tuna.tsinghua.edu.cn/debian/" -firmware --cdimage "cn"
网易:
bash InstallNET.sh -d 11 -v 64 -a -mirror "http://mirrors.163.com/debian/" -firmware --cdimage "cn"
腾讯云:
bash InstallNET.sh -d 11 -v 64 -a -mirror "http://mirrors.cloud.tencent.com/debian/" -firmware --cdimage "cn"
阿里云:
bash InstallNET.sh -d 11 -v 64 -a -mirror "http://mirrors.aliyun.com/debian/" -firmware --cdimage "cn"
Debian 11 (如果你的机器在中国大陆以外,连接速度都不会差,就近选择官方源即可)
日本:
bash InstallNET.sh -d 11 -v 64 -a -mirror "http://ftp.riken.jp/Linux/debian/debian/" -firmware
香港:
bash InstallNET.sh -d 11 -v 64 -a -mirror "http://ftp.hk.debian.org/debian/" -firmware
新加坡:
bash InstallNET.sh -d 11 -v 64 -a -mirror "http://ftp.sg.debian.org/debian/" -firmware
韩国:
bash InstallNET.sh -d 11 -v 64 -a -mirror "http://studenno.kugi.kyoto-u.ac.jp/debian/" -firmware
台湾:
bash InstallNET.sh -d 11 -v 64 -a -mirror "http://ftp.tw.debian.org/debian/" -firmware
美国:
bash InstallNET.sh -d 11 -v 64 -a -mirror "http://debian.csail.mit.edu/debian/" -firmware
加拿大:
bash InstallNET.sh -d 11 -v 64 -a -mirror "http://ftp.ca.debian.org/debian/" -firmware
英国:
bash InstallNET.sh -d 11 -v 64 -a -mirror "http://ftp.uk.debian.org/debian/" -firmware
德国:
bash InstallNET.sh -d 11 -v 64 -a -mirror "http://ftp.de.debian.org/debian/" -firmware
法国:
bash InstallNET.sh -d 11 -v 64 -a -mirror "http://ftp.fr.debian.org/debian/" -firmware
俄罗斯:
bash InstallNET.sh -d 11 -v 64 -a -mirror "http://ftp.ru.debian.org/debian/" -firmware
澳大利亚:
bash InstallNET.sh -d 11 -v 64 -a -mirror "http://ftp.au.debian.org/debian/" -firmware
Debian 当地源的格式基本上都是“http://ftp.地区名称缩写.debian.org/debian/”,如果以上例子里不包含你 VPS 所在地,去 https://zh.wikipedia.org/zh-sg/%E5%9C%8 … 3%E7%A2%BC (国家地区代码 - Wiki) 找到对应的,替换掉上面链接里“地区名称缩写”位置,放浏览器里访问一下,如果出现一个文件服务器目录,即可使用。日本的 Debian 源来自日本理化学研究所,一个科研机构;韩国的 Debian 官方源 ftp.kr.debian.org 总是宕机,换成了京都大学的;美国的 Debian 源来自麻省理工学院,请放心使用。
通用的 Debian 源链接:http://ftp.debian.org/debian/
--------------------------------------------------------------------------
Ubuntu 16.04
bash InstallNET.sh -u 16.04 -v 64 -a
Ubuntu 18.04
bash InstallNET.sh -u 18.04 -v 64 -a
Ubuntu 20.04
bash InstallNET.sh -u 20.04 -v 64 -a
--------------------------------------------------------------------------
Cent OS 6
bash InstallNET.sh -c 6.10 -v 64 -a
本节既是备注亦作为参考文献。所有列于此的书籍与论文皆值得阅读。
译注: 备注后面跟随的数字即书中的页码
备注 viii (Notes viii)
Steele, Guy L., Jr., Scott E. Fahlman, Richard P. Gabriel, David A. Moon, Daniel L. Weinreb , Daniel G. Bobrow, Linda G. DeMichiel, Sonya E. Keene, Gregor Kiczales, Crispin Perdue, Kent M. Pitman, Richard C. Waters, 以及 John L White。Common Lisp: the Language, 2nd Edition. Digital Press, Bedford (MA), 1990.
备注 1 (Notes 1)
McCarthy, John.Recursive Functions of Symbolic Expressions and their Computation by Machine, Part I. CACM, 3:4 (April 1960), pp. 184-195.
McCarthy, John.History of Lisp. In Wexelblat, Richard L. (Ed.) Histroy of Programming Languages. Academic Press, New York, 1981, pp. 173-197.
备注 3 (Notes 3)
Brooks, Frederick P. The Mythical Man-Month. Addison-Wesley, Reading (MA), 1975, p. 16.
Rapid prototyping is not just a way to write programs faster or better. It is a way to write programs that otherwise might not get written at all. Even the most ambitious people shrink from big undertakings. It’s easier to start something if one can convince oneself (however speciously) that it won’t be too much work. That’s why so many big things have begun as small things. Rapid prototyping lets us start small.
备注 4 (Notes 4)
同上, 第 i 页。
备注 5 (Notes 5)
Murray, Peter and Linda. The Art of the Renaissance. Thames and Hudson, London, 1963, p.85.
备注 5-2 (Notes 5-2)
Janson, W.J. History of Art, 3rd Edition. Abrams, New York, 1986, p. 374.
The analogy applies, of course, only to paintings done on panels and later on canvases. Well-paintings continued to be done in fresco. Nor do I mean to suggest that painting styles were driven by technological change; the opposite seems more nearly true.
备注 12 (Notes 12)
car 与 cdr 的名字来自最早的 Lisp 实现里,列表内部的表示法:car 代表“寄存器位址部分的内容”、cdr 代表“寄存器递减部分的内容”。
备注 17 (Notes 17)
对递归概念有困扰的读者,可以查阅下列的书籍:
Touretzky, David S. Common Lisp: A Gentle Introduction to Symbolic Computation. Benjamin/Cummings, Redwood City (CA), 1990, Chapter 8.
Friedman, Daniel P., and Matthias Felleisen. The Little Lisper. MIT Press, Cambridge, 1987.
譯註:這本書有再版,可在這裡找到。
备注 26 (Notes 26)
In ANSI Common Lisp there is also a lambda macro that allows you to write (lambda (x) x) for #'(lambda (x) x) . Since the use of this macro obscures the symmetry between lambda expressions and symbolic function names (where you still have to use sharp-quote), it yields a specious sort of elegance at best.
备注 28 (Notes 28)
Gabriel, Richard P. Lisp Good News, Bad News, How to Win BigAI Expert, June 1991, p.34.
备注 46 (Notes 46)
Another thing to be aware of when using sort: it does not guarantee to preserve the order of elements judged equal by the comparison function. For example, if you sort (2 1 1.0) by < , a valid Common Lisp implementation could return either (1 1.0 2) or (1.0 1 2) . To preserve as much as possible of the original order, use instead the slower stable-sort (also destructive), which could only return the first value.
备注 61 (Notes 61)
A lot has been said about the benefits of comments, and little or nothing about their cost. But they do have a cost. Good code, like good prose, comes from constant rewriting. To evolve, code must be malleable and compact. Interlinear comments make programs stiff and diffuse, and so inhibit the evolution of what they describe.
备注 62 (Notes 62)
Though most implementations use the ASCII character set, the only ordering that Common Lisp guarantees for characters is as follows: the 26 lowercase letters are in alphabetically ascending order, as are the uppercase letters, and the digits from 0 to 9.
备注 76 (Notes 76)
The standard way to implement a priority queue is to use a structure called a heap. See: Sedgewick, Robert. Algorithms. Addison-Wesley, Reading (MA), 1988.
备注 81 (Notes 81)
The definition of progn sounds a lot like the evaluation rule for Common Lisp function calls (page 9). Though progn is a special operator, we could define a similar function:
(defun our-progn (ftrest args)
(car (last args)))
This would be horribly inefficient, but functionally equivalent to the real progn if the last argument returned exactly one value.
备注 84 (Notes 84)
The analogy to a lambda expression breaks down if the variable names are symbols that have special meanings in a parameter list. For example,
(let ((&key 1) (&optional 2)))
is correct, but the corresponding lambda expression
((lambda (ftkey ftoptional)) 1 2)
is not. The same problem arises if you try to define do in terms of labels . Thanks to David Kuznick for pointing this out.
备注 89 (Notes 89)
Steele, Guy L., Jr., and Richard P. Gabriel. The Evolution of Lisp. ACM SIGPLANNotices 28:3 (March 1993). The example in the quoted passage was translated from Scheme into Common Lisp.
备注 91 (Notes 91)
To make the time look the way people expect, you would want to ensure that minutes and seconds are represented with two digits, as in:
(defun get-time-string ()
(multiple-value-bind (s m h) (get-decoded-time)
(format nil "~A:~2,,,'0@A:~2,,,'O@A" h m s)))
备注 94 (Notes 94)
In a letter of March 18 (old style) 1751, Chesterfield writes:
“It was notorious, that the Julian Calendar was erroneous, and had overcharged the solar year with eleven days. Pope Gregory the Thirteenth corrected this error [in 1582]; his reformed calendar was immediately received by all the Catholic powers of Europe, and afterwards adopted by all the Protestant ones, except Russia, Sweden, and England. It was not, in my opinion, very honourable for England to remain in a gross and avowed error, especially in such company; the inconveniency of it was likewise felt by all those who had foreign correspondences, whether political or mercantile. I determined, therefore, to attempt the reformation; I consulted the best lawyers, and the most skillful astronomers, and we cooked up a bill for that purpose. But then my difficulty began; I was to bring in this bill, which was necessarily composed of law jargon and astronomical calculations, to both of which I am an utter stranger. However, it was absolutely necessary to make the House of Lords think that I knew something of the matter; and also to make them believe that they knew something of it themselves, which they do not. For my own part, I could just as soon have talked Celtic or Sclavonian to them, as astronomy, and they would have understood me full as well; so I resolved to do better than speak to the purpose, and to please instead of informing them. I gave them, therefore, only an historical account of calendars, from the Egyptian down to the Gregorian, amusing them now and then with little episodes; but I was particularly attentive to the choice of my words, to the harmony and roundness of my periods, to my elocution, to my action. This succeeded, and ever will succeed; they thought I informed them, because I pleased them; and many of them said I had made the whole very clear to them; when, God knows, I had not even attempted it.”
See: Roberts, David (Ed.) Lord Chesterfield’s Letters. Oxford University Press, Oxford, 1992.
备注 95 (Notes 95)
In Common Lisp, a universal time is an integer representing the number of seconds since the beginning of 1900. The functions encode-universal-time and decode-universal-time translate dates into and out of this format. So for dates after 1900, there is a simpler way to do date arithmetic in Common Lisp:
(defun num->date (n)
(multiple-value-bind (ig no re d m y)
(decode-universal-time n)
(values d m y)))
(defun date->num (d m y)
(encode-universal-time 1 0 0 d m y))
(defun date+ (d m y n)
(num->date (+ (date->num d m y)
(* 60 60 24 n))))
Besides the range limit, this approach has the disadvantage that dates tend not to be fixnums.
备注 100 (Notes 100)
Although a call to setf can usually be understood as a reference to a particular place, the underlying machinery is more general. Suppose that a marble is a structure with a single field called color:
(defstruct marble
color)
The following function takes a list of marbles and returns their color, if they all have the same color, or n i l if they have different colors:
(defun uniform-color (1st)
(let ((c (marble-color (car 1st))))
(dolist (m (cdr 1st))
(unless (eql (marble-color m) c)
(return nil)))
c))
Although uniform-color does not refer to a particular place, it is both reasonable and possible to have a call to it as the first argument to setf . Having defined
(defun (setf uniform-color) (val 1st)
(dolist (m 1st)
(setf (marble-color m) val)))
we can say
(setf (uniform-color *marbles*) 'red)
to make the color of each element of *marbles* be red.
备注 100-2 (Notes 100-2)
In older Common Lisp implementations, you have to use defsetf to define how a call should be treated when it appears as the first argument to setf. Be careful when translating, because the parameter representing the new value comes last in the definition of a function whose name is given as the second argument to defsetf . That is, the call
(defun (setf primo) (val 1st) (setf (car 1st) val))
is equivalent to
(defsetf primo set-primo)
(defun set-primo (1st val) (setf (car 1st) val))
备注 106 (Notes 106)
C, for example, lets you pass a pointer to a function, but there’s less you can pass in a function (because C doesn’t have closures) and less the recipient can do with it (because C has no equivalent of apply). What’s more, you are in principle supposed to declare the type of the return value of the function you pass a pointer to. How, then, could you write map-int or filter , which work for functions that return anything? You couldn’t, really. You would have to suppress the type-checking of arguments and return values, which is dangerous, and even so would probably only be practical for 32-bit values.
备注 109 (Notes 109)
For many examples of the versatility of closures, see: Abelson, Harold, and Gerald Jay Sussman, with Julie Sussman.Structure and Interpretation of Computer Programs. MIT Press, Cambridge, 1985.
备注 109-2 (Notes 109-2)
For more information about Dylan, see: Shalit, Andrew, with Kim Barrett, David Moon, Orca Starbuck, and Steve Strassmann. Dylan Interim Reference Manual. Apple Computer, 1994.
At the time of printing this document was accessible from several sites, including http://www.harlequin.com andhttp://www.apple.com. Scheme is a very small, clean dialect of Lisp. It was invented by Guy L. Steele Jr. and Gerald J. Sussman in 1975, and is currently defined by: Clinger, William, and Jonathan A. Rees (Eds.) Revised4 Report on the Algorithmic Language Scheme. 1991.
This report, and various implementations of Scheme, were at the time of printing available by anonymous FTP from swiss-ftp.ai.mit.edu:pub.
There are two especially good textbooks that use Scheme—Structure and Interpretation (see preceding note) and: Springer, George and Daniel P. Friedman. Scheme and the Art of Programming. MIT Press, Cambridge, 1989.
备注 112 (Notes 112)
The most horrible Lisp bugs may be those involving dynamic scope. Such errors almost never occur in Common Lisp, which has lexical scope by default. But since so many of the Lisps used as extension languages still have dynamic scope, practicing Lisp programmers should be aware of its perils.
One bug that can arise with dynamic scope is similar in spirit to variable capture (page 166). You pass one function as an argument to another. The function passed as an argument refers to some variable. But within the function that calls it, the variable has a new and unexpected value.
Suppose, for example, that we wrote a restricted version of mapcar as follows:
(defun our-mapcar (fn x)
(if (null x)
nil (cons (funcall fn (car x))
(our-mapcar fn (cdr x)))))
Then suppose that we used this function in another function, add-to-all , that would take a number and add it to every element of a list:
(defun add-to-all (1st x)
(our-mapcar #'(lambda (num) (+ num x))
1st))
In Common Lisp this code works fine, but in a Lisp with dynamic scope it would generate an error. The function passed as an argument to our-mapcar refers to x . At the point where we send this function to our-mapcar , x would be the number given as the second argument to add-to-all . But where the function will be called, within our-mapcar , x would be something else: the list passed as the second argument to our-mapcar . We would get an error when this list was passed as the second argument to + .
备注 123 (Notes 123)
Newer implementations of Common Lisp include avariable *read-eval* that can be used to turn off the # . read-macro. When calling read-from-string on user input, it is wise to bind *read-eval* to nil . Otherwise the user could cause side-effects by using # . in the input.
备注 125 (Notes 125)
There are a number of ingenious algorithms for fast string-matching, but string-matching in text files is one of the cases where the brute-force approach is still reasonably fast. For more on string-matching algorithms, see: Sedgewick, Robert. Algorithms. Addison-Wesley, Reading (MA), 1988.
备注 141 (Notes 141)
In 1984 CommonLisp, reduce did not take a :key argument, so random-next would be defined:
(defun random-next (prev)
(let* ((choices (gethash prev *words*))
(i (random (let ((x 0))
(dolist (c choices)
(incf x (cdr c)))
x))))
(dolist (pair choices)
(if (minusp (decf i (cdr pair)))
(return (car pair))))))
备注 141-2 (Notes 141-2)
In 1989, a program like Henley was used to simulate netnews postings by well-known flamers. The fake postings fooled a significant number of readers. Like all good hoaxes, this one had an underlying point. What did it say about the content of the original flames, or the attention with which they were read, that randomly generated postings could be mistaken for the real thing?
One of the most valuable contributions of artificial intelligence research has been to teach us which tasks are really difficult. Some tasks turn out to be trivial, and some almost impossible. If artificial intelligence is concerned with the latter, the study of the former might be called artificial stupidity. A silly name, perhaps, but this field has real promise—it promises to yield programs that play a role like that of control experiments.
Speaking with the appearance of meaning is one of the tasks that turn out to be surprisingly easy. People’s predisposition to find meaning is so strong that they tend to overshoot the mark. So if a speaker takes care to give his sentences a certain kind of superficial coherence, and his audience are sufficiently credulous, they will make sense of what he says.
This fact is probably as old as human history. But now we can give examples of genuinely random text for comparison. And if our randomly generated productions are difficult to distinguish from the real thing, might that not set people to thinking?
The program shown in Chapter 8 is about as simple as such a program could be, and that is already enough to generate “poetry” that many people (try it on your friends) will believe was written by a human being. With programs that work on the same principle as this one, but which model text as more than a simple stream of words, it will be possible to generate random text that has even more of the trappings of meaning.
For a discussion of randomly generated poetry as a legitimate literary form, see: Low, Jackson M. Poetry, Chance, Silence, Etc. In Hall, Donald (Ed.) Claims for Poetry. University of Michigan Press, Ann Arbor, 1982. You bet.
Thanks to the Online Book Initiative, ASCII versions of many classics are available online. At the time of printing, they could be obtained by anonymous FTP from ftp.std.com:obi.
See also the Emacs Dissociated Press feature, which uses an equivalent algorithm to scramble a buffer.
备注 150 (Notes 150)
下面这个函数会显示在一个给定实现中,16 个用来标示浮点表示法的限制的全局常量:
(defun float-limits ()
(dolist (m '(most least))
(dolist (s '(positive negative))
(dolist (f '(short single double long))
(let ((n (intern (string-upcase
(format nil "~A-~A-~A-float"
m s f)))))
(format t "~30A ~A ~%" n (symbol-value n)))))))
备注 164 (Notes 164)
快速排序演算法由霍尔于 1962 年发表,并被描述在 Knuth, D. E. Sorting and Searching. Addison-Wesley, Reading (MA), 1973.一书中。
备注 173 (Notes 173)
Foderaro, John K. Introduction to the Special Lisp Section. CACM 34:9 (Setember 1991), p.27
备注 176 (Notes 176)
关于 CLOS 更详细的信息,参考下列书目:
Keene, Sonya E. Object Oriented Programming in Common Lisp , Addison-Wesley, Reading (MA), 1989
Kiczales, Gregor, Jim des Rivieres, and Daniel G. Bobrow. The Art of the Metaobject Protocol MIT Press, Cambridge, 1991
备注 178 (Notes 178)
让我们再回放刚刚的句子一次:*我们甚至不需要看程序中其他的代码一眼,就可以完成种种的改动。*这个想法或许对某些读者听起来担忧地熟悉。这是写出面条式代码的食谱。
面向对象模型使得通过一点一点的来构造程序变得简单。但这通常意味著,在实践上它提供了一种有结构的方法来写出面条式代码。这不一定是坏事,但也不会是好事。
很多现实世界中的代码是面条式代码,这也许不能很快改变。针对那些终将成为面条式代码的程序来说,面向对象模型是好的:它们最起码会是有结构的面条。但针对那些也许可以避免误入崎途的程序来说,面向对象抽象只是更加危险的,而不是有用的。
备注 183 (Notes 183)
When an instance would inherit a slot with the same name from several of its superclasses, the instance inherits a single slot that combines the properties of the slots in the superclasses. The way combination is done varies from property to property:
The :allocation , :initform (if any), and :documentation (if any), will be those of the most specific classes.
The :initargs will be the union of the :initargs of all the superclasses. So will the :accessors , :readers , and:writers , effectively.
The :type will be the intersection of the :types of all the superclasses.
备注 191 (Notes 191)
You can avoid explicitly uninterning the names of slots that you want to be encapsulated by using uninterned symbols as the names to start with:
(progn
(defclass counter () ((#1=#:state :initform 0)))
(defmethod increment ((c counter))
(incf (slot-value c '#1#)))
(defmethod clear ((c counter))
(setf (slot-value c '#1#) 0)))
The progn here is a no-op; it is used to ensure that all the references to the uninterned symbol occur within the same expression. If this were inconvenient, you could use the following read-macro instead:
(defvar *symtab* (make-hash-table :test #'equal))
(defun pseudo-intern (name)
(or (gethash name *symtab*)
(setf (gethash name *symtab*) (gensym))))
(set-dispatch-macro-character #\# #\[
#'(lambda (stream char1 char2)
(do ((acc nil (cons char acc))
(char (read-char stream) (read-char stream)))
((eql char #\]) (pseudo-intern acc)))))
Then it would be possible to say just:
(defclass counter () ((#[state] :initform 0)))
(defmethod increment ((c counter))
(incf (slot-value c '#[state])))
(defmethod clear ((c counter))
(setf (slot-value c '#[state]) 0))
备注 204 (Notes 204)
下面这个宏将新元素推入二叉搜索树:
(defmacro bst-push (obj bst <)
(multiple-value-bind (vars forms var set access)
(get-setf-expansion bst)
(let ((g (gensym)))
`(let* ((,g ,obj)
,@(mapcar #'list vars forms)
(,(car var) (bst-insert! ,g ,access ,<)))
,set))))
备注 213 (Notes 213)
Knuth, Donald E. Structured Programming with goto Statements.Computing Surveys , 6:4 (December 1974), pp. 261-301
备注 214 (Notes 214)
Knuth, Donald E. Computer Programming as an ArtIn ACM Turing Award Lectures: The First Twenty Years. ACM Press, 1987
This paper and the preceding one are reprinted in: Knuth, Donald E. Literate Programming. CSLI Lecture Notes #27, Stanford University Center for the Study of Language and Information, Palo Alto, 1992.
备注 216 (Notes 216)
Steele, Guy L., Jr. Debunking the “Expensive Procedure Call” Myth or, Procedural Call Implementations Considered Harmful or, LAMBDA: The Ultimate GOTO. Proceedings of the National Conference of the ACM, 1977, p. 157.
Tail-recursion optimization should mean that the compiler will generate the same code for a tail-recursive function as it would for the equivalent do. The unfortunate reality, at least at the time of printing, is that many compilers generate slightly faster code for dos.
备注 217 (Notes 217)
For some examples of calls to disassemble on various processors, see: Norvig, Peter. Paradigms ofArtificial Intelligence Programming: Case Studies in Common Lisp. Morgan Kaufmann, San Mateo (CA), 1992.
备注 218 (Notes 218)
A lot of the increased popularity of object-oriented programming is more specifically the increased popularity of C++, and this in turn has a lot to do with typing. C++ gives you something that seems like a miracle in the conceptual world of C: the ability to define operators that work for different types of arguments. But you don’t need an object-oriented language to do this—all you need is run-time typing. And indeed, if you look at the way people use C++, the class hierarchies tend to be flat. C++ has become so popular not because people need to write programs in terms of classes and methods, but because people need a way around the restrictions imposed by C’s approach to typing.
备注 219 (Notes 219)
Macros can make declarations easier. The following macro expects a type name and an expression (probably numeric), and expands the expression so that all arguments, and all intermediate results, are declared to be of that type. If you wanted to ensure that an expression e was evaluated using only fixnum arithmetic, you could say (with-type fixnum e).
(defmacro with-type (type expr)
`(the ,type ,(if (atom expr)
expr
(expand-call type (binarize expr)))))
(defun expand-call (type expr)
`(,(car expr) ,@(mapcar #'(lambda (a)
`(with-type ,type ,a))
(cdr expr))))
(defun binarize (expr)
(if (and (nthcdr 3 expr)
(member (car expr) '(+ - * /)))
(destructuring-bind (op a1 a2 . rest) expr
(binarize `(,op (,op ,a1 ,a2) ,@rest)))
expr))
The call to binarize ensures that no arithmetic operator is called with more than two arguments. As the Lucid reference manual points out, a call like
(the fixnum (+ (the fixnum a)
(the fixnum b)
(the fixnum c)))
still cannot be compiled into fixnum additions, because the intermediate results (e.g. a + b) might not be fixnums.
Using with-type , we could duplicate the fully declared version of poly on page 219 with:
(defun poly (a b x)
(with-type fixnum (+ (* a (expt x 2)) (* b x))))
If you wanted to do a lot of fixnum arithmetic, you might even want to define a read-macro that would expand into a(with-type fixnum ...) .
备注 224 (Notes 224)
在许多 Unix 系统里, /usr/dict/words 是个合适的单词文件。
备注 226 (Notes 229)
T is a dialect of Scheme with many useful additions, including support for pools. For more on T, see: Rees, Jonathan A., Norman I. Adams, and James R. Meehan. The T Manual, 5th Edition. Yale University Computer Science Department, New Haven, 1988.
The T manual, and T itself, were at the time of printing available by anonymous FTP from hing.lcs.mit.edu:pub/t3.1 .
备注 229 (Notes 229)
The difference between specifications and programs is a difference in degree, not a difference in kind. Once we realize this, it seems strange to require that one write specifications for a program before beginning to implement it. If the program has to be written in a low-level language, then it would be reasonable to require that it be described in high-level terms first. But as the programming language becomes more abstract, the need for specifications begins to evaporate. Or rather, the implementation and the specifications can become the same thing.
If the high-level program is going to be re-implemented in a lower-level language, it starts to look even more like specifications. What Section 13.7 is saying, in other words, is that the specifications for C programs could be written in Lisp.
备注 230 (Notes 230)
Benvenuto Cellini’s story of the casting of his Perseus is probably the most famous (and the funniest) account of traditional bronze-casting: Cellini, Benvenuto. Autobiography. Translated by George Bull, Penguin Books, Harmondsworth, 1956.
备注 239 (Notes 239)
Even experienced Lisp hackers find packages confusing. Is it because packages are gross, or because we are not used to thinking about what happens at read-time?
There is a similar kind of uncertainty about def macro, and there it does seem that the difficulty is in the mind of the beholder. A good deal of work has gone into finding a more abstract alternative to def macro. But def macro is only gross if you approach it with the preconception (common enough) that defining a macro is like defining a function. Then it seems shocking that you suddenly have to worry about variable capture. When you think of macros as what they are, transformations on source code, then dealing with variable capture is no more of a problem than dealing with division by zero at run-time.
So perhaps packages will turn out to be a reasonable way of providing modularity. It is prima facie evidence on their side that they resemble the techniques that programmers naturally use in the absence of a formal module system.
备注 242 (Notes 242)
It might be argued that loop is more general, and that we should not define many operators to do what we can do with one. But it’s only in a very legalistic sense that loop is one operator. In that sense, eval is one operator too. Judged by the conceptual burden it places on the user, loop is at least as many operators as it has clauses. What’s more, these operators are not available separately, like real Lisp operators: you can’t break off a piece of loop and pass it as an argument to another function, as you could map-int .
备注 248 (Notes 248)
关于更深入讲述逻辑推论的资料,参见:Stuart Russell 及 Peter Norvig 所著的 Artificial Intelligence: A Modern Approach。
备注 273 (Notes 273)
Because the program in Chapter 17 takes advantage of the possibility of having a setf form as the first argument todefun , it will only work in more recent Common Lisp implementations. If you want to use it in an older implementation, substitute the following code in the final version:
(proclaim '(inline lookup set-lookup))
(defsetf lookup set-lookup)
(defun set-lookup (prop obj val)
(let ((off (position prop (layout obj) :test #'eq)))
(if off
(setf (svref obj (+ off 3)) val)
(error "Can't set ~A of ~A." val obj))))
(defmacro defprop (name &optioanl meth?)
`(progn
(defun ,name (obj &rest args)
,(if meth?
`(run-methods obj ',name args)
`(rget ',name obj nil)))
(defsetf ,name (obj) (val)
`(setf (lookip ',',name ,obj) ,val))))
备注 276 (Notes 276)
If defmeth were defined as
(defmacro defmeth (name obj parms &rest body)
(let ((gobj (gensym)))
`(let ((,gobj ,obj))
(setf (gethash ',name ,gobj)
#'(lambda ,parms
(labels ((next ()
(funcall (get-next ,gobj ',name)
,@parms)))
,@body))))))
then it would be possible to invoke the next method simply by calling next :
(defmeth area grumpy-circle (c)
(format t "How dare you stereotype me!""/,")
(next))
备注 284 (Notes 284)
For really fast access to slots we would use the following macro:
(defmacro with-slotref ((name prop class) &rest body)
(let ((g (gensym)))
`(let ((,g (+ 3 (position ,prop (layout ,class)
:test #'eq))))
(macrolet ((,name (obj) `(svref ,obj ,',g)))
,@body))))
It defines a local macro that refers directly to the vector element corresponding to a slot. If in some segment of code you wanted to refer to the same slot in many instances of the same class, with this macro the slot references would be straight svrefs.
For example, if the balloon class is defined as follows,
(setf balloon-class (class nil size))
then this function pops (in the old sense) a list of ballons:
(defun popem (ballons)
(with-slotref (bsize 'size balloon-class)
(dolist (b ballons)
(setf (bsize b) 0))))
备注 284-2 (Notes 284-2)
Gabriel, Richard P. Lisp Good News, Bad News, How to Win BigAI Expert, June 1991, p.35.
早在 1973 年, Richard Fateman 已经能证明在 PDP-10 主机上, MacLisp 编译器比制造商的 FORTRAN 编译器,产生出更快速的代码。
译注:该篇 MacLisp 编译器在 PDP-10 可产生比 Fortran 快的代码的论文在这可以找到
备注 399 (Notes 399)
It’s easiest to understand backquote if we suppose that backquote and comma are like quote, and that ```,x`` simply expands into (bq (comma x)) . If this were so, we could handle backquote by augmenting eval as in this sketch:
(defun eval2 (expr)
(case (and (consp expr) (car expr))
(comma (error "unmatched comma"))
(bq (eval-bq (second expr) 1))
(t (eval expr))))
(defun eval-bq (expr n)
(cond ((atom expr)
expr)
((eql (car expr) 'comma)
(if (= n 1)
(eval2 (second expr))
(list 'comma (eval-bq (second expr)
(1- n)))))
((eql (car expr) 'bq)
(list 'bq (eval-bq (second expr) (1+ n))))
(t
(cons (eval-bq (car expr) n)
(eval-bq (cdr expr) n)))))
In eval-bq , the parameter n is used to determine which commas match the current backquote. Each backquote increments it, and each comma decrements it. A comma encountered when n = 1 is a matching comma. Here is the example from page 400:
> (setf x 'a a 1 y 'b b 2)
2
> (eval2 '(bq (bq (w (comma x) (comma (comma y))))))
(BQ (W (COMMA X) (COMMA B)))
> (eval2 *)
(W A 2)
At some point a particularly remarkable molecule was formed by accident. We will call it the Replicator. It may not necessarily have been the biggest or the most complex molecule around, but it had the extraordinary property of being able to create copies of itself.
Richard Dawkins
The Selfish Gene
We shall first define a class of symbolic expressions in terms of ordered pairs and lists. Then we shall define five elementary functions and predicates, and build from them by composition, conditional expressions, and recursive definitions an extensive class of functions of which we shall give a number of examples. We shall then show how these functions themselves can be expressed as symbolic expressions, and we shall define a universal function apply that allows us to compute from the expression for a given function its value for given arguments.
John McCarthy
Recursive Functions of Symbolic Expressions and their Computation by Machine, Part I
这个附录包含了 58 个最常用的 Common Lisp 操作符。因为如此多的 Lisp 是(或可以)用 Lisp 所写成,而由于 Lisp 程序(或可以)相当精简,这是一种方便解释语言的方式。
这个练习也证明了,概念上 Common Lisp 不像看起来那样庞大。许多 Common Lisp 操作符是有用的函式库;要写出所有其它的东西,你所需要的操作符相当少。在这个附录的这些定义只需要:
applyarefbackquoteblockcarcdrceilingchar=consdefmacrodocumentationeqerrorexptfdefinitionfunction``floorgensymget-setf-expansionifimagpartlabelslengthmultiple-value-bindnth-valuequoterealpartsymbol-functiontagbodytype-oftypep=+-/<>
这里给出的代码作为一种解释 Common Lisp 的方式,而不是实现它的方式。在实际的实现上,这些操作符可以更高效,也会做更多的错误检查。为了方便参找,这些操作符本身按字母顺序排列。如果你真的想要这样定义 Lisp,每个宏的定义需要在任何调用它们的代码之前。
(defun -abs (n)
(if (typep n 'complex)
(sqrt (+ (expt (realpart n) 2) (expt (imagpart n) 2)))
(if (< n 0) (- n) n)))
(defun -adjoin (obj lst &rest args)
(if (apply #'member obj lst args) lst (cons obj lst)))
(defmacro -and (&rest args)
(cond ((null args) t)
((cdr args) `(if ,(car args) (-and ,@(cdr args))))
(t (car args))))
(defun -append (&optional first &rest rest)
(if (null rest)
first
(nconc (copy-list first) (apply #'-append rest))))
(defun -atom (x) (not (consp x)))
(defun -butlast (lst &optional (n 1))
(nreverse (nthcdr n (reverse lst))))
(defun -cadr (x) (car (cdr x)))
(defmacro -case (arg &rest clauses)
(let ((g (gensym)))
`(let ((,g ,arg))
(cond ,@(mapcar #'(lambda (cl)
(let ((k (car cl)))
`(,(cond ((member k '(t otherwise))
t)
((consp k)
`(member ,g ',k))
(t `(eql ,g ',k)))
(progn ,@(cdr cl)))))
clauses)))))
(defun -cddr (x) (cdr (cdr x)))
(defun -complement (fn)
#'(lambda (&rest args) (not (apply fn args))))
(defmacro -cond (&rest args)
(if (null args)
nil
(let ((clause (car args)))
(if (cdr clause)
`(if ,(car clause)
(progn ,@(cdr clause))
(-cond ,@(cdr args)))
`(or ,(car clause)
(-cond ,@(cdr args)))))))
(defun -consp (x) (typep x 'cons))
(defun -constantly (x) #'(lambda (&rest args) x))
(defun -copy-list (lst)
(labels ((cl (x)
(if (atom x)
x
(cons (car x)
(cl (cdr x))))))
(cons (car lst)
(cl (cdr lst)))))
(defun -copy-tree (tr)
(if (atom tr)
tr
(cons (-copy-tree (car tr))
(-copy-tree (cdr tr)))))
(defmacro -defun (name parms &rest body)
(multiple-value-bind (dec doc bod) (analyze-body body)
`(progn
(setf (fdefinition ',name)
#'(lambda ,parms
,@dec
(block ,(if (atom name) name (second name))
,@bod))
(documentation ',name 'function)
,doc)
',name)))
(defun analyze-body (body &optional dec doc)
(let ((expr (car body)))
(cond ((and (consp expr) (eq (car expr) 'declare))
(analyze-body (cdr body) (cons expr dec) doc))
((and (stringp expr) (not doc) (cdr body))
(if dec
(values dec expr (cdr body))
(analyze-body (cdr body) dec expr)))
(t (values dec doc body)))))
这个定义不完全正确,参见 let
(defmacro -do (binds (test &rest result) &rest body)
(let ((fn (gensym)))
`(block nil
(labels ((,fn ,(mapcar #'car binds)
(cond (,test ,@result)
(t (tagbody ,@body)
(,fn ,@(mapcar #'third binds))))))
(,fn ,@(mapcar #'second binds))))))
(defmacro -dolist ((var lst &optional result) &rest body)
(let ((g (gensym)))
`(do ((,g ,lst (cdr ,g)))
((atom ,g) (let ((,var nil)) ,result))
(let ((,var (car ,g)))
,@body))))
(defun -eql (x y)
(typecase x
(character (and (typep y 'character) (char= x y)))
(number (and (eq (type-of x) (type-of y))
(= x y)))
(t (eq x y))))
(defun -evenp (x)
(typecase x
(integer (= 0 (mod x 2)))
(t (error "non-integer argument"))))
(defun -funcall (fn &rest args) (apply fn args))
(defun -identity (x) x)
这个定义不完全正确:表达式 (let ((&key 1) (&optional 2))) 是合法的,但它产生的表达式不合法。
(defmacro -let (parms &rest body)
`((lambda ,(mapcar #'(lambda (x)
(if (atom x) x (car x)))
parms)
,@body)
,@(mapcar #'(lambda (x)
(if (atom x) nil (cadr x)))
parms)))
(defun -list (&rest elts) (copy-list elts))
(defun -listp (x) (or (consp x) (null x)))
(defun -mapcan (fn &rest lsts)
(apply #'nconc (apply #'mapcar fn lsts)))
(defun -mapcar (fn &rest lsts)
(cond ((member nil lsts) nil)
((null (cdr lsts))
(let ((lst (car lsts)))
(cons (funcall fn (car lst))
(-mapcar fn (cdr lst)))))
(t
(cons (apply fn (-mapcar #'car lsts))
(apply #'-mapcar fn
(-mapcar #'cdr lsts))))))
(defun -member (x lst &key test test-not key)
(let ((fn (or test
(if test-not
(complement test-not))
#'eql)))
(member-if #'(lambda (y)
(funcall fn x y))
lst
:key key)))
(defun -member-if (fn lst &key (key #'identity))
(cond ((atom lst) nil)
((funcall fn (funcall key (car lst))) lst)
(t (-member-if fn (cdr lst) :key key))))
(defun -mod (n m)
(nth-value 1 (floor n m)))
(defun -nconc (&optional lst &rest rest)
(if rest
(let ((rest-conc (apply #'-nconc rest)))
(if (consp lst)
(progn (setf (cdr (last lst)) rest-conc)
lst)
rest-conc))
lst))
(defun -not (x) (eq x nil))
(defun -nreverse (seq)
(labels ((nrl (lst)
(let ((prev nil))
(do ()
((null lst) prev)
(psetf (cdr lst) prev
prev lst
lst (cdr lst)))))
(nrv (vec)
(let* ((len (length vec))
(ilimit (truncate (/ len 2))))
(do ((i 0 (1+ i))
(j (1- len) (1- j)))
((>= i ilimit) vec)
(rotatef (aref vec i) (aref vec j))))))
(if (typep seq 'vector)
(nrv seq)
(nrl seq))))
(defun -null (x) (eq x nil))
(defmacro -or (&optional first &rest rest)
(if (null rest)
first
(let ((g (gensym)))
`(let ((,g ,first))
(if ,g
,g
(-or ,@rest))))))
这两个 Common Lisp 没有,但这里有几的定义会需要用到。
(defun pair (lst)
(if (null lst)
nil
(cons (cons (car lst) (cadr lst))
(pair (cddr lst)))))
(defun -pairlis (keys vals &optional alist)
(unless (= (length keys) (length vals))
(error "mismatched lengths"))
(nconc (mapcar #'cons keys vals) alist))
(defmacro -pop (place)
(multiple-value-bind (vars forms var set access)
(get-setf-expansion place)
(let ((g (gensym)))
`(let* (,@(mapcar #'list vars forms)
(,g ,access)
(,(car var) (cdr ,g)))
(prog1 (car ,g)
,set)))))
(defmacro -prog1 (arg1 &rest args)
(let ((g (gensym)))
`(let ((,g ,arg1))
,@args
,g)))
(defmacro -prog2 (arg1 arg2 &rest args)
(let ((g (gensym)))
`(let ((,g (progn ,arg1 ,arg2)))
,@args
,g)))
(defmacro -progn (&rest args) `(let nil ,@args))
(defmacro -psetf (&rest args)
(unless (evenp (length args))
(error "odd number of arguments"))
(let* ((pairs (pair args))
(syms (mapcar #'(lambda (x) (gensym))
pairs)))
`(let ,(mapcar #'list
syms
(mapcar #'cdr pairs))
(setf ,@(mapcan #'list
(mapcar #'car pairs)
syms)))))
(defmacro -push (obj place)
(multiple-value-bind (vars forms var set access)
(get-setf-expansion place)
(let ((g (gensym)))
`(let* ((,g ,obj)
,@(mapcar #'list vars forms)
(,(car var) (cons ,g ,access)))
,set))))
(defun -rem (n m)
(nth-value 1 (truncate n m)))
(defmacro -rotatef (&rest args)
`(psetf ,@(mapcan #'list
args
(append (cdr args)
(list (car args))))))
(defun -second (x) (cadr x))
(defmacro -setf (&rest args)
(if (null args)
nil
`(setf2 ,@args)))
(defmacro setf2 (place val &rest args)
(multiple-value-bind (vars forms var set)
(get-setf-expansion place)
`(progn
(let* (,@(mapcar #'list vars forms)
(,(car var) ,val))
,set)
,@(if args `((setf2 ,@args)) nil))))
(defun -signum (n)
(if (zerop n) 0 (/ n (abs n))))
(defun -stringp (x) (typep x 'string))
(defun -tailp (x y)
(or (eql x y)
(and (consp y) (-tailp x (cdr y)))))
(defun -third (x) (car (cdr (cdr x))))
(defun -truncate (n &optional (d 1))
(if (> n 0) (floor n d) (ceiling n d)))
(defmacro -typecase (arg &rest clauses)
(let ((g (gensym)))
`(let ((,g ,arg))
(cond ,@(mapcar #'(lambda (cl)
`((typep ,g ',(car cl))
(progn ,@(cdr cl))))
clauses)))))
(defmacro -unless (arg &rest body)
`(if (not ,arg)
(progn ,@body)))
(defmacro -when (arg &rest body)
`(if ,arg (progn ,@body)))
(defun -1+ (x) (+ x 1))
(defun -1- (x) (- x 1))
(defun ->= (first &rest rest)
(or (null rest)
(and (or (> first (car rest)) (= first (car rest)))
(apply #'->= rest))))
这个附录演示了如何调试 Lisp 程序,并给出你可能会遇到的常见错误。
中断循环 (Breakloop)
追踪与回溯 (Traces and Backtraces)
当什么事都没发生时 (When Noting Happens)
没有值或未绑定 (No Value/Unbound)
意料之外的 Nil (Unexpected Nils)
重新命名 (Renaming)
作为选择性参数的关键字 (Keywords as Optional Parameters)
错误声明 (Misdeclarations)
警告 (Warnings)
中断循环 (Breakloop)
如果你要求 Lisp 做些它不能做的事,求值过程会被一个错误讯息中断,而你会发现你位于一个称为中断循环的地方。中断循环工作的方式取决于不同的实现,但通常它至少会显示三件事:一个错误信息,一组选项,以及一个特别的提示符。
在中断循环里,你也可以像在顶层那样给表达式求值。在中断循环里,你或许能够找出错误的起因,甚至是修正它,并继续你程序的求值过程。然而,在一个中断循环里,你想做的最常见的事是跳出去。多数的错误起因于打错字或是小疏忽,所以通常你只会想终止程序并返回顶层。在下面这个假定的实现里,我们输入 :abort 来回到顶层。
> (/ 1 0)
Error: Division by zero.
Options: :abort, :backtrace
>> :abort
>
在这些情况里,实际上的输入取决于实现。
当你在中断循环里,如果一个错误发生的话,你会到另一个中断循环。多数的 Lisp 会指出你是在第几层的中断循环,要嘛通过印出多个提示符,不然就是在提示符前印出数字:
>> (/ 2 0)
Error: Division by zero.
Options: :abort, :backtrace, :previous
>>>
现在我们位于两层深的中断循环。此时我们可以选择回到前一个中断循环,或是直接返回顶层。
追踪与回溯 (Traces and Backtraces)
当你的程序不如你预期的那样工作时,有时候第一件该解决的事情是,它在做什么?如果你输入 (trace foo) ,则 Lisp 会在每次调用或返回 foo 时显示一个信息,显示传给 foo 的参数,或是 foo 返回的值。你可以追踪任何自己定义的 (user-defined)函数。
一个追踪通常会根据调用树来缩进。在一个做遍历的函数,像下面这个函数,它给一个树的每一个非空元素加上 1,
(defun tree1+ (tr)
(cond ((null tr) nil)
((atom tr) (1+ tr))
(t (cons (treel+ (car tr))
(treel+ (cdr tr))))))
一个树的形状会因此反映出它被遍历时的数据结构:
> (trace tree1+)
(tree1+)
> (tree1+ '((1 . 3) 5 . 7))
1 Enter TREE1+ ((1 . 3) 5 . 7)
2 Enter TREE1+ (1.3)
3 Enter TREE1+ 1
3 Exit TREE1+ 2
3 Enter TREE1+ 3
3 Exit TREE1+ 4
2 Exit TREE1+ (2 . 4)
2 Enter TREE1+ (5 . 7)
3 Enter TREE1+ 5
3 Exit TREE1+ 6
3 Enter TREE1+ 7
3 Exit TREE1+ 8
2 Exit TREE1+ (6 . 8)
1 Exit TREE1+ ((2 . 4) 6 . 8)
((2 . 4) 6 . 8)
要关掉 foo 的追踪,输入 (untrace foo) ;要关掉所有正在追踪的函数,只要输入 (untrace) 就好。
一个更灵活的追踪办法是在你的代码里插入诊断性的打印语句。如果已经知道结果了,这个经典的方法大概会与复杂的调适工具一样被使用数十次。这也是为什么可以互动地重定义函数式多么有用的原因。
一个回溯 (backtrace)是一个当前存在栈的调用的列表,当一个错误中止求值时,会由一个中断循环生成此列表。如果追踪像是”让我看看你在做什么”,一个回溯像是询问”我们是怎么到达这里的?” 在某方面上,追踪与回溯是互补的。一个追踪会显示在一个程序的调用树里,选定函数的调用。一个回溯会显示在一个程序部分的调用树里,所有函数的调用(路径为从顶层调用到发生错误的地方)。
在一个典型的实现里,我们可通过在中断循环里输入 :backtrace 来获得一个回溯,看起来可能像下面这样:
> (tree1+ ' ( ( 1 . 3) 5 . A))
Error: A is not a valid argument to 1+.
Options: :abort, :backtrace
» :backtrace
(1+ A)
(TREE1+ A)
(TREE1+ (5 . A))
(TREE1+ ((1 . 3) 5 . A))
出现在回溯里的臭虫较容易被发现。你可以仅往回检查调用链,直到你找到第一个不该发生的事情。另一个函数式编程 (2.12 节)的好处是所有的臭虫都会在回溯里出现。在纯函数式代码里,每一个可能出错的调用,在错误发生时,一定会在栈出现。
一个回溯每个实现所提供的信息量都不同。某些实现会完整显示一个所有待调用的历史,并显示参数。其他实现可能仅显示调用历史。一般来说,追踪与回溯解释型的代码会得到较多的信息,这也是为什么你要在确定你的程序可以工作之后,再来编译。
传统上我们在解释器里调试代码,且只在工作的情况下才编译。但这个观点也是可以改变的:至少有两个 Common Lisp 实现没有包含解释器。
当什么事都没发生时 (When Noting Happens)
不是所有的 bug 都会打断求值过程。另一个常见并可能更危险的情况是,当 Lisp 好像不鸟你一样。通常这是程序进入无穷循环的徵兆。
如果你怀疑你进入了无穷循环,解决方法是中止执行,并跳出中断循环。
如果循环是用迭代写成的代码,Lisp 会开心地执行到天荒地老。但若是用递归写成的代码(没有做尾递归优化),你最终会获得一个信息,信息说 Lisp 把栈的空间给用光了:
> (defun blow-stack () (1+ (blow-stack)))
BLOW-STACK
> (blow-stack)
Error: Stack Overflow
在这两个情况里,如果你怀疑进入了无穷循环,解决办法是中断执行,并跳出由于中断所产生的中断循环。
有时候程序在处理一个非常庞大的问题时,就算没有进入无穷循环,也会把栈的空间用光。虽然这很少见。通常把栈空间用光是编程错误的徵兆。
递归函数最常见的错误是忘记了基本用例 (base case)。用英语来描述递归,通常会忽略基本用例。不严谨地说,我们可能说“obj 是列表的成员,如果它是列表的第一个元素,或是剩余列表的成员” 严格上来讲,应该添加一句“若列表为空,则 obj 不是列表的成员”。不然我们描述的就是个无穷递归了。
在 Common Lisp 里,如果给入 nil 作为参数, car 与 cdr 皆返回 nil :
> (car nil)
NIL
> (cdr nil)
NIL
所以若我们在 member 函数里忽略了基本用例:
(defun our-member (obj lst)
(if (eql (car lst) obj)
lst
(our-member obj (cdr lst))))
要是我们找的对象不在列表里的话,则会陷入无穷循环。当我们到达列表底端而无所获时,递归调用会等价于:
(our-member obj nil)
在正确的定义中(第十六页「译注: 2.7 节」),基本用例在此时会停止递归,并返回 nil 。但在上面错误的定义里,函数愚昧地寻找 nil 的 car ,是 nil ,并将 nil 拿去跟我们寻找的对象比较。除非我们要找的对象刚好是 nil ,不然函数会继续在 nil 的 cdr里寻找,刚好也是 nil ── 整个过程又重来了。
如果一个无穷循环的起因不是那么直观,可能可以通过看看追踪或回溯来诊断出来。无穷循环有两种。简单发现的那种是依赖程序结构的那种。一个追踪或回溯会即刻演示出,我们的 our-member 究竟哪里出错了。
比较难发现的那种,是因为数据结构有缺陷才发生的无穷循环。如果你无意中创建了环状结构(见 199页「12.3 节」,遍历结构的代码可能会掉入无穷循环里。这些 bug 很难发现,因为不在后面不会发生,看起来像没有错误的代码一样。最佳的解决办法是预防,如同 199 页所描述的:避免使用破坏性操作,直到程序已经正常工作,且你已准备好要调优代码来获得效率。
如果 Lisp 有不鸟你的倾向,也有可能是等待你完成输入什么。在多数系统里,按下回车是没有效果的,直到你输入了一个完整的表达式。这个方法的好事是它允许你输入多行的表达式。坏事是如果你无意中少了一个闭括号,或是一个闭引号,Lisp 会一直等你,直到你真正完成输入完整的表达式:
> (format t "for example ~A~% 'this)
这里我们在控制字符串的最后忽略了闭引号。在此时按下回车是没用的,因为 Lisp 认为我们还在输入一个字符串。
在某些实现里,你可以回到上一行,并插入闭引号。在不允许你回到前行的系统,最佳办法通常是中断执行,并从中断循环回到顶层。
没有值或未绑定 (No Value/Unbound)
一个你最常听到 Lisp 的抱怨是一个符号没有值或未绑定。数种不同的问题都用这种方式呈现。
局部变量,如 let 与 defun 设置的那些,只在创建它们的表达式主体里合法。所以要是我们试著在 创建变量的 let 外部引用它,
> (progn
(let ((x 10))
(format t "Here x = ~A. ~%" x))
(format t "But now it's gone...~%")
x)
Here x = 10.
But now it's gone...
Error: X has no value.
我们获得一个错误。当 Lisp 抱怨某些东西没有值或未绑定时,它的意思通常是你无意间引用了一个不存在的变量。因为没有叫做 x的局部变量,Lisp 假定我们要引用一个有着这个名字的全局变量或常量。错误会发生是因为当 Lisp 试著要查找它的值的时候,却发现根本没有给值。打错变量的名字通常会给出同样的结果。
一个类似的问题发生在我们无意间将函数引用成变量。举例来说:
> defun foo (x) (+ x 1))
Error: DEFUN has no value
这在第一次发生时可能会感到疑惑: defun 怎么可能会没有值?问题的症结点在于我们忽略了最初的左括号,导致 Lisp 把符号defun 解读错误,将它视为一个全局变量的引用。
有可能你真的忘记初始化某个全局变量。如果你没有给 defvar 第二个参数,你的全局变量会被宣告出来,但没有初始化;这可能是问题的根源。
意料之外的 Nil (Unexpected Nils)
当函数抱怨传入 nil 作为参数时,通常是程序先前出错的徵兆。数个内置操作符返回 nil 来指出失败。但由于 nil 是一个合法的 Lisp 对象,问题可能之后才发生,在程序某部分试著要使用这个信以为真的返回值时。
举例来说,返回一个月有多少天的函数有一个 bug;假设我们忘记十月份了:
(defun month-length (mon)
(case mon
((jan mar may jul aug dec) 31)
((apr jun sept nov) 30)
(feb (if (leap-year) 29 28))))
如果有另一个函数,企图想计算出一个月当中有几个礼拜,
(defun month-weeks (mon) (/ (month-length mon) 7.0))
则会发生下面的情形:
> (month-weeks 'oct)
Error: NIL is not a valud argument to /.
问题发生的原因是因为 month-length 在 case 找不到匹配 。当这个情形发生时, case 返回 nil 。然后 month-weeks ,认为获得了一个数字,将值传给 / ,/ 就抱怨了。
在这里最起码 bug 与 bug 的临床表现是挨著发生的。这样的 bug 在它们相距很远时很难找到。要避免这个可能性,某些 Lisp 方言让跑完 case 或 cond 又没匹配的情形,产生一个错误。在 Common Lisp 里,在这种情况里可以做的是使用 ecase ,如 14.6 节所描述的。
重新命名 (Renaming)
在某些场合里(但不是全部场合),有一种特别狡猾的 bug ,起因于重新命名函数或变量,。举例来说,假设我们定义下列(低效的) 函数来找出双重嵌套列表的深度:
(defun depth (x)
(if (atom x)
1
(1+ (apply #'max (mapcar #'depth x)))))
测试函数时,我们发现它给我们错误的答案(应该是 1):
> (depth '((a)))
3
起初的 1 应该是 0 才对。如果我们修好这个错误,并给这个函数一个较不模糊的名称:
(defun nesting-depth (x)
(if (atom x)
0
(1+ (apply #'max (mapcar #'depth x)))))
当我们再测试上面的例子,它返回同样的结果:
> (nesting-depth '((a)))
3
我们不是修好这个函数了吗?没错,但答案不是来自我们修好的代码。我们忘记也改掉递归调用中的名称。在递归用例里,我们的新函数仍调用先前的 depth ,这当然是不对的。
作为选择性参数的关键字 (Keywords as Optional Parameters)
若函数同时接受关键字与选择性参数,这通常是个错误,无心地提供了关键字作为选择性参数。举例来说,函数 read-from-string有着下列的参数列表:
(read-from-string string &optional eof-error eof-value
&key start end preserve-whitespace)
这样一个函数你需要依序提供值,给所有的选择性参数,再来才是关键字参数。如果你忘记了选择性参数,看看下面这个例子,
> (read-from-string "abcd" :start 2)
ABCD
4
则 :start 与 2 会成为前两个选择性参数的值。若我们想要 read 从第二个字符开始读取,我们应该这么说:
> (read-from-string "abcd" nil nil :start 2)
CD
4
错误声明 (Misdeclarations)
第十三章解释了如何给变量及数据结构做类型声明。通过给变量做类型声明,你保证变量只会包含某种类型的值。当产生代码时,Lisp 编译器会依赖这个假定。举例来说,这个函数的两个参数都声明为 double-floats ,
(defun df* (a b)
(declare (double-float a b))
(* a b))
因此编译器在产生代码时,被授权直接将浮点乘法直接硬连接 (hard-wire)到代码里。
如果调用 df* 的参数不是声明的类型时,可能会捕捉一个错误,或单纯地返回垃圾。在某个实现里,如果我们传入两个定长数,我们获得一个硬体中断:
> (df* 2 3)
Error: Interrupt.
如果获得这样严重的错误,通常是由于数值不是先前声明的类型。
警告 (Warnings)
有些时候 Lisp 会抱怨一下,但不会中断求值过程。许多这样的警告是错误的警钟。一种最常见的可能是由编译器所产生的,关于未宣告或未使用的变量。举例来说,在 66 页「译注: 6.4 节」, map-int 的第二个调用,有一个 x 变量没有使用到。如果想要编译器在每次编译程序时,停止通知你这些事,使用一个忽略声明:
(map-int #'(lambda (x)
(declare (ignore x))
(random 100))
10)
在本章里,我们将使用 Lisp 来自己实现面向对象语言。这样子的程序称为嵌入式语言 (embedded language)。嵌入一个面向对象语言到 Lisp 里是一个绝佳的例子。同時作为一个 Lisp 的典型用途,並演示了面向对象的抽象是如何多自然地在 Lisp 基本的抽象上构建出来。
17.1 继承 (Inheritance)
17.2 多重继承 (Multiple Inheritance)
17.3 定义对象 (Defining Objects)
17.4 函数式语法 (Functional Syntax)
17.5 定义方法 (Defining Methods)
17.6 实例 (Instances)
17.7 新的实现 (New Implementation)
17.8 分析 (Analysis)
17.1 继承 (Inheritance)
11.10 小节解释过通用函数与消息传递的差别。
在消息传递模型里,
对象有属性,
并回应消息,
并从其父类继承属性与方法。
当然了,我们知道 CLOS 使用的是通用函数模型。但本章我们只对于写一个迷你的对象系统 (minimal object system)感兴趣,而不是一个可与 CLOS 匹敌的系统,所以我们将使用消息传递模型。
我们已经在 Lisp 里看过许多保存属性集合的方法。一种可能的方法是使用哈希表来代表对象,并将属性作为哈希表的条目保存。接著可以通过 gethash 来存取每个属性:
(gethash 'color obj)
由于函数是数据对象,我们也可以将函数作为属性保存起来。这表示我们也可以有方法;要调用一个对象特定的方法,可以通过funcall 一下哈希表里的同名属性:
(funcall (gethash 'move obj) obj 10)
我们可以在这个概念上,定义一个 Smalltalk 风格的消息传递语法,
(defun tell (obj message &rest args)
(apply (gethash message obj) obj args))
所以想要一个对象 obj 移动 10 单位,我们可以说:
(tell obj 'move 10)
事实上,纯 Lisp 唯一缺少的原料是继承。我们可以通过定义一个递归版本的 gethash 来实现一个简单版,如图 17.1 。现在仅用共 8 行代码,便实现了面向对象编程的 3 个基本元素。
(defun rget (prop obj)
(multiple-value-bind (val in) (gethash prop obj)
(if in
(values val in)
(let ((par (gethash :parent obj)))
(and par (rget prop par))))))
(defun tell (obj message &rest args)
(apply (rget message obj) obj args))
图 17.1:继承
让我们用这段代码,来试试本来的例子。我们创建两个对象,其中一个对象是另一个的子类:
> (setf circle-class (make-hash-table)
our-circle (make-hash-table)
(gethash :parent our-circle) circle-class
(gethash 'radius our-circle) 2)
2
circle-class 对象会持有给所有圆形使用的 area 方法。它是接受一个参数的函数,该参数为传来原始消息的对象:
> (setf (gethash 'area circle-class)
#'(lambda (x)
(* pi (expt (rget 'radius x) 2))))
#<Interpreted-Function BF1EF6>
现在当我们询问 our-circle 的面积时,会根据此类所定义的方法来计算。我们使用 rget 来读取一个属性,用 tell 来调用一个方法:
> (rget 'radius our-circle)
2
T
> (tell our-circle 'area)
12.566370614359173
在开始改善这个程序之前,值得停下来想想我们到底做了什么。仅使用 8 行代码,我们使纯的、旧的、无 CLOS 的 Lisp ,转变成一个面向对象语言。我们是怎么完成这项壮举的?应该用了某种秘诀,才会仅用了 8 行代码,就实现了面向对象编程。
的确有一个秘诀存在,但不是编程的奇技淫巧。这个秘诀是,Lisp 本来就是一个面向对象的语言了,甚至说,是种更通用的语言。我们需要做的事情,不过就是把本来就存在的抽象,再重新包装一下。
17.2 多重继承 (Multiple Inheritance)
到目前为止我们只有单继承 ── 一个对象只可以有一个父类。但可以通过使 parent 属性变成一个列表来获得多重继承,并重新定义 rget ,如图 17.2 所示。
在只有单继承的情况下,当我们想要从对象取出某些属性,只需要递归地延著祖先的方向往上找。如果对象本身没有我们想要属性的有关信息,可以检视其父类,以此类推。有了多重继承后,我们仍想要执行同样的搜索,但这件简单的事,却被对象的祖先可形成一个图,而不再是简单的树给复杂化了。不能只使用深度优先来搜索这个图。有多个父类时,可以有如图 17.3 所示的层级存在: a 起源于 b 及 c ,而他们都是 d 的子孙。一个深度优先(或说高度优先)的遍历结果会是 a , b , d, c , d 。而如果我们想要的属性在 d与 c 都有的话,我们会获得存在 d 的值,而不是存在 c 的值。这违反了子类可覆写父类提供缺省值的原则。
如果我们想要实现普遍的继承概念,就不应该在检查其子孙前,先检查该对象。在这个情况下,适当的搜索顺序会是 a , b , c , d 。那如何保证搜索总是先搜子孙呢?最简单的方法是用一个对象,以及按正确优先顺序排序的,由祖先所构成的列表。通过调用traverse 开始,建构一个列表,表示深度优先遍历所遇到的对象。如果任一个对象有共享的父类,则列表中会有重复元素。如果仅保存最后出现的复本,会获得一般由 CLOS 定义的优先级列表。(删除所有除了最后一个之外的复本,根据 183 页所描述的算法,规则三。)Common Lisp 函数 delete-duplicates 定义成如此作用的,所以我们只要在深度优先的基础上调用它,我们就会得到正确的优先级列表。一旦优先级列表创建完成, rget 根据需要的属性搜索第一个符合的对象。
我们可以通过利用优先级列表的优点,举例来说,一个爱国的无赖先是一个无赖,然后才是爱国者:
> (setf scoundrel (make-hash-table)
patriot (make-hash-table)
patriotic-scoundrel (make-hash-table)
(gethash 'serves scoundrel) 'self
(gethash 'serves patriot) 'country
(gethash :parents patriotic-scoundrel)
(list scoundrel patriot))
(#<Hash-Table C41C7E> #<Hash-Table C41F0E>)
> (rget 'serves patriotic-scoundrel)
SELF
T
到目前为止,我们有一个强大的程序,但极其丑陋且低效。在一个 Lisp 程序生命周期的第二阶段,我们将这个初步框架提炼成有用的东西。
17.3 定义对象 (Defining Objects)
第一个我们需要改善的是,写一个用来创建对象的函数。我们程序表示对象以及其父类的方式,不需要给用户知道。如果我们定义一个函数来创建对象,用户将能够一个步骤就创建出一个对象,并指定其父类。我们可以在创建一个对象的同时,顺道构造优先级列表,而不是在每次当我们需要找一个属性或方法时,才花费庞大代价来重新构造。
如果我们要维护优先级列表,而不是在要用的时候再构造它们,我们需要处理列表会过时的可能性。我们的策略会是用一个列表来保存所有存在的对象,而无论何时当某些父类被改动时,重新给所有受影响的对象生成优先级列表。这代价是相当昂贵的,但由于查询比重定义父类的可能性来得高许多,我们会省下许多时间。这个改变对我们的程序的灵活性没有任何影响;我们只是将花费从频繁的操作转到不频繁的操作。
图 17.4 包含了新的代码。 λ 全局的 *objs* 会是一个包含所有当前对象的列表。函数 parents 取出一个对象的父类;相反的 (setfparents) 不仅配置一个对象的父类,也调用 make-precedence 来重新构造任何需要变动的优先级列表。这些列表与之前一样,由precedence 来构造。
用户现在不用调用 make-hash-table 来创建对象,调用 obj 来取代, obj 一步完成创建一个新对象及定义其父类。我们也重定义了rget 来利用保存优先级列表的好处。
(defvar *objs* nil)
(defun parents (obj) (gethash :parents obj))
(defun (setf parents) (val obj)
(prog1 (setf (gethash :parents obj) val)
(make-precedence obj)))
(defun make-precedence (obj)
(setf (gethash :preclist obj) (precedence obj))
(dolist (x *objs*)
(if (member obj (gethash :preclist x))
(setf (gethash :preclist x) (precedence x)))))
(defun obj (&rest parents)
(let ((obj (make-hash-table)))
(push obj *objs*)
(setf (parents obj) parents)
obj))
(defun rget (prop obj)
(dolist (c (gethash :preclist obj))
(multiple-value-bind (val in) (gethash prop c)
(if in (return (values val in))))))
图 17.4:创建对象
17.4 函数式语法 (Functional Syntax)
另一个可以改善的空间是消息调用的语法。 tell 本身是无谓的杂乱不堪,这也使得动词在第三顺位才出现,同时代表著我们的程序不再可以像一般 Lisp 前序表达式那样阅读:
(tell (tell obj 'find-owner) 'find-owner)
我们可以使用图 17.5 所定义的 defprop 宏,通过定义作为函数的属性名称来摆脱这种 tell 语法。若选择性参数 meth? 为真的话,会将此属性视为方法。不然会将属性视为槽,而由 rget 所取回的值会直接返回。一旦我们定义了属性作为槽或方法的名字,
(defmacro defprop (name &optional meth?)
`(progn
(defun ,name (obj &rest args)
,(if meth?
`(run-methods obj ',name args)
`(rget ',name obj)))
(defun (setf ,name) (val obj)
(setf (gethash ',name obj) val))))
(defun run-methods (obj name args)
(let ((meth (rget name obj)))
(if meth
(apply meth obj args)
(error "No ~A method for ~A." name obj))))
图 17.5: 函数式语法
(defprop find-owner t)
我们就可以在函数调用里引用它,则我们的代码读起来将会再次回到 Lisp 本来那样:
(find-owner (find-owner obj))
我们的前一个例子在某种程度上可读性变得更高了:
> (progn
(setf scoundrel (obj)
patriot (obj)
patriotic-scoundrel (obj scoundrel patriot))
(defprop serves)
(setf (serves scoundrel) 'self
(serves patriot) 'country)
(serves patriotic-scoundrel))
SELF
T
17.5 定义方法 (Defining Methods)
到目前为止,我们借由叙述如下的东西来定义一个方法:
(defprop area t)
(setf circle-class (obj))
(setf (area circle-class)
#'(lambda (c) (* pi (expt (radius c) 2))))
(defmacro defmeth (name obj parms &rest body)
(let ((gobj (gensym)))
`(let ((,gobj ,obj))
(setf (gethash ',name ,gobj)
(labels ((next () (get-next ,gobj ',name)))
#'(lambda ,parms ,@body))))))
(defun get-next (obj name)
(some #'(lambda (x) (gethash name x))
(cdr (gethash :preclist obj))))
图 17.6 定义方法。
在一个方法里,我们可以通过给对象的 :preclist 的 cdr 获得如内置 call-next-method 方法的效果。所以举例来说,若我们想要定义一个特殊的圆形,这个圆形在返回面积的过程中印出某个东西,我们可以说:
(setf grumpt-circle (obj circle-class))
(setf (area grumpt-circle)
#'(lambda (c)
(format t "How dare you stereotype me!~%")
(funcall (some #'(lambda (x) (gethash 'area x))
(cdr (gethash :preclist c)))
c)))
这里 funcall 等同于一个 call-next-method 调用,但他..
图 17.6 的 defmeth 宏提供了一个便捷方式来定义方法,并使得调用下个方法变得简单。一个 defmeth 的调用会展开成一个 setf 表达式,但 setf 在一個 labels 表达式里定义了 next 作为取出下个方法的函数。这个函数与 next-method-p 类似(第 188 页「譯註: 11.7 節」),但返回的是我们可以调用的东西,同時作為 call-next-method 。 λ 前述两个方法可以被定义成:
(defmeth area circle-class (c)
(* pi (expt (radius c) 2)))
(defmeth area grumpy-circle (c)
(format t "How dare you stereotype me!~%")
(funcall (next) c))
顺道一提,注意 defmeth 的定义也利用到了符号捕捉。方法的主体被插入至函数 next 是局部定义的一个上下文里。
17.6 实例 (Instances)
到目前为止,我们还没有将类别与实例做区别。我们使用了一个术语来表示两者,对象(object)。将所有的对象视为一体是优雅且灵活的,但这非常没效率。在许多面向对象应用里,继承图的底部会是复杂的。举例来说,模拟一个交通情况,我们可能有少于十个对象来表示车子的种类,但会有上百个对象来表示特定的车子。由于后者会全部共享少数的优先级列表,创建它们是浪费时间的,并且浪费空间来保存它们。
图 17.7 定义一个宏 inst ,用来创建实例。实例就像其他对象一样(现在也可称为类别),有区别的是只有一个父类且不需维护优先级列表。它们也没有包含在列表 *objs** 里。在前述例子里,我们可以说:
(setf grumpy-circle (inst circle-class))
由于某些对象不再有优先级列表,函数 rget 以及 get-next 现在被重新定义,检查这些对象的父类来取代。获得的效率不用拿灵活性交换。我们可以对一个实例做任何我们可以给其它种对象做的事,包括创建一个实例以及重定义其父类。在后面的情况里, (setfparents) 会有效地将对象转换成一个“类别”。
17.7 新的实现 (New Implementation)
我们到目前为止所做的改善都是牺牲灵活性交换而来。在这个系统的开发后期,一个 Lisp 程序通常可以牺牲些许灵活性来获得好处,这里也不例外。目前为止我们使用哈希表来表示所有的对象。这给我们带来了超乎我们所需的灵活性,以及超乎我们所想的花费。在这个小节里,我们会重写我们的程序,用简单向量来表示对象。
(defun inst (parent)
(let ((obj (make-hash-table)))
(setf (gethash :parents obj) parent)
obj))
(defun rget (prop obj)
(let ((prec (gethash :preclist obj)))
(if prec
(dolist (c prec)
(multiple-value-bind (val in) (gethash prop c)
(if in (return (values val in)))))
(multiple-value-bind (val in) (gethash prop obj)
(if in
(values val in)
(rget prop (gethash :parents obj)))))))
(defun get-next (obj name)
(let ((prec (gethash :preclist obj)))
(if prec
(some #'(lambda (x) (gethash name x))
(cdr prec))
(get-next (gethash obj :parents) name))))
图 17.7: 定义实例
这个改变意味著放弃动态定义新属性的可能性。目前我们可通过引用任何对象,给它定义一个属性。现在当一个类别被创建时,我们会需要给出一个列表,列出该类有的新属性,而当实例被创建时,他们会恰好有他们所继承的属性。
在先前的实现里,类别与实例没有实际区别。一个实例只是一个恰好有一个父类的类别。如果我们改动一个实例的父类,它就变成了一个类别。在新的实现里,类别与实例有实际区别;它使得将实例转成类别不再可能。
在图 17.8-17.10 的代码是一个完整的新实现。图片 17.8 给创建类别与实例定义了新的操作符。类别与实例用向量来表示。表示类别与实例的向量的前三个元素包含程序自身要用到的信息,而图 17.8 的前三个宏是用来引用这些元素的:
(defmacro parents (v) `(svref ,v 0))
(defmacro layout (v) `(the simple-vector (svref ,v 1)))
(defmacro preclist (v) `(svref ,v 2))
(defmacro class (&optional parents &rest props)
`(class-fn (list ,@parents) ',props))
(defun class-fn (parents props)
(let* ((all (union (inherit-props parents) props))
(obj (make-array (+ (length all) 3)
:initial-element :nil)))
(setf (parents obj) parents
(layout obj) (coerce all 'simple-vector)
(preclist obj) (precedence obj))
obj))
(defun inherit-props (classes)
(delete-duplicates
(mapcan #'(lambda (c)
(nconc (coerce (layout c) 'list)
(inherit-props (parents c))))
classes)))
(defun precedence (obj)
(labels ((traverse (x)
(cons x
(mapcan #'traverse (parents x)))))
(delete-duplicates (traverse obj))))
(defun inst (parent)
(let ((obj (copy-seq parent)))
(setf (parents obj) parent
(preclist obj) nil)
(fill obj :nil :start 3)
obj))
图 17.8: 向量实现:创建
parents 字段取代旧实现中,哈希表条目里 :parents 的位置。在一个类别里, parents 会是一个列出父类的列表。在一个实例里, parents 会是一个单一的父类。
layout 字段是一个包含属性名字的向量,指出类别或实例的从第四个元素开始的设计 (layout)。
preclist 字段取代旧实现中,哈希表条目里 :preclist 的位置。它会是一个类别的优先级列表,实例的话就是一个空表。
因为这些操作符是宏,他们全都可以被 setf 的第一个参数使用(参考 10.6 节)。
class 宏用来创建类别。它接受一个含有其基类的选择性列表,伴随著零个或多个属性名称。它返回一个代表类别的对象。新的类别会同时有自己本身的属性名,以及从所有基类继承而来的属性。
> (setf *print-array* nil
gemo-class (class nil area)
circle-class (class (geom-class) radius))
#<Simple-Vector T 5 C6205E>
这里我们创建了两个类别: geom-class 没有基类,且只有一个属性, area ; circle-class 是 gemo-class 的子类,并添加了一个属性, radius 。 [1]circle-class 类的设计
> (coerce (layout circle-class) 'list)
(AREA RADIUS)
显示了五个字段里,最后两个的名称。 [2]
class 宏只是一个 class-fn 的介面,而 class-fn 做了实际的工作。它调用 inherit-props 来汇整所有新对象的父类,汇整成一个列表,创建一个正确长度的向量,并适当地配置前三个字段。( preclist 由 precedence 创建,本质上 precedence 没什么改变。)类别余下的字段设置为 :nil 来指出它们尚未初始化。要检视 circle-class 的 area 属性,我们可以:
> (svref circle-class
(+ (position 'area (layout circle-class)) 3))
:NIL
稍后我们会定义存取函数来自动办到这件事。
最后,函数 inst 用来创建实例。它不需要是一个宏,因为它仅接受一个参数:
> (setf our-circle (inst circle-class))
#<Simple-Vector T 5 C6464E>
比较 inst 与 class-fn 是有益学习的,它们做了差不多的事。因为实例仅有一个父类,不需要决定它继承什么属性。实例可以仅拷贝其父类的设计。它也不需要构造一个优先级列表,因为实例没有优先级列表。创建实例因此与创建类别比起来来得快许多,因为创建实例在多数应用里比创建类别更常见。
(declaim (inline lookup (setf lookup)))
(defun rget (prop obj next?)
(let ((prec (preclist obj)))
(if prec
(dolist (c (if next? (cdr prec) prec) :nil)
(let ((val (lookup prop c)))
(unless (eq val :nil) (return val))))
(let ((val (lookup prop obj)))
(if (eq val :nil)
(rget prop (parents obj) nil)
val)))))
(defun lookup (prop obj)
(let ((off (position prop (layout obj) :test #'eq)))
(if off (svref obj (+ off 3)) :nil)))
(defun (setf lookup) (val prop obj)
(let ((off (position prop (layout obj) :test #'eq)))
(if off
(setf (svref obj (+ off 3)) val)
(error "Can't set ~A of ~A." val obj))))
图 17.9: 向量实现:存取
现在我们可以创建所需的类别层级及实例,以及需要的函数来读写它们的属性。图 17.9 的第一个函数是 rget 的新定义。它的形状与图 17.7 的 rget 相似。条件式的两个分支,分别处理类别与实例。
若对象是一个类别,我们遍历其优先级列表,直到我们找到一个对象,其中欲找的属性不是 :nil 。如果没有找到,返回 :nil。
若对象是一个实例,我们直接查找属性,并在没找到时递回地调用 rget 。
rget 与 next? 新的第三个参数稍后解释。现在只要了解如果是 nil , rget 会像平常那样工作。
函数 lookup 及其反相扮演著先前 rget 函数里 gethash 的角色。它们使用一个对象的 layout ,来取出或设置一个给定名称的属性。这条查询是先前的一个复本:
> (lookup 'area circle-class)
:NIL
由于 lookup 的 setf 也定义了,我们可以给 circle-class 定义一个 area 方法,通过:
(setf (lookup 'area circle-class)
#'(lambda (c)
(* pi (expt (rget 'radius c nil) 2))))
在这个程序里,和先前的版本一样,没有特别区别出方法与槽。一个“方法”只是一个字段,里面有着一个函数。这将很快会被一个更方便的前端所隐藏起来。
(declaim (inline run-methods))
(defmacro defprop (name &optional meth?)
`(progn
(defun ,name (obj &rest args)
,(if meth?
`(run-methods obj ',name args)
`(rget ',name obj nil)))
(defun (setf ,name) (val obj)
(setf (lookup ',name obj) val))))
(defun run-methods (obj name args)
(let ((meth (rget name obj nil)))
(if (not (eq meth :nil))
(apply meth obj args)
(error "No ~A method for ~A." name obj))))
(defmacro defmeth (name obj parms &rest body)
(let ((gobj (gensym)))
`(let ((,gobj ,obj))
(defprop ,name t)
(setf (lookup ',name ,gobj)
(labels ((next () (rget ,gobj ',name t)))
#'(lambda ,parms ,@body))))))
图 17.10: 向量实现:宏介面
图 17.10 包含了新的实现的最后部分。这个代码没有给程序加入任何威力,但使程序更容易使用。宏 defprop 本质上没有改变;现在仅调用 lookup 而不是 gethash 。与先前相同,它允许我们用函数式的语法来引用属性:
> (defprop radius)
(SETF RADIUS)
> (radius our-circle)
:NIL
> (setf (radius our-circle) 2)
2
如果 defprop 的第二个选择性参数为真的话,它展开成一个 run-methods 调用,基本上也没什么改变。
最后,函数 defmeth 提供了一个便捷方式来定义方法。这个版本有三件新的事情:它隐含了 defprop ,它调用 lookup 而不是gethash ,且它调用 regt 而不是 278 页的 get-next (译注: 图 17.7 的 get-next )来获得下个方法。现在我们理解给 rget 添加额外参数的理由。它与 get-next 非常相似,我们同样通过添加一个额外参数,在一个函数里实现。若这额外参数为真时, rget 取代get-next 的位置。
现在我们可以达到先前方法定义所有的效果,但更加清晰:
(defmeth area circle-class (c)
(* pi (expt (radius c) 2)))
注意我们可以直接调用 radius 而无须调用 rget ,因为我们使用 defprop 将它定义成一个函数。因为隐含的 defprop 由 defmeth实现,我们也可以调用 area 来获得 our-circle 的面积:
> (area our-circle)
12.566370614359173
17.8 分析 (Analysis)
我们现在有了一个适合撰写实际面向对象程序的嵌入式语言。它很简单,但就大小来说相当强大。而在典型应用里,它也会是快速的。在一个典型的应用里,操作实例应比操作类别更常见。我们重新设计的重点在于如何使得操作实例的花费降低。
在我们的程序里,创建类别既慢且产生了许多垃圾。如果类别不是在速度为关键考量时创建,这还是可以接受的。会需要速度的是存取函数以及创建实例。这个程序里的没有做编译优化的存取函数大约与我们预期的一样快。 λ 而创建实例也是如此。且两个操作都没有用到构造 (consing)。除了用来表达实例的向量例外。会自然的以为这应该是动态地配置才对。但我们甚至可以避免动态配置实例,如果我们使用像是 13.4 节所提出的策略。
我们的嵌入式语言是 Lisp 编程的一个典型例子。只不过是一个嵌入式语言就可以是一个例子了。但 Lisp 的特性是它如何从一个小的、受限版本的程序,进化成一个强大但低效的版本,最终演化成快速但稍微受限的版本。
Lisp 恶名昭彰的缓慢不是 Lisp 本身导致(Lisp 编译器早在 1980 年代就可以产生出与 C 编译器一样快的代码),而是由于许多程序员在第二个阶段就放弃的事实。如同 Richard Gabriel 所写的,
要在 Lisp 撰写出性能极差的程序相当简单;而在 C 这几乎是不可能的。 λ
这完全是一个真的论述,但也可以解读为赞扬或贬低 Lisp 的论点:
通过牺牲灵活性换取速度,你可以在 Lisp 里轻松地写出程序;在 C 语言里,你没有这个选择。
除非你优化你的 Lisp 代码,不然要写出缓慢的软件根本易如反掌。
你的程序属于哪一种解读完全取决于你。但至少在开发初期,Lisp 使你有牺牲执行速度来换取时间的选择。
有一件我们示例程序没有做的很好的事是,它不是一个称职的 CLOS 模型(除了可能没有说明难以理解的 call-next-method 如何工作是件好事例外)。如大象般庞大的 CLOS 与这个如蚊子般微小的 70 行程序之间,存在多少的相似性呢?当然,这两者的差别是出自于教育性,而不是探讨有多相似。首先,这使我们理解到“面向对象”的广度。我们的程序比任何被称为是面向对象的都来得强大,而这只不过是 CLOS 的一小部分威力。
我们程序与 CLOS 不同的地方是,方法是属于某个对象的。这个方法的概念使它们与对第一个参数做派发的函数相同。而当我们使用函数式语法来调用方法时,这看起来就跟 Lisp 的函数一样。相反地,一个 CLOS 的通用函数,可以派发它的任何参数。一个通用函数的组件称为方法,而若你将它们定义成只对第一个参数特化,你可以制造出它们是某个类或实例的方法的错觉。但用面向对象编程的消息传递模型来思考 CLOS 最终只会使你困惑,因为 CLOS 凌驾在面向对象编程之上。
CLOS 的缺点之一是它太庞大了,并且 CLOS 费煞苦心的隐藏了面向对象编程,其实只不过是改写 Lisp 的这个事实。本章的例子至少阐明了这一点。如果我们满足于旧的消息传递模型,我们可以用一页多一点的代码来实现。面向对象编程不过是 Lisp 可以做的小事之一而已。更发人深省的问题是,Lisp 除此之外还能做些什么?
脚注
[1] | 当类别被显示时, *print-array* 应当是 nil 。 任何类别的 preclist 的第一个元素都是类别本身,所以试图显示类别的内部结构会导致一个无限循环。
[2] | 这个向量被 coerced 成一个列表,只是为了看看里面有什么。有了 *print-array* 被设成 nil ,一个向量的内容应该不会显示出来。
本章的目标是完成一个简单的 HTML 生成器 —— 这个程序可以自动生成一系列包含超文本链接的网页。除了介绍特定 Lisp 技术之外,本章还是一个典型的自底向上编程(bottom-up programming)的例子。 我们以一些通用 HTML 实用函数作为开始,继而将这些例程看作是一门编程语言,从而更好地编写这个生成器。
16.1 超文本标记语言 (HTML)
16.2 HTML 实用函数 (HTML Utilities)
16.3 迭代式实用函数 (An Iteration Utility)
16.4 生成页面 (Generating Pages)
16.1 超文本标记语言 (HTML)
HTML (HyperText Markup Language,超文本标记语言)用于构建网页,是一种简单、易学的语言。本节就对这种语言作概括性介绍。
当你使用网页浏览器阅览网页时,浏览器从远程服务器获取 HTML 文件,并将它们显示在你的屏幕上。每个 HTML 文件都包含任意多个标签(tag),这些标签相当于发送给浏览器的指令。
../_images/Figure-16.1.png
图 16.1 一个 HTML 文件
图 16.1 给出了一个简单的 HTML 文件,图 16.2 展示了这个 HTML 文件在浏览器里显示时大概是什么样子。
../_images/Figure-16.2.png
图 16.2 一个网页
注意在尖角括号之间的文本并没有被显示出来,这些用尖角括号包围的文本就是标签。 HTML 的标签分为两种,一种是成双成对地出现的:
<tag>...</tag>
第一个标签标志着某种情景(environment)的开始,而第二个标签标志着这种情景的结束。 这种标签的一个例子是 <h2> :所有被<h2> 和 </h2> 包围的文本,都会使用比平常字体尺寸稍大的字体来显示。
另外一些成双成对出现的标签包括:创建带编号列表的 <ol> 标签(ol 代表 ordered list,有序表),令文本居中的 <center> 标签,以及创建链接的 <a> 标签(a 代表 anchor,锚点)。
被 <a> 和 </a> 包围的文本就是超文本(hypertext)。 在大多数浏览器上,超文本都会以一种与众不同的方式被凸显出来 —— 它们通常会带有下划线 —— 并且点击这些文本会让浏览器跳转到另一个页面。 在标签 a 之后的部分,指示了链接被点击时,浏览器应该跳转到的位置。
一个像
<a href="foo.html">
这样的标签,就标识了一个指向另一个 HTML 文件的链接,其中这个 HTML 文件和当前网页的文件夹相同。 当点击这个链接时,浏览器就会获取并显示 foo.html 这个文件。
当然,链接并不一定都要指向相同文件夹下的 HTML 文件,实际上,一个链接可以指向互联网的任何一个文件。
和成双成对出现的标签相反,另一种标签没有结束标记。 在图 16.1 里有一些这样的标签,包括:创建一个新文本行的 <br> 标签(br 代表 break ,断行),以及在列表情景中,创建一个新列表项的 <li> 标签(li 代表 list item ,列表项)。
HTML 还有不少其他的标签,但是本章要用到的标签,基本都包含在图 16.1 里了。
16.2 HTML 实用函数 (HTML Utilities)
(defmacro as (tag content)
`(format t "<~(~A~)>~A</~(~A~)>"
',tag ,content ',tag))
(defmacro with (tag &rest body)
`(progn
(format t "~&<~(~A~)>~%" ',tag)
,@body
(format t "~&</~(~A~)>~%" ',tag)))
(defmacro brs (&optional (n 1))
(fresh-line)
(dotimes (i n)
(princ "<br>"))
(terpri))
图 16.3 标签生成例程
本节会定义一些生成 HTML 的例程。 图 16.3 包含了三个基本的、生成标签的例程。 所有例程都将它们的输出发送到 *standard-output* ;可以通过重新绑定这个变量,将输出重定向到一个文件。
宏 as 和 with 都用于在标签之间生成表达式。其中 as 接受一个字符串,并将它打印在两个标签之间:
> (as center "The Missing Lambda")
<center>The Missing Lambda</center>
NIL
with 则接受一个代码体(body of code),并将它放置在两个标签之间:
> (with center
(princ "The Unbalanced Parenthesis"))
<center>
The Unbalanced Parenthesis
</center>
NIL
两个宏都使用了 ~(...~) 来进行格式化,从而将标签转化为小写字母的标签。 HTML 并不介意标签是大写还是小写,但是在包含许许多多标签的 HTML 文件中,小写字母的标签可读性更好一些。
除此之外, as 倾向于将所有输出都放在同一行,而 with 则将标签和内容都放在不同的行里。 (使用 ~& 来进行格式化,以确保输出从一个新行中开始。) 以上这些工作都只是为了让 HTML 更具可读性,实际上,标签之外的空白并不影响页面的显示方式。
图 16.3 中的最后一个例程 brs 用于创建多个文本行。 在很多浏览器中,这个例程都可以用于控制垂直间距。
(defun html-file (base)
(format nil "~(~A~).html" base))
(defmacro page (name title &rest body)
(let ((ti (gensym)))
`(with-open-file (*standard-output*
(html-file ,name)
:direction :output
:if-exists :supersede)
(let ((,ti ,title))
(as title ,ti)
(with center
(as h2 (string-upcase ,ti)))
(brs 3)
,@body))))
图 16.4 HTML 文件生成例程
图 16.4 包含用于生成 HTML 文件的例程。 第一个函数根据给定的符号(symbol)返回一个文件名。 在一个实际应用中,这个函数可能会返回指向某个特定文件夹的路径(path)。 目前来说,这个函数只是简单地将 .html 后缀追加到给定符号名的后边。
宏 page 负责生成整个页面,它的实现和 with-open-file 很相似: body 中的表达式会被求值,求值的结果通过 *standard-output* 所绑定的流,最终被写入到相应的 HTML 文件中。
6.7 小节展示了如何临时性地绑定一个特殊变量。 在 113 页的例子中,我们在 let 的体内将 *print-base* 绑定为 16 。 这一次,通过将 *standard-output* 和一个指向 HTML 文件的流绑定,只要我们在 page 的函数体内调用 as 或者 princ ,输出就会被传送到 HTML 文件里。
page 宏的输出先在顶部打印 title ,接着求值 body 中的表达式,打印 body 部分的输出。
如果我们调用
(page 'paren "The Unbalanced Parenthesis"
(princ "Something in his expression told her..."))
这会产生一个名为 paren.html 的文件(文件名由 html-file 函数生成),文件中的内容为:
<title>The Unbalanced Parenthesis</title>
<center>
<h2>THE UNBALANCED PARENTHESIS</h2>
</center>
<br><br><br>
Something in his expression told her...
除了 title 标签以外,以上输出的所有 HTML 标签在前面已经见到过了。 被 <title> 标签包围的文本并不显示在网页之内,它们会显示在浏览器窗口,用作页面的标题。
(defmacro with-link (dest &rest body)
`(progn
(format t "<a href=\"~A\">" (html-file ,dest))
,@body
(princ "</a>")))
(defun link-item (dest text)
(princ "<li>")
(with-link dest
(princ text)))
(defun button (dest text)
(princ "[ ")
(with-link dest
(princ text))
(format t " ]~%"))
图 16.5 生成链接的例程
图片 16.5 给出了用于生成链接的例程。 with-link 和 with 很相似:它根据给定的地址 dest ,创建一个指向 HTML 文件的链接。 而链接内部的文本,则通过求值 body 参数中的代码段得出:
> (with-link 'capture
(princ "The Captured Variable"))
<a href="capture.html">The Captured Variable</a>
"</a>"
with-link 也被用在 link-item 当中,这个函数接受一个字符串,并创建一个带链接的列表项:
> (link-item 'bq "Backquote!")
<li><a href="bq.html">Backquote!</a>
"</a>"
最后, button 也使用了 with-link ,从而创建一个被方括号包围的链接:
> (button 'help "Help")
[ <a href="help.html">Help</a> ]
NIL
16.3 迭代式实用函数 (An Iteration Utility)
在这一节,我们先暂停一下编写 HTML 生成器的工作,转到编写迭代式例程的工作上来。
你可能会问,怎样才能知道,什么时候应该编写主程序,什么时候又应该编写子例程?
实际上,这个问题,没有答案。
通常情况下,你总是先开始写一个程序,然后发现需要写一个新的例程,于是你转而去编写新例程,完成它,接着再回过头去编写原来的程序。 时间关系,要在这里演示这个开始-完成-又再开始的过程是不太可能的,这里只展示这个迭代式例程的最终形态,需要注意的是,这个程序的编写并不如想象中的那么简单。 程序通常需要经历多次重写,才会变得简单。
(defun map3 (fn lst)
(labels ((rec (curr prev next left)
(funcall fn curr prev next)
(when left
(rec (car left)
curr
(cadr left)
(cdr left)))))
(when lst
(rec (car lst) nil (cadr lst) (cdr lst)))))
图 16.6 对树进行迭代
图 16.6 里定义的新例程是 mapc 的一个变种。它接受一个函数和一个列表作为参数,对于传入列表中的每个元素,它都会用三个参数来调用传入函数,分别是元素本身,前一个元素,以及后一个元素。(当没有前一个元素或者后一个元素时,使用 nil 代替。)
> (map3 #'(lambda (&rest args) (princ args))
'(a b c d))
(A NIL B) (B A C) (C B D) (D C NIL)
NIL
和 mapc 一样, map3 总是返回 nil 作为函数的返回值。需要这类例程的情况非常多。在下一个小节就会看到,这个例程是如何让每个页面都实现“前进一页”和“后退一页”功能的。
map3 的一个常见功能是,在列表的两个相邻元素之间进行某些处理:
> (map3 #'(lambda (c p n)
(princ c)
(if n (princ " | ")))
'(a b c d))
A | B | C | D
NIL
程序员经常会遇到上面的这类问题,但只要花些功夫,定义一些例程来处理它们,就能为后续工作节省不少时间。
16.4 生成页面 (Generating Pages)
一本书可以有任意数量的大章,每个大章又有任意数量的小节,而每个小节又有任意数量的分节,整本书的结构呈现出一棵树的形状。
尽管网页使用的术语和书本不同,但多个网页同样可以被组织成树状。
本节要构建的是这样一个程序,它生成多个网页,这些网页带有以下结构: 第一页是一个目录,目录中的链接指向各个节点(section)页面。 每个节点包含一些指向项(item)的链接。 而一个项就是一个包含纯文本的页面。
除了页面本身的链接以外,根据页面在树状结构中的位置,每个页面都会带有前进、后退和向上的链接。 其中,前进和后退链接用于在同级(sibling)页面中进行导航。 举个例子,点击一个项页面中的前进链接时,如果这个项的同一个节点下还有下一个项,那么就跳到这个新项的页面里。 另一方面,向上链接将页面跳转到树形结构的上一层 —— 如果当前页面是项页面,那么返回到节点页面;如果当前页面是节点页面,那么返回到目录页面。 最后,还会有索引页面:这个页面包含一系列链接,按字母顺序排列所有项。
../_images/Figure-16.7.png
图 16.7 网站的结构
图 16.7 展示了生成程序创建的页面所形成的链接结构。
(defparameter *sections* nil)
(defstruct item
id title text)
(defstruct section
id title items)
(defmacro defitem (id title text)
`(setf ,id
(make-item :id ',id
:title ,title
:text ,text)))
(defmacro defsection (id title &rest items)
`(setf ,id
(make-section :id ',id
:title ,title
:items (list ,@items))))
(defun defsite (&rest sections)
(setf *sections* sections))
图 16.8 定义一个网站
图 16.8 包含定义页面所需的数据结构。程序需要处理两类对象:项和节点。这两类对象的结构很相似,不过节点包含的是项的列表,而项包含的是文本块。
节点和项两类对象都带有 id 域。 标识符(id)被用作符号(symbol),并达到以下两个目的:在 defitem 和 defsection 的定义中, 标识符会被设置到被创建的项或者节点当中,作为我们引用它们的一种手段;另一方面,标识符还会作为相应文件的前缀名(base name),比如说,如果项的标识符为 foo ,那么项就会被写到 foo.html 文件当中。
节点和项也同时带有 title 域。这个域的值应该为字符串,并且被用作相应页面的标题。
在节点里,项的排列顺序由传给 defsection 的参数决定。 与此类似,在目录里,节点的排列顺序由传给 defsite 的参数决定。
(defconstant contents "contents")
(defconstant index "index")
(defun gen-contents (&optional (sections *sections*))
(page contents contents
(with ol
(dolist (s sections)
(link-item (section-id s) (section-title s))
(brs 2))
(link-item index (string-capitalize index)))))
(defun gen-index (&optional (sections *sections*))
(page index index
(with ol
(dolist (i (all-items sections))
(link-item (item-id i) (item-title i))
(brs 2)))))
(defun all-items (sections)
(let ((is nil))
(dolist (s sections)
(dolist (i (section-items s))
(setf is (merge 'list (list i) is #'title<))))
is))
(defun title< (x y)
(string-lessp (item-title x) (item-title y)))
图 16.9 生成索引和目录
图 16.9 包含的函数用于生成索引和目录。 常量 contents 和 index 都是字符串,它们分别用作 contents 页面的标题和 index 页面的标题;另一方面,如果有其他页面包含了目录和索引这两个页面,那么这两个常量也会作为这些页面文件的前缀名。
函数 gen-contents 和 gen-index 非常相似。 它们都打开一个 HTML 文件,生成标题和链接列表。 不同的地方是,索引页面的项必须是有序的。 有序列表通过 all-items 函数生成,它遍历各个项并将它加入到保存已知项的列表当中,并使用 title< 函数作为排序函数。 注意,因为 title< 函数对大小写敏感,所以在对比标题前,输入必须先经过 string-lessp 处理,从而忽略大小写区别。
实际程序中的对比操作通常更复杂一些。举个例子,它们需要忽略无意义的句首词汇,比如 "a" 和 "the" 。
(defun gen-site ()
(map3 #'gen-section *sections*)
(gen-contents)
(gen-index))
(defun gen-section (sect <sect sect>)
(page (section-id sect) (section-title sect)
(with ol
(map3 #'(lambda (item <item item>)
(link-item (item-id item)
(item-title item))
(brs 2)
(gen-item sect item <item item>))
(section-items sect)))
(brs 3)
(gen-move-buttons (if <sect (section-id <sect))
contents
(if sect> (section-id sect>)))))
(defun gen-item (sect item <item item>)
(page (item-id item) (item-title item)
(princ (item-text item))
(brs 3)
(gen-move-buttons (if <item (item-id <item))
(section-id sect)
(if item> (item-id item>)))))
(defun gen-move-buttons (back up forward)
(if back (button back "Back"))
(if up (button up "Up"))
(if forward (button forward "Forward")))
图 16.10 生成网站、节点和项
图 16.10 包含其余的代码: gen-site 生成整个页面集合,并调用相应的函数,生成节点和项。
所有页面的集合包括目录、索引、各个节点以及各个项的页面。 目录和索引的生成由图 16.9 中的代码完成。 节点和项由分别由生成节点页面的 gen-section 和生成项页面的 gen-item 完成。
这两个函数的开头和结尾非常相似。 它们都接受一个对象、对象的左兄弟、对象的右兄弟作为参数;它们都从对象的 title 域中提取标题内容;它们都以调用 gen-move-buttons 作为结束,其中 gen-move-buttons 创建指向左兄弟的后退按钮、指向右兄弟的前进按钮和指向双亲(parent)对象的向上按钮。 它们的不同在于函数体的中间部分: gen-section 创建有序列表,列表中的链接指向节点包含的项,而 gen-item 创建的项则链接到相应的文本页面。
项所包含的内容完全由用户决定。 比如说,将 HTML 标签作为内容也是完全没问题的。 项的文本当然也可以由其他程序来生成。
图 16.11 演示了如何手工地定义一个微型网页。 在这个例子中,列出的项都是 Fortune 饼干公司新推出的产品。
(defitem des "Fortune Cookies: Dessert or Fraud?" "...")
(defitem case "The Case for Pessimism" "...")
(defsection position "Position Papers" des case)
(defitem luck "Distribution of Bad Luck" "...")
(defitem haz "Health Hazards of Optimism" "...")
(defsection abstract "Research Abstracts" luck haz)
(defsite position abstract)
接下来三章提供了大量的 Lisp 程序例子。选择这些例子来说明那些较长的程序所采取的形式,和 Lisp 所擅长解决的问题类型。
在这一章中我们将要写一个基于一组 if-then 规则的推论程序。这是一个经典的例子 —— 不仅在于其经常出现在教科书上,还因为它反映了 Lisp 作为一个“符号计算”语言的本意。这个例子散发着很多早期 Lisp 程序的气息。
15.1 目标 (The Aim)
15.2 匹配 (Matching)
15.3 回答查询 (Answering Queries)
15.4 分析 (Analysis)
15.1 目标 (The Aim)
在这个程序中,我们将用一种熟悉的形式来表示信息:包含单个判断式,以及跟在之后的零个或多个参数所组成的列表。要表示 Donald 是 Nancy 的家长,我们可以这样写:
(parent donald nancy)
事实上,我们的程序是要表示一些从已有的事实作出推断的规则。我们可以这样来表示规则:
(<- head body)
其中, head 是 那么...部分 (then-part), body 是 如果...部分 (if-part)。在 head 和 body 中我们使用以问号为前缀的符号来表示变量。所以下面这个规则:
(<- (child ?x ?y) (parent ?y ?x))
表示:如果 y 是 x 的家长,那么 x 是 y 的孩子;更恰当地说,我们可以通过证明 (parent y x) 来证明 (child x y) 的所表示的事实。
可以把规则中的 body 部分(if-part) 写成一个复杂的表达式,其中包含 and , or 和 not 等逻辑操作。所以当我们想要表达 “如果 x 是 y 的家长,并且 x 是男性,那么 x 是 y 的父亲” 这样的规则,我们可以写:
(<- (father ?x ?y) (and (parent ?x ?y) (male ?x)))
一些规则可能依赖另一些规则所产生的事实。比如,我们写的第一个规则是为了证明 (child x y) 的事实。如果我们定义如下规则:
(<- (daughter ?x ?y) (and (child ?x ?y) (female ?x)))
然后使用它来证明 (daughter x y) 可能导致程序使用第一个规则去证明 (child x y) 。
表达式的证明可以回溯任意数量的规则,只要它最终结束于给出的已知事实。这个过程有时候被称为反向链接 (backward-chaining)。之所以说 反向 (backward) 是因为这一类推论先考虑 head 部分,这是为了在继续证明 body 部分之前检查规则是否有效。链接 (chaining) 来源于规则之间的依赖关系,从我们想要证明的内容到我们的已知条件组成一个链接 (尽管事实上它更像一棵树)。 λ
15.2 匹配 (Matching)
我们需要有一个函数来做模式匹配以完成我们的反向链接 (back-chaining) 程序,这个函数能够比较两个包含变量的列表,它会检查在给变量赋值后是否可以使两个列表相等。举例,如果 ?x 和 ?y 是变量,那么下面两个列表:
(p ?x ?y c ?x)
(p a b c a)
当 ?x = a 且 ?y = b 时匹配,而下面两个列表:
(p ?x b ?y a)
(p ?y b c a)
当 ?x = ?y = c 时匹配。
我们有一个 match 函数,它接受两棵树,如果这两棵树能匹配,则返回一个关联列表(assoc-list)来显示他们是如何匹配的:
(defun match (x y &optional binds)
(cond
((eql x y) (values binds t))
((assoc x binds) (match (binding x binds) y binds))
((assoc y binds) (match x (binding y binds) binds))
((var? x) (values (cons (cons x y) binds) t))
((var? y) (values (cons (cons y x) binds) t))
(t
(when (and (consp x) (consp y))
(multiple-value-bind (b2 yes)
(match (car x) (car y) binds)
(and yes (match (cdr x) (cdr y) b2)))))))
(defun var? (x)
(and (symbolp x)
(eql (char (symbol-name x) 0) #\?)))
(defun binding (x binds)
(let ((b (assoc x binds)))
(if b
(or (binding (cdr b) binds)
(cdr b)))))
图 15.1: 匹配函数。
> (match '(p a b c a) '(p ?x ?y c ?x))
((?Y . B) (?X . A))
T
> (match '(p ?x b ?y a) '(p ?y b c a))
((?Y . C) (?X . ?Y))
T
> (match '(a b c) '(a a a))
NIL
当 match 函数逐个元素地比较它的参数时候,它把 binds 参数中的值分配给变量,这被称为绑定 (bindings)。如果成功匹配,match 函数返回生成的绑定;否则,返回 nil 。当然并不是所有成功的匹配都会产生绑定,我们的 match 函数就像 gethash 函数那样返回第二个值来表明匹配成功:
> (match '(p ?x) '(p ?x))
NIL
T
如果 match 函数像上面那样返回 nil 和 t ,表明这是一个没有产生绑定的成功匹配。下面用中文来描述 match 算法是如何工作的:
如果 x 和 y 在 eql 上相等那么它们匹配;否则,
如果 x 是一个已绑定的变量,并且绑定匹配 y ,那么它们匹配;否则,
如果 y 是一个已绑定的变量,并且绑定匹配 x ,那么它们匹配;否则,
如果 x 是一个未绑定的变量,那么它们匹配,并且为 x 建立一个绑定;否则,
如果 y 是一个未绑定的变量,那么它们匹配,并且为 y 建立一个绑定;否则,
如果 x 和 y 都是 cons ,并且它们的 car 匹配,由此产生的绑定又让 cdr 匹配,那么它们匹配。
下面是一个例子,按顺序来说明以上六种情况:
> (match '(p ?v b ?x d (?z ?z))
'(p a ?w c ?y ( e e))
'((?v . a) (?w . b)))
((?Z . E) (?Y . D) (?X . C) (?V . A) (?W . B))
T
match 函数通过调用 binding 函数在一个绑定列表中寻找变量(如果有的话)所关联的值。这个函数必须是递归的,因为有这样的情况 “匹配建立一个绑定列表,而列表中变量只是间接关联到它的值: ?x 可能被绑定到一个包含 (?x . ?y) 和 (?y . a) 的列表”:
> (match '(?x a) '(?y ?y))
((?Y . A) (?X . ?Y))
T
先匹配 ?x 和 ?y ,然后匹配 ?y 和 a ,我们间接确定 ?x 是 a 。
15.3 回答查询 (Answering Queries)
在介绍了绑定的概念之后,我们可以更准确的说一下我们的程序将要做什么:它得到一个可能包含变量的表达式,根据我们给定的事实和规则返回使它正确的所有绑定。比如,我们只有下面这个事实:
(parent donald nancy)
然后我们想让程序证明:
(parent ?x ?y)
它会返回像下面这样的表达:
(((?x . donald) (?y . nancy)))
它告诉我们只有一个可以让这个表达式为真的方法: ?x 是 donald 并且 ?y 是 nancy 。
在通往目标的路上,我们已经有了一个的重要部分:一个匹配函数。 下面是用来定义规则的一段代码:
(defvar *rules* (make-hash-table))
(defmacro <- (con &optional ant)
`(length (push (cons (cdr ',con) ',ant)
(gethash (car ',con) *rules*))))
图 15.2 定义规则
规则将被包含于一个叫做 *rules* 的哈希表,通过头部 (head) 的判断式构建这个哈系表。这样做加强了我们无法使用判断式中的变量的限制。虽然我们可以通过把所有这样的规则放在分离的列表中来消除限制,但是如果这样做,当我们需要证明某件事的时侯不得不和每一个列表进行匹配。
我们将要使用同一个宏 <- 去定义事实 (facts)和规则 (rules)。一个事实将被表示成一个没有 body 部分的规则。这和我们对规则的定义保持一致。一个规则告诉我们你可以通过证明 body 部分来证明 head 部分,所以没有 body 部分的规则意味着你不需要通过证明任何东西来证明 head 部分。这里有两个对应的例子:
> (<- (parent donald nancy))
1
> (<- (child ?x ?y) (parent ?y ?x))
1
调用 <- 返回的是给定判断式下存储的规则数量;用 length 函数来包装 push 能使我们免于看到顶层中的一大堆返回值。
下面是我们的推论程序所需的大多数代码:
(defun prove (expr &optional binds)
(case (car expr)
(and (prove-and (reverse (cdr expr)) binds))
(or (prove-or (cdr expr) binds))
(not (prove-not (cadr expr) binds))
(t (prove-simple (car expr) (cdr expr) binds))))
(defun prove-simple (pred args binds)
(mapcan #'(lambda (r)
(multiple-value-bind (b2 yes)
(match args (car r)
binds)
(when yes
(if (cdr r)
(prove (cdr r) b2)
(list b2)))))
(mapcar #'change-vars
(gethash pred *rules*))))
(defun change-vars (r)
(sublis (mapcar #'(lambda (v) (cons v (gensym "?")))
(vars-in r))
r))
(defun vars-in (expr)
(if (atom expr)
(if (var? expr) (list expr))
(union (vars-in (car expr))
(vars-in (cdr expr)))))
图 15.3: 推论。
上面代码中的 prove 函数是推论进行的枢纽。它接受一个表达式和一个可选的绑定列表作为参数。如果表达式不包含逻辑操作,它调用 prove-simple 函数,前面所说的链接 (chaining)正是在这个函数里产生的。这个函数查看所有拥有正确判断式的规则,并尝试对每一个规则的 head 部分和它想要证明的事实做匹配。对于每一个匹配的 head ,使用匹配所产生的新的绑定在 body 上调用 prove。对 prove 的调用所产生的绑定列表被 mapcan 收集并返回:
> (prove-simple 'parent '(donald nancy) nil)
(NIL)
> (prove-simple 'child '(?x ?y) nil)
(((#:?6 . NANCY) (#:?5 . DONALD) (?Y . #:?5) (?X . #:?6)))
以上两个返回值指出有一种方法可以证明我们的问题。(一个失败的证明将返回 nil。)第一个例子产生了一组空的绑定,第二个例子产生了这样的绑定: ?x 和 ?y 被(间接)绑定到 nancy 和 donald 。
顺便说一句,这是一个很好的例子来实践 2.13 节提出的观点。因为我们用函数式的风格来写这个程序,所以可以交互式地测试每一个函数。
第二个例子返回的值里那些 gensyms 是怎么回事?如果我们打算使用含有变量的规则,我们需要避免两个规则恰好包含相同的变量。如果我们定义如下两条规则:
(<- (child ?x ?y) (parent ?y ?x))
(<- (daughter ?y ?x) (and (child ?y ?x) (female ?y)))
第一条规则要表达的意思是:对于任何的 x 和 y , 如果 y 是 x 的家长,则 x 是 y 的孩子。第二条则是:对于任何的 x 和 y , 如果 y 是 x 的孩子并且 y 是女性,则 y 是 x 的女儿。在每一条规则内部,变量之间的关系是显著的,但是两条规则使用了相同的变量并非我们刻意为之。
如果我们使用上面所写的规则,它们将不会按预期的方式工作。如果我们尝试证明“ a 是 b 的女儿”,匹配到第二条规则的 head 部分时会将 a 绑定到 ?y ,将 b 绑定到 ?x。我们无法用这样的绑定匹配第一条规则的 head 部分:
> (match '(child ?y ?x)
'(child ?x ?y)
'((?y . a) (?x . b)))
NIL
为了保证一条规则中的变量只表示规则中各参数之间的关系,我们用 gensyms 来代替规则中的所有变量。这就是 change-vars 函数的目的。一个 gensym 不可能在另一个规则中作为变量出现。但是因为规则可以是递归的,我们必须防止出现一个规则和自身冲突的可能性,所以在定义和使用一个规则时都要调用 chabge-vars 函数。
现在只剩下定义用以证明复杂表达式的函数了。下面就是需要的函数:
(defun prove-and (clauses binds)
(if (null clauses)
(list binds)
(mapcan #'(lambda (b)
(prove (car clauses) b))
(prove-and (cdr clauses) binds))))
(defun prove-or (clauses binds)
(mapcan #'(lambda (c) (prove c binds))
clauses))
(defun prove-not (clause binds)
(unless (prove clause binds)
(list binds)))
图 15.4 逻辑操作符 (Logical operators)
操作一个 or 或者 not 表达式是非常简单的。操作 or 时,我们提取在 or 之间的每一个表达式返回的绑定。操作 not 时,当且仅当在 not 里的表达式产生 none 时,返回当前的绑定。
prove-and 函数稍微复杂一点。它像一个过滤器,它用之后的表达式所建立的每一个绑定来证明第一个表达式。这将导致 and 里的表达式以相反的顺序被求值。除非调用 prove 中的 prove-and 函数则会先逆转它们。
现在我们有了一个可以工作的程序,但它不是很友好。必须要解析 prove-and 返回的绑定列表是令人厌烦的,它们会变得更长随着规则变得更加复杂。下面有一个宏来帮助我们更愉快地使用这个程序:
(defmacro with-answer (query &body body)
(let ((binds (gensym)))
`(dolist (,binds (prove ',query))
(let ,(mapcar #'(lambda (v)
`(,v (binding ',v ,binds)))
(vars-in query))
,@body))))
图 15.5 介面宏 (Interface macro)
它接受一个 query (不被求值)和若干表达式构成的 body 作为参数,把 query 所生成的每一组绑定的值赋给 query 中对应的模式变量,并计算 body 。
> (with-answer (parent ?x ?y)
(format t "~A is the parent of ~A.~%" ?x ?y))
DONALD is the parent of NANCY.
NIL
这个宏帮我们做了解析绑定的工作,同时为我们在程序中使用 prove 提供了一个便捷的方法。下面是这个宏展开的情况:
(with-answer (p ?x ?y)
(f ?x ?y))
;;将被展开成下面的代码
(dolist (#:g1 (prove '(p ?x ?y)))
(let ((?x (binding '?x #:g1))
(?y (binding '?y #:g1)))
(f ?x ?y)))
图 15.6: with-answer 调用的展开式
下面是使用它的一个例子:
(<- (parent donald nancy))
(<- (parent donald debbie))
(<- (male donald))
(<- (father ?x ?y) (and (parent ?x ?y) (male ?x)))
(<- (= ?x ?y))
(<- (sibling ?x ?y) (and (parent ?z ?x)
(parent ?z ?y)
(not (= ?x ?y))))
;;我们可以像下面这样做出推论
> (with-answer (father ?x ?y)
(format t "~A is the father of ~A.~%" ?x ?y))
DONALD is the father of DEBBIE.
DONALD is the father of NANCY.
NIL
> (with-answer (sibling ?x ?y))
(format t "~A is the sibling of ~A.~%" ?x ?y))
DEBBLE is the sibling of NANCY.
NANCY is the sibling of DEBBIE.
NIL
图 15.7: 使用中的程序
15.4 分析 (Analysis)
看上去,我们在这一章中写的代码,是用简单自然的方式去实现这样一个程序。事实上,它的效率非常差。我们在这里是其实是做了一个解释器。我们能够把这个程序做得像一个编译器。
这里做一个简单的描述。基本的思想是把整个程序打包到两个宏 <- 和 with-answer ,把已有程序中在运行期做的多数工作搬到宏展开期(在 10.7 节的 avg 可以看到这种构思的雏形) 用函数取代列表来表示规则,我们不在运行时用 prove 和 prove-and 这样的函数来解释表达式,而是用相应的函数把表达式转化成代码。当一个规则被定义的时候就有表达式可用。为什么要等到使用的时候才去分析它呢?这同样适用于和 <- 调用了相同的函数来进行宏展开的 with-answer 。
听上去好像比我们已经写的这个程序复杂很多,但其实可能只是长了两三倍。想要学习这种技术的读者可以看 On Lisp 或者Paradigms of Artificial Intelligence Programming ,这两本书有一些使用这种风格写的示例程序。