본문 바로가기

프로그래밍/C&C++

Unicode

반응형

프로그래밍에서 문자열을 다루는 일은 굉장히 잦지만,
문자열이 어떻게 구현되어 있는지 생각해 본 적 있으신가요?
 
물론 단순히 문자(character)의 배(array)이라는 답도 근본적으로는 틀리지 않지만요 :3


C/C++에서 배열은 암묵적(implicit)으로 포인터로 변형(decay)되어요. 이때, 배열의 범위 밖을 접근하는 경우,
미정의 된 행동(undefined behaviour)이 발생하고 이는 굉장히 심각한 보안 취약점으로 이어질 수 있어요.
따라서 함수에 배열을 전달할 때, 포인터와 배열의 길이를 둘 다 전달해줘야 하는 번거로움이 있어요.
(혹은 template의 기능을 이용해 길이와 함께 배열의 참조(reference)를 전달할 수 있어요.)


이처럼 매번 문자열의 포인터와 문자의 길이를 전달해줘야 하는 수고로움을 해결하기 위해
그리고 문자열의 길이를 표현하는 부호 없는 정수가 요구하는 메모리를 절약하기 위해
C Standard Library는 임의의 문자를 사용해 문자열의 끝을 표현하기 시작했어요.
이때 문자열의 끝을 표현하는 임의의 문자 '\0'를 null terminator라고 해요.

#include <cstddef>
#include <cstring>
#include <iostream>

int main()
{
	const char* example {"hello world"};
	// strlen 함수는 문자열의 길이를 반환해요.
	// 이때 널 종료 문자('\0')는 포함되지 않아요.
	const size_t length {strlen(example)};
	
	std::cout << (example[length] == '\0' ? "true" : "false") << std::endl; // true
}

ASCII(American Standard Code for Information Interchange)는 영어권 문자를 표현하기 위해 만들어졌으며,
parity bit 덕분에 일부 bit가 누락된 경우에도 오류검출이 가능하기에 높은 신뢰성을 뽐내는 부호예요.
 
다만 영어권 외의 수많은 문자들을 전부 표현하려면 ASCII의 127개의 글자로는 부족해서
ASCII와의 호환성을 고려한 정보 교환 표준 부호(이하 유니코드)가 고안되었어요.


C/C++에서 char 자료형은 1 byte이기에 각 문자가 1 byte인 ASCII 문자를 표현할 때 적합해요.
하지만 유니코드는 수많은 문자를 지원해야 하기에 최소 1 byte부터 최대 4 byte가 필요해요.
 
이때 4 byte 이하의 문자를 모두 4 byte로 표현하는 것은 비효율적이기에
가변적으로 크기를 할당하여 사용하는 UTF-8/16 인코딩이 탄생했어요.

UTF-8

  chunk chunk chunk chunk
1 chunk 0xxxxxxx - - -
2 chunk 110xxxxx 10xxxxxx - -
3 chunk 1110xxxx 10xxxxxx 10xxxxxx -
4 chunk 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
  1 byte 1 byte 1 byte 1 byte

UTF-16

  chunk chunk
1 chunk if not high -
2 chunk (high) 0xD800 <= code && code <= 0xDBFF (low) 0xDC00 <= code && code <= 0xDFFF
  1 byte 1 byte 1 byte 1 byte


연속되는 2개의 chunk가 각각 high, low 라면 (if surrogate pair)
codepoint = ((high - 0xD800) << 10) + (low - 0xDC00) + 0x10000


 

위 같은 규칙으로 인해 n번째 문자가 배열의 n번째의 요소 1개 만을 사용한다는 장담을 할 수 없어요.
따라서 UTF-8/16 인코딩에서는 n번째 문자를 확인하거나 문자열의 길이를 알기 위해 순회가 필요해요

UTF-32

UTF32는 모든 문자를 4byte를 사용해 표현하는 인코딩이에요.
 
앞서 살펴보았듯 UTF-8/16의 메모리의 구조는 효율적이지만
무작위 접근의 시간 복잡도가 O(n)이라는 단점이 있었어요.
 
만약 각 문자의 시작 위치를 별개의 배열로 저장해서 O(1)을 달성한다고 해도
1. 문자의 정보를 저장한 배열의 메모리의 크기
2. 문자의 시작 위치를 저장한 배열의 메모리 크기
각각을 더하면 문자당 4 byte를 초과하기에 오히려 UTF-32 보다 무척 비효율적이에요.
 
즉 UTF-32는 메모리의 공간 효율성을 포기한 대신 상수시간으로 무작위 접근이 가능해요.
다만 문자열을 문자로 분해해 다루는 대다수의 경우, 문자를 연속적(sequential)으로 처리하고
이 경우 특수한 iterator를 구현하면 접근 시간을 보다 개선할 수 있어서 UTF-32는 자주 사용되지 않아요.


C/C++에서 문자열 리터럴의 자료형은 기본적으로 const char* 이에요.
이때 ASCII의 표현 범위 밖의 문자를 문자열에 포함시킨다면 어떻게 될까요?

#include <cstring>
#include <iostream>

int main()
{
	const char* example {"😊"};

	std::cout << strlen(example) << std::endl; // 4
}

 
U+1F60A (😊)는 BMP(Basic Multilingual Plane)를 초과하는 4byte의 supplementary 문자로,
1 byte 크기의 char을 사용해 표현하면 총 4개가 필요하고, 따라서 STL의 strlen 함수는 4를 출력해요.


그렇다면 std::string의 경우는 어떨까요..?

#include <string>
#include <iostream>

int main()
{
	std::string example {"😊"};

	std::cout << example.length() << std::endl; // 4
}

 
마찬가지로 4를 출력하는 것을 볼 수 있어요.


또한 단순히 부정확한 길이를 출력하는 것뿐만이 아니라
특정 위치의 문자에 접근하는 경우에도 문제가 발생해요.

#include <string>
#include <iostream>

int main()
{
	std::string example {"😊"};
	
	/*
	for (const auto code : example)
	{
		std::cout << ((short) code) << std::endl;	
	}
	*/
	
	std::cout << ((short) example[0]) << std::endl;	// 16
}

 
이처럼 각 배열의 요소가 표현할 수 있는 범위를 초과하는 값을
배열에 대입하는 경우에 의도하지 않은 결과가 출력될 수 있어요.
그렇다면 각 문자에 4 byte를 할당하면 이러한 문제를 해결 가능할까요?


유니코드는 사실 매우 하자가 많은 부호에요.
설계 실수로 할당한 문자부터, 많은 종류의 정규화 방식이 있어서
아래에 예시로 제공한 표 처럼, 하나의 문자를 표현하는 방법이 매우 다양해요.

Combining sequence Ç C+◌̧
Ordering of combining marks q+◌̇+◌̣ q+◌̣+◌̇
Hangul & conjoining jamo ᄀ +ᅡ
Singleton equivalence Ω Ω

 

더욱이 각각의 정규화 방식은 저마다의 장단점이 있기에, 어느 방식이 우월하다고는 할 수 없어요.

심지어 실수로 할당한 문자들의 경우에도 이전 판과 호환성을 유지하기 위해서 방치되고 있어요.

Credits

 

UAX #15: Unicode Normalization Forms

Unicode® Standard Annex #15 Unicode Normalization Forms Summary This annex describes normalization forms for Unicode text. When implementations keep strings in a normalized form, they can be assured that equivalent strings have a unique binary representat

unicode.org

 

Unicode in the Library, Part 2: Normalization

The Unicode algorithms are low-level tools that most C++ users will not need to touch, even if their code needs to be Unicode-aware. C++ users should also be provided higher-level, string-like abstractions (provisionally called std::text) that will handle

www.open-std.org

 

ICU - International Components for Unicode - C++

We use a lot of old-fashioned C and C++, which makes it cumbersome and error-prone to implement ICU4C. It seems like we should be able, in 2009, to rely on C++ in a way that we couldn't in 1999. For example, it should be possible to assume that compilers s

icu.unicode.org

 
 
반응형

'프로그래밍 > C&C++' 카테고리의 다른 글

the rule of 0/3/5  (1) 2025.03.18
String & SSO  (3) 2025.02.17
Union & Bit-field  (0) 2025.02.17
Struct Padding  (8) 2025.02.13
Metaprogramming  (3) 2024.12.20