Một trong các bài tập rất hay cho người
đang học lập trình (bất kể ngôn ngữ nào) là:
hãy viết một chương trình in chính nó ra. Chú ý là phải in ra giống hệt, từng giòng từng
chữ từng cái xuống hàng. Ở
đây ta giả sử có thể viết chương trình bằng
một tệp (file) thôi.
Nếu bạn chưa bao giờ nghĩ về
các chương trình kiểu này, hãy dành vài chục phút nghĩ
trước khi đọc các ví dụ dưới đây.
Bạn sẽ thấy bài tập này liên quan mật thiết
đến khái niệm "tự tham chiếu", một
trong những khái niệm trung tâm trong bài Chung qui chỉ
tại Cantor.
Thử xét vài ví dụ sau:
main(){char *c="main(){char *c=%c%s%c;printf(c,34,c,34);}";printf(c,34,c,34);}
import java.text.*;class a{public static void main(String x[]){char b[]={34};
char c[]={123};String s[]=new String[3];s[0]="import java.text.*;class a{2}
public static void main(String x[]){2}char b[]={2}34};char c[]={2}123};
String s[]=new String[3];s[0]={1}{0}{1};s[1]=new String(b);s[2]=new String(c);
System.out.println(MessageFormat.format(s[0],s));}}";s[1]=new String(b);s[2]=
new String(c);System.out.println(MessageFormat.format(s[0],s));}}
((lambda (x)
(list x (list (quote quote) x)))
(quote
(lambda (x)
(list x (list (quote quote) x)))))
#!/usr/bin/perl
$_=<<'eof';eval $_; print "#!/usr/bin/perl\n\$_=<<'eof';eval \$_;\n${_}eof\n"
eof
Các ví dụ trên tôi lấy ở đây, và ở đây. Dưới đây ta sẽ thiết kế một
ví dụ cho riêng ta, không cần đi "chôm" về.
Các trương trình tự in bản thân còn
được gọi là Quine, họ của triết
gia Willard van Orman Quine. Có
hai câu hỏi liên quan:
Trước hết, ta bàn về nguyên tắc viết các chương trình tự in được bản thân. Ta sẽ làm việc trên một mô hình tổng quát (nhưng hoàn có thể hiện thực được với bất kỳ ngôn ngữ máy tính nào). Giả sử trong mô hình máy tính này có một vùng nhớ mà tất cả các chương trình đều lấy input từ đó và ghi ra đó được. Có hai quan sát nhỏ:
strcpy(buffer, "Học như nghịch thủy hành châu");
trong ngôn ngữ C chẳng hạn.
Ý
tưởng của chương trình tự in bản thân
(vào vùng nhớ chung) như sau:
chương trình này có hai đoạn, đoạn A và đoạn
B, nghĩa là nối A với B thì ta có toàn bộ
chương trình.
Đoạn B làm công việc như sau:
Chú
ý rằng, sau khi B hoạt động xong thì trong vùng nhớ
chung chứa P(x) rồi đến x.
Bạn nên tưởng tưởng viết một đoạn
chương trình như B trong ngôn ngữ bạn thích.
Đoạn chương trình A thì làm gì? Nó sẽ ghi x vào vùng nhớ cho B đọc và
ghi P(x), x. Thế A ghi gì? A ghi ra chính đoạn B
ở trên!!!
Vì A ghi B vào vùng nhớ, A = P(B), và x
= B. Còn B ghi ra "P(x)x" = "P(B)B" =
"AB", chính là chương trình ta đang viết.
Ta
thử dùng nguyên tắc mô tả trong ví dụ trước
để viết một chương trình C tự in bản
thân. Lần thử đầu tiên, chương trình A ghi x
vào biến buffer, chương trình B in ra đoạn
chương trình P(x), sau đó in x. Kết quả đại
khái như sau:
int main() {
char* buffer = "printf(\"int
main() {\\n char* buffer = %s;\\n %s\\n}\\n\", buffer, buffer);";
printf("int main() {\n char* buffer
= \"%s\";\n %s\n}", buffer, buffer);
}
Lần thử này không thành công lắm, dù chương trình
này in ra một chương trình gần giống nó. Vấn đề nằm ở cả đống
ký tự thoát (escape characters '\'). Nó in thế này:
int main() {
char* buffer = "printf("int
main() {\n char* buffer = %s;\n %s\n}\n", buffer, buffer);";
printf("int main() {\n char* buffer
= %s;\n %s\n}\n", buffer, buffer);
}
Rút kinh nghiệm, tôi tránh các escape characters và thay chúng bằng
mã ASCII. Mã ASCII của ký tự xuống dòng
(linefeed) là 10, và của ký tự ngoặc kép (") là 34.
Kết quả lần thử thứ hai cho đoạn
chương trình:
int main() {
char* buffer = "printf(\"int
main() {%c char* buffer = %c%s%c;%c %s%c}\", 10, 10, 34, buffer, 34, 10,
buffer, 10);";
printf("int main() {%c char* buffer
= %c%s%c;%c %s%c}", 10, 34, buffer, 34, 10, buffer, 10);
}
Chương trình này in ra
int main() {
char* buffer = "printf("int
main() {%c char* buffer = %c%s%c;%c %s%c}", 10, 10, 34, buffer, 34, 10,
buffer, 10);";
printf("int main() {%c char* buffer
= %c%s%c;%c %s%c}", 10, 10, 34, buffer, 34, 10, buffer, 10);
}
Giống lắm rồi. Nhưng vẫn bị một escape
character \ cho dấu " của printf. Lý
do là vì cả printf lẫn định nghĩa buffer đều
dùng ", mà nếu bỏ " vào trong
định nghĩa buffer thì phải escape nó. Nhưng ta hoàn
toàn có thể printf mà không dùng " như sau:
int main() {
char* buf1 = "char buf2[] = {'i',
'n', 't', ' ', 'm', 'a', 'i', 'n', '(', ')', ' ', '{', '%', 'c', ' ', 'c', 'h',
'a', 'r', '*', ' ', 'b', 'u', 'f', '1', ' ', '=', ' ', '%', 'c', '%', 's', '%',
'c', ';', '%', 'c', ' ', '%', 's', '%', 'c', '}', (char) 0}; printf(buf2, 10,
34, buf1, 34, 10, buf1, 10);";
char buf2[] = {'i', 'n', 't', ' ', 'm',
'a', 'i', 'n', '(', ')', ' ', '{', '%', 'c', ' ', 'c', 'h', 'a', 'r', '*', ' ',
'b', 'u', 'f', '1', ' ', '=', ' ', '%', 'c', '%', 's', '%', 'c', ';', '%', 'c',
' ', '%', 's', '%', 'c', '}', (char) 0}; printf(buf2, 10, 34, buf1, 34, 10,
buf1, 10);
}
Bạn có thể dịch và chạy thử đoạn trên,
in ra chương trình y hệt.
Chương trình tự tái sinh có nhiều ứng dụng
thú vị. "In bản thân" chỉ
là một ví dụ của sự tái sinh.
Các virus máy tính đều có chức năng tái
sinh "y chang" này, nhưng thay vì in ra stdout, chúng tự
copy bản thân qua một địa chỉ mới (trong bộ
nhớ, qua mạng, hay xuống đĩa cứng).
Trong bài tiến
hóa số tôi có đề cập đến các
chương trình máy tính tự tái sinh dùng để mô phỏng
tiến hóa của các loại "vi khuẩn số"
đơn giản.
Trong tương lai, bạn có thể tưởng tượng
các chương trình phức tạp hơn, hay các robots tự
tái sinh. Hy vọng đến đây bạn
đã được thuyết phục rằng sự tự
tái sinh không phải là thứ vô vọng về mặt triết
học.
Trong các ví dụ trước, ta chỉ thiết
kế các chương trình có chức năng duy nhất là
in ra bản thân. Còn bọn "vi khuẩn số"
hay viruses còn làm các việc khác nữa. Chuyện này không có gì khó. Giả sử ta có
chương trình T làm việc gì đó (phá đĩa cứng
chẳng hạn), ta có thể thiết kế chương
trình ABT bao gồm đoạn A = P(x), đoạn B giống
như trước, và đoạn T. Thay vì A ghi x = B vào
vùng nhớ, thì trong trường hợp này A sẽ ghi x
= BT, còn B thì in P(x)x như cũ.
Như vậy ta đã bổ túc cho
chương trình T chức năng tự tái sinh mà không cần
suy nghĩ gì nhiều.
Cái mô hình máy có bộ vi xử lý truy cập
một vùng nhớ chung của ta chính là mô hình máy Turing. Lần
tới ta sẽ dùng ý tưởng về sự tự tái
sinh này để chứng minh rằng có bài toán không quyết
định được bằng máy Turing (xem thêm chung qui chỉ tại Cantor). Như đã nói trong bài "chung qui chỉ tại
Cantor", khái niệm tự tham chiếu là một khái niệm
then chốt. Vì thế, cũng không ngạc
nhiên lắm khi sự tự tái sinh có liên quan đến vấn
đề không quyết định được.
Bài tập 1: viết một
chương trình C chép file X sang file Y, sau đó tự in bản
thân nó (C) ra stdout.
Để minh họa khái niệm tự in và tự tham chiếu
một lần nữa, hãy xét ví dụ sau (tôi lấy ở
đây):
Bài tập 2: viết một
chương trình C tìm một câu tiếng Việt có nội
dung tương tự.
Bài tập 3: có phải tất
cả các ngôn ngữ đều có một câu dạng này hay
không? Ngôn ngữ với tính chất gì thì có một
câu như vậy?
Ta
đã "chứng minh" rằng mọi chương
trình có thể lấy được đoạn mã của
chính nó. (Chứng minh điều này một cách chặt chẽ
bằng toán học cũng không có gì khó khăn, tương
tự như các ý tưởng ta đã trình bày, chỉ cần
định nghĩa rõ ràng thế nào là một chương
trình.) Phát biểu này thường được
gọi là định lý đệ qui (recursion theorem).
Đến đây, ta minh họa một ứng
dụng của định lý đệ qui. Ta sẽ
dùng định lý này để chứng minh rằng bài
toán dừng không quyết định được. Tôi sẽ thảo luận ý tưởng chính, bỏ
qua các chi tiết toán không quan trọng. Bài toán dừng
đại khái có thể phát biểu như sau: "cho một đoạn mã chương trình [M]
và một chuỗi w, quyết định xem với
input w thì M có dừng sau một số hữu hạn
các bước hay không?" (Ta dùng [M] để
chỉ đoạn mã của chương trình M.)
Định lý: không tồn tại
chương trình nào giải bài toán dừng.
Chứng minh: giả sử có chương trình A
giải bài toán dừng, ta thiết kế một
chương trình M để làm phản chứng
như sau
Như
vậy A không thể nào quyết
định xem M có dừng hay không!
Chứng minh trên rất đặc trưng cho
lập luận đường chéo của Cantor, thường
được dùng cho các bài toán mà đối tượng
có tính tự tham chiếu. Lý luận kiểu
như trên là một trong những công cụ rất mạnh
để chứng minh phản chứng trong lý thuyết
tính toán.
Không biết các bạn thế nào chứ lúc
đầu tôi thấy việc thiết kế chương
trình tự in khá là phản trực quan. (Tưởng
tượng một nhà máy sản xuất gỗ lại có
thể sản xuất một nhà máy sản xuất gỗ
y hệt như nó.) Thế mà câu trả lời
không những là có các chương trình như vậy, mà còn
có cả một nguyên tắc viết các chương trình
đó.
Trong bài này, ta đã đi từ một câu hỏi tự
tham chiếu này (chương trình tự in) sang câu hỏi tự
tham chiếu khác (bài toán dừng). Ta dùng câu trả lời có cho câu hỏi đầu
để trả lời không
cho câu hỏi thứ hai.