C 언어가 해야 할 명백한 사항들
(digitalmars.com)- C23까지 표준 C는 계속 바뀌었지만, Walter Bright는 상수식 평가, 전방 참조, 헤더 의존 같은 오래된 불편이 여전히 개발 흐름을 방해한다고 봄
- D 컴파일러에 내장된 C 컴파일러 ImportC는 새 구현이라는 장점을 활용해
constant-expression위치에서 조건을 만족하는 함수를 컴파일 시점에 실행할 수 있음 - CTFE가 가능해지면
_Static_assert(sum(3, 4) == 7,...)같은 단위 테스트를 별도 실행 파일 없이 컴파일 때마다 돌릴 수 있음 - 표준 C의 선언 순서 제약은 전방 선언 반복과 역순 코드 배치를 낳지만, ImportC는 전역 선언 순서에 덜 묶이는 방식을 택함
__import dex;처럼.c파일의 선언을 직접 가져오면 별도.h파일을 줄이고, 헤더와 구현 불일치로 생기는 디버깅 부담도 낮아짐
표준 C에 남아 있는 불편
- 표준 C는 C23까지 정기적으로 개선됐지만, Walter Bright는 여전히 고쳐지지 않은 부분들이 남아 있다고 봄
- Dlang 커뮤니티는 D 프로그래밍 언어 컴파일러 안에 C 컴파일러를 내장했고, 이 컴파일러를 ImportC라고 부름
- ImportC는 처음부터 새로 만들어져 현대 컴파일러 기술로 C의 부족한 부분을 다룰 여지가 있었음
- 글에서 다루는 항목은 네 가지임
constant-expression평가- 컴파일 시점 단위 테스트
- 선언의 전방 참조
- 선언 가져오기
컴파일 시점 함수 실행과 상수식 평가
- C는 단순한 식을 상수 접기(constant folding) 로 컴파일 시점에 계산할 수 있지만, 표준 C에서는 함수를 컴파일 시점에 실행할 수 없음
- 예시 코드
enum E { A = 3, B = 4, C = sum(5, 6) };를 gcc로 컴파일하면C의 열거자 값이 정수 상수가 아니라는 오류가 남 - ImportC는 같은 코드를 컴파일할 수 있음
- C 문법에서
constant-expression이 나타나는 모든 곳에서 컴파일러가 함수를 컴파일 시점에 실행할 수 있어야 한다는 방향임 - 다만 해당 함수는 I/O, 변경 가능한 전역 변수 접근, 시스템 호출 같은 작업을 하지 않아야 함
컴파일할 때마다 실행되는 단위 테스트
- 컴파일 시점 함수 평가인 CTFE가 가능해지면 단위 테스트 방식도 달라질 수 있음
- C 코드에서 단위 테스트가 드문 이유는 별도 빌드 타깃과 실행 파일을 만들고 실행해야 하는 번거로움에 있음
- 예시 코드
_Static_assert(sum(3, 4) == 7, "test #1");를 gcc로 컴파일하면 정적 assertion 안의 식이 상수가 아니라는 오류가 발생함 - ImportC는 이 코드를 컴파일할 수 있음
- 이 방식은 컴파일 시점에 실행 가능한 함수의 단위 테스트를 간단하게 만듦
- 별도 빌드가 필요 없음
- 추가 작업이 필요 없음
- 코드를 컴파일할 때마다 테스트가 실행됨
- ImportC 테스트 스위트에서도 이 방식을 광범위하게 사용함
전방 참조가 만드는 선언 반복
- 표준 C에서는 컴파일러가 어휘적으로 앞에 나온 선언만 알기 때문에 전방 참조가 허용되지 않음
- 예시 코드에서
floo()가 뒤에 정의된dex()를 먼저 호출하면 gcc는dex의 암시적 선언과 실제 정의의 타입이 충돌한다고 오류를 냄 floo와dex의 순서를 바꾸면 같은 코드는 정상 컴파일됨- ImportC는 전역 선언이 어떤 순서로 나오더라도 받아들일 수 있음
- 전방 참조가 안 되면 각 전방 정의마다 별도 선언을 추가해야 함
char dex(char *s, int i);같은 선언을 먼저 쓰고- 이후 함수 정의를 다시 작성해야 함
- 이런 방식은 불필요한 반복 작업이며, 프로그래머가 leaf 함수는 위에 두고 전역 인터페이스 함수는 아래에 두는 역순 배치를 하게 만듦
- 이런 배치는 신문 기사를 아래에서 위로 읽는 것과 같아 말이 되지 않는다고 비판함
헤더 파일 없이 선언 가져오기
- C에서 외부 모듈을 쓰려면 보통
.h파일에 선언을 만들고,.c파일에서 이를 include해야 함 - 예시 구조는 다음과 같음
floo.c는dex.h를 include하고dex()를 호출함dex.h는char dex(char *s, int i);를 선언함dex.c는dex.h를 include하고dex()를 정의함
- 외부 모듈마다
.h파일을 만드는 일은 반복 작업을 늘림 .h파일이.c파일과 정확히 일치하지 않으면 무엇이 잘못됐는지 찾는 데 많은 시간이 들 수 있음- 제안된 방식은
dex.c를 직접 가져오는 것임floo.c에서__import dex;를 사용함dex.c에는char dexx(char *s, int i) { return s[i]; }같은 정의만 둠
- 이 방식이면
.h파일을 아예 작성할 필요가 없음 - ImportC는 이 방식도 지원함
관련 문서
- ImportC: ImportC 문서
- D Language: D 언어 문서
댓글과 토론
Hacker News 의견들
-
C가 아닌 언어를 쓸 때 가장 그리운 것 중 하나가 헤더 파일임
공개/비공개, 인터페이스/구현이 아주 명확히 나뉘는 점이 C 코드에서 특히 마음에 듦. 라이브러리의.h파일만 훑어도 사용법을 알 수 있는 게 좋고, 보통.h에는 사용 문서가 들어가며.c와 중복하지 않음. 문서를.c에 넣을 수도 있지만, 사용하는 입장에서는 인터페이스를 읽기가 훨씬 덜 쾌적해짐- 이 주장은 늘 이상하게 느껴졌는데, 다른 언어들은 이 문제를 도구로 해결하고 그게 더 낫다고 봄
예를 들어 Rust에서는 라이브러리 인터페이스와 사용법을 보려면cargo doc --open이면 됨. 전체 공개 API가 검색 가능한 형태로 자동 생성되고, 헤더와 소스 사이에 코드를 중복하거나 수동 관리할 필요가 없음 - 헤더 파일은 70년대의 자원 제약 플랫폼을 다루기 위한 약한 해킹에 가깝다고 봄
관례를 지킬 때만 제대로 동작하고, Ada처럼 인터페이스와 구현 명세가 잘 설계되어 반복 파싱이 필요 없는 언어와 비교하면 초라함. C를 쓰는 건 좋아하지만 이 부분은 더 잘 설계됐어야 함 - 동의하면서도 동의하지 않음
C의 헤더 파일은 텍스트를 그대로 끼워 넣는 방식이라 “대충 되는 것처럼 보이는” 수준임. 반면 Ada에는 패키지와 패키지 본문 개념이 있었고, 패키지가 헤더 파일에, 패키지 본문이 구현에 해당했음. 오래전 Ada를 썼을 때는 패키지 본문 구현이 준비되지 않아도 모두가 패키지를 기준으로 컴파일할 수 있어서, 구현 전에도 인터페이스를 맞춰 갈 수 있었음. 또 다른 방향으로는 Python의import가 파일 시스템에 자연스럽게 매핑되고 C의 include 의미론을 다루지 않아도 돼서 “헤더 파일” 역할로 마음에 듦 - 여기에는 사고방식의 차이가 깔려 있는 것 같음
헤더 파일이 있으면 인터페이스를 구현과 다른 것으로 생각하게 됨. 그래서 이쪽 방식에 익숙한 사람은 인터페이스가 도구로 생성된다는 생각을 불편해함. 인터페이스는 구현의 부산물이 아니라 별도로 의도적으로 설계하는 것이며, 어떤 사람에게는 구현보다 더 중요함. 반대로 자동 생성 문서에 익숙한 사람은 구현 소스라는 단일 진실 공급원에서 인터페이스가 생성되지 않는 걸 불편해함. 별도 인터페이스 파일이 있는 언어와 없는 언어를 오래 쓰다 보면 각자의 진영으로 굳어지고, 반대 방식으로 생각하던 감각을 잊게 됨 - 대부분의 컴파일형 모듈 언어에는 이미 가능함
Modula-2, Modula-3, Ada, Standard ML, Caml Light, OCaml, F#, D처럼 별도로 두거나, Object Pascal, D, Haskell, Java, C#, F#, Swift, Go, Rust처럼 텍스트나 그래픽 도구로 생성할 수 있음. 모두 더 강한 타입, 더 빠른 컴파일, 제대로 된 이름공간을 갖추고 있음. Rust와 Swift 도구체인은 아직 손볼 곳이 있지만, C 도구는 Bell Labs 밖에서 벌어지던 일에 비해 늘 원시적이었음. AT&T가 상업적으로 활용할 수 있었다면 역사가 달라졌을 텐데, 결과적으로 잘 익은 오렌지 대신 공짜 레몬을 받은 셈임. 그래도 C용 TypeScript 같은 것을 만들었고, 요즘은 경계 검사 컬렉션 타입과 제대로 된 모듈도 지원함
- 이 주장은 늘 이상하게 느껴졌는데, 다른 언어들은 이 문제를 도구로 해결하고 그게 더 낫다고 봄
-
이 분야에서는 글쓴이가 훨씬 더 많은 지식과 경험을 갖고 있으니, 다음 문제들을 어떻게 풀지 궁금함
상수식 평가는 번역 단위 안에서만 하면 단순해지지만 코드 반복 없이 할 수 있는 일이 크게 제한됨. 컴파일 타임 단위 테스트는 테스트를 매크로로 표현할 수 있으면 어느 정도 가능하고, 앞 항목이 더해지면 쉬워짐. 선언의 전방 참조는 반발이 클 수 있는데, 컴파일러가 1패스에서 2패스로 바뀌며 성능 영향이 생기기 때문임. 거대한 코드베이스를 컴파일하면서 번역 단위 병렬화까지 하는 사람들에게는 받아들이기 어려울 수 있음. 선언 가져오기는 호환성을 깨는 변경임. C에서 변수 정의 후.c파일을 import하고, 변수를 바꾼 뒤 같은.c를 다시 import하는 식으로 템플릿 비슷한 걸 구현해 본 적이 있음. SQLite C Amalgamation 앞에 여러 정의를 넣고 내부 함수를 노출하려고 함수를 추가한 적도 있는데, 이런 용례는 모두 깨질 것 같음. 이런 문제에 대한 해결책이 있는지 궁금함- 평가되는 함수의 소스 코드는 컴파일러가 볼 수 있어야 한다는 점은 맞음
C에서는#include로 할 수 있고, D에서는 필요한 코드가 있는 모듈을 import해서 처리함. 테스트를 매크로로 표현하는 방식은 함수를 테스트하려는 경우에는 맞지 않음. 예시는 이해를 돕기 위해 단순하게 든 것이고 실제 사용은 훨씬 복잡해질 수 있음. 성능 면에서 D는 C 컴파일러보다 빠르게 컴파일되는데, 주된 이유는 C 전처리기가 여러 패스를 요구하는 화석 같은 구조이고, D는#include대신 import를 써서.h파일을 반복해서 컴파일하지 않기 때문임. D의 전략은 파싱과 의미 분석을 분리하는 것이고, 약간 느릴 수는 있어도 중복 선언을 다시 컴파일해 하나로 접는 비용이 없음. 컴파일 타임 함수 실행은 많이 쓰면 병목이 될 수 있지만, 가볍게 쓰면 성능은 괜찮음. C에서 템플릿을 해킹으로 구현하고 있다면 이미 언어를 넘어선 것이고 더 강력한 언어가 필요함. D는 최상급 메타프로그래밍을 갖고 있으며, 다른 템플릿 언어들이 흔히 D의 길을 따라오고 있음 - 개인적으로 전방 참조는 코드 읽기를 어렵게 만들어서 좋아하지 않음
더 이상 의존성 그래프가 위상 정렬 순서로 놓여 있다고 믿을 수 없게 됨 - 다른 언어들은 헤더 파일이나 전방 선언 없이도 잘 되는 것 같은데, 왜 반발이 있는지 이해하기 어려움
현대 C 컴파일러가 실제로 아직도 단일 패스인지도 궁금함
- 평가되는 함수의 소스 코드는 컴파일러가 볼 수 있어야 한다는 점은 맞음
-
기사 속 상수식 평가 예시는 꽤 단순하지만, 더 복잡한 경우에는 컴파일러 속도와 메모리 사용량이 크게 나빠지고 이를 활용하려면 가상 머신이 필요할 것 같음
그래서 표준에 넣기엔 “너무 복잡하다”고 여겨졌을 가능성은 이해됨. C++20에 정의된 이상한 혼합물 대신 C++나 C가 선언 가져오기 방향으로 갔으면 좋았겠음. 예를 들어#import "string.c" as str처럼 모듈을 어떤 심볼로 가져오고, 파일의 모든 비정적 심볼을str.trim(" Hello World ");처럼 접근할 수 있게 하는 방식임. 별개로__import dex;처럼 파일 경로가 명시적이지 않은 건 마음에 들지 않음. 이 경우dex.d를 가져오는지dex.c를 가져오는지 알 수 없음- 다른 인기 언어들도 할 수 있고, D에서는 컴파일 타임 함수 실행이 매우 인기 있고 유용한 기능임
많이 쓰면 당연히 컴파일 시간과 메모리를 쓰게 됨. 가상 머신에 대해서는, 상수 접기 자체가 이미 가상 머신이고 여기에 함수 호출 처리를 확장하는 셈임. C의 의미론은 단순해서 그렇게 나쁘지 않음. 제안한 import 방식은 D import가 하는 일과 거의 같음: https://dlang.org/spec/module.html#import-declaration.dex.d인지dex.c인지 문제는 실제로 생기며, 답은 import 경로 설정임. C 컴파일러의 include 경로와 비슷함 - 대부분의 실제 C 컴파일러는 최적화를 위해 이미 어느 정도 컴파일 타임 평가를 하고 있을 것 같음
C에도 이미 상수식이 있음. 더 큰 장애물은 컴파일러가 함수의 소스 코드에 접근해야 한다는 점이라서, 아마 같은 번역 단위의 함수로 제한될 가능성이 큼. 그리고 여러 컴파일러 대표가 모인 위원회가 그런 상수 평가의 의미론에 합의해야 하는 사람 문제도 훨씬 클 수 있음 - 이건 사실 C++의
constexpr기능과 거의 1:1로 보임
거의 모든 C 컴파일러가 이미 C++ 컴파일러이기도 하니, C에서constexpr함수와 평가를 지원하는 게 그렇게까지 나쁠 수 있을지 의문임
- 다른 인기 언어들도 할 수 있고, D에서는 컴파일 타임 함수 실행이 매우 인기 있고 유용한 기능임
-
C 코드에 단위 테스트를 항상 작성함
좋은 빌드 시스템을 쓰고 약간의 보일러플레이트를 감수하면 어렵지 않음.npy라이브러리의 테스트는test_load_uint8()에서npy_load("tests/npy/uint8.npy")를 호출하고 차원, 크기, 타입을assert한 뒤npy_free하며,main에서PRINT_RUN(test_load_uint8);처럼 실행함. 전처리기로 테스트 일부를 생성할 수도 있겠지만, 단순하게 유지하는 편을 선호함- 그 함수는 I/O를 하는 것처럼 보여서 컴파일 타임 테스트로는 동작하지 않음
ImportC 컴파일러의 단위 테스트 예시는_Static_assert(sizeof(struct S22079){1,2,3} == sizeof(int)*3, "ok");와_Static_assert(sizeof(struct S22079){1,2,3}.a == sizeof(int), "ok");같은 식임. 의미론을 컴파일 시간에 검사하므로 링크하고 실행할 필요가 없음. 테스트가 많을수록 이 방식이 상당히 빨라지고, 테스트 모음이 빨리 돌수록 생산성이 올라감 - 나도 거의 같은 방식으로 함
한때 화려한 단위 테스트 프레임워크를 깊게 파 봤지만, 별 이득이 없다는 걸 깨닫고 거의 동일한 형태로 정착했음. 다른 사람이 안 좋게 볼 수도 있는 점은, 정적 함수를 호출하기 위해 테스트 대상.c파일을 기꺼이#include한다는 것임. 단 한 개의.c파일만 include함. 또#include전에 작은 전처리기 처리를 해서, 누군가 “릴리스 모드”로 빌드해assert가 비활성화되는 경우를 막기 위해NDEBUG가 정의되지 않도록 함 - 테스트를 최대한 쉽게 실행할 수 있게 만드는 것이 실제로 테스트가 있는 코드베이스로 가는 데 큰 도움이 된다고 봄
비슷한 것을 쓰고 있음: https://github.com/ensisoft/detonator/blob/master/base/test_....boost.test.minimal에서 많이 빌려왔고 원래는 단일 헤더였지만, 세월이 지나며 단일 번역 단위를 하나 추가해야 했음. 코드베이스를 테스트가 항상 통과하는 상태로 유지하면, 테스트 도구의 오류 보고나 결함 허용 같은 복잡성이 훨씬 덜 필요하다는 결론임
- 그 함수는 I/O를 하는 것처럼 보여서 컴파일 타임 테스트로는 동작하지 않음
-
컴파일 타임 단위 테스트는 “사용하지 않는 import/변수/결과”를 경고가 아니라 오류로 만드는 것만큼 좋지 않은 발상임
개발자에게서 통제권을 빼앗고, 결국 일을 끝내기 위해 관료적 절차를 밟게 만드는 “보모 기능”임. 이런 빌드 실패 테스트는 “이제 끝난 것 같다” 빌드에는 좋지만, 빌드의 99%를 차지하는 “아직 작업 중” 빌드에는 맞지 않음. “드릴을 치우기 전에는 테이블 톱을 쓸 수 없다”고 말하는 것과 비슷함- 이 부분은 동의하기 어려움
어떤 생각을 표현하려 했는데 컴파일 타임 테스트가 틀렸다고 알려준다면, 사실 그 생각이 아직 불완전하거나 그 표현의 모든 결과를 충분히 생각하지 못했을 수 있음. Haskell의 타입 검사와 비슷함. 타입 검사를 통과하지 않는 프로그램은 컴파일할 수 없고, 프로그래머가 항상 완전한 생각만 표현하도록 강제함. 이론적으로는 더 잘 숙고된 프로그램으로 이어질 수 있음. 다만 프로그래머가 “유효하지 않다는 걸 알지만 신경 쓰지 않는” 구석까지 발견하도록 강제하므로 작성은 더 어려워짐 - 이런 컴파일 타임 테스트는
static_assert에 더 가까울 수 있고, 라이브러리 함수의 호환되지 않는 사용을 잡아내는 데 가치가 큼
꽤 좋은 아이디어라고 봄
- 이 부분은 동의하기 어려움
-
“leaf 함수가 먼저 오고 전역 인터페이스 함수가 마지막에 온다”는 건 나에게는 거꾸로임
여러 이유로 코드를 위상 정렬 순서로 쓰는 걸 선호함. 함수 안에서 코드를 쓰는 방식과 닮아 있고, 모듈 안에서 함수를 어디에 둬야 할지 명확하며, 가장 중요하게는 모듈 내부 코드 조각 사이의 순환 의존성이 아주 뚜렷해짐. 순환 의존성은 코드베이스를 더 얽히게 만들고 모듈을 독립 단위로 이해하기 어렵게 하므로 별로 좋아하지 않음. Python에서는 실행 전까지 드러나지 않는 문제를 만들 수도 있고[0], 순환 import가 워낙 흔해서 현재 타입 검사기들은 그 진단을 기본으로 꺼 두는 듯함[1]. 전방 참조를 지원하지 않는 C, OCaml, SML 같은 언어는 순환 의존성에 “최소 놀람 원칙”을 적용하게 해 줌. OCaml은let rec fn1 = .. and fn2 = ..처럼 선언하지 않으면 함수 간 재귀 의존성도 금지하는데, 작성 중에는 조금 성가셔도 읽을 때 중요한 정보가 됨
[0]: https://gist.github.com/Mark24Code/2073470277437f2241033c200...
[1]: https://microsoft.github.io/pyright/#/configuration?id=type-... (reportImportCycles참고) -
컴파일러가 어휘적으로 앞선 것만 알고 있어서 leaf 함수가 먼저 오고 전역 인터페이스 함수가 마지막에 오는 “아래에서 위로” 순서가 된다는 설명이 있는데, Python처럼 전방 참조를 허용하는 언어에서도 이런 순서는 흔함[0]
전방 참조를 허용하지 않는 언어의 잔재인지, 아니면 특정 종류의 코드에는 실제로 더 말이 되는 방식인지 궁금함
[0] https://stackoverflow.com/a/73131538- Python에는 전방 참조가 없음
단지 함수 본문 안의 식별자는 함수 자체가 실행될 때까지 해석되지 않고, 그 시점에는 모듈 스코프의 모든 것이 정의되어 있을 뿐임. 함수 본문에 아무 이름이나 넣고 모듈을 로드해 보면 직접 확인할 수 있음 - 내 코드에서는 공개 인터페이스가 늘 위로 올라오고, 구현 세부사항은 파일 끝으로 가는 경향이 있음
엄격한 규칙이 있는 것도 아닌데 그렇게 읽는 게 더 깔끔하고 말이 되는 것 같음. 특히 구현이 크면, 그걸 모두 스크롤한 뒤에야 기본적으로 무엇을 하는지 보는 건 원하지 않음. 다른 사람들은 어떻게 하는지 궁금함
- Python에는 전방 참조가 없음
-
C가 해 줬으면 하는 “당연한 것들”에는 포인터와 길이를 담는 슬라이스 타입 지원, 전역 상태를 쓰는 API들의 재진입 가능하고 가능하면 스레드 안전한 버전, Go나 Zig의
defer또는 GCC의cleanup속성 같은 것의 표준화, 그리고 Unicode와 UTF-8에 대한 이식 가능한 지원 정도가 있음- 이들 대부분은 언어 자체가 할 일이 아니라 표준 라이브러리에 원하는 기능 아닌가?
-
C 문법에서 상수식이 나오는 모든 곳에서, 함수가 I/O, 변경 가능한 전역 변수 접근, 시스템 호출 등을 하지 않는 한 컴파일러가 컴파일 시간에 함수를 실행할 수 있어야 한다는 건 쉽게 깨질 수 있음
아주 오래 도는 순수 함수를 고르면 됨. 예를 들어int busybeaver(int n) {...}가 n상태 바쁜 비버 기계의 최대 수명을 반환하는 순수 함수라면,int x = busybeaver(99);같은 코드가 문제가 됨 -
C23에는
constexpr가 있지만 아직 함수에는 붙일 수 없음
다만 제안은 있음: https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2976.pdf