메아리

Tour de IOCCC: 1984/anonymous

int i;main(){for(;i["]<i;++i){--i;}"];read('-'-'-',i+++"hell\
o, world!\n",'/'/'/'));}read(j,i,p){write(j/p+p,i---j,i/i);}

이 코드는 1984년, 그러니까 최초의 IOCCC에서 Dishonorable mention이라는 (당황스러운) 상을 받았습니다. 얼마나 dishonorable했으면 만든 사람이 쪽팔려서 누가 짠 것인지를 밝히지 않겠다고 했을 정도일까요. (반쯤 농담입니다.) 하지만 최초이니만큼 이 코드는 꽤 해석하기 쉬운 편이고, 앞으로의 여정에 여러 가지로 도움이 될 만하니 이걸 먼저 다루기로 하겠습니다.

뭘 하는 프로그램인가?

이 프로그램은 전통적인 "Hello, world!" 프로그램입니다. (흔히 프로그래밍 언어를 처음 배우고 나서 테스트 용도로 자주 출력하는 문장입니다. 한국어로는 "안녕 세상아!" 정도 됩니다.)

제 컴퓨터에서 실행해 보았습니다. (유닉스 비스무리한 환경이 필요합니다. 앞으로 나올 거의 모든 프로그램도 마찬가지입니다.)

$ gcc anonymous.c -o anonymous
$ ./anonymous
hello, world!

어떻게 동작하는가?

아마 이 프로그램을 만든 사람은 멀쩡한 코드를 먼저 만들고 그것을 서서히 찌그러뜨려 괴상한 코드로 만들었을 겁니다. 그러나 우리한텐 원래 코드가 없으니 반대로 되짚어 봐야 겠지요. 앞으로 나올 모든 코드도 이런 식으로, 하지만 이번보다는 덜 친절하게(^^;) 설명할 것입니다.

먼저 기억해야 할 것은 C에서 공백은 (대부분) 무시된다는 것입니다. 무시 안 되는 공백은 문자열 안에 있는 공백과 각 토큰(이를테면 int foo;intfoo;는 다르지요)을 구분해 주는 공백 정도 뿐이지요. 그리고 문자열 중간에 들어 있는 \ 문자는 단지 문자열을 두 줄로 쓰기 위한 거라는 것도 알아 둡시다.

한 번 코드에 공백을 넣어서 조금 보기 쉽게 해 보겠습니다. vim이나 이맥스 같이 문법 강조를 잘 해 주는 에디터가 있다면 좀 더 편하겠지요.

int i;
main() {
    for ( ;
        i["]<i;++i){--i;}"];
        read('-'-'-', i+++"hello, world!\n", '/'/'/')
    );
}
read(j, i, p) {
    write(j/p+p, i---j, i/i);
}

""로 묶인 내용 사이에는 공백을 넣을 수 없다는 점을 유의합시다.

코드를 보니 함수가 두 개 있는 것 같습니다. 그런데 함수 문법이 좀 이상하지요? 이건 K&R이라고 해서 C 언어가 표준화되기 전에 사용하던 문법입니다. (흔히 K&R prototype이라고 합니다.) 이 형태의 함수 선언은 다음과 같이 읽어야 합니다.

여기에 주의하면서 현대적인 C 코드로 바꾸면 다음과 같이 되겠습니다.

int i;

int main(void) {
    for ( ;
        i["]<i;++i){--i;}"];
        read('-'-'-', i+++"hello, world!\n", '/'/'/')
    );
    return 0;
}

void read(int j, int i, int p) {
    write(j/p+p, i---j, i/i);
}

좀 더 살펴 봅시다. '-'-'-''-' - '-'와 같고, 어떤 숫자에서 자기 자신을 빼니 결과는 0입니다. 마찬가지로 '/'/'/''/' / '/'이므로 1이지요. 따라서 read라는 함수에 전달되는 첫번째 인자와 세번째 인자는 단순히 0과 1로 고정되어 있게 됩니다. 이제 read의 jp 인자는 필요 없으니 치워 버립시다.

int i;

int main(void) {
    for ( ;
        i["]<i;++i){--i;}"];
        read(i+++"hello, world!\n")
    );
    return 0;
}

void read(int i) {
    write(0/1+1, i---0, i/i);
}

이제 read 함수를 잘 살펴 봅시다. 아, 물론 이름은 read입니다만 실제로 하는 일은 write군요. (IOCCC에서는 이름을 말도 안 되게 짓는 것으로도 상을 탈 수 있습니다.) write의 세번째 인자인 i/i는 상식적으로 결과가 1이 된다는 걸 알 수 있습니다.1 첫번째 인자야 두말할 것도 없이 1이고요.

두번째 인자는 좀 더 설명이 필요합니다. C에서, 예를 들어 a->b이라는 코드가 a- >b라는 코드와 다른지 어떻게 알아 낼까요? 이 경우를 처리하기 위해 C는 모든 토큰(-> 같은 것들)을 최대한 뭉쳐서 처리하도록 하는 규칙을 가지고 있습니다. 예를 들어 -는 물론 올바른 토큰이지만, ->도 올바른 토큰이고 -보다 길기 때문에 ->가 선택되는 것입니다. 그 뒤에 *가 오고 ->*가 올바른 토큰이라면(C++에서는 그렇습니다) ->*가 선택되겠지만 이건 C 얘기가 아니군요.

따라서 두번째 인자는 i-- - 0, 즉 i--이 됩니다. 하지만 i는 그 뒤의 코드에서 전혀 쓰이지 않기 때문에 사실 그냥 i를 넘긴다는 얘기죠. 이제 read의 내용물을 정리하면,

int i;

int main(void) {
    for ( ;
        i["]<i;++i){--i;}"];
        read(i+++"hello, world!\n")
    );
    return 0;
}

void read(int i) {
    write(1, i, 1);
}

여기서 write 함수는 유닉스에서 제공하는 표준 함수입니다. (맨 페이지를 봅시다.) 첫 인자는 이미 열려 있는 파일의 번호를 나타내는 파일 서술자(file descriptor)이고, 두번째와 세번째는 거기에 출력될 문자열의 포인터와 그 길이입니다. 잠시, "포인터"라고요? 분명 read의 인자인 iint 형으로 암시적으로 선언된다고 했습니다. 근데 write에서 두번째 인자가 char*(정확히는 const char*)라고 합니다. 이게 어찌 된 일일까요?

사실 알고 보면 간단한 일입니다. C는 모르는 함수 이름이 등장했을 때는 들어가는 인자들이 따로 형변환되지 않는다고 가정하고 링커가 모든 것들을 합칠 때까지 기다립니다. 링커는 이 모르는 함수 이름을 같이 링크되는 다른 코드나 라이브러리들과 대조해서 합칠 수 있는 경우 합치고, 못 합치면 에러를 냅니다. (write가 아니라 please_writ_em_all 같은 이름을 썼다면 에러가 났겠죠.) 하지만 이렇게 헤더 없이 컴파일을 할 경우, 당연히 함수의 프로토타입을 모르기 때문에 잘못된 인자를 넘길 수도 있으며, 사실은 보통 잘 돌아 가기도 합니다. 우리가 아는 많은 플랫폼에서는 intchar*의 크기가 같고, 따라서 write에 들어 간 문자열 포인터는 잘 처리됩니다.

그래도 웬만하면 정확한 프로토타입을 써 주는 게 좋겠죠. 이 함수는 unistd.h 헤더 파일에 선언되어 있습니다. 따라서,

#include <unistd.h>

int i;

int main(void) {
    for ( ;
        i["]<i;++i){--i;}"];
        read(i+++"hello, world!\n")
    );
    return 0;
}

void read(int i) {
    write(1, (const char*)i, 1);
}

한편, 파일 서술자 자리에 박혀 있는 1은 표준 출력(stdout)을 나타냅니다. 사실 유닉스에서 모든 것은 파일이고, 표준 출력이 파일이 아닌 것도 좀 이상하긴 하겠죠. 이 표준 출력에 대응하는 파일은 몇 가지 경로를 거쳐 여러분이 보고 있는 터미널에게 전달되어 출력됩니다. 그러니까 read 함수는 i가 가리키는 문자 하나를 출력해 주는 함수였던 것입니다. 많이 간단해졌죠?

#include <stdio.h>

int i;

int main(void) {
    for ( ;
        i["]<i;++i){--i;}"];
        read(i+++"hello, world!\n")
    );
    return 0;
}

void read(int i) {
    putchar(*(const char*)i);
}

read가 끝났으니 main 함수를 봅시다. for (A;B;C) D;A; while (B) {D; C;}로 바꿀 수 있다는 걸 상기해 보면,

#include <stdio.h>

int i;

int main(void) {
    while (i["]<i;++i){--i;}"]) {
        read(i+++"hello, world!\n");
    }
    return 0;
}

void read(int i) {
    putchar(*(const char*)i);
}

여기서 남아 도는 ;는 생략했습니다. (for 문의 내용물은 빈 문장입니다. 물론 실제 코드에서 이렇게 쓰는 건 미친 것입니다.) 위에서 설명한 토큰 규칙에 따라 i+++"..."i++ + "..."로 해석되고, 이왕 나눈 겸 i를 증가시키는 부분을 따로 문장으로 만들겠습니다.

#include <stdio.h>

int i;

int main(void) {
    while (i["]<i;++i){--i;}"]) {
        read("hello, world!\n" + i);
        i++;
    }
    return 0;
}

void read(int i) {
    putchar(*(const char*)i);
}

이제 read 함수가 뭘 하는지 확실해졌으니 read 함수를 없애고 main에 합치는 게 좋겠습니다. 이 때 주의할 점은, 앞에서와 완벽하게 똑같은 이유로 read 함수의 인자는 전혀 체크되지 않는다는 것입니다. 그리고 read에서는 그렇게 전달된 문자열 포인터를 int로 해석하고, 아무 생각 없이 write로 넘긴 다음에, write에서 그걸 다시 문자열 포인터로 변환하는 뻘짓-_-;을 하게 됩니다. 뭐 잘 동작하니 그러려니 하고 코드를 정리해 봅시다.

#include <stdio.h>

int i;

int main(void) {
    while (i["]<i;++i){--i;}"]) {
        putchar(*("hello, world!\n" + i));
        i++;
    }
    return 0;
}

이제 두 개의 이상하게 생긴(?) 포인터 연산만 남았습니다. 이 이상하게 생긴 포인터 연산은 C가 사실은 고급 언어가 아니라 고급 언어의 털을 쓴 어셈블리 언어라는 걸 증명해 준다고 뻥을 칠 수 있는 기능 중 하나인데, 예를 들어 s가 문자열 포인터고 i가 숫자라고 하면,

s[i] -> *(s + i) -> *(i + s) -> i[s]

라는 말도 안 되는 변형이 가능합니다! 이것은 C의 [] 연산자가 포인터에 대해서는 특수하게 정의되어 있기 때문에 가능합니다. a[b]는 "항상" *(a + b)와 같고, 덧셈에 교환 법칙이 성립하니 b[a]도 말이 되는 것이지요. (C++에서는 a와 b 중 하나가 포인터여야 하는 조건이 있습니다만 여전히 허용됩니다.) 이제 이 코드가 어떻게 동작하는지 의심할 여지가 없게 되었습니다.

#include <stdio.h>

int i;

int main(void) {
    while ("]<i;++i){--i;}"[i]) {
        putchar("hello, world!\n"[i]);
        i++;
    }
    return 0;
}

마지막으로 이 코드는 읽기 헷갈리도록 전혀 쓸데 없는 문자열을 while문의 조건으로 끼워 넣었습니다만, 사실 이게 하는 일은 "hello, world!\n"가 모두 출력된 뒤 결과값을 0으로 만드는 것 뿐입니다. (두 문자열의 길이는 14바이트로 같습니다. 앞의 문자열을 뒷쪽 문자열로 바꿔도 잘 동작하겠죠?) 따라서 지금까지의 삽질을 모두 정리하면,

#include <stdio.h>

int main(void) {
    printf("hello, world!\n");
    return 0;
}

가 됩니다.

그래서 뭐 어쩌란 말인가?

뭐 어쩌란 말인가요. 더 할 말이 있으니까 코드 다 설명하고 이러고 있습니다.

사실 이 작품은 IOCCC에 출품된 코드 중에서 가장 골때리는 역사를 갖고 있습니다. 일단 이 코드는 IOCCC 역사상 얼마 안 되는 익명 출품작(두번째는 2000년)이고, 그 익명으로 낸 사람은 (대회 주최자인 랜던 커트 놀에 따르면) C 프로그래밍 언어와 밀접한 연관을 맺고 있다고 합니다. 무슨 K&R에 나오는 그 K나 R일까요? :)2

아직까지 공식적으로 누군지 밝혀진 건 아니지만 몇 가지 힌트가 있습니다. 자기 자신을 빼고 코드를 짠 저자의 정체를 알고 있다는 유일한 사람인 주최자 놀은 슬래시닷 글이나 이 코드를 왼팔에 문신한 토머스 스코벨의 글에 몇 가지 얘기를 적어 놓았습니다. 예를 들어,

이제 이 글을 쓰는 시점으로 이 코드는 작성된지 25년이 됩니다. "이런 쓰레기같은 코드를 짤 수 있다는 사실에" 쪽팔려서 공개되길 포기했다고 하지만, IOCCC의 역사를 보면 이것보다 훨씬 쓰레기같고 변태같은 코드가 넘쳐 나는 광경을 볼 수 있습니다. 언제가 될진 모르겠지만 이 코드의 저자가 밝혀질 그 날은 인터넷 상의 뿌리 깊은 미스테리 하나가 드디어 풀리는 날이 될 것입니다.


  1. 그러나 가끔은 상식이 통하지 않는 경우도 있게 마련입니다. 이 코드는 표준 C에서 정해지지 않은 동작(undefined behavior)을 할 수 있다고 정해 놓았는데, 왜냐하면 앞의 i--와 다음의 두 i가 평가되는 시점이 꼬일 수 있기 때문입니다. 하지만 현존하는 거의 모든 컴파일러는 후자를 먼저 1로 간소화하기 때문에 웬만하면 잘 동작할 것입니다.

  2. K&R은 C 표준이 나오기 이전에 표준의 역할을 수행한 The C Programming Language의 저자 이름, 커니건(Kernighan)과 리치(Ritchie)의 머릿글자를 딴 것입니다.

  3. "저자"라고 쓰긴 했지만 영어 원문은 복수인 they로 시작합니다. 어쩌면 여러 사람이 같이 짠 게 아닐까요?


Copyright © 1999–2009, Kang Seonghoon.