메아리

Tour de IOCCC: 2000/natori

#include <stdio.h>
#include <math.h>
double l;main(_,o,O){return putchar((_--+22&&_+44&&main(_,-43,_),_&&o)?(main(-43,++o,O),((l=(o+21)/sqrt(3-O*22-O*O),l*l<4&&(fabs(((time(0)-607728)%2551443)/405859.-4.7+acos(l/2))<1.57))[" #"])):10);}

Best Small Program이라는 이름으로 2000년에 IOCCC에서 수상한 작품입니다. (왜 Best one-liner가 아닌지가 좀 궁금하네요.) 이런 짧은 프로그램이 IOCCC에서 수상할 경우, 대개 이 프로그램들은 길이에 비해 "매우" 놀라운 기능들을 수행하는 경우가 많았죠. 이번에는 무슨 프로그램일까요?

뭘 하는 프로그램인가?

컴파일하고 실행해 봅시다. 리눅스에서는 -lm 옵션이 필요합니다. (이 프로그램은 시간에 영향을 받습니다. 다음 출력은 대략 2009년 1월 15일을 기준으로 합니다.)

$ gcc natori.c -o natori
$ ./natori

                  ######                   
            ################               
        #######################            
      ##########################           
    #############################          
   ###############################         
  #################################        
 ###################################       
####################################       
#####################################      
#####################################      
#####################################      
#####################################      
#####################################      
####################################       
 ###################################       
  #################################        
   ###############################         
    #############################          
      ##########################           
        #######################            
            ################               
                  ######                   

우엇 이건 뭡니까? 동그랗게 생기긴 했는데 오른쪽이 약간 날아 갔군요.

날짜에 영향을 받는다고 했으므로 한 번 날짜를 속여-_- 봅시다. 코드에서 날짜를 결정하는 부분은 time() 함수 호출 하나 뿐입니다. 이걸 매크로로 갈아 치워 봅시다.

$ gcc natori.c -o natori -D'time(s)'=$((`date +%s` - 86400*14))
$ ./natori

                       ##                  
                           ####            
                             ######        
                              #######      
                               ########    
                                ########   
                                 ########  
                                 ######### 
                                  #########
                                  #########
                                  #########
                                  #########
                                  #########
                                  #########
                                  #########
                                 ######### 
                                 ########  
                                ########   
                               ########    
                              #######      
                             ######        
                           ####            
                       ##                  

(bash 대신에 다른 셸을 쓰는 사람은 $((...)) 부분을 적절한 내용으로 수정해 주시길 바랍니다. 어차피 이 글은 C에 대한 글이지 셸 스크립트에 대한 글은 아니니까요.)

2주 전 날짜를 집어 넣으니 왠 달 같이 생긴 것이 나오는군요! 그렇습니다. 이 프로그램은 무려 (북반구에서) 현재 달의 모양을 보여 주는 프로그램인 것입니다. 직접 확인해 보시거나, 달의 모양을 보여 주는 사이트를 찾아 보시면 실제로 2009년 1월 1일1의 달 모양이 다음과 같음을 알 수 있습니다.

2009&#45380; 1&#50900; 1&#51068;&#51032; &#45804; &#47784;&#50577;

어떻게 동작하는가?

코드 분석의 첫 단계는 코드를 깔끔하게 정리하는 것입니다. 이번에도 크게 다르지는 않습니다.

#include <stdio.h>
#include <math.h>

double l;

main(_,o,O) {
    return putchar((_-- + 22 && _ + 44 && main(_, -43, _), _ && o) ?
        (main(-43, ++o, O),
         ((l = (o + 21) / sqrt(3 - O * 22 - O * O),
           l * l < 4 &&
            (fabs(((time(0) - 607728) % 2551443) / 405859. - 4.7 + acos(l/2)) < 1.57)
          )[" #"]
         )
        ) : 10);
}

...이거 코드를 정리하긴 했는데 도움이 별로 안 되는군요. 가장 큰 문제는 main 함수를 재귀호출하는 트릭을 썼기 때문이고, 특히 putchar의 인자를 평가하는 도중에 재귀 호출을 사용하고 있기 때문입니다.

이중 재귀 호출

putchar의 맨 바깥에 있는 수식은 삼항 연산자(?:)입니다. 일단 이걸 if를 쓰도록 바꿉시다. if는 수식에 쓸 수 없으니 putchar에 들어 갈 값을 중간에 저장해야 겠지요.

#include <stdio.h>
#include <math.h>

double l;

main(_,o,O) {
    char ch;

    if (_-- + 22 && _ + 44 && main(_, -43, _), _ && o) {
        ch = (main(-43, ++o, O),
            ((l = (o + 21) / sqrt(3 - O * 22 - O * O),
              l * l < 4 &&
                   (fabs(((time(0) - 607728) % 2551443) / 405859. - 4.7 + acos(l/2)) < 1.57)
             )[" #"]
            ));
    } else {
        ch = 10;
    }

    return putchar(ch);
}

불행 중 다행으로 대부분의 코드는 콤마(,)로 연결되어 있습니다. 콤마로 연결된 수식들은 순서대로 평가되고, 맨 마지막 것만이 결과로 반환되기 때문에 독립된 문장으로 나누기가 쉽습니다. 하나 하나 정리해 보죠. 이 코드의 경우 l * l < 4 && fabs(...) < 1.57 하나만이 마지막에 출력될 문자를 결정하고 나머지는 순서대로 실행되게 됩니다.

#include <stdio.h>
#include <math.h>

double l;

main(_,o,O) {
    char ch;

    _-- + 22 && _ + 44 && main(_, -43, _);
    if (_ && o) {
        main(-43, ++o, O);
        l = (o + 21) / sqrt(3 - O * 22 - O * O);
        ch = (l * l < 4 &&
            (fabs(((time(0) - 607728) % 2551443) / 405859. - 4.7 + acos(l/2)) < 1.57)
             )[" #"];
    } else {
        ch = 10;
    }

    return putchar(ch);
}

ch는 크게 (...)[" #"] 형태의 수식으로 정리되는데, 문자열과 그 인덱스를 뒤집는 것은 전형적인 기법으로 " #"[...]와 동일한 뜻입니다. 특히 이 경우 ... 부분에 0 아니면 1만 가능하므로 if 문으로 바꿔서 표현해도 되겠습니다.

#include <stdio.h>
#include <math.h>

double l;

main(_,o,O) {
    char ch;
    double x;

    _-- + 22 && _ + 44 && main(_, -43, _);
    if (_ && o) {
        main(-43, ++o, O);
        l = (o + 21) / sqrt(3 - O * 22 - O * O);
        x = fabs(((time(0) - 607728) % 2551443) / 405859. - 4.7 + acos(l/2));
        if (l * l < 4 && x < 1.57) {
            ch = '#';
        } else {
            ch = ' ';
        }
    } else {
        ch = 10;
    }

    return putchar(ch);
}

맨 앞의 재귀 호출도 정리해 보겠습니다. &&로 이어져 있다는 얘기는 사실 if 문으로 고쳐 써도 된다는 얘기죠. (&&&와 달리 순서대로 실행되며 그 중 하나라도 0이 나오면 뒤에 있는 값을 평가하지 않고 버립니다. 예를 들어 f() && g()라는 수식에서 f가 0을 반환하면 g는 불려지지도 않습니다.)

#include <stdio.h>
#include <math.h>

double l;

main(_,o,O) {
    char ch;
    double x;

    if (_-- + 22) {
        if (_ + 44) main(_, -43, _);
    }
    if (_ && o) {
        main(-43, ++o, O);
        l = (o + 21) / sqrt(3 - O * 22 - O * O);
        x = fabs(((time(0) - 607728) % 2551443) / 405859. - 4.7 + acos(l/2));
        if (l * l < 4 && x < 1.57) {
            ch = '#';
        } else {
            ch = ' ';
        }
    } else {
        ch = 10;
    }

    return putchar(ch);
}

첫 두 if 문의 조건을 정리해 봅시다. if 문의 조건 condcond != 0와 같은 의미이기 때문에 _-- + 22_-- + 22 != 0과 같은 의미이고, 따라서 _-- != -22가 됩니다. 후위 -- 연산자를 전위 연산자로 바꾸면 --_ + 1 != -22, 즉 --_ != -23과 같은 의미가 되겠지요. 두번째 if 문의 조건도 마찬가지로 _ != -44가 됩니다. 따라서,

#include <stdio.h>
#include <math.h>

double l;

main(_,o,O) {
    char ch;
    double x;

    --_; /* 맨 첫 if 문의 조건에서 독립된 문장으로 바꿈 */
    if (_ != -23) {
        if (_ != -44) main(_, -43, _);
    }
    if (_ && o) {
        main(-43, ++o, O);
        l = (o + 21) / sqrt(3 - O * 22 - O * O);
        x = fabs(((time(0) - 607728) % 2551443) / 405859. - 4.7 + acos(l/2));
        if (l * l < 4 && x < 1.57) {
            ch = '#';
        } else {
            ch = ' ';
        }
    } else {
        ch = 10;
    }

    return putchar(ch);
}

위의 코드에서 저는 일부러 --_ 부분을 별도의 문장으로 바꿨습니다. 하지만 그 뒤에 _ 변수를 "변경"하는 부분이 없다는 걸 생각하면, --_를 빼고 모든 __ - 1로 바꾸는 게 더 이해하기 편할 것 같습니다. 이를 반영하면 다음과 같이 됩니다.

#include <stdio.h>
#include <math.h>

double l;

main(_,o,O) {
    char ch;
    double x;

    if (_ - 1 != -23) {
        if (_ - 1 != -44) main(_ - 1, -43, _ - 1);
    }
    if (_ - 1 && o) {
        main(-43, ++o, O);
        l = (o + 21) / sqrt(3 - O * 22 - O * O);
        x = fabs(((time(0) - 607728) % 2551443) / 405859. - 4.7 + acos(l/2));
        if (l * l < 4 && x < 1.57) {
            ch = '#';
        } else {
            ch = ' ';
        }
    } else {
        ch = 10;
    }

    return putchar(ch);
}

조건문을 정리하면,

#include <stdio.h>
#include <math.h>

double l;

main(_,o,O) {
    char ch;
    double x;

    if (_ != -22 && _ != -43) main(_ - 1, -43, _ - 1);
    if (_ != 1 && o != 0) {
        main(-43, ++o, O);
        l = (o + 21) / sqrt(3 - O * 22 - O * O);
        x = fabs(((time(0) - 607728) % 2551443) / 405859. - 4.7 + acos(l/2));
        if (l * l < 4 && x < 1.57) {
            ch = '#';
        } else {
            ch = ' ';
        }
    } else {
        ch = 10;
    }

    return putchar(ch);
}

이제 전체적으로 문장이 정리되었으니 재귀 호출을 평범한 루프로 바꿔 봅시다.

우선 위 코드를 유심히 살펴 보면 두번째 main 호출에서 _에 무조건 -43을 넣는다는 것을 알 수 있습니다. 거 참 수상한 일이군요. 한 번 여기에 해당하는 코드들만 따로 분리해 봅시다.

#include <stdio.h>
#include <math.h>
#include <assert.h>

double l;

main_43(_,o,O) {
    char ch;
    double x;

    assert(_ == -43);

    if (o != 0) {
        main_43(-43, ++o, O);
        l = (o + 21) / sqrt(3 - O * 22 - O * O);
        x = fabs(((time(0) - 607728) % 2551443) / 405859. - 4.7 + acos(l/2));
        if (l * l < 4 && x < 1.57) {
            ch = '#';
        } else {
            ch = ' ';
        }
    } else {
        ch = 10;
    }

    return putchar(ch);
}

main(_,o,O) {
    char ch;
    double x;

    if (_ == -43) return main_43(_, o, O);

    if (_ != -22) main(_ - 1, -43, _ - 1);
    if (_ != 1 && o != 0) {
        main_43(-43, ++o, O);
        l = (o + 21) / sqrt(3 - O * 22 - O * O);
        x = fabs(((time(0) - 607728) % 2551443) / 405859. - 4.7 + acos(l/2));
        if (l * l < 4 && x < 1.57) {
            ch = '#';
        } else {
            ch = ' ';
        }
    } else {
        ch = 10;
    }

    return putchar(ch);
}

이제 main 함수의 일부 코드가 main_43으로 옮겨 갔습니다. 원래 main 함수에 있던 첫번째 if 문은 _이 -43일 때는 아예 거들떠 보지도 않는 문장이므로 빼 버릴 수 있겠습니다. 한편 우리는 main 함수에 _가 -43일 경우를 특별 처리했기 때문에 그 뒤의 조건 중 하나도 빼 버릴 수 있었지요.

남아 있는 main 함수를 분석해 봅시다. 일단 _의 초기값은 몇일까요? 아니 그보다 인자 세 개짜리 main이 가능이나 한 건가요? 일단 C 표준에서는 다음과 같은 두 종류의 main 함수를 허용합니다.

...하지만 그 밖에 "구현체가 지정하는 다른 방법"도 허용한다고 되어 있습니다. (따라서 void main(void) 같은 것은 모든 곳에서 동작하지 않을 수는 있어도 엄밀하게 "비표준"은 아닙니다.) 흥미롭게도 많은 컴파일러들이 int main(int argc, char *argv[], char *envp[])라는 형태의 제 3의 main 함수를 허용하는데, 세번째 인자에는 NAME=value 형태의 환경 변수들의 목록이 들어 오는 식입니다.2

따라서 이 인자 세 개짜리 main 함수는 바로 이 제 3의 경우에 해당합니다. 물론 _는 1보다 크거나 같겠고요. 편의를 위해 프로그램이 인자 없이 실행되었다고 가정하면, _를 처음에 1이겠지요. 그럼 main 함수는 자기 자신을 재귀 호출하면서 다음과 같은 식으로 동작한다는 걸 알 수 있습니다.

따라서 main 함수는 하나의 루프로 표현할 수 있습니다. 주의할 점은 재귀 호출이 앞쪽에 있기 때문에 루프가 반대로 돈다는 것입니다. 그리고 (정리된 코드에서) main에 _이 -43이 되는 경우는 없다는 게 확실하니까 안심하고 해당 코드도 지울 수 있습니다. 그럼,

main(_,o,O) {
    int argc = _;
    char ch;
    double x;

    assert(_ != -43);

    for (O = -22; O <= argc; ++O) {
        /* 재귀 호출의 인자를 그대로 반영함. */
        _ = O;
        o = -43;

        if (_ != 1 && o != 0) {
            main_43(-43, ++o /* 이 값은 항상 -42임 */, O);
            l = (o + 21) / sqrt(3 - O * 22 - O * O);
            x = fabs(((time(0) - 607728) % 2551443) / 405859. - 4.7 + acos(l/2));
            if (l * l < 4 && x < 1.57) {
                ch = '#';
            } else {
                ch = ' ';
            }
        } else {
            ch = 10;
        }
        putchar(ch);
    }

    return 0; /* 이 프로그램에서는 반환값이 의미가 없음 */
}

(코드 분석을 할 때 assert 문을 써 주는 것은 나중에 바뀌는 코드를 체크할 때도 유용할 뿐만 아니라 잘못 고쳤는지 확인할 때도 유용합니다. 사실 IOCCC가 아니더라도 충분히 유용하고요.)

조금 더 정리하겠습니다. 일단 o는 main_43 함수에서나 바뀌고 main 함수 안에서는 항상 -43으로 설정되기 때문에 정리할 수 있습니다. 또한 _O는 함수 안에서 동일하므로 하나로 합칠 수 있겠고요. 마지막으로 _가 1일 경우와 아닐 경우를 정리하면 다음과 같이 되겠군요.

int main(int argc /* 원래 _였음 */, char *argv[]) {
    int i; /* 원래 O였음 */
    int j; /* 원래 o였음 */
    char ch;
    double x;

    assert(argc == 1); /* 뒤에 설명함 */

    for (i = -22; i < argc; ++i) {
        main_43(-43, -42, i);
        j = -42;
        l = (j + 21) / sqrt(3 - i * 22 - i * i);
        x = fabs(((time(0) - 607728) % 2551443) / 405859. - 4.7 + acos(l/2));
        if (l * l < 4 && x < 1.57) {
            putchar('#');
        } else {
            putchar(' ');
        }
    }

    putchar('\n'); /* '\n' == 10 */
    return 0;
}

코드가 매우 깔끔해지기는 했는데, 맨 앞의 assert(argc == 1);이 수상합니다. 제가 이 코드를 넣은 가장 큰 이유는 argc가 1이 아니면 이 프로그램은 뻗을 수도 있기 때문입니다(!). 이유는 맨 마지막에 설명하도록 하겠으니 일단 계속 갑시다.

main 함수를 루프로 바꿨으니 main_43도 바꿔야 겠지요? 원래 코드는 이랬습니다.

main_43(_,o,O) {
    char ch;
    double x;

    assert(_ == -43);

    if (o != 0) {
        main_43(-43, ++o, O);
        l = (o + 21) / sqrt(3 - O * 22 - O * O);
        x = fabs(((time(0) - 607728) % 2551443) / 405859. - 4.7 + acos(l/2));
        if (l * l < 4 && x < 1.57) {
            ch = '#';
        } else {
            ch = ' ';
        }
    } else {
        ch = 10;
    }

    return putchar(ch);
}

main 함수에서 main_43을 호출하는 광경을 보면 _o가 각각 -43과 -42로 고정되어 있음을 알 수 있습니다. 또한 재귀 호출은 o가 0일 때 종료됩니다. 따라서 이 함수는 다음과 같이 재귀 호출이 일어나게 됩니다.

이제 main_43도 main 함수와 마찬가지 방법으로 루프로 바꿀 수 있습니다. 재귀 호출이 코드보다 앞에 실행되기 때문에 순서가 반대라는 것은 이번에도 똑같습니다.

int main_43(int _, int o, int O) {
    int i; /* 원래는 o였음 (위의 o는 함수 프로토타입을 맞추기 위함) */
    char ch;
    double x;

    assert(_ == -43);
    assert(o == -42);

    putchar('\n'); /* o가 0일 때 */

    for (i = 0; i >= -41; --i) {
        l = (i + 21) / sqrt(3 - O * 22 - O * O);
        x = fabs(((time(0) - 607728) % 2551443) / 405859. - 4.7 + acos(l/2));
        if (l * l < 4 && x < 1.57) {
            ch = '#';
        } else {
            ch = ' ';
        }
        putchar(ch);
    }

    return 0; /* 반환값은 사용되지 않음 */
}

main 함수에는 main_43의 루프 안에 들어 있는 내용과 정확히 똑같은 내용의 코드가 있습니다. main 안의 코드는 main_43에서 i가 -42일 때의 코드와 똑같은데, 애초에 main과 main_43을 나눈 이유가 분석을 위한 것이었으니 합치는 게 좋겠습니다.

#include <stdio.h>
#include <math.h>
#include <assert.h>

int main(int argc, char *argv[]) {
    int i, j;
    double x, l;

    assert(argc == 1);

    for (i = -22; i < argc; ++i) {
        putchar('\n');
        for (j = 0; j >= -42; --j) {
            l = (j + 21) / sqrt(3 - i * 22 - i * i);
            x = fabs(((time(0) - 607728) % 2551443) / 405859. - 4.7 + acos(l/2));
            if (l * l < 4 && x < 1.57) {
                putchar('#');
            } else {
                putchar(' ');
            }
        }
    }

    putchar('\n');
    return 0;
}

달을 출력하기

지금까지 코드를 분석하면서 눈치챈 분도 많겠지만, 이 이중 루프는 각각 "행"과 "열"을 나타내고, 각 칸에 점을 찍을지 말지 결정하여 # 또는 공백을 출력하도록 되어 있습니다. i는 -22부터 0까지, j는 0부터 -42까지 변화하도록 되어 있는데, 헷갈리니까 0부터 시작하고 증가하도록 바꾸겠습니다. 이렇게 하려면 ii - 22로 바꾸고 j-j로 바꾸면 되겠지요.

#include <stdio.h>
#include <math.h>
#include <assert.h>

int main(int argc, char *argv[]) {
    int i, j;
    double x, l;

    assert(argc == 1);

    for (i = 0; i < 23; ++i) {
        putchar('\n');
        for (j = 0; j < 43; ++j) {
            l = (-j + 21) / sqrt(3 - (i - 22) * 22 - (i - 22) * (i - 22));
            x = fabs(((time(0) - 607728) % 2551443) / 405859. - 4.7 + acos(l/2));
            if (l * l < 4 && x < 1.57) {
                putchar('#');
            } else {
                putchar(' ');
            }
        }
    }

    putchar('\n');
    return 0;
}

l에 해당하는 수식을 정리해 봅시다. 약간의 소인수 분해가 필요합니다.

3 - (i-22) \cdot 22 - (i-22) (i-22) \\ = 3 + 11^2 - 11^2 - 2 (i - 22) \cdot 22 - (i-22)^2 \\ = 3 + 11^2 - ((i-22)+11)^2 \\ = 124 - (i-11)^2

따라서 l = \frac{-j + 21}{\sqrt{124 - (i-11)^2}}가 됩니다. 분모의 \sqrt{124 - (i-11)^2}는 반지름이 \sqrt{124}이고 중심이 원점인 원3에서 y = i - 11일 때 x의 (양수) 값입니다. 편의상 이 값을 d라고 씁시다.

지금까지 우리는 코드에서 ij를 행과 열을 나타내는 번호로 이해했습니다. 하지만 수학식을 더 간단하게 하기 위해 우리는 출력되는 문자들의 정 가운데를 원점으로 쓸 필요가 있습니다. (i는 0부터 22까지 변화하지만, 실제로 사용되는 i - 11은 -11부터 11까지 변화합니다. j의 경우에도 마찬가지입니다.) 따라서 y = i - 11, x = j - 21로 정규화하도록 코드를 고치겠습니다.

#include <stdio.h>
#include <math.h>
#include <assert.h>

int main(int argc, char *argv[]) {
    int x, y;
    double a /* 원래 x였음 */, l;

    assert(argc == 1);

    putchar('\n');
    for (y = -11; y <= 11; ++y) {
        for (x = -21; x <= 21; ++x) {
            l = -x / sqrt(124 - y * y);
            a = fabs(((time(0) - 607728) % 2551443) / 405859. - 4.7 + acos(l/2));
            if (l * l < 4 && a < 1.57) {
                putchar('#');
            } else {
                putchar(' ');
            }
        }
        putchar('\n');
    }

    return 0;
}

이제 첫 번째 조건을 풀겠습니다. l^2 < 4라는 건 뭘 의미할까요? l을 정의에 따라 치환하면 \left(\frac{-x}{d}\right)^2 < 4, 즉 -2d < x < 2d라는 식으로 정리됩니다. 원과 y축이 y인 수평선이 만나는 지점은 x' = \pm d입니다만, 우리가 출력하려는 그림은 가로로 찌그러져 있다는 걸 생각하면(그래서 23행 43열인 것입니다!) 실제로는 x = 2x'의 관계가 성립하므로 -2d < x < 2d 범위는 원 안에 있고, 따라서 점을 찍어야 한다는 걸 알 수 있습니다.

따라서 첫번째 조건은 원을 출력하는 역할을 합니다. 이 원은 세로 크기가 \sqrt{124} \approx 11.14이고 가로 크기가 2\sqrt{124} \approx 22.27-21 < x < 21,\ -11 < y < 11에 해당하는 직사각형보다 약간 큽니다. 하지만 "원 안에 있는" 점만 출력되고 원에 놓여 있는 점은 출력되지 않기 때문에 실제 직사각형보다 약간 더 크게 잡는 게 더 보기 좋을 것입니다. (저자는 아마 테스트를 통해 얼마나 더 키울 지를 결정했을 것입니다.)

이제 이 내용을 정리해서 코드로 씁시다. 참고로 우리가 다루고 있는 원은 가로로 두 배 뻥튀기 된 원(\left(\frac{x}{2}\right)^2 + y^2 = 124)이었기 때문에 d2\sqrt{124 - y^2}로 쓰는 것이 더 보기 좋을 것입니다.

#include <stdio.h>
#include <math.h>
#include <assert.h>

int main(int argc, char *argv[]) {
    int x, y;
    double a, l, d;

    assert(argc == 1);

    putchar('\n');
    for (y = -11; y <= 11; ++y) {
        d = 2 * sqrt(124 - y * y); /* -d < x < d 범위에서만 그려야 함 */
        for (x = -21; x <= 21; ++x) {
            l = -2 * x / d;
            a = fabs(((time(0) - 607728) % 2551443) / 405859. - 4.7 + acos(l/2));
            if (l * l < 4 && a < 1.57) {
                putchar('#');
            } else {
                putchar(' ');
            }
        }
        putchar('\n');
    }

    return 0;
}

이제 쓸모가 없어진(?) l을 다른 식들에 통합시키면 다음 코드가 됩니다.

#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <assert.h>

int main(int argc, char *argv[]) {
    int x, y;
    double a, d;

    assert(argc == 1);

    putchar('\n');
    for (y = -11; y <= 11; ++y) {
        d = 2 * sqrt(124 - y * y); /* -d < x < d 범위에서만 그려야 함 */
        for (x = -21; x <= 21; ++x) {
            a = fabs(((time(0) - 607728) % 2551443) / 405859. - 4.7 + acos(-x / d));
            if (abs(x) < d && a < 1.57) {
                putchar('#');
            } else {
                putchar(' ');
            }
        }
        putchar('\n');
    }

    return 0;
}

이제 두번째 조건을 살펴 봅시다. 앞의 조건이 달의 동그란 모양을 그리는 역할이었으니, 이 조건은 원에서 일부를 잘라 내서 달 모양으로 만드는 역할을 해야 합니다.

먼저 (time(0) - 607728) % 2551443이라는 식을 살펴 봅시다. 2551443초가 무슨 의미일까요? 이 프로그램이 뭘 하는지 알고 있다면 이 값은 달 모양이 바뀌는 주기, 즉 삭망월(synodic period)일 수 밖에 없습니다.4 달의 삭망월은 평균 29일 12시간 44분 2.9초(2551442.9초)로 여기서 사용된 값과 일치합니다. 607728초의 차이는 실제 달의 모양과 맞추기 위한 것이 되겠지요. 이 식을 t라고 하겠습니다.

이 식 바깥쪽에는 405859로 나누는 식이 있습니다. 0 \le t < 2551443이니 0 \le \frac{t}{405859} < 6.286526이군요. 왠지 6.286526이 2\pi와 비슷해 보이는 건 눈의 착각일까요? 물론 소숫점 셋째자리부터 틀리지만(2\pi \approx 6.283185) 원의 크기를 일부러 크게 했던 전적(?)을 생각해 보면 그 값이 맞을 수도 있습니다. 일단 편의를 위해 이 식을 p라고 하겠습니다. 그럼,

int t = (time(0) - 607728) % 2551443;
double p = t / 405859.;
a = fabs(p - 4.7 + acos(-x / d));

그럼 두번째 조건은 \left|p - 4.7 + \arccos \frac{-x}{d}\right| < 1.57로 정리됩니다. 이 식을 적절히 정리해 봅시다. 먼저 절대값을 풀죠.

-1.57 < p - 4.7 + \arccos \frac{-x}{d} < 1.57

아크코사인 항만 남기고 나머지를 모두 조건으로 옮기면,

-1.57 + 4.7 - p < \arccos \frac{-x}{d} < 1.57 + 4.7 - p

3.13 - p < \arccos \frac{-x}{d} < 6.27 - p

또 눈에 익은 숫자가 나왔군요. 3.13 \approx \pi이고 6.27 \approx 2\pi입니다. 아까 전에 0 \le p < 2\pi일 것 같다고 얘기했으니 아마 예측이 틀리진 않았네요. 따라서,

\pi - p < \arccos \frac{-x}{d} < 2\pi - p

편의를 위해서 아크코사인 안의 마이너스는 빼내겠습니다.

\pi - p < \pi - \arccos \frac{x}{d} < 2\pi - p

p - \pi < \arccos \frac{x}{d} < p

아크코사인의 정의에 따라 0 \le \arccos \frac{x}{d} \le \pi가 성립합니다. 따라서 두 조건을 조합하면 다음과 같은 식을 얻습니다.

\max \left\{p - \pi, 0\right\} < \arccos \frac{x}{d} < \min \left\{p, \pi\right\}

이 식은 p\pi보다 작을 때와 클 때로 나눠서 정리할 수 있습니다. 코사인은 \left[0, \pi\right] 범위에서 단조 감소, 즉 각도가 커지면 함수값이 작아진다는 걸 주의합시다.

수식만으로 이해하기 약간 곤란하므로 예를 들어 봅시다. p = \frac{\pi}{3}일 때 \cos p = \frac12이고, 따라서 \frac{d}{2} < x < d에 해당하는 달의 오른쪽 1/4 부분만 나타날 것입니다. 머릿속에 그림이 그려지십니까? 아무래도 바나나처럼 생긴 초승달이 되겠지요. (여기서 중요한 것은 dy에 영향을 받는다는 것입니다. 따라서 이 경계선은 수직선이 아닌 둥그스름한 곡선이 됩니다.)

마찬가지로 p = \frac32\pi일 때 \cos p = 0이고, 따라서 -d < x < 0에 해당하는 달의 왼쪽 1/2 부분이 나타날 것이며 이는 하현입니다. 두말할 것도 없이 보름달은 p = \pi일 때 나타납니다.

지금까지의 두 조건을 조합하면 어떻게 그럴듯한 달 모양이 나오는지 이해할 수 있을 것입니다. (엄밀하게는, 첫번째 조건은 아크코사인에 -1보다 작거나 1보다 큰 값을 넣는 경우를 방지하기 위해서 있습니다.) 이제 어떻게 동작하는지 알았으니 코드를 마지막으로 정리합니다.

#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <assert.h>

const double PI = 3.1415926535897932;

int main(int argc, char *argv[]) {
    int x, y;
    int t;
    double d;
    double p;

    assert(argc == 1);

    t = (time(0) - 607728) % 2551443;
    p = t / 405859.; /* 0부터 2pi 사이 */

    putchar('\n');
    for (y = -11; y <= 11; ++y) {
        d = 2 * sqrt(124 - y * y); /* -d < x < d 범위에서만 그려야 함 */
        for (x = -21; x <= 21; ++x) {
            if (abs(x) >= d) {
                /* 원 바깥에 있는 경우 공백을 출력한다. */
                putchar(' ');
            } else if (p < PI) {
                /* x > d cos(p)일 때만 그린다.
                 * 달의 오른쪽부터 서서히 나타나게 함. */
                putchar(x > d * cos(p) ? '#' : ' ');
            } else {
                /* x < -d cos(p)일 때만 그린다.
                 * 달의 왼쪽을 향해 서서히 사라지게 함. */
                putchar(x < -d * cos(p) ? '#' : ' ');
            }
        }
        putchar('\n');
    }

    return 0;
}

자, 끝났습니다. 수식 쓰느라 죽는 줄 알았어요. 아 근데 왜 argc가 1이어야 하는지를 설명 안 했군요. 한 번 argc가 3이라고 가정하고 호출되는 경로를 살펴 봅시다.

argc가 1보다 크면 main 함수는 공백으로만 이루어진 빈 줄을 조금 출력하다가(main(-43, -43, 1) 같은 경우), 맨 마지막에는 쓰레기값, 정확히는 argv로 전달되었던 포인터 값을 그대로 안쪽 루프에 넘겨 주게 됩니다. 애초에 정해진 값만 처리하도록 만들어진 재귀 코드다 보니 무한히 재귀 호출을 시도하다가 스택을 소진하게 되지요.

원래 코드를 살짝 고치면 이 문제를 해결할 수 있습니다.

double l;main(_,o,O){return putchar((_--+22&&_+44&&main(_,-43,_),_<0&&o)?(main(-43,++o,O),((l=(o+21)/sqrt(3-O*22-O*O),l*l<4&&(fabs(((time(0)-607728)%2551443)/405859.-4.7+acos(l/2))<1.57))[" #"])):10);}

바뀐 점은 _&&o 대신 _<0&&o를 쓴 건데, 그다지 멋이 안 나 보이니 아무래도 그냥 버그가 있는 채로 사는 게 나을 것 같습니다.


  1. 정확히는 한국 표준시 기준 2009년 1월 1일 16시. 사이트에서 검색할 때는 2009년 1월 2일 자정 UTC로 검색하면 비슷한 시각이 나옵니다.

  2. 그러나 실질적으로는 POSIX에서 지정하는 (같은 용도의) environ 변수를 쓰거나 getenv 함수를 쓰는 것이 더 좋습니다. 이 제 3의 main 함수는 공식적으로 표준화된 바가 없습니다.

  3. 이 원의 방정식은 x^2 + y^2 = 124입니다. x에 대해 정리하면 x^2 = 124 - y^2, 즉 x = \pm \sqrt{124 - y^2}이고, 우리는 이 중 양수 해만 취합니다.

  4. 행성이나 달의 공전 주기는 여러 가지로 나눌 수 있습니다. 삭망월은 지구와 태양의 위치를 고정시켰다고 가정할 때 달이 원래 위치로 돌아 오는 시간입니다. 이에 비해 항성월(orbital period)은 말 그대로 달이 지구를 한 바퀴 도는데 걸리는 시간으로, 이 시간동안 지구도 태양을 공전하기 때문에 달의 모양은 원래 위치에서의 모양과 다르게 됩니다. (지구에서 보이는 달의 모양은 태양의 영향을 받습니다.)


Copyright © 1999–2009, Kang Seonghoon.