요약
std::string 타입은 글자가 아니라 바이트를 저장하며, 오늘날의 텍스트에서 한 글자는 한 바이트에서 네 바이트까지 차지할 수 있다. 이 수업은 일상 언어가 혼동하는 세 가지를 구별한다. 유니코드가 코드 포인트라는 번호로 식별하는 추상 문자, UTF-8이 그것을 부호화하는 바이트, 그리고 화면이 그리는 모양이다. UTF-8이 코드 포인트의 비트를 바이트들에 어떻게 나누어 싣는지, 왜 “하나”가 여섯 바이트를 차지하는지, 문자열을 디코딩하지 않고 문자 수를 어떻게 세는지 설명한다. 이어서 어떤 연산이 텍스트를 깨뜨리는지(바이트 단위 뒤집기, 바이트 단위 대문자 변환, 문자 중간에서 자르기), 어떤 연산이 아무 주의 없이도 작동하는지(저장, 복사, 연결, 검색, 출력), 그리고 C++23 표준 라이브러리가 무엇을 하지 않는지 보여 준다: ASCII 밖의 대문자와 소문자, 언어에 따른 사전순, 정규화.
학습 목표
- UTF-8이 코드 포인트를 한 바이트에서 네 바이트로 어떻게 부호화하는지 설명하고, “ñ”의 바이트를 손으로 디코딩한다.
- ASCII가 아닌 글자가 들어 있는 문자열의 바이트 단위 크기를 예측한다. 예를 들어
"하나"문자열(6)이나"프로그래밍"문자열(15). - 연속 바이트가 아닌 바이트를
(c & 0xC0) != 0x80조건으로 세어 UTF-8 문자열의 문자 수를 센다. - UTF-8 텍스트를 깨뜨리는 연산(바이트 단위 뒤집기, 바이트마다 적용하는
std::toupper함수, 문자 중간에서 자르기)을 알아보고 각 경우에 무엇을 할지 정한다.
문자, 코드 포인트, 바이트
세 층위
문자, 불리언, 형 변환, 상수 수업은 char 타입을 ASCII 표에 따라 글자로 쓰이는 한 바이트짜리 정수로 소개하고, '성' 문자가 거기에 들어가지 않는다고 경고했다. 컴파일, 실행, 설정 수업은 "새해" 문자열이 여섯 바이트를 차지함을 보이고, 그것을 출력할 수 있도록 콘솔을 준비했다. 이 수업은 그 이유를 설명한다. ASCII는 0부터 127까지 128개 문자에 번호를 매기며, 이 번호들은 일곱 비트에 들어간다: 영어 알파벳, 숫자, 문장 부호, 몇몇 제어 코드다. 그러나 실제 텍스트에는 한글과 한자, 강세 부호와 분음 부호가 붙은 글자, 그리스 문자, 수학 기호, 이모지가 들어 있으며, 그 수는 수십만에 이른다. 그러므로 첫째로 훨씬 큰 표가 필요하고, 둘째로 그 번호를 바이트로 쓰는 방법이 필요하다.
세 층위를 구별해 두는 것이 좋다. 첫째는 추상 문자로, 어떻게 저장되거나 그려지는지와 상관없는 “소문자 에녜”라는 글자 자체다. 유니코드는 각 추상 문자에 번호, 곧 코드 포인트를 부여하는 표준이며, 코드 포인트는 U+ 기호 뒤에 적어도 네 자리의 16진 숫자를 붙여 쓴다: “ñ”는 U+00F1 코드 포인트(241), “A”는 U+0041 코드 포인트(65, ASCII와 같은 번호), “€”는 U+20AC 코드 포인트(8364)다. 코드 포인트는 0부터 U+10FFFF까지로, 백만 개를 조금 넘는다. 둘째 층위는 인코딩, 곧 각 코드 포인트를 바이트의 열로 바꾸는 규칙이다. UTF-8은 웹과 오늘날의 시스템과 이 강좌가 쓰는 인코딩이다. 셋째 층위는 표시, 곧 글꼴이 화면에 그리는 모양으로, 문자가 바뀌지 않아도 서체에 따라 달라질 수 있다.
그렇다면 텍스트 데이터는 그 인코딩을 알기 전까지는 텍스트가 아니다: 바이트 c3 b1 쌍은 UTF-8에서는 “ñ”를 나타내지만, 옛 콘솔의 표로 읽으면 그것과 아무 관계없는 두 기호가 된다. 이것이 1.4 수업에서 진단하는 법을 배운 고장이다. 같은 문제를 문서 조판의 관점에서 본 것이 학술적 글쓰기를 위한 LaTeX 강좌의 “언어와 글꼴” 수업에 나온다.
바이트 보기
문자열의 바이트를 보려면 그것을 훑기만 하면 된다: std::string 타입과 std::string_view 타입은 char 값의 열이고, 범위 기반 for 루프가 그 바이트를 하나씩 건네준다. 그것을 16진으로 쓰려면 {:02x} 형식 지정(16진 두 자리, 왼쪽을 0으로 채움)을 쓴다. 2.4 수업의 {:d} 지정과 마찬가지로, 2023년에 채택되어 GCC 15에 적용된 같은 표준 수정에 따라 {:02x} 지정은 char 값을 부호 없는 바이트로 쓴다. 이 규칙은 중요하다. x86-64의 GCC에서 char 타입은 부호가 있기 때문이다: 바이트 c3 값을 int 타입으로 변환하면 수 이 되고, {:02x} 지정은 그것을 -3d 형태로 쓸 것이다.
#include <print>#include <string_view>// 텍스트의 바이트.// 사전 조건: 없음.// 사후 조건: text, 그 바이트 단위 크기, 각 바이트의 16진 표기를 한 줄에 쓴다.void printBytes(std::string_view text){ std::print("{:<4}{}:", text, text.size()); for (const char c : text) { std::print(" {:02x}", c); } std::println("");}int main(){ printBytes("A"); printBytes("ñ"); printBytes("€"); printBytes("😀"); printBytes("하나");}A 1: 41ñ 2: c3 b1€ 3: e2 82 ac😀 4: f0 9f 98 80하나6: ed 95 98 eb 82 98각 줄은 텍스트, 그 크기, 그 바이트를 보여 준다. 길이마다 한 문자씩: “A”는 한 바이트, “ñ”는 두 바이트, “€”는 세 바이트, 이모지는 네 바이트를 차지한다. 낱말 “하나”는 여섯 바이트, 곧 ed 95 98, eb 82 98 바이트를 차지하며, 그 size() 값은 2가 아니라 6이다. “A”가 바이트 41, 곧 ASCII와 같다는 점에 주목하자: UTF-8은 ASCII의 128개 문자를 ASCII와 똑같이 쓴다.
그림은 “ñ”의 인코딩을 위에서 아래로 따라간다: 코드 포인트, 그 비트, 비트의 분배, 그리고 결과로 나오는 두 바이트이며, 접두부 비트가 강조되어 있다.
증명
“ñ”는 코드 포인트
U+00F1, 곧 에 해당한다.2진법으로 은
11110001꼴로 쓴다. 127보다 크고 2048보다 작으므로, UTF-8은 이것을 열한 비트를 나르는 두 바이트로 쓴다: 열한 자리가 될 때까지 왼쪽을 0으로 채워00011110001꼴로 만든다.열한 비트는 두 묶음으로 나뉜다: 앞의 다섯 비트
00011묶음과 뒤의 여섯 비트110001묶음이다.첫째 바이트는 두 바이트 문자를 예고하는 접두부
110뒤에 첫째 묶음의 다섯 비트를 붙인11000011바이트다. 둘째 바이트는 연속 바이트임을 표시하는 접두부10뒤에 나머지 여섯 비트를 붙인10110001바이트다.16진으로는
1100 0011묶음이c3바이트,1011 0001묶음이b1바이트다: 앞의 프로그램이 “ñ”에 대해 쓴 두 바이트다. 네 비트씩 하는 2진과 16진 사이의 기수 변환은 대수학과 기하학 I 강좌의 “위치적 기수법” 수업에서 다룬 것이다.
콘텐츠를 불러오는 중…