这篇文章发表于2004年第《CSDN开发高手》第5期。本来是投稿给《程序员》杂志的,但是给“调剂”到《CSDN开发高手》上去了,是一大遗憾。《CSDN开发高手》目前已经停刊。
另外,“打印自身的程序”有一个很简洁的C语言解答,虽然核心思想是类似的,但是实现技巧很玄妙,不象文章中的程序可以很自然地一步一步推导出来,因此没有写进去。这个程序是:
#include <stdio.h>
int main() { char *s = "#include <stdio.h>%cint main() { char *s = %c%s%c; printf( s, 10, 34, s, 34 ); return 0; }"; printf( s, 10, 34, s, 34 ); return 0; }
(雙引號 " 的值為 34, ASCII 中的值一樣。)
“打印自身的程序”杂谈
“写一个程序,其执行结果是把程序自身打印出来。”
这个论题我在网上看到过若干次,不过真正意义上的回答却从来没看见过。学过计算理论的高手们应该都知道答案,但是大多数程序员们可能没有精力去啃理论书籍,就让我对这个有趣的命题来做个介绍吧。
首先要澄清,什么是“打印”,什么是“自身”,以及程序执行环境的限制。“打印”可以有各种理解,比如向屏幕输出,向磁盘输出,向打印机输出,向内存输出,等等,但是本质上都是一样的,因此不做限制。
“自身”的一种理解是源程序,另一理解是机器码。提到“机器码”这个名词,你可能立刻就想到了“病毒”了吧,对,病毒就是最典型的自我打印程序,病毒的起源“磁心大战”就是各个程序在存储器中复制自己抢地盘,发展到现在更是丰富多彩,从传统的文件到时髦的Email,无所不用其极。
但是各种病毒型的自打印程序都有一个限制,即需要宿主系统提供的服务才能完成关键的“得到自己”的动作。如果自身代码在内存中,如何才能知道其起始地址呢?问操作系统吧。或者读取指令指针的值再做调整,这其实也是利用了执行环境之一的“指令指针”。又如邮件病毒,甚至不需要知道自己在哪里,只要调用软件的“转发”接口就行了。作为病毒当然是不错的构思,但是要作为“打印自身”的题解,显然就是耍赖皮了。推向极端,操作系统提供一个“把我自己打出来”的API,程序调用一下不就完了?
因此,限定程序的执行环境是讨论问题的必要前提。现代计算机和操作系统的“外部环境”实在太复杂了,搬出图灵机理论在这里也不太适合,干脆就这么定义命题:使用某种高级语言(比如说C语言),除了屏幕输出(如printf函数)以外不得使用其他系统相关函数和IO函数,打印出程序自身的源代码。虽说这种定义细究下去也有许多含糊的地方(比如,C语言规定全局变量的初值会自动赋为零值,这个特性就不应该被允许),我们还是把它抛到一边,赶紧进入正题吧。
为了简化讨论,我们假设有一种智能的PRINT语句,所谓智能就是说,看到语句PRINT X时,程序会自动根据X的类型按照常规把X的内容打出来,还有就是可以随着我们讨论的进行它可以自然获得我们希望它有的特性,这样我就不用一上来就很突兀地给它定义很多特性。
好,不管怎样,程序里一定是会有一句打印语句的:
PRINT X [1]
执行后屏幕上就出现了X的内容。
为了满足“打印自身”的要求,必定还有一句把上面那句中的“PRINT”给打出来:
PRINT Y [2]
最简单的当然是写成这样:
PRINT PRINT X
这里,第一个PRINT自动认出了后面的是一个字符串常量,将之打印出来。
我想,你一定意识到了,这样做是行不通的,因为每次都会多出一个PRINT,这样下去就陷入了无限。
[花絮],如果允许无限长的程序话,倒真是可以这么写程序:
PRINT
PRINT PRINT
PRINT PRINT PRINT
......
第一个PRINT因为没有参数,所以什么也不打。还有一种特殊的程序是“空”,就是一句话也没有的程序(可以搜smr.c。呵呵,这篇文章以趣味性为主,千万不要找我抬杠哟。
[/花絮]
因此,[2]中的Y必然不能是常量,而是间接引用的变量。或者说就是一段存储,里边放着一个串“PRINT”,后面跟着X的内容。给这段存储起个名字叫做S,类型是字符串,这样,[2]就成为:
PRINT S [3]
现在[1]已经由[3]打印出来了,[3]又由谁来打印呢?只有[1]了。于是,我们的程序变成:
PRINT PRINT S [4]
PRINT S [5]
虽然看上去有循环依赖的嫌疑,但是关键点在于,[5]是个间接打印,只是把某个固定起始地址的串发送到屏幕上,是个完全固定的操作,并不依赖于其他语句!更进一步,[5]也不一定非要是PRINT语句不可,可以换成任何固定的对S的操作序列,而[4]只需要把[5]的语句原汁原味打印出来就可以了:
PRINT A(S) B(S) C(S) [6]
A(S) B(S) C(S) [7]
[6][7]就是解决“自我打印”的基本方案。也许并不是非常明显,让我对它做一个特殊的解释就清楚了。
假定S不是普通内存,而是字符模式下的“显存”,也就是里边有什么字符屏幕上就会出现什么字符;而A(S) B(S) C(S)是作用在字符串S上的一连串操作代码,对S操作完成后S的新值成为 PRINT S [换行] S。在这种解释下,[6][7]就是一个“自我打印”程序。
紧接着我们用一个简单的技巧把“显存”给干掉:
S=A(S) B(S) C(S) [8]
A(S) B(S) C(S) [9]
[8]不直接打印到屏幕,而是打印到字符串S。[9]中,A(S) B(S) C(S)把S变换为 'S'={S} [换行] {S}(其中'S'指单个字符S,{S}指S的内容),然后在屏幕上打印出S。
这就是“打印自身”的一个解的模式,可以适用于任何高级语言。如果把A(S) B(S) C(S)具体写出来的话,就是这样的伪码:
S= S="S="+{S}+"
"+{S} PRINT S [10]
S="S="+{S}+"
"+{S} PRINT S [11]
当然这个写法中的语法是需要“灵活理解”的,如果真的要用现实的编程语言来写的话,还有许多细节要处理。文章最后附有一个我用C语言写的程序,有兴趣的话可以自行分析。核心思想就是[10][11],只是用了很多技巧来处理字符。
主题内容讲完了,接下来就是杂谈了。
其实,如果用自然语言来描述程序的思想,其实可以写成这样:
把下面这个字符串抄两遍,并且在第二句上加上引号。
“把下面这个字符串抄两遍,并且在第二句上加上引号。”
之所以要用两句话,是因为程序语言中没有“把我自己抄一遍”的对应物。抽象的“自我指称”可是很危险的东西哟,“罗素悖论”就是这么来的。
一个很迷惑人的观点是,机床比螺丝复杂,汽车厂比汽车复杂,创造者要比被创造者复杂。而生命是可以自我复制的(繁殖),因此生命无法用机械原理来理解。假如是在1950年(DNA是1953年发现的),你该如何反驳呢?我们的“自我打印”程序证明了,“创造者要比被创造者复杂”是错误的。我们的程序加以扩展,可以携带任意的信息,执行任意额外的动作,而仍可以完全复制自身,这就是计算理论中的“递归定理”。20世纪三四十年代,在现代电子计算机还没有诞生的时候,计算理论的先行者图灵、丘奇、哥德尔等就已经解决了“什么是计算”、“什么是可计算的”等深刻的问题。我们这些现代的程序员,在解决了“编程技巧”、“项目管理”等吃饭相关的问题之余,如果不花点时间去领略一下计算理论的美丽与神奇,不是太可惜了吗?
附:“打印自身”的C语言程序,其中的注释和#if 0 / #endif 中的文字是帮助阅读的,并不是代码的一部分,也不会被打印出来。
#include <stdio.h> char buf[100][1000]; int cur=-1; void P1(char *p) {sprintf(buf[++cur],p);} //record p to buf void P2(char *p) { printf(p); putchar(10);} //print p and change line void P3() {int i;for(i=0;i<=cur;i++){ putchar(80);putchar(49);putchar(40);putchar(34);printf(buf[i]);putchar(34);putchar(41);putchar(59);putchar(10);} for(i=0;i<cur;i++){ putchar(80);putchar(50);putchar(40);putchar(34);printf(buf[i]);putchar(34);putchar(41);putchar(59);putchar(10);} printf(buf[cur]);putchar(10);putchar(125);putchar(10);} void main() { P1("#include <stdio.h>"); P1("char buf[100][1000]; int cur=-1;"); P1("void P1(char *p) {sprintf(buf[++cur],p);}"); P1("void P2(char *p) { printf(p); putchar(10);}"); P1("void P3() {int i;for(i=0;i<=cur;i++){ putchar(80);putchar(49);putchar(40);putchar(34);printf(buf[i]);putchar(34);putchar(41);putchar(59);putchar(10);} for(i=0;i<cur;i++){ putchar(80);putchar(50);putchar(40);putchar(34);printf(buf[i]);putchar(34);putchar(41);putchar(59);putchar(10);} printf(buf[cur]);putchar(10);putchar(125);putchar(10);}"); P1("void main() {"); P1("P3();"); P2("#include <stdio.h>"); P2("char buf[100][1000]; int cur=-1;"); P2("void P1(char *p) {sprintf(buf[++cur],p);}"); P2("void P2(char *p) { printf(p); putchar(10);}"); P2("void P3() {int i;for(i=0;i<=cur;i++){ putchar(80);putchar(49);putchar(40);putchar(34);printf(buf[i]);putchar(34);putchar(41);putchar(59);putchar(10);} for(i=0;i<cur;i++){ putchar(80);putchar(50);putchar(40);putchar(34);printf(buf[i]);putchar(34);putchar(41);putchar(59);putchar(10);} printf(buf[cur]);putchar(10);putchar(125);putchar(10);}"); P2("void main() {"); P3(); } #if 0 //read P3() convinently void P3() { int i; for(i=0;i<=cur;i++){ putchar(80); //'P' putchar(49); //'1' putchar(40); //'(' putchar(34); //'"' printf(buf[i]); putchar(34); //'"' putchar(41); //')' putchar(59); //';' putchar(10); //' ' } for(i=0;i<cur;i++){ putchar(80); //'P' putchar(50); //'2' putchar(40); //'(' putchar(34); //'"' printf(buf[i]); putchar(34); //'"' putchar(41); //')' putchar(59); //';' putchar(10); //' ' } printf(buf[cur]); //for P3(); putchar(10); putchar(125); //'}' putchar(10); } #endif
转载请注明作者:phylips@bmy 出处:http://duanple.blog.163.com/blog/static/70971767200822371557244/
今天看了下<<计算理论导引>>,是计算理论领域的知名权威MIT的Michael Sipser所撰写。果然一般来说,计算机科学的书还是要看国外的。主要讲了自动机与语言、可计算性和计算复杂性,很多东西比较偏理论,主要看了下里面的递归定理,自动机。
几个证明方法:例证,反证,构造,归纳,循环不变式。看到很多初等但还算比较有意思的题目,比如证明sqrt(2)的无理性,求和公式,实数的不可数,以及有理数的可数。
另外还有比较有趣的是递归定理,这个定理的应用比较好的一个例子是关于自打印程序的,一会再详细说明。
还有就是自动机,主要是出于对字符串匹配的关心来看了下,不过这里的匹配主要是关于正则表达式方面的。
不过其中一个思想引起了我的注意,就是如何将多个DFA转化为一个DFA,因为在字符串匹配中,CLRS上主要是单串匹配,而多串匹配的方法没有明确讲解。而利用自动机的方法,便可以根据多个模式串生成。然后剩下的问题就是如何利用这些多个自动机,合并为一个执行,这样便可以单次扫描。
而合并的思想就是将原来的两个自动机的状态组合表示为一个新的状态,这样如果原来有i和j个状态则新的就有i*j个。状态转移方程表示为$(<i,j>,input) =<$(i,input),$(j,input)> 。
自打印程序:一个程序,其执行结果是把程序自身打印出来。
一个很迷惑人的观点是,机床比螺丝复杂,汽车厂比汽车复杂,创造者要比被创造者复杂。而生命是可以自我复制的(繁殖),因此生命无法用机械原理来理解。假如是在1950年(DNA是1953年发现的),你该如何反驳呢?我们的“自我打印”程序证明了,“创造者要比被创造者复杂”是错误的。我们的程序加以扩展,可以携带任意的信息,执行任意额外的动作,而仍可以完全复制自身,这就是计算理论中的“递归定理”。20世纪三四十年代,在现代电子计算机还没有诞生的时候,计算理论的先行者图灵、丘奇、哥德尔等就已经解决了“什么是计算”、“什么是可计算的”等深刻的问题。另外生命存在很多一个自我复制的系统,如DNA,其实计算机的病毒也有类似的行为。
下面先给出一个流传已广的c自打印程序:
int main(){char *s="int main(){char*s=%c%s%c;printf(s,24,s,24);return 0;}";printf(s,24,s,24);return 0;}
其实自打印的程序很具有模版化,可以分成两部分:AB
A的输出就是B的程序文字;
B部分可以得到A部分的输出,同时可以根据A部分的输出得到A部分的程序文字,这样B便可以把AB的程序文字联合起来,print即可。
所以对A部分有两个要求:通过它的输出即要得到A的程序文字,也能得到B的程序文字。
而在写A的时候,通常通过一个字符串赋值完成,但是这个赋值需要等到B部分完成之后才能真正完成。首先A将自身包含,然后写B部分,然后再A中将B包含。
下面结合上述思想给出一个C++的例子:
#include <iostream>
#include <string>
using namespace std;
int main (){string a="#include <iostream>T#include <string>Tusing namespace std;Tint main (){string a=;a[19]=a[37]=a[58]=10;cout<<a.substr(0,80)<<(char)34;a[19]=a[37]=a[58]=84;cout<<a<<(char)34<<a.substr(80);return 0;}";a[19]=a[37]=a[58]=10;cout<<a.substr(0,80)<<(char)34;a[19]=a[37]=a[58]=84;cout<<a<<(char)34<<a.substr(80);return 0;}
主要思想如上,在于使string a的值可以产生A,B的代码
另外a[19]=10;a[37]=10;a[58]=10;和a[19]=84;a[37]=84;a[58]=84;
主要作用是将字符"T"换为回车符号然后打印完第一部分后还原为"T"。
仔细观察可以发现AB两部分都是由string a生成的。
a.substr(0,80)<<(char)34<<a<<(char)34实际上形成了A(从开头到a定义之后的;)
而a.substr(80)则是B部分(从cout到结束)