PEP 393 – 유연한 문자열 표현
- Author:
- Martin von Löwis <martin at v.loewis.de>
- Status:
- Final
- Type:
- Standards Track
- Created:
- 24-Jan-2010
- Python-Version:
- 3.3
- Post-History:
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
유니코드 문자열 타입은 유니코드 서수가 가장 큰 문자에 따라 여러 내부 표현(1, 2 또는 4바이트)을 지원하도록 변경됩니다. 이를 통해 일반적인 경우에는 공간 효율적인 표현을 사용하면서도, 모든 시스템에서 완전한 UCS-4를 사용할 수 있게 됩니다. 기존 API와의 호환성을 위해 여러 표현이 동시에 존재할 수 있습니다. 이 호환성은 시간이 지나면서 단계적으로 폐지되어야 합니다. 좁은(narrow) 유니코드 빌드와 넓은(wide) 유니코드 빌드의 구분은 없어집니다. 이 PEP의 구현은 [1]에서 확인할 수 있습니다.
근거
현재 유니코드 타입 구현에 대한 불만은 두 가지로 나뉩니다. UTF-16만 지원하는 시스템에서는 사용자들이 BMP 외 문자가 제대로 지원되지 않는다고 불평합니다. 내부적으로 UCS-4를 사용하는 시스템(그리고 때로는 UCS-2를 사용하는 시스템)에서는 유니코드 문자열이 메모리를 너무 많이 차지한다는 불만이 있습니다. 특히 같은 코드가 대개 ASCII 문자열(즉, ASCII로 인코딩된 바이트 문자열)을 사용하던 Python 2.x와 비교하면 더욱 그렇습니다. 제안된 방식에서는 ASCII 문자만으로 이루어진 유니코드 문자열이 다시 문자당 1바이트만 사용하면서도, BMP 외 문자를 포함하는 문자열을 여전히 효율적으로 인덱싱할 수 있습니다(그러한 문자를 포함하는 문자열은 문자당 4바이트를 사용하기 때문입니다).
이 방식의 한 가지 문제는 기존 애플리케이션(예: 확장 모듈)을 지원하는 것입니다. 호환성을 위해 중복된 표현을 계산할 수 있습니다. 애플리케이션은 가능하다면 특정 내부 표현에 의존하는 부분을 단계적으로 없애는 것이 좋습니다. 다른 라이브러리와 상호 작용하려면 대개 어떤 형태로든 내부 표현이 필요하므로, 이 명세는 문자열을 C 코드에 노출하는 권장 방법으로 UTF-8을 선택합니다.
많은 문자열(예: ASCII)에서는 실제로 여러 표현이 메모리를 공유할 수 있습니다(예: 모든 문자가 ASCII라면 가장 짧은 형식을 UTF-8 형식과 공유할 수 있습니다). 이렇게 공유하면 호환성용 표현으로 인한 오버헤드가 줄어듭니다. 표현들이 실제로 데이터를 공유한다면 구조체 필드를 생략하여 문자열 객체의 기본 크기를 줄일 수도 있습니다.
명세
이제 유니코드 구조체는 다음과 같은 구조체 계층으로 정의됩니다.:
typedef struct {
PyObject_HEAD
Py_ssize_t length;
Py_hash_t hash;
struct {
unsigned int interned:2;
unsigned int kind:2;
unsigned int compact:1;
unsigned int ascii:1;
unsigned int ready:1;
} state;
wchar_t *wstr;
} PyASCIIObject;
typedef struct {
PyASCIIObject _base;
Py_ssize_t utf8_length;
char *utf8;
Py_ssize_t wstr_length;
} PyCompactUnicodeObject;
typedef struct {
PyCompactUnicodeObject _base;
union {
void *any;
Py_UCS1 *latin1;
Py_UCS2 *ucs2;
Py_UCS4 *ucs4;
} data;
} PyUnicodeObject;
생성 시점에 크기와 최대 문자를 모두 알고 있는 객체를 “컴팩트(compact)” 유니코드 객체라고 합니다. 이 객체의 문자 데이터는 기본 구조체 바로 뒤에 이어집니다. 최대 문자가 128 미만이면 PyASCIIObject 구조체를 사용하며, UTF-8 데이터, UTF-8 길이, wstr 길이는 ASCII 데이터의 길이와 같습니다. ASCII가 아닌 문자열에는 PyCompactObject 구조체를 사용합니다. 컴팩트 객체의 크기 조정은 지원되지 않습니다.
생성 시점에 최대 문자가 주어지지 않는 객체를 “레거시(legacy)” 객체라고 하며, PyUnicode_FromStringAndSize(NULL, length)를 통해 생성됩니다. 이 객체는 PyUnicodeObject 구조체를 사용합니다. 처음에는 데이터가 wstr 포인터에만 있으며, PyUnicode_READY가 호출되면 data 포인터(공용체)가 할당됩니다. PyUnicode_READY가 호출되지 않은 동안에는 크기를 조정할 수 있습니다.
각 필드는 다음과 같이 해석됩니다.
- length: 문자열에 있는 코드 포인트의 수(sq_length의 결과)
- interned: 3.2에서와 같은 인턴 상태(SSTATE_*)
- kind: 문자열의 형식
- 00 => str이 초기화되지 않았습니다(데이터는 wstr에 있습니다)
- 01 => 1바이트 (Latin-1)
- 10 => 2바이트 (UCS-2)
- 11 => 4바이트 (UCS-4);
- compact: 객체가 compact 표현 중 하나를 사용합니다(ready를 함축합니다)
- ascii: 객체가 PyASCIIObject 표현을 사용합니다(compact와 ready를 함축합니다)
- ready: PyUnicode_DATA와 PyUnicode_GET_LENGTH를 통해 정규 표현에 접근할 준비가 되었습니다. 이 값은 객체가 compact이거나, data 포인터와 길이가 초기화되었을 때 설정됩니다.
- wstr_length, wstr: 플랫폼의 wchar_t로 된 표현입니다(널 종료). wchar_t가 16비트이면 이 형식은 서로게이트 쌍을 사용할 수 있습니다(이 경우 wstr_length는 length와 다릅니다). wstr_length는 표현에 서로게이트 쌍이 있을 때만 length와 다릅니다.
- utf8_length, utf8: UTF-8 표현입니다(널 종료).
- data: 유니코드 문자열의 가장 짧은 형식의 표현입니다. 문자열은 (각 표현에서) 널 종료됩니다.
세 가지 표현은 모두 선택 사항이지만, data 형식은 정규 표현으로 간주되며 문자열이 생성되는 동안에만 없을 수 있습니다. 표현이 없으면 포인터는 NULL이며, 해당 길이 필드에는 임의의 데이터가 들어 있을 수 있습니다.
Py_UNICODE 타입은 여전히 지원되지만 폐지 예정입니다. 이 타입은 항상 wchar_t에 대한 typedef로 정의되므로, wstr 표현을 Py_UNICODE 표현으로 겸용할 수 있습니다.
문자열이 ASCII 문자만 사용하면 data 포인터와 utf8 포인터는 같은 메모리를 가리킵니다(Latin-1만 사용하는 것으로는 충분하지 않습니다). 문자열이 마침 플랫폼의 wchar_t 타입에 정확히 들어맞으면(즉, sizeof(wchar_t)가 2일 때 Latin-1이 아닌 BMP 문자를 일부 사용하고, sizeof(wchar_t)가 4일 때 BMP가 아닌 문자를 일부 사용하면) data 포인터와 wstr 포인터는 같은 메모리를 가리킵니다.
문자열 생성
유니코드 객체를 생성하는 권장 방법은 PyUnicode_New 함수를 사용하는 것입니다:
PyObject* PyUnicode_New(Py_ssize_t size, Py_UCS4 maxchar);
두 매개변수 모두 문자열의 최종 크기/범위를 나타내야 합니다. 특히 이 API를 사용하는 코덱은 문자 수와 최대 문자를 모두 미리 계산해야 합니다. 문자열은 지정된 크기와 문자 범위에 따라 할당되고 널 종료됩니다. 그 안의 실제 문자는 초기화되지 않았을 수 있습니다.
PyUnicode_FromString과 PyUnicode_FromStringAndSize는 UTF-8 입력을 처리하는 용도로 계속 지원됩니다. 입력은 디코딩되며, 해당 문자열의 UTF-8 표현은 아직 설정되지 않습니다.
PyUnicode_FromUnicode는 계속 지원되지만 폐지 예정입니다. Py_UNICODE 포인터가 널이 아니면 data 표현이 설정됩니다. 포인터가 NULL이면 적절한 크기의 wstr 표현이 할당되며, 이 표현은 (명시적으로든 암묵적으로든) PyUnicode_READY()가 호출될 때까지 수정할 수 있습니다. 유니코드 문자열은 확정될 때까지 크기를 계속 조정할 수 있습니다.
PyUnicode_READY()는 wstr 표현만 가진 문자열을 정규 표현으로 변환합니다. wstr과 data가 메모리를 공유할 수 없으면 변환 후 wstr 표현은 폐기됩니다. 이 매크로는 성공하면 0을, 실패하면 -1을 반환하며, 특히 메모리 할당에 실패하면 이 매크로도 실패합니다.
문자열 접근
정규 표현에는 PyUnicode_Kind와 PyUnicode_Data라는 두 매크로를 사용하여 접근할 수 있습니다. PyUnicode_Kind는 PyUnicode_WCHAR_KIND (0), PyUnicode_1BYTE_KIND (1), PyUnicode_2BYTE_KIND (2), PyUnicode_4BYTE_KIND (3) 중 하나의 값을 돌려줍니다. PyUnicode_DATA는 데이터를 가리키는 void 포인터를 돌려줍니다. 개별 문자에 접근할 때는 PyUnicode_{READ|WRITE}[_CHAR]를 사용해야 합니다:
- PyUnicode_READ(kind, data, index)
- PyUnicode_WRITE(kind, data, index, value)
- PyUnicode_READ_CHAR(유니코드, index)
이 매크로들은 모두 문자열이 정규 형식이라고 가정하므로, 호출자는 PyUnicode_READY를 호출하여 이를 보장해야 합니다.
UTF-8 표현에 접근하기 위한 새 함수 PyUnicode_AsUTF8이 제공됩니다. 따라서 이 함수는 기존의 _PyUnicode_AsString과 동일하며, _PyUnicode_AsString은 제거됩니다. 이 함수는 처음 호출될 때 utf8 표현을 계산합니다. 이 표현은 문자열 객체가 해제될 때까지 메모리를 차지하므로, 애플리케이션은 가능하면 기존의 PyUnicode_AsUTF8String을 사용해야 합니다(이 함수는 호출될 때마다 새 문자열 객체를 생성합니다). 문자열을 char*로 암묵적으로 변환하는 API(ParseTuple 함수 등)는 PyUnicode_AsUTF8을 사용하여 변환을 계산합니다.
새 API
이 절에서는 추가된 API를 요약합니다.
유니코드 객체의 내부 표현에 접근하는 매크로(읽기 전용):
- PyUnicode_IS_COMPACT_ASCII(o), PyUnicode_IS_COMPACT(o), PyUnicode_IS_READY(o)
- PyUnicode_GET_LENGTH(o)
- PyUnicode_KIND(o), PyUnicode_CHARACTER_SIZE(o), PyUnicode_MAX_CHAR_VALUE(o)
- PyUnicode_DATA(o), PyUnicode_1BYTE_DATA(o), PyUnicode_2BYTE_DATA(o), PyUnicode_4BYTE_DATA(o)
문자 접근 매크로:
- PyUnicode_READ(kind, data, index), PyUnicode_READ_CHAR(o, index)
- PyUnicode_WRITE(kind, data, index, value)
기타 매크로:
- PyUnicode_READY(o)
- PyUnicode_CONVERT_BYTES(from_type, to_type, begin, end, to)
문자열 생성 함수:
- PyUnicode_New(size, maxchar)
- PyUnicode_FromKindAndData(kind, data, size)
- PyUnicode_Substring(o, start, end)
문자 접근 유틸리티 함수:
- PyUnicode_GetLength(o), PyUnicode_ReadChar(o, index), PyUnicode_WriteChar(o, index, character)
- PyUnicode_CopyCharacters(to, to_start, from, from_start, how_many)
- PyUnicode_FindChar(str, ch, start, end, direction)
표현 변환:
- PyUnicode_AsUCS4(o, 버퍼, buflen)
- PyUnicode_AsUCS4Copy(o)
- PyUnicode_AsUnicodeAndSize(o, size_out)
- PyUnicode_AsUTF8(o)
- PyUnicode_AsUTF8AndSize(o, size_out)
UCS4 유틸리티 함수:
- Py_UCS4_{strlen, strcpy, strcat, strncpy, strcmp, strncpy, strcmp, strncmp, strchr, strrchr}
안정 ABI
다음 함수들은 유니코드 객체의 실제 표현과 무관하므로 안정 ABI(PEP 384)에 추가됩니다: PyUnicode_New, PyUnicode_Substring, PyUnicode_GetLength, PyUnicode_ReadChar, PyUnicode_WriteChar, PyUnicode_Find, PyUnicode_FindChar.
GDB 디버깅 훅
Tools/gdb/libpython.py에는 PyUnicodeObject 인스턴스를 비롯한 CPython 데이터 타입의 내부 구조에 관한 지식을 담은 디버깅 훅이 들어 있습니다. 이 파일은 이번 변경 사항을 반영하도록 갱신되었습니다.
폐지, 제거 및 비호환성
이 PEP에 따라 Py_UNICODE 표현과 API는 폐지되지만, 해당 API를 제거할 일정은 잡혀 있지 않습니다. 이 API들은 PEP가 수락된 후 최소 5년 동안 계속 사용할 수 있어야 합니다. 이 API들을 제거하기 전에 기존 확장 모듈을 조사하여 PyPI에 있는 오픈 소스 코드의 충분한 다수가 새 API로 이식되었는지 확인해야 합니다. 새 코드에서도 폐지된 API를 사용할 합당한 이유로는 Python 2와 Python 3 모두에서 동작해야 하는 코드를 들 수 있습니다.
다음 매크로와 함수는 폐지됩니다:
- PyUnicode_FromUnicode
- PyUnicode_GET_SIZE, PyUnicode_GetSize, PyUnicode_GET_DATA_SIZE,
- PyUnicode_AS_UNICODE, PyUnicode_AsUnicode, PyUnicode_AsUnicodeAndSize
- PyUnicode_COPY, PyUnicode_FILL, PyUnicode_MATCH
- PyUnicode_Encode, PyUnicode_EncodeUTF7, PyUnicode_EncodeUTF8, PyUnicode_EncodeUTF16, PyUnicode_EncodeUTF32, PyUnicode_EncodeUnicodeEscape, PyUnicode_EncodeRawUnicodeEscape, PyUnicode_EncodeLatin1, PyUnicode_EncodeASCII, PyUnicode_EncodeCharmap, PyUnicode_TranslateCharmap, PyUnicode_EncodeMBCS, PyUnicode_EncodeDecimal, PyUnicode_TransformDecimalToASCII
- Py_UNICODE_{strlen, strcat, strcpy, strcmp, strchr, strrchr}
- PyUnicode_AsUnicodeCopy
- PyUnicode_GetMax
_PyUnicode_AsDefaultEncodedString은 제거됩니다. 이 함수는 이전에 UTF-8로 인코딩된 bytes 객체에 대한 빌린 참조(borrowed reference)를 반환했습니다. 유니코드 객체가 더 이상 그러한 참조를 캐시할 수 없으므로, 메모리 누수 없이 이 함수를 구현하는 것은 불가능합니다. 내부 전용 API였으므로 폐지 예고(deprecation) 단계는 제공되지 않습니다.
레거시 API를 사용하는 확장 모듈은 객체가 준비된 상태여야 하는 API를 호출함으로써 의도치 않게 PyUnicode_READY를 호출한 뒤, (이제는 유효하지 않은) Py_UNICODE 포인터에 계속 접근할 수 있습니다. 이러한 코드는 이 PEP로 인해 동작하지 않게 됩니다. (문자열 크기가 조정될 가능성 때문에) API 호출 후에도 PyUnicode_AS_UNICODE 결과가 유효하게 유지된다는 명시적인 보장이 없었으므로, 이러한 코드는 3.2에서도 이미 결함이 있었습니다. 이 문제를 겪는 모듈은 API 호출 후에 Py_UNICODE 포인터를 다시 가져와야 합니다. 이렇게 해도 이전 Python 버전에서 계속 올바르게 동작합니다.
논의
여기에서 제시한 접근 방식에 대해 몇 가지 우려가 제기되었습니다:
구현이 더 복잡해집니다. 사실이지만, 얻는 이점을 고려하면 그만한 가치가 있다고 판단했습니다.
Py_UNICODE 표현을 즉시 사용할 수 없으므로, 이를 요청하는 애플리케이션의 속도가 느려집니다. 이 또한 사실이지만, 이 문제를 신경 쓰는 애플리케이션은 data 표현을 사용하도록 다시 작성할 수 있습니다.
성능
이 패치의 성능은 메모리 소비와 런타임 효율성 양쪽 모두에 대해 고려해야 합니다. 메모리 소비의 경우, 큰 문자열을 많이 가진 애플리케이션에서는 메모리 사용량이 줄어들 것으로 예상됩니다. 작은 문자열의 경우, 그 효과는 시스템의 포인터 크기와 Py_UNICODE/wchar_t 타입의 크기에 따라 달라집니다. 다음 표는 다양한 크기의 작은 ASCII 및 Latin-1 문자열과 여러 플랫폼에 대해 이를 보여 줍니다.
| string size | Python 3.2 | This PEP | ||||||
| 16-bit wchar_t | 32-bit wchar_t | ASCII | Latin-1 | |||||
| 32-bit | 64-bit | 32-bit | 64-bit | 32-bit | 64-bit | 32-bit | 64-bit | |
| 1 | 32 | 64 | 40 | 64 | 32 | 56 | 40 | 80 |
| 2 | 40 | 64 | 40 | 72 | 32 | 56 | 40 | 80 |
| 3 | 40 | 64 | 48 | 72 | 32 | 56 | 40 | 80 |
| 4 | 40 | 72 | 48 | 80 | 32 | 56 | 48 | 80 |
| 5 | 40 | 72 | 56 | 80 | 32 | 56 | 48 | 80 |
| 6 | 48 | 72 | 56 | 88 | 32 | 56 | 48 | 80 |
| 7 | 48 | 72 | 64 | 88 | 32 | 56 | 48 | 80 |
| 8 | 48 | 80 | 64 | 96 | 40 | 64 | 48 | 88 |
런타임에 미치는 영향은 사용하는 API에 따라 크게 달라집니다. 관련 코드 부분을 새 API로 이식한 후, iobench, stringbench, json 벤치마크에서는 일반적으로 1%에서 30%의 속도 저하가 나타납니다. 특정 벤치마크에서는 속도가 향상될 수도 있고, 훨씬 더 큰 속도 저하가 발생할 수도 있습니다.
Django 애플리케이션을 실제로 측정한 결과([2]), 메모리 사용량이 크게 줄어든 것을 확인할 수 있었습니다. 예를 들어, 유니코드 객체의 저장 공간은 와이드 유니코드 빌드의 6378540바이트, 내로우 유니코드 빌드의 3694694바이트에서 2216807바이트로 줄어들었습니다(모두 32비트 시스템 기준). 이러한 감소는 이 애플리케이션에서 ASCII 문자열이 대부분을 차지했기 때문입니다. 36,000개의 문자열(1,310,000자) 중 35713개가 ASCII 문자열(1,300,000자)이었습니다. 이 문자열들의 출처는 추가로 분석하지 않았습니다. 그중 상당수는 라이브러리의 식별자와 Django 소스 코드의 문자열 상수에서 비롯된 것으로 보입니다.
Python 2와 비교하려면 유니코드 문자열과 바이트 문자열을 모두 고려해야 합니다. 테스트 애플리케이션에서 유니코드 문자열과 바이트 문자열을 합친 길이는 2.x에서 2,046,000단위(바이트/문자), 3.x에서 2,200,000단위였습니다. 2.x 빌드가 32비트 wchar_t/Py_UNICODE를 사용한 32비트 시스템에서, 2.x 테스트는 3,620,000바이트를, 3.x 빌드는 3,340,000바이트를 사용했습니다. 이 PEP를 적용한 3.x에서 2.x 대비 나타나는 이러한 감소는 와이드 유니코드 빌드와 비교할 때에만 발생합니다.
이식 지침
이 PEP의 영향을 받는 C 코드는 극히 일부이며, 구체적으로는 유니코드 문자열의 “내부”를 들여다봐야 하는 코드입니다. 기존 API는 앞으로도 계속 올바르게 동작하므로, 그러한 코드를 반드시 이 API로 이식해야 하는 것은 아닙니다. 특히 Python 2와 Python 3을 모두 지원해야 하는 모듈은 이 새 API와 기존 유니코드 API를 동시에 지원하려 하면 지나치게 복잡해질 수 있습니다.
모듈을 새 API로 이식하려면 다음 API 요소의 사용을 없애도록 하십시오:
- Py_UNICODE 타입,
- PyUnicode_AS_UNICODE와 PyUnicode_AsUnicode,
- PyUnicode_GET_SIZE와 PyUnicode_GetSize, 그리고
- PyUnicode_FromUnicode.
기존 문자열을 순회하거나 특정 문자를 살펴볼 때는 포인터 산술 대신 인덱싱 연산을 사용하십시오. 인덱싱은 PyUnicode_READ(_CHAR) 및 PyUnicode_WRITE와 잘 맞습니다. 컴파일러가 잘못된 역참조 연산을 감지할 수 있도록 문자 버퍼 타입으로 void*를 사용하십시오. 포인터 산술을 꼭 사용하고 싶다면(예: 기존 코드를 변환할 때) 버퍼 타입으로 (unsigned) char*를 사용하고, 요소 크기(1, 2 또는 4)를 변수에 보관하십시오. 버퍼 종류(kind)가 주어지면 (1<<(kind-1))로 요소 크기를 구할 수 있다는 점에 유의하십시오.
Python에서는 새 문자열을 생성할 때 휴리스틱으로 정한 버퍼 크기로 시작한 뒤, 휴리스틱이 맞지 않으면 버퍼를 늘리거나 줄이는 방식이 일반적이었습니다. 이 PEP에서는 문자열 길이뿐 아니라 최대 문자에 대해서도 휴리스틱이 필요하므로, 이제 이 방식은 덜 실용적입니다.
휴리스틱을 피하려면 입력을 두 번 훑어야 합니다. 먼저 출력 길이와 최대 문자를 알아낸 다음, PyUnicode_New로 대상 문자열을 할당하고 입력을 한 번 더 순회하여 최종 출력을 만듭니다. 이 방법은 비용이 커 보일 수 있지만, 실제로는 다음에 설명하는 방식처럼 결과를 다시 복사해야 하는 것보다 더 저렴할 수 있습니다.
휴리스틱 방식을 택한다면 나중에 크기를 조정할 문자열을 할당하는 것은 피하십시오. 문자열의 정규 표현에서는 크기 조정이 동작하지 않기 때문입니다. 대신 문자를 모을 별도의 버퍼를 할당한 다음, PyUnicode_FromKindAndData를 사용하여 그 버퍼로부터 유니코드 객체를 생성하십시오. 한 가지 방법은 문자 서수(ordinal)가 최악인 경우를 가정하여 버퍼 요소로 Py_UCS4를 사용하는 것입니다. 이렇게 하면 포인터 산술을 사용할 수 있지만, 메모리가 많이 필요할 수 있습니다. 또는 1바이트 버퍼로 시작하여 더 큰 문자를 만날 때마다 요소 크기를 늘리는 방법도 있습니다. 어느 경우든 PyUnicode_FromKindAndData는 버퍼를 훑어 최대 문자를 확인합니다.
일반적인 작업에서는 문자열 표현에 직접 접근할 필요가 없을 수도 있습니다. PyUnicode_Find, PyUnicode_FindChar, PyUnicode_Ord, PyUnicode_CopyCharacters는 데이터 포인터 대신 인덱스로 동작하면서 문자열 객체를 분석하고 생성하는 데 도움이 됩니다.
참고 문헌
Copyright
This document has been placed in the public domain.