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와의 호환성을 위해 여러 표현이 동시에 존재할 수 있지만, 시간이 지나면서 이러한 호환성은 단계적으로 폐기되어야 합니다. 좁은 유니코드 빌드와 넓은 유니코드 빌드의 구분은 제거됩니다. 이 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 구조체가 사용됩니다. compact 객체의 크기 조정은 지원되지 않습니다.
생성 시점에 최대 문자가 지정되지 않은 객체를 “legacy” 객체라고 하며, PyUnicode_FromStringAndSize(NULL, length)를 통해 생성됩니다. 이러한 객체는 PyUnicodeObject 구조체를 사용합니다. 처음에는 데이터가 wstr 포인터에만 있으며, PyUnicode_READY가 호출되면 데이터 포인터(유니온)가 할당됩니다. PyUnicode_READY가 호출되지 않은 동안에는 크기 조정이 가능합니다.
필드는 다음과 같이 해석됩니다.
- length: 문자열의 코드 포인트 수(sq_length의 결과)
- interned: 3.2에서와 같은 interned 상태(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이거나 데이터 포인터와 길이가 초기화된 경우에 설정됩니다.
- 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 형식에 정확히 들어맞는 경우 data 포인터와 wstr 포인터는 동일한 메모리를 가리킵니다 (즉, sizeof(wchar_t)가 2이면 BMP이면서 Latin-1이 아닌 문자를 일부 사용하고, sizeof(wchar_t)가 4이면 BMP 외부 문자를 일부 사용합니다).
문자열 생성
유니코드 객체를 생성하는 권장 방법은 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 포인터가 null이 아니면 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(unicode, 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, buffer, 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년 동안 사용할 수 있어야 하며, 제거하기 전에 기존 확장 모듈을 조사하여 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로 인코딩된 바이트 객체에 대한 빌린 참조를 반환했습니다. 유니코드 객체가 더 이상 이러한 참조를 캐시할 수 없으므로, 메모리 누수 없이 이를 구현하는 것은 불가능합니다. 내부 사용만을 위한 API였으므로 사용 중단 단계는 제공되지 않습니다.
레거시 API를 사용하는 확장 모듈은 객체가 준비된 상태여야 하는 일부 API를 호출하면서 부주의하게 PyUnicode_READY를 호출한 다음, (이제 유효하지 않은) Py_UNICODE 포인터에 계속 액세스할 수 있습니다. 이러한 코드는 이 PEP에 따라 작동하지 않게 됩니다. 문자열 크기 조정 가능성 때문에 API 호출 후에도 PyUnicode_AS_UNICODE 결과가 유효하게 유지된다는 명시적 보장이 없었으므로, 이 코드는 3.2에서도 이미 결함이 있었습니다. 이 문제에 직면한 모듈은 API 호출 후 Py_UNICODE 포인터를 다시 가져와야 하며, 그렇게 하면 이전 Python 버전에서도 계속 올바르게 작동합니다.
논의
여기서 제시한 접근 방식에 대해 몇 가지 우려가 제기되었습니다.
구현이 더 복잡해집니다. 이는 사실이지만, 이점을 고려하면 감수할 가치가 있다고 판단됩니다.
Py_UNICODE 표현은 즉시 사용할 수 없으므로, 이를 요청하는 애플리케이션의 속도가 느려집니다. 이 문제를 중요하게 여기는 애플리케이션은 데이터 표현을 사용하도록 다시 작성할 수 있지만, 이 점 역시 사실입니다.
성능
이 패치의 성능은 메모리 소비와 런타임 효율성 모두를 고려해야 합니다. 메모리 소비 측면에서는 큰 문자열을 많이 보유한 애플리케이션의 메모리 사용량이 감소할 것으로 예상됩니다. 작은 문자열의 경우 효과는 시스템의 포인터 크기와 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바이트에서 2216807바이트로, 내로 유니코드 빌드의 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)를 변수에 보관하십시오. (1<<(kind-1))이 버퍼 kind에 대한 요소 크기를 산출한다는 점에 유의하십시오.
새 문자열을 만들 때, Python에서는 휴리스틱한 버퍼 크기로 시작한 다음 그 휴리스틱이 맞지 않을 경우 늘리거나 줄이는 것이 일반적이었습니다. 이 PEP에서는 문자열 길이에 대한 휴리스틱뿐만 아니라 최대 문자에 대한 휴리스틱도 필요하기 때문에, 이제 이 방식은 덜 실용적입니다.
휴리스틱을 피하려면 입력에 대해 두 번의 패스를 거쳐야 합니다. 한 번은 출력 길이와 최대 문자를 결정하기 위한 것이고, 그다음 PyUnicode_New로 대상 문자열을 할당한 뒤 입력을 두 번째로 순회하여 최종 출력을 생성합니다. 이것이 비용이 많이 드는 것처럼 들릴 수 있지만, 실제로는 다음에 나오는 방식처럼 결과를 다시 복사해야 하는 것보다 더 저렴할 수 있습니다.
휴리스틱한 방식을 택한다면, 문자열의 정규 표현에는 크기 조정이 통하지 않으므로 크기 조정을 염두에 둔 문자열 할당은 피하십시오. 대신 문자들을 모을 별도의 버퍼를 할당한 다음, PyUnicode_FromKindAndData를 사용해 그것으로부터 유니코드 객체를 구성하십시오. 한 가지 선택지는 최악의 경우 문자 서수를 가정하여 버퍼 요소로 Py_UCS4를 사용하는 것입니다. 이렇게 하면 포인터 연산이 가능해지지만, 메모리를 많이 필요로 할 수 있습니다. 다른 방법으로는, 1바이트 버퍼로 시작하여 더 큰 문자를 마주칠 때마다 요소 크기를 늘리는 것입니다. 어느 경우든, PyUnicode_FromKindAndData는 최대 문자를 검증하기 위해 버퍼를 훑어봅니다.
일반적인 작업의 경우 문자열 표현에 직접 접근할 필요가 없을 수도 있습니다. PyUnicode_Find, PyUnicode_FindChar, PyUnicode_Ord, PyUnicode_CopyCharacters는 데이터 포인터 대신 인덱스로 동작하면서 문자열 객체를 분석하고 생성하는 데 도움이 됩니다.
참고 자료
Copyright
This document has been placed in the public domain.