Fixing C Strings With String Views
문자열 뷰로 C 문자열 다루기
C의 널 종료 문자열은 길이를 구할 때 데이터를 순회해야 하고, 부분 문자열을 따로 쓰려면 복사가 필요합니다. 글쓴이는 포인터와 길이를 담는 읽기 전용 `str_view`, 데이터를 소유하는 `str_owned`를 제안하고 슬라이스, 검색, 비교, 분할 함수를 구현합니다.
- 주제
에디터 노트
포인터와 길이를 묶으면 부분 문자열을 복사하지 않고 표현하고, 길이 조회도 상수 시간에 처리합니다. 이 점은 글의 구현이 직접 보여주는 장점입니다. 다만 매크로가 문자열 리터럴에만 안전하게 맞고, 슬라이스 함수는 인덱스 범위를 확인하지 않으며, ASCII 대소문자 비교는 유니코드 처리를 제공하지 않습니다. 댓글에서 제기된 libc와 입출력의 NUL 종료 관행도 실제 적용 범위를 좁히는 조건입니다. 따라서 이 코드는 C 문자열 전반을 해결하는 범용 라이브러리라기보다, 특정 프로젝트에서 쓸 최소한의 바이트 문자열 도구로 보는 편이 정확합니다.
AI 요약
C 문자열은 끝에 NUL 문자를 붙여 길이를 표시합니다. 이 방식은 길이를 따로 저장하지 않아도 되지만 문자열 길이를 구할 때 종료 지점까지 훑어야 합니다. 기존 문자열의 일부만 읽으려 해도 그 부분을 NUL로 끝내야 하므로 원본을 바꾸거나 데이터를 복사해야 합니다. 글쓴이는 포인터와 길이를 한 쌍으로 다루는 string view로 이 문제를 줄이고자 합니다.
두 문자열 유형과 소유권
struct str_view는 읽기 전용 데이터 포인터와 길이를 보관합니다. struct str_owned는 수정할 수 있는 데이터를 소유하고 길이도 함께 기록합니다. 소유 문자열에서 뷰를 만들면 포인터와 길이를 그대로 재사용하므로 복사가 필요하지 않습니다. 반대로 뷰를 소유 문자열로 바꿀 때는 메모리를 할당하고 데이터를 복사합니다. 이 구현은 arena allocator를 사용하며, 소유 문자열이 해제된 뒤에도 이를 참조하는 뷰를 쓰면 안 된다고 설명합니다.
글쓴이는 데이터를 char 대신 8비트 정수형 u8로 표현합니다. char의 비트 수는 플랫폼에 따라 달라질 수 있다는 이유입니다. 문자열 리터럴에서 뷰를 만드는 STR 매크로는 sizeof로 길이를 구하고 마지막 NUL 문자를 제외합니다. 따라서 이 매크로는 문자열 리터럴처럼 배열 크기를 알 수 있는 값에 맞춰져 있습니다. 문자열 포인터에 적용하면 포인터 크기를 기준으로 길이를 잘못 계산할 수 있습니다.
제공하는 연산
문자열 비교는 길이를 먼저 확인한 뒤 memcmp로 내용을 비교합니다. 대소문자를 무시하는 비교 함수는 ASCII 대문자에 32를 더해 소문자로 바꿉니다. 글쓴이는 이 방식이 ASCII에서만 동작한다고 명시합니다. 시작 문자열 확인과 부분 문자열 검색 함수도 제공하며, 검색은 각 위치에서 뒷부분을 만들고 시작 문자열인지 검사하는 방식입니다.
슬라이스 함수는 시작과 끝을 선택적으로 받습니다. 시작은 포함하고 끝은 제외합니다. 인덱스에 음수를 넣으면 문자열 끝을 기준으로 위치를 계산합니다. 예를 들어 -1은 마지막 문자를 가리킵니다. 구분 문자열로 나누는 함수는 먼저 구분 문자열이 나타나는 횟수를 세고, arena에서 결과 뷰 배열을 한 번 할당합니다. 분할 결과는 원본 데이터를 가리키므로 복사하지 않습니다. 빈 구분 문자열은 분할하지 않고 원문 전체를 하나의 결과로 돌려줍니다.
소유 문자열은 길이 뒤에 NUL 문자도 붙이는 관례를 둡니다. 길이에는 NUL 문자를 포함하지 않지만, data를 NUL 종료 문자열을 받는 표준 라이브러리 함수에 전달할 수 있습니다. 다만 string view 자체는 NUL 종료를 보장하지 않으므로 출력할 때는 %.*s와 길이를 사용합니다. 이 형식 지정자는 길이 인자로 int를 요구합니다.
구현 범위와 토론
글쓴이는 라이브러리를 단일 C 파일로 공유하고 테스트도 작성했다고 설명합니다. 다만 글에 실은 구현에는 치환 함수 등 저장소에 있는 일부 함수가 빠져 있습니다. 개인 프로젝트에서는 C 파일을 직접 포함하는 unity build도 사용한다고 덧붙입니다. 이 방식은 링크 단계를 생략하지만 대규모 프로젝트에는 적합하지 않을 수 있다고 설명합니다.
Lobsters 반응
- @lor_louis — 텍스트를 제대로 다뤄야 하는 코드베이스마다 거의 같은 것을 구현합니다. 다만 libc가 계속 널 종료 문자열로 되돌아가게 하는 점이 주된 문제입니다.
.*s에서 문자열 길이를int로 넘겨야 하는 점도 싫습니다. 곧 바뀔지도 모르겠습니다.- @eliasebner — 저도 같은 일을 반복해서 구현하다가 한 번 제대로 만들어 재사용하려고 했습니다. 길이를
int로 넘겨야 하는 출력 부분이 정말 성가신 점은 인정합니다. C니까 어쩌겠습니까. 이런 거친 부분이 있어도 C를 좋아하잖아요. - @lonjil — 필요한 경우를 빼면 libc를 쓰지 않는 편을 권합니다.
- @eliasebner — 때로는 libc 없이 지내기 어렵습니다. 소켓이나 일반적인 파일 입출력이 그렇습니다.
- @eliasebner — 저도 같은 일을 반복해서 구현하다가 한 번 제대로 만들어 재사용하려고 했습니다. 길이를
- @ManuLinares — C23의
[[nodiscard]]를 제외하면 이 코드는 표준 C99입니다. C3 같은 언어는 내장 슬라이스로 이런 문제를 이미 해결합니다. - @olliej —
STR과LENGTH_OF매크로가 걱정됩니다.STR("Foo")는 동작하지만const char* str = "Foo";에STR(str)를 적용하면LENGTH_OF가 포인터 크기를 돌려줍니다. 운이 좋으면 경고가 나겠지만, 글쓴이는 문자열 리터럴용이라고 밝혔을 뿐 매크로 자체가 다른 입력을 막지는 않습니다. 대부분의 API는 포인터만 받으므로 길이 정보가 사라지고, 코드가 정상 동작하는 것처럼 보일 수도 있습니다. - @jklowden — 글쓴이는 문제를 충분히 이해했다고 가정하고 해결책을 만듭니다. 하지만 입출력이라는 첫 장애물에서 NUL 종료 문자열을 받아들이고, 인코딩도 다루지 않습니다.
u8을 강조하면서 유니코드를 고려하지 않는 이유가 무엇인지 묻고 싶습니다. 함수 구성도 독자적이며 기존 C 문자열 라이브러리나 C++의std::string과 비교하지 않습니다. 길이를 모른 채 바이트 단위로 비교해도 불일치 지점에서 멈출 수 있고, NUL 종료 지점을 비교하는 것도 한 경우일 뿐이라고 지적합니다.- @masklinn — 현대 언어 다수가 내부 표현에 UTF-8을 쓰므로
u8은 쓸모가 있습니다. UTF-16은 일부 전문적인 사용 사례에서 공간을 아낄 수 있지만, 일반적인 표현으로 UTF-32를 쓸 이유는 거의 없습니다. 유니코드 처리는 가변 폭인 경우가 많아 넓은 문자형이 복잡성을 없애지도 않습니다. - @david_chisnall — Java와 JavaScript의 문자열은 사실상 UTF-16을 전제로 하므로 현대 언어가 대체로 UTF-8을 쓴다는 말에는 동의하기 어렵습니다. 문자열을 저장하는 방식과 문자 접근을 분리하면 처리 속도가 크게 좋아질 수도 있습니다. ASCII 문자열 특례나 짧은 문자열을 포인터 안에 저장하는 방식은 캐시 효율과 비교 성능을 높이고 할당을 줄입니다. 데스크톱 작업 부하 측정에서는 이런 최적화가 문자열뿐 아니라 전체 객체의 힙 할당을 10~20% 줄였다고 말합니다.
- @eliasebner — 이 구현은 제 단순한 문자열 처리 용도에 맞춘 것입니다. 다른 사람도 쓸 만할 것 같아 공유했습니다. 라이브러리라고 부르는 것이 정확하지 않을 수도 있고, 제가 작성한 코드에 가깝습니다.
- @alandekok — 흥미롭고 유용합니다. 비슷한 제안이 여러 번 나왔지만 C 표준에는 들어가지 않아 각자 해결책을 만들고 있습니다. 저도 코드에서 비슷한 방식을 썼습니다.
- @masklinn — 현대 언어 다수가 내부 표현에 UTF-8을 쓰므로
원문: Elias Ebner / 번역·요약: Trawling