요약
Rust로 시스템 프로그래밍을 처음 배운 개발자가 C를 익히며 놀란 점을 정리했습니다.
개발자 BD103은 Rust를 첫 시스템 프로그래밍 언어로 배웠습니다. 'C 프로그래머를 위한 Rust' 글은 많아도 그 반대는 드물어서, 최근 C를 독학하며 낯설었던 점을 모아 글로 썼습니다.
왜 중요한가
- Rust부터 배운 개발자가 C로 내려갈 때 부딪히는 차이를 거꾸로 짚은 드문 안내입니다.
- Rust에서 컴파일러가 맡던 오류 처리와 메모리 검사를 C에서는 프로그래머가 직접 챙겨야 한다는 점을 예제로 보여 줍니다.
핵심 내용
- C는 -1이나 널 포인터 같은 약속된 값으로 실패를 알릴 뿐, 오류 처리를 강제하지 않습니다.
- 참조와 빌림 검사기(메모리 사용 규칙을 컴파일할 때 검사하는 장치)가 없어 버퍼 오버플로 같은 보안 취약점이 생기기 쉽습니다.
- 문자열은 길이를 따로 저장하지 않고 끝에 널 바이트를 붙이므로, 이를 빠뜨리면 범위 밖을 읽게 됩니다.
- 배열을 함수에 넘기면 포인터로 바뀌어
sizeof가 배열 크기 대신 포인터 크기인 8바이트를 돌려줍니다. long이 64비트 윈도에서는 32비트일 만큼 정수 크기가 플랫폼마다 달라stdint.h의 고정 폭 타입을 권했습니다.
HN 반응
- 여러 이용자가 C는 언어 명세보다 도구에 기대야 한다며 컴파일러 경고, 새니타이저(실행 중 메모리 오류 검사 도구), 정적 분석을 함께 다뤘어야 한다고 지적했습니다.
- 배열이 곧 포인터라는 설명에는, 배열은 포인터로 바뀔 뿐
sizeof결과가 달라 같은 것이 아니라는 반론이 여러 번 나왔습니다.
C 튜토리얼이라면 컴파일러 경고와, 코드를 더 안전하게 만들어 주는 여러 도구를 꼭 설명해야 합니다. C에서는 언어 명세가 아니라 도구에 의지하게 됩니다. 특히 Rust 프로그래머 대상이라면 이 점을 더 분명히 짚어 줘야 합니다. 물론 C에서도 타입으로 추상화를 만들 수 있으니, 튜토리얼은 그쪽에도 무게를 두는 편이 좋고, 저수준 문자열 뒤집기 함수로 시작하지 않는 편이 낫겠습니다.
맞습니다. 경고를 에러로 바꾸고, 성능을 재는 때가 아니라면 새니타이저(ASAN, UBSAN, TSAN)를 켜고 빌드·테스트하세요. 컴파일러 경고를 넘어서는 정적 분석(Clang Tidy, Clang Static Analyzer)도 쓰시고요.
다만 저자는 훌륭한 튜토리얼을 쓰려는 것 같지는 않습니다. 자기 관점에서 무엇을 어떻게 배웠는지 나누는 데 더 무게를 둔 글로 보입니다.
Rust 재단에서 저자에게 후원금을 줬는지도 모르죠. 거기서 블로그 글 쓰라고 사람들에게 돈을 준 적이 있거든요. https://rustfoundation.org/media/introducing-our-newest-project-grantees/ . 기술적인 일보다 홍보를 좋아하는 사람들이에요.
제발 컴파일러 진단에서 뉘앙스를 이런 식으로 다 뭉개지 말아 주세요.
Rust가 거둔 가장 큰 성공 가운데 하나가 훌륭한 진단 메시지인데, 제가 쓴 코드가 덧셈 연산자 구현처럼 보인다고 알려 주는 Clippy 린트를, 변수를 초기화하지 않았을 때 나오는 치명적 에러와 똑같이 만든다고 도움이 될 리는 절대 없습니다.†
Rust에는 "이 경고는 여기 있는 게 맞다"고 표시하는 진단 범주(비어 있는 채로 시작하는 ["expect"])도 있습니다. 눈여겨볼 만한 일이 여기서 일어날 수도 있고 일어나도 무시해도 되는 정도가 아니라, 더는 감지되지 않는다는 것 자체가 수상하니 진단해야 하는 경우를 잡아 줍니다.
† 네, 실제로 덧셈 구현 맞고요, Add를 구현할까도 생각했지만 그러지 않을 만한 이유가 있었습니다. 그래서 경고를 끄는 어노테이션을 달아 둔 겁니다.
동의해요. 글에서 이미 Clang이 배열 디케이(array decay) 버그를 잡아내는 걸 보여 주니까, 저자가 "항상 -Wall -Wextra -fsanitize=address,undefined로 빌드하라"는 안내를 넣고 설명도 곁들이면 될 것 같아요.
https://news.ycombinator.com/item?id=50016878
다음 대목에 미묘한 오류가 있습니다.
오버커밋이 켜져 있으면(기본값인 경우가 많습니다) 기기의 메모리가 바닥나도 malloc은 널을 반환하지 않습니다. 널을 반환하는 경우는 인자가 잘못되었을 때(예: 너무 큰 값)입니다.
배열은 포인터라는 얘기에 덧붙이면요.
C에서 이게 얼마나 맨살 그대로인지 보여 주는 제가 제일 좋아하는 예가 배열 접근의 교환법칙입니다.
C는 가끔 정말 단순해서 멋지다니까요.
이 사실을 안 지는 꽤 됐는데, 생각하면 할수록 쓸모없어 보입니다. 밑바닥의 기계 연산이 덧셈이고 덧셈이 교환법칙을 만족하는 건 맞지만, 타입 시스템 수준에서는 포인터와 오프셋의 역할이 다릅니다. 타입만 보면 컴파일러가 알아서 처리할 수 있다고 해서 strchr 같은 함수의 인자 순서를 바꿔 쓰게 해 주는 게 말이 안 되는 것처럼, 이것도 서로 바꿔 쓰게 허용할 이유가 없습니다. "offset + pointer"나 "index[array]"라고 써야 할 실용적인 이유가 마지막으로 있었던 게 언제입니까?
대부분의 언어에서 인덱싱 연산자는 교환법칙이 성립하지 않습니다. Rust에서도 포인터 오프셋은 함수나 메서드 호출로 표현하고, 마찬가지로 교환법칙이 없습니다. 이에 대한 불만은 단 한 번도 본 적이 없습니다. 사람들이 원하거나 신경 쓰는 게 아니라 그냥 번거로운 세부 사항일 뿐입니다.
관용적인 방식은 아니지만, C의 인덱싱은 문법 설탕입니다. 왜 문법에서 이걸 허용하는지는 모르겠지만, 배열이 특별한 타입이 아니라 포인터라는 점을 잊으면 버그를 부르는 거나 다름없습니다.
C에서 배열은 포인터가 아닙니다. 아주 비슷하고 암묵적으로 변환되긴 하지만 차이가 있어요. 크고 분명한 차이는 배열을 선언하면 그 공간이 할당된다는 겁니다. 함수 안에서는 스택에, 구조체 안에서는 인라인으로요. sizeof도 다릅니다. 기억나지 않는 다른 차이도 몇 가지 더 있을 거예요.
C에서 배열이 포인터로 디케이되지 않는 연산은 딱 두 가지입니다.
sizeof(array_var)와&array_var입니다. 후자는 좀처럼 보기 힘든 "배열 포인터"를 만듭니다.int (*)[10]같은 타입이죠(배열 포인터 문법은 함수 포인터 문법과 비슷하게 동작합니다).재미있긴 하지만, 위키백과에 따르면 1[c] 표기는 C29에서 "폐기 예정"으로 표시될 거예요.
배열이 포인터라는 게 다 재미있는 소리로 들리죠, 함수 안에서 배열을 선언해서 그걸 반환하기 전까지는요.
정적 분석이나 -Werror -Wall로 쉽게 잡히지 않나요? 저는 이것 때문에 실무에서 문제를 겪은 적이 없습니다.
그리고 C에서 배열은 포인터가 아니라 포인터로 디케이될 뿐입니다. 배열을 만든 함수와 포인터를 매개변수로 받는 함수에서 sizeof가 다르게 동작하는 걸 보면 알 수 있습니다.
C를 처음 접하고 몇 년이 지나서야 알게 된 재미있는 특이점들입니다.
= {0};로 0 초기화를 해도 패딩과, 유니언에서 가장 작은 멤버가 덮지 않는 데이터는 초기화되지 않은 채로 남습니다. 그래서 이 방식으로 0을 채운 자료구조를 직렬화하는 건 안전하지 않습니다포인터가 유효한 메모리 영역의 끝(예: 버퍼의 끝)에서 1칸을 넘어 더 증가하면 역참조하지 않더라도 곧바로 미정의 동작입니다
서로 다른 타입의 포인터에는 기본적으로 엄격한 앨리어싱이 적용되지만, void/char/uchar는 예외입니다. 그래서
struct sockaddr와struct sockaddr_in포인터가 같은 구조체를 가리키면 UB입니다.알면 알수록 C에서 도망치고 싶어집니다.
C에서 제가 가장 당황스러운 점은, free가 동작하려면 malloc이 버퍼 크기를 저장해 둘 수밖에 없는데도, 수십 년간 이 정보를 프로그래머가 쓸 수 있게 하자고 생각한 사람이 아무도 없다는 겁니다! C에서 버퍼 경계를 추적하고 싶으면 수공업으로 직접 해야 하죠. 대부분의 구현에서 그 정보가 데이터 바로 옆에 있고, 그래서 이미 L1 캐시에 올라와 있는데도요.
이건 분명히 사실이 아닙니다. GNU에는 malloc_usable_size(3)이 있어요. 표준화되지 않았다는 건 맞지만, C에서는 거의 모든 것의 표준이 아주 빈약하니 놀랄 일은 아닙니다.
그건 구현 세부 사항 아닌가요? 게다가 호출하는 쪽에서 이미 알고 있는 정보이기도 하고요. 파일 핸들에서 파일 이름을 돌려주는 거나 마찬가지입니다. API에 불필요한 세부 사항이 많아질수록 유연성은 떨어집니다.
진짜 프로그래머는 언어에 정체성을 걸지 않습니다(즉, 스스로를 ___아무_언어 프로그래머라고 부르지 않습니다).
진짜 음악가는 악기에 정체성을 걸지 않죠(즉, 스스로를 피아니스트나 바이올리니스트, 가수라고 부르지 않죠).
(저는 다장조 피아니스트 지망생으로서 하는 말이에요.)
이상적인 세상이라면 진짜 프로그래머는 "isEven" 같은 라이브러리를 퍼블리시하는 데 정체성을 걸지 않을 테고, 멀쩡한 라이브러리를 가장 새롭고 가장 잘나가는 플랫폼으로 바이브 포팅해 놓고 유명세를 주장하지도 않을 텐데요...
... 하지만 그 배는 이미 떠났죠
그럼 높으신 분들이 무슨 수로 언어 파편화를 일으키겠어요? /s
큰맘 먹고 막 찍어서 추측해 보겠습니다.
불리언은 사실상 정수 체계의 확장이어서, 이제 1비트만 저장하는 정수 타입이 생긴 거예요.
그 비트가 'true' 또는 'false' 값이 되는 거고요.
어쨌든 메모리나 저장 공간, 대역폭(네트워크)에 엄격한 제약이 있는 시스템 개발에서 불리언이 쓸모 있다는 건 알고 있어요.
네, 저는 이 정도면 납득이 돼요.
C가 (C23 이전에) 불리언을 다루는 방식은 어셈블리에서 불리언을 다루는 방식과 꽤 비슷합니다.
CPU에는 타입이 없고 전부 정수나 부동소수점입니다. 크기가 고정된 레지스터 위에서 작업하죠. CPU에는 "이 레지스터가 0이 아닌가"를 확인하는 명령이 내장되어 있는데, 이게 C로 새어 나온 겁니다. C에서 1은 참이고, 2도 참입니다.
또 불리언에 비트 하나를 쓰는 일은 드문데, 비트 하나를 뽑아내는 데 CPU 연산이 더 들기 때문입니다. 최소한 바이트 단위로 정렬되는 게 기본입니다.
네트워크 코드 같은 걸 짤 때 불리언 여러 개를 저장하려면 보통 한 바이트에 묶어 넣거나, 남는 비트를 버리고 불리언 값 하나에 한 바이트를 보냅니다. 보통은 플래그와 마스크를 썼죠.
꼭 그렇지는 않습니다. 요즘 CPU에서는 메모리 접근이 병목인 경우가 많거든요. 비트가 많다면 비트 배열에 저장하는 게 성능에 꽤 이득일 수 있습니다.
SQL Server는 지금도 불리언 타입이 없고, 같은 행에 있는 bit 컬럼은 가능하면 한 바이트로 묶어 저장합니다.
C에서 'bool'은 여전히 최소 1바이트예요. 불리언 하나를 비트 하나에 저장하고 싶으면 비트맵을 직접 구현해야 해요.
아니면 C++를 꺼내서 std::vector<bool>을 쓰거나요... 근데 그건 아마 절대 하지 않는 게 좋을 거예요