TL;DR

  • C는 러스트 프로그래머에게 불리언, 문자열, 정수 폭, 오류 처리, 필드 접근, 배열, 포인터에서 서로 다른 규칙을 적용하는 시스템 프로그래밍 언어임.
  • C의 불리언은 C23 이전에는 기본 언어 요소가 아니며, 이전 버전에서는 <stdbool.h>의 정의를 사용해야 함.
  • C 문자열은 길이를 별도로 저장하지 않고 널 종료 문자(\0)를 사용하므로, 종료 공간 확보와 삽입이 필요하며 누락 시 범위 밖 읽기가 발생할 수 있음.
  • C 정수형의 폭은 플랫폼에 따라 달라지며, 이식성이 중요하면 <stdint.h>의 고정 폭 정수형 사용이 권장됨.
  • C에는 러스트의 빌림 검사와 참조가 없고, 오류 처리를 강제하지 않으며, 함수 인자로 전달된 배열은 포인터로 변환됨.

불리언은 기본 제공되지 않음

  • 최초 C에는 불리언 기본형이 없어 프로그램에서 정수 0과 1을 사용했으며, C99에서는 선택적 헤더 <stdbool.h>가 불리언을 제공함.
  • C99에서 true와 false는 다른 언어의 키워드나 리터럴이 아니라 각각 1과 0으로 확장되는 정의임.
  • C23에서는 불리언이 언어의 기본 요소가 됐지만, 이전 버전으로 컴파일할 때는 <stdbool.h>를 포함해야 함.
  • _Bool은 헤더 없이 사용할 수 있지만, true와 false 정의에는 여전히 해당 헤더가 필요함. Clang의 C23 지원은 아직 부분적일 수 있음.

널 종료 문자열

  • 러스트의 &str은 메모리 주소 8바이트와 문자열 길이 8바이트를 포함해 16바이트를 사용함. 동적으로 크기가 정해지는 타입인 str의 길이를 포인터 메타데이터로 추적하기 때문임.
  • 이 방식은 문자열 길이를 효율적으로 가져오지만 &str 참조마다 메모리를 더 사용함. 반면 C는 길이를 별도로 저장하지 않고 모든 문자열 끝에 널 바이트(\0)를 둠.
  • C의 널 종료 방식에는 다음 특성이 따름.
  • 문자열과 함께 별도의 길이 변수를 관리할 필요가 없음.
  • 모든 문자열에 널 바이트를 위한 공간이 필요하므로 빈 문자열 ""도 메모리 1바이트를 차지함.
  • 문자열 중간에 널 바이트를 넣으면 <string.h> 함수가 제대로 동작하지 않을 수 있음.
  • 널 종료 문자를 빼먹으면 범위 밖 읽기가 발생할 수 있음.
  • 문자열을 뒤집는 C 함수는 길이를 구한 뒤 문자열과 널 종료 문자 공간을 합쳐 len + 1바이트를 할당하고, 마지막 위치에 \0을 직접 기록함.
  • 대응하는 러스트 함수는 널 종료 문자를 따로 처리하지 않음. 다만 제시된 함수는 관용적인 러스트 코드가 아니며 ASCII 텍스트만 올바르게 처리함. 실제 사용을 위한 구현은 forward.chars().rev().collect::<String>() 한 줄로 작성할 수 있음.

플랫폼에 따라 달라지는 정수 폭

  • C 정수형은 정확한 비트 수가 보장되지 않으며 폭은 대상 플랫폼에 따라 달라짐.
  • char: C 표준상 최소 8비트, 64비트 유닉스와 윈도우에서 8비트임.
  • short: 최소 16비트, 두 플랫폼에서 16비트임.
  • int: 최소 16비트, 두 플랫폼에서 32비트임.
  • long: 최소 32비트, 64비트 유닉스에서 64비트이고 64비트 윈도우에서 32비트임.
  • long long: 최소 64비트, 두 플랫폼에서 64비트임.
  • 플랫폼 간 호환성이 중요하다면 <stdint.h>가 제공하는 고정 폭 정수형을 사용하는 방식이 권장됨.
  • 러스트 u8, u16, u32, u64에 대응하는 C 형식은 각각 uint8_t, uint16_t, uint32_t, uint64_t임.
  • 러스트 usize에 대응하는 C 형식은 size_t이며, i8, i16, i32, i64에는 각각 int8_t, int16_t, int32_t, int64_t가 대응함.
  • 러스트 isize에 대응하는 C 형식은 ptrdiff_t임. usize와 isize는 size_t, ptrdiff_t와 의미상 완전히 일치하지 않으므로 사용 전 차이를 확인할 필요가 있음.

오류 처리는 번거로움

  • 러스트의 Result, 합 타입인 열거형, match 문은 오류 처리를 강제하고 이를 쉽게 작성하도록 돕지만, C의 오류 처리는 주로 -1 같은 특정 정수나 널 포인터를 오류 신호로 반환하는 방식임.
  • 스레드 로컬 정수인 errno를 확인하면 특정 오류 종류에 관한 정보를 더 얻을 수 있지만, 실제 오류 메시지나 스택 추적을 얻기는 훨씬 어려움.
  • C는 오류 처리를 강제하지 않으므로 함수가 실패할 수 있다는 점을 직접 기억해야 함. 예를 들어 malloc()은 실패 시 널 포인터를 반환하며, 이를 확인하지 않고 reversed[i]에 접근하면 메모리 부족 상황에서 세그멘테이션 오류가 발생할 수 있음.
  • 할당 결과가 널 포인터인지 확인하고, 널이면 perror("Error")로 오류를 출력한 뒤 프로그램을 종료하면 세그멘테이션 오류보다 명확한 결과를 제공함. 예시 출력은 Error: Cannot allocate memory임.
  • 태그가 있는 공용체로 러스트의 Result를 C에서 흉내 내는 방법을 시험했지만, 사용하기 복잡하고 오류 처리를 하지 않은 채 result.value.ptr에 접근하는 것도 막지 못함.
  • private와 public 같은 가시성 제어자가 없다는 점은 의도적인 설계 선택으로 보이며, C는 프로그래머가 올바르게 처리할 것이라는 전제에 크게 의존하고 계약이나 안전한 추상화를 위한 기능은 제한적임.
  • 프로그램 요구 사항을 타입 시스템에 표현해 컴파일러가 검사하도록 하는 편을 선호하며, 이를 통해 코드가 컴파일되면 올바르게 작성됐다는 점을 합리적으로 확신할 수 있다는 관점임. 러스트 타입 시스템으로 코드 정확성을 보장하는 SIMD 사례를 다룬 글도 참고 대상으로 언급됨.

필드 접근 문법

  • C에는 값의 필드에 접근하는 . 연산자와 포인터를 통한 필드 접근에 쓰는 -> 연산자가 있음.
  • 러스트는 모든 경우에 .을 사용하므로 C의 두 연산자 구분이 처음에는 낯설 수 있음.
  • -> 연산자가 생긴 역사에 관해서는 Stack Overflow의 설명이 참고 자료로 언급됨.

배열은 포인터로 변환됨

  • C 배열은 상황에 따라 sizeof()로 크기를 확인할 수 있는 값이 되거나, 크기를 알 수 없는 포인터처럼 보임. 일반적으로 배열이 정의된 함수 안에서 다루는지 여부가 차이를 만듦.
  • 함수 안에서 선언된 크기 지정 배열과 초기값으로 크기를 추론한 배열은 각각 3바이트를 차지하며, sizeof()로 이를 확인할 수 있음.
  • 배열을 함수에 인자로 전달하면 첫 요소를 가리키는 포인터로 암묵 변환됨. 따라서 char declared_size[3]와 char inferred_size[]를 매개변수로 선언해도 함수의 관점에서는 둘 다 char*임.
  • 예시 환경에서 함수 안의 sizeof() 결과는 배열의 3바이트가 아니라 포인터의 8바이트임. Clang은 배열 함수 매개변수에 sizeof()를 적용하면 배열 대신 char *의 크기를 반환한다는 경고를 표시함.

참조는 없고 포인터만 있음

  • C에는 빌림 검사도 참조 개념도 없으며, 모든 참조 작업은 원시 포인터로 처리함.
  • 포인터 산술을 사용해 배열 인덱스 대신 포인터를 순회할 수 있지만, 이 방식이 좋은 선택인지는 확실하지 않음.
  • 포인터를 다룰 때는 주의가 필요함. 메모리 처리 실수는 버퍼 오버플로와 범위 밖 쓰기를 일으킬 수 있으며, 이는 코드 보안에 상당한 위협이 됨.

결론

  • C를 배우는 과정은 즐거웠으며, 개인 프로젝트에서 선택할 가능성은 낮더라도 시스템 프로그래머가 알아야 할 핵심 언어라는 결론임.
  • 글의 모든 예제는 GitHub에서 확인할 수 있다고 안내함.