요약
strtod 계열 함수의 오류 보고 방식이 구현마다 달라 표준 C만으로는 변환 오류를 확실히 잡을 수 없습니다.
sebsite.pw의 글쓴이가 C 표준 조항과 POSIX 규격, 리눅스 매뉴얼 페이지를 하나씩 대조한 글입니다. 수학 함수의 오류 보고 방식을 알려 주는 매크로 math_errhandling을 다룬 앞선 글의 후속편입니다.
왜 중요한가
- 매뉴얼 페이지가 권하는 "호출 전에
errno를 0으로 두고 확인하기" 관용구가 모든 C 구현에서 통하지는 않습니다. - 오버플로와 언더플로를 제대로 잡으려면 대상 구현이 어떻게 동작하는지 미리 알아야 합니다.
핵심 내용
- 표준 C에서
strtod는math_errhandling값에 따라errno를 설정할 수도, 부동소수점 예외만 올릴 수도 있습니다. - 언더플로(0에 너무 가까워 제대로 표현할 수 없는 값)를 알릴지는 구현이 정하므로 아예 보고되지 않을 수도 있습니다.
- POSIX는 늘
errno에ERANGE를 넣으라고 요구하는데, 글쓴이는 이것이 표준 C 규칙과 어긋날 수 있다고 봤습니다. - 숫자가 아닌 입력에 대해 glibc는
errno를 그대로 두지만 musl은EINVAL을 넣습니다. - 리눅스 매뉴얼 페이지에는 이런 차이와 POSIX 확장이라는 사실이 적혀 있지 않다고 지적했습니다.
HN 반응
- 함수가 두 값을 함께 돌려주기 어려운 언어 구조가
errno같은 API를 낳았다는 지적에, C도 구조체를 돌려줄 수 있다는 반론이 맞섰습니다. - 로캘에 따라 소수점이 쉼표로 바뀌는 문제까지 있으니 C++의
std::from_chars나 별도 라이브러리를 쓰라는 조언이 여럿 나왔습니다.
FORTRAN부터 Java/C#에 이르기까지, 널리 쓰이는 프로그래밍 언어들이 함수 호출에서 값을 두 개 편하게 돌려주지 못했던 역사 때문에 사람들이 온갖 정신 나간 API와 규약을 만들어 냈다니, 참 놀라워요.
그 결과 우리에게 남은 건, 잘해야 "반환값이 -1인지 확인하고, 맞으면 전역 변수 errno를 확인하라"는 것이고, 못하면 "이 함수는 에러를 제대로 알려주지 않으니 잘못된 데이터는 넣지 마세요, 난 몰라" 수준이에요.
C는 적어도 C89부터 구조체 반환을 지원했습니다:
함수 반환 타입마다 고유한 구조체를 typedef 해야 하는 것은 그다지 편하지 않지만, C23에서는 이를 우회할 수 있습니다:
그러니까 그 방식이 거의 40년 동안 C에서 왜 자리 잡지 못했는지, 그 수수께끼가 풀린 것 같네요.
솔직히 아무것도 C를 대신하지 못하고 C가 여전히 공용어(lingua franca) 자리에 있다는 건 컴퓨터 과학 최대의 실망거리일 거예요. 이제 AI까지 등장했으니 더는 아무도 신경 쓰지 않을 것 같고요. 미래는 Rust가 되겠지만, 편해서가 아니라 메모리 오류를 막아 주기 때문일 거예요.
C에서도 멤버가 두 개인 구조체를 반환할 수 있습니다. 표준 라이브러리가 그렇게 하지 않는 이유는 사용성 때문이 아니라 성능 때문입니다. 어쩌면 ABI가 정의된 방식 탓에, 컴파일러가 그것을 관용적인 레지스터 할당으로 옮기지 못했던 오랜 한계 때문일 수도 있습니다.
Java와 C#은 에러를 위한 숨겨진 암묵적 반환값, 즉 예외로 이 문제를 해결한 것으로 유명합니다. 요즘 언어 설계의 흐름은 같은 발상을 명시적으로 드러내는 쪽이고, 이때 합 타입(sum type)을 씁니다. Rust의
Result처럼 결과가 정상 값이나 에러 중 하나를 담고, 둘을 동시에 담지는 않는 식입니다.물론 C++는 위의 방법을 전부 고를 수 있게 해 주면서도, 여전히 정신 나간 API 규약을 품고 있습니다.
그걸로 문제가 전부 해결되는 건 아니죠. "0, 1, 무한"이라는 규칙대로, 둘보다 많은 값을 돌려주고 싶을 때가 흔하고, 그중 어느 것도 에러가 아닌 경우도 많거든요.
하지만 Rust는 몇 개를 반환하든 신경 안 쓰는데요?
반환 타입은 크기가 정해져(sized) 있어야 합니다. 그렇지 않으면 그걸 어디에 놓겠어요? 물론 힙에 있는 Box 안에 넣어서 돌려주면 되긴 하지만요...
저는 예외를 반환값이라고 하고 싶지 않습니다. C 맥락에서 말하자면, 예외는 "return"보다는 "longjmp"에 가깝습니다.
둘 다예요! 예외는 분명 또 하나의 소통 채널로 여겨집니다. 그리고 스택 되감기(stack unwinding)는 그 소통의 한 측면이고요. 참고: https://nedbatchelder.com/text/exceptions-vs-status#h_valuable_channels (그 밖에도 많아요)
언어가 문제였던 적이 있기나 했나요? 아니면 결국 그린필드 자유 소프트웨어 저자들의 어떤 특별한 관심사를 건드릴 수 있느냐의 문제였던 건 아닐까요?
프로그래밍 언어 개발은 사실상 끝난 거 아닌가요? 이제는 어떤 문제에든 특별히 관심을 갖도록 시킬 수 있는, 강력하고 지치지 않는 "특별한 관심사형 인격"이 생겼으니까요. 진짜 문제였던 것, 즉 특별한 관심사형 인격들이 돈을 거의 받지 않고도 해 줄 일이 부족하다는 문제는 이미 해결됐고요.
C의 숫자 파싱 함수는 로케일에 의존하기 때문에 근본적으로 망가져 있습니다. 숫자에 소수점이 있다고 가정하고 파싱했는데, 개발자의 머신과 로케일이 다른 시스템에서는 갑자기 점 대신 쉼표가 나오는 버그가 많습니다. 그러니 이런 함수는 거의 항상 피하고, std::from_chars(최신 C++ 버전에서 사용 가능)나 다른 서드파티 라이브러리를 쓰는 게 좋습니다.
std::from_chars에 +1입니다. 표준 변환 루틴 중에서 압도적으로 가장 빠르기도 합니다.그리고 errno도요.
errno는 대체로 널 포인터보다 더 큰 실수예요.
제 경험상 리눅스 man 페이지 관리자들은 아주 친절하니, 이 내용을 그쪽에 제보해 볼 만합니다 0 1.
float이요? 정수조차 파싱할 수 없는데요! https://blog.habets.se/2022/10/No-way-to-parse-integers-in-C.html
마이크로컨트롤러용 어셈블리로 문자열 <--> float 변환을 짜던 때, 이 작업을 어떻게 하는 게 가장 좋은지에 관한 자료를 거의 찾지 못했습니다.
방법은 사실상 세 가지였습니다. 순진한 구현으로 어떻게든 버티거나, 오래된 8비트 머신의 어셈블리를 들여다보거나. 마지막 부류가 압도적으로 많이 제안된 방법이라서, 다른 방법은 이제 거의 잊힌 지식처럼 느껴질 정도입니다. 그 해법은 C 표준 함수를 쓰라는 것이었습니다.
이 작업이 워낙 불쾌해서, 꼭 직접 다 해야 하는 게 아닌 사람이라면 누구든 곧바로 블랙박스 해법으로 떠넘긴 것 같습니다.
그에 비해 취미용 3D 엔진이 넘쳐나는 현상에 대해서도 할 말이 있을 것입니다.
한동안 마이크로소프트의 strtod 구현은 망가져 있었기 때문에, 대부분은 netlib이나 더 새로운 알고리즘 중 하나를 쓰고 있을 거라고 생각합니다.
https://www.exploringbinary.com/visual-c-plus-plus-strtod-still-broken/
https://lemire.me/blog/2026/02/01/converting-floats-to-strings-quickly/
IEEE-754는 무자비한 밤의 여왕이죠.
https://en.wikipedia.org/wiki/IEEE_754
그 무렵 대안 표준 제안으로 VAX 부동소수점이 나와 있었습니다. 논쟁은 인텔이 이겼습니다.
https://sixlettervariable.blogspot.com/2007/05/vax-floating-point-numbers.html?m=1
그럴 만한 이유가 있었습니다. VAX 부동소수점 명세는 구현 비용이 조금 더 싸긴 했어도 훨씬 나빴습니다.
IEEE 754는 표준화된 분야를 크게 진전시킨 몇 안 되는 표준이었습니다. 사용자에게 중요한 것이 벤더에게 중요한 것보다 우선했기 때문입니다.
IEEE 754 이전에는 모든 부동소수점 구현이 하드웨어 비용을 최소화하려 했고, 그 때문에 어떤 비정상적인 동작이 생기든 개의치 않았습니다. 그런 동작은 순진한 사용자를 혼란스럽게 했습니다.
로케일도 잊지 마세요! 잘못된 로케일에서는 strtod("3,4", ...)가 3.4를 반환할 수도 있고, 그 밖에도 이런저런 일이 생깁니다.
맞아요. 그래서 수치 계산 앱 중에는 자체 수학 라이브러리를 함께 배포하거나 소스째 가져다 넣는 경우가 많죠.
errno의 지저분함은 제쳐 두더라도 이건 꽤 심각합니다. 저는 libc는 가능한 한 적게 쓰려고 하는 단계에 와 있습니다. 요즘은 C를 많이 쓰지는 않지만요.
음, 직접 "시스템 콜"을 하지 않는 한 다른 누군가가 대신 해 주고 있는 거죠.
libc에는 시스템 콜 말고도 많은 게 있어요.
저는 libc 함수가 크게 세 부류로 나뉜다고 봅니다.
이식 가능한 C로는 구현할 수 없는 함수 - 시스템 콜, 시그널, longjmp나 setcontext처럼 ABI에 종속된 것들
이식 가능한 C로 직접 구현할 수는 있지만 나머지 시스템과 잘 어울려 동작해야 하는 함수 - POSIX 사용자 관련 기능 다수, DNS 관리, dlopen, nsswitch 난장판 전부. C의 이식성 요구가 극단적이라 표준 C에서는 떠오르는 예가 없습니다. 로케일 관련 기능이 어쩌면 해당하겠지만 쓸모는 의심스럽습니다.
편의를 위해 있는 함수, 그리고 가끔 컴파일러가 특별 취급하는 함수 - math, str*, 다행히 glibc에서 제거된
<db.h>관련 기능 등#3은 대부분 무시해야 합니다. #2는 선택 사항입니다. 특히 소프트웨어가 최종 사용자용이고 배포판이 패키징하지 않는 경우에는 그렇습니다. 예를 들어 배포판의 기본값이 제정신이길 바라기보다 직접 만든 MDNS 구현을 같이 배포하는 편이 더 안전할 때도 있습니다.
저는 리눅스에서 합리적인 DNS/mDNS 구현 하나를 쓰는 세상을 밀고 싶어요. 제각각인 변종 동물원에 하나를 더 보태기보다요. 저는 여러 배포판의 여러 버전에 있는 서로 다른 DNS 관리자를 전부 만족시키려는 코드를 써 본 적이 있어요. 이제 그만해야 해요. 다들 보편적인 DNS 처리 방식이 미쳤다고 생각하니까 자기만의 새 방식을 만들어 내거든요.
https://xkcd.com/927/
물론이죠. 농담이라는 단서를 붙이는 걸 깜빡했네요.
FPU 형식 표준이 크록포드의 DEC64 같은 것으로 이기거나 발전해서 주류 표준이 되는 타임라인에 가 봤으면 할 때가 있어요. 더 단순하고 일관된 수학이 이렇게 어려울 이유가 없잖아요.
https://www.crockford.com/dec64.html