C 컴파일러의 tail-call 최적화, 알고 보니 2000년대 이후에야 자리잡은 최근 기능
- anton(Gforth 개발자)이 1994년 당시 C 컴파일러들은 인터프리터 디스패치에 쓰이는 방식의 tail-call 최적화를 지원하지 않았다고 밝힘
- 2001년 Mark Probst가 GCC에 별도 호출 규약으로 tail-call 최적화를 구현했지만 당시엔 간접 호출(indirect call)을 처리하지 못했다는 한계가 있었음
- HN 댓글에서 GCC의 간접 호출 tail-call 최적화는 Compiler Explorer 기준 2006년 3월 GCC 3.4.6에서 처음 확인되어 실제로는 약 20년 전부터 지원됐다는 반박이 나옴
- C 표준(ISO/IEC 9899:2024)에는 tail call 관련 규정이 전혀 없어 이는 Scheme의 R7RS와 달리 컴파일러별 확장 기능일 뿐이라는 지적이 나옴
- MSVC는 2010년대에야 tail-call 최적화를 추가했고, GCC/Clang의
[[musttail]]속성은 TCO가 실패하면 컴파일 에러를 내도록 강제하는 방식으로 신뢰성을 보완함
Hacker News opinions
JS도 TCO 넣었다가 뺐잖아. 이거 때문에 스택 오버플로우 버그 많이 나옴, 해결법은 flatten 하는 거임.
C 프로그래머 입장에선 잘 모르겠는데 Common Lisp 쓰는 Scheme 프로그래머들한테는 TCO 없는 게 흔한 함정이더라.
JS는 진짜 TCO 있어야 하는데 없음. 함수형 스타일에서 Java 클래스 스타일로 넘어간 게 좀 아쉬움.
이 글 원문 저자(anton)가 나한테 답한 거 같은데, C가 그렇게 늦게 TCO 지원한 줄 몰랐음. 나도 컴파일러 쪼렙이라 2000년대 초를 오래된 걸로 치는데, ML 같은 언어는 1980~90년대부터 있었던 거 생각하면 그렇게 오래된 것도 아님.
C 표준(ISO/IEC 9899:2024)엔 TCO 관련 내용이 아예 없음. 이건 그냥 컴파일러별 확장이고, Scheme의 R7RS 3.5절처럼 표준에 박혀 있는 거랑은 다름.
anton 말이 좀 틀린 거 같은데. C89~C23 이전엔 int f(); 선언에 실제 인자 안 맞게 호출하면 UB였고, C23부터는 int f(void)랑 같아져서 컴파일 에러가 됨. 가변 인자 함수는 va_start/va_arg/va_end로 다 소비하고 va_list 안 남기면 컴파일러가 tail call 만들 수 있음.
'2001년에 나온 게 최신'이라는 게 25년 전인데 그게 recent임? indirect call 지원은 GCC 3.4.6(2006년 3월)이 Compiler Explorer에서 확인되는 가장 이른 버전이라 20년 정도 됨.
GCC가 1987년에 처음 나왔으니까 TCO(indirect call 포함)는 GCC 역사의 절반 넘게 있었던 거임. 그러니 '거의 존재 기간 내내 TCO 있었다'는 말이 맞음.
30대 넘으면 20살 이후에 일어난 일은 다 최근처럼 느껴짐. 나한텐 9/11도 최근 기억이고 2008년 금융위기도 그럼.
MSVC는 2010년대 되어서야 TCO 넣은 걸로 기억함. 2000년대 중후반에 tail-recursive C++ 프로그램 보냈다가 크래시 나는 거 직접 봤음.
MSVC는 그냥 C99 몇 가지 기능도 지원 안 하는 걸로 유명함. 순수 C 지원엔 별로 신경 안 쓰는 듯.
언어가 TCO를 보장 안 해주면 tail recursive 코드 짜는 게 불편함. TCO를 '최적화'라고 부르는 프레이밍 자체가 잘못된 거 같음.
GCC에 [[gnu::musttail]] return 있잖아. 근데 맞음, TCO를 optimization이라고 부르는 거 자체가 문제임.
엄밀히 말하면 TCO는 프로그램 semantics를 안 바꾸니까 optimization이긴 함. 근데 메모리 사용량(특히 스택)에 영향 주는 몇 안 되는 최적화라서 놓치면 진짜 치명적임. 그래서 musttail 같은 속성으로 컴파일 에러를 내는 게 낫다고 봄.
일부 언어는 TCO 안 되면 컴파일 에러 내주는 애노테이션 있음. 필요한 건 강한 타입 시스템이지 컴파일러 최적화에 기도하는 게 아님.
C#이랑 F#은 같은 런타임 쓰는데 F#은 IL에 tail prefix 써서 TCO 보장하지만 Roslyn(C# 컴파일러)은 그 prefix를 절대 안 냄. 그래서 C#은 JIT 상태(RyuJIT 워밍업 여부)에 따라 스택 오버플로우 날 수도 있고 안 날 수도 있음.
gcc/clang에 [[musttail]] 있어서 TCO 안 되면 컴파일 에러 내니까 그거 쓰면 실질적으로 보장받는 거임. 컴파일만 되면 진짜 TCO 된 거라고 믿을 수 있음.
gnu::musttail이랑 clang::musttail 둘 다 있음, 문서에도 나와 있음.
누가 여기서 return 대신 become 키워드 쓰자는 아이디어 냈던데, 딱 맞는 방향인데 왜 아직 아무도 안 채택하는지 이해가 안 됨.
C에서 TCO로 뭘 얻는지 잘 모르겠음. tail call은 그냥 loop로 더 자연스럽게 쓸 수 있는 거 아닌가. 함수형 언어에서 mutable loop 변수가 없으니까 tail call이 중요한 거지, factorial(n<=1 then 1 else n*factorial(n-1))처럼 non-tail-recursive면 컴파일러가 최적화 못 하면 오버플로우 나는 게 오히려 자연스러운 문제 아님?