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의 달 모양이 다음과 같음을 알 수 있습니다.
어떻게 동작하는가?
코드 분석의 첫 단계는 코드를 깔끔하게 정리하는 것입니다. 이번에도 크게 다르지는 않습니다.
#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 문의 조건 cond
는 cond != 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 함수를 허용합니다.
int main(void)
int main(int argc, char *argv[])
...하지만 그 밖에 "구현체가 지정하는 다른 방법"도 허용한다고 되어 있습니다. (따라서 void main(void)
같은 것은 모든 곳에서 동작하지 않을 수는 있어도 엄밀하게 "비표준"은 아닙니다.) 흥미롭게도 많은 컴파일러들이 int main(int argc, char *argv[], char *envp[])
라는 형태의 제 3의 main 함수를 허용하는데, 세번째 인자에는 NAME=value
형태의 환경 변수들의 목록이 들어 오는 식입니다.2
따라서 이 인자 세 개짜리 main 함수는 바로 이 제 3의 경우에 해당합니다. 물론 _
는 1보다 크거나 같겠고요. 편의를 위해 프로그램이 인자 없이 실행되었다고 가정하면, _
를 처음에 1이겠지요. 그럼 main 함수는 자기 자신을 재귀 호출하면서 다음과 같은 식으로 동작한다는 걸 알 수 있습니다.
main(1, ..., ...)
이 호출됩니다. 그 안에서는,main(0, -43, 0)
가 호출됩니다. 그 안에서는,main(-1, -43, -1)
가 호출됩니다. 그 안에서는,- ...
main(-22, -43, -22)
가 호출됩니다. 그 안에서는,main_43(-43, -42, -22)
가 호출됩니다.#
또는 공백을 출력합니다. (_
는 -22)
main_43(-43, -42, -21)
이 호출됩니다.#
또는 공백을 출력합니다. (_
는 -21)
- ...
- ...
main_43(-43, -42, -1)
이 호출됩니다.#
또는 공백을 출력합니다. (_
는 -1)
- 10, 즉 개행 문자를 출력합니다. (
_
는 0)
따라서 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(-43, -42, ...)
가 호출됩니다. 그 안에서는,main_43(-43, -41, ...)
이 호출됩니다. 그 안에서는,- ...
main_43(-43, 0, ...)
이 호출됩니다.- 개행 문자를 출력합니다. (
o
가 증가되기 전에o != 0
조건을 확인하므로)
- 개행 문자를 출력합니다. (
#
또는 공백을 출력합니다. (o
는 0)
- ...
- ...
#
또는 공백을 출력합니다. (o
는 -41)
이제 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부터 시작하고 증가하도록 바꾸겠습니다. 이렇게 하려면 i
를 i - 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
와 j
를 행과 열을 나타내는 번호로 이해했습니다. 하지만 수학식을 더 간단하게 하기 위해 우리는 출력되는 문자들의 정 가운데를 원점으로 쓸 필요가 있습니다. (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;
}
이제 첫 번째 조건을 풀겠습니다. 라는 건 뭘 의미할까요?
을 정의에 따라 치환하면
, 즉
라는 식으로 정리됩니다. 원과 y축이
인 수평선이 만나는 지점은
입니다만, 우리가 출력하려는 그림은 가로로 찌그러져 있다는 걸 생각하면(그래서 23행 43열인 것입니다!) 실제로는
의 관계가 성립하므로
범위는 원 안에 있고, 따라서 점을 찍어야 한다는 걸 알 수 있습니다.
따라서 첫번째 조건은 원을 출력하는 역할을 합니다. 이 원은 세로 크기가 이고 가로 크기가
로
에 해당하는 직사각형보다 약간 큽니다. 하지만 "원 안에 있는" 점만 출력되고 원에 놓여 있는 점은 출력되지 않기 때문에 실제 직사각형보다 약간 더 크게 잡는 게 더 보기 좋을 것입니다. (저자는 아마 테스트를 통해 얼마나 더 키울 지를 결정했을 것입니다.)
이제 이 내용을 정리해서 코드로 씁시다. 참고로 우리가 다루고 있는 원은 가로로 두 배 뻥튀기 된 원()이었기 때문에
를
로 쓰는 것이 더 보기 좋을 것입니다.
#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;
}
이제 쓸모가 없어진(?) 을 다른 식들에 통합시키면 다음 코드가 됩니다.
#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로 나누는 식이 있습니다. 이니
이군요. 왠지 6.286526이
와 비슷해 보이는 건 눈의 착각일까요? 물론 소숫점 셋째자리부터 틀리지만(
) 원의 크기를 일부러 크게 했던 전적(?)을 생각해 보면 그 값이 맞을 수도 있습니다. 일단 편의를 위해 이 식을
p
라고 하겠습니다. 그럼,
int t = (time(0) - 607728) % 2551443;
double p = t / 405859.;
a = fabs(p - 4.7 + acos(-x / d));
그럼 두번째 조건은 로 정리됩니다. 이 식을 적절히 정리해 봅시다. 먼저 절대값을 풀죠.
아크코사인 항만 남기고 나머지를 모두 조건으로 옮기면,
또 눈에 익은 숫자가 나왔군요. 이고
입니다. 아까 전에
일 것 같다고 얘기했으니 아마 예측이 틀리진 않았네요. 따라서,
편의를 위해서 아크코사인 안의 마이너스는 빼내겠습니다.
아크코사인의 정의에 따라 가 성립합니다. 따라서 두 조건을 조합하면 다음과 같은 식을 얻습니다.
이 식은 가
보다 작을 때와 클 때로 나눠서 정리할 수 있습니다. 코사인은
범위에서 단조 감소, 즉 각도가 커지면 함수값이 작아진다는 걸 주의합시다.
일 때,
일 때,
수식만으로 이해하기 약간 곤란하므로 예를 들어 봅시다. 일 때
이고, 따라서
에 해당하는 달의 오른쪽 1/4 부분만 나타날 것입니다. 머릿속에 그림이 그려지십니까? 아무래도 바나나처럼 생긴 초승달이 되겠지요. (여기서 중요한 것은
는
에 영향을 받는다는 것입니다. 따라서 이 경계선은 수직선이 아닌 둥그스름한 곡선이 됩니다.)
마찬가지로 일 때
이고, 따라서
에 해당하는 달의 왼쪽 1/2 부분이 나타날 것이며 이는 하현입니다. 두말할 것도 없이 보름달은
일 때 나타납니다.
지금까지의 두 조건을 조합하면 어떻게 그럴듯한 달 모양이 나오는지 이해할 수 있을 것입니다. (엄밀하게는, 첫번째 조건은 아크코사인에 -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이라고 가정하고 호출되는 경로를 살펴 봅시다.
main(3, ..., ...)
이 호출됩니다. 그 안에서는,main(2, -43, 2)
가 호출됩니다. 그 안에서는,main(1, -43, 1)
이 호출됩니다. 그 안에서는,main(0, -43, 0)
가 호출됩니다. 그 안에서는,main(-1, -43, -1)
가 호출됩니다. 그 안에서는,- ...
main(-43, -42, -1)
이 호출됩니다.#
또는 공백을 출력합니다.
- 개행 문자를 출력합니다.
main(-43, -43, 1)
이 호출됩니다.#
또는 공백을 출력합니다.
main(-43, ... + 1, 2)
가 호출됩니다. 어?
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
를 쓴 건데, 그다지 멋이 안 나 보이니 아무래도 그냥 버그가 있는 채로 사는 게 나을 것 같습니다.
정확히는 한국 표준시 기준 2009년 1월 1일 16시. 사이트에서 검색할 때는 2009년 1월 2일 자정 UTC로 검색하면 비슷한 시각이 나옵니다. ↩
그러나 실질적으로는 POSIX에서 지정하는 (같은 용도의)
environ
변수를 쓰거나 getenv 함수를 쓰는 것이 더 좋습니다. 이 제 3의 main 함수는 공식적으로 표준화된 바가 없습니다. ↩이 원의 방정식은
입니다.
에 대해 정리하면
, 즉
이고, 우리는 이 중 양수 해만 취합니다. ↩
행성이나 달의 공전 주기는 여러 가지로 나눌 수 있습니다. 삭망월은 지구와 태양의 위치를 고정시켰다고 가정할 때 달이 원래 위치로 돌아 오는 시간입니다. 이에 비해 항성월(orbital period)은 말 그대로 달이 지구를 한 바퀴 도는데 걸리는 시간으로, 이 시간동안 지구도 태양을 공전하기 때문에 달의 모양은 원래 위치에서의 모양과 다르게 됩니다. (지구에서 보이는 달의 모양은 태양의 영향을 받습니다.) ↩