Реализация строкового типа в CPython

в 19:16, , рубрики: python, realization, Программирование

Продолжу неспешный разбор реализации базовых типов в CPython, ранее были рассмотрены словари и целые числа. Тем, кто думает, что в их реализации не может быть ничего интересного и хитрого, рекомендуется приобщиться к данным статьям. Те, же, кто уже их прочёл, знают, что CPython хранит в себе множество интересностей и особенностей реализации. Их может быть полезно знать при написании своих скриптов, так и в качестве пособия по архитектурным и алгоритмическим решениям. Не являются исключением здесь и строки.
Реализация строкового типа в CPython - 1

Начнём с небольшого экскурса в историю. Python появился в 1990-91 годах. Изначально при разработке базовой кодировкой в python была однобайтовая, старая добрая ascii. Но, примерно в то же время (чуть позже), человечеству надоело разбираться с «зоопарком» кодировок и в 1991 году был предложен стандарт юникод. Однако с первого раза тоже не получилось. Началось внедрение двухбайтных кодировок, но довольно скоро стало понятно, что двух байт на всех не хватит, была предложена 4-байтная кодировка. К сожалению, выделение по 4 байта на каждый символ выглядело напрасной тратой дискового места и памяти, особенно, в тех странах, где раньше вполне хватало однобайтовой ascii. Было запилено несколько костылей в 2-байтную кодировку, чтобы поддерживать больше символов, и всё это стало напоминать предыдущую ситуацию с «зоопарком» кодировок.

Но в 1993 году был представлен utf-8. Который являлся компромиссом: ascii была валидным подмножеством utf-8, все остальные символы, его расширяли, однако, для поддержки такой возможности, пришлось расстаться с фиксированной длиной каждого символа. Но именно ему суждено было править всеми стать именно юникодом, то есть единой кодировкой, поддерживаемой большинством программ, в которой хранится большинство файлов. Особенно на это повлияло развитие интернета, так как web-странички обычно использовали именно utf-8.

Поддержка этой кодировки постепенно внедрялась в языки программирования, которые, как и python, были разработаны раньше utf-8, а потому использовали другие кодировки. Существует PEP с красивым номером 100, в котором обсуждалась поддержка юникода. А в PEP-0263 появилась возможность объявлять кодировку исходных файлов. Базовой кодировкой всё ещё оставалась ascii, для объявления юникодных строк использовался префикс `u`, работа с ними всё ещё не была достаточно удобной и естественной. Зато появилась возможность творить следующую ересь:

class 비빔밥:
    моя_переменная = 2

א = 비빔밥()
print(א)

3 декабря 2008 года произошло историческое событие для всего python-сообщества (а учитывая, как широко этот язык распространился сейчас, то, пожалуй, и для всего мира) — вышел python 3. В нём было решено раз и навсегда покончить с проблемами из-за множества кодировок, а потому базовой кодировкой стал юникод. Но мы же помним, что кодировки это сложно и с первого раза не выходит. Не получилось и в этот раз.

Большим недостатком utf-8 является то, что длина символа не фиксированна, это приводит к тому, что такая простая операция, как обращение по индексу имеет сложность O(N), так как смещение элемента не известно заранее, кроме того, зная размер буфера, выделенного под хранение строки, нельзя вычислить её длину в символах.

Во избежание всех этих проблем в python было решено использовать 2-ух и 4-байтные кодировки (в зависимости от платформы). Обращение по индексу упростилось — необходимо было лишь умножить индекс на 2 или 4. Однако, это повлекло свои проблемы:

  1. На каждой платформе была своя кодировка, что могло повлечь проблемы с переносимостью кода
  2. Повышенный расход памяти и/или проблемы с кодированием хитрых символов, не влезающих в два байта

Решение этих проблем было предложено в PEP-393, о нём и поговорим.

Было решено оставить строки как массив символов, для облегчения доступа по индексу и других операций, однако длина символов стала варьироваться. При создании строки, интерпретатор сканирует все символы и выделяет для каждого количество байт, которое необходимо для хранения самого «большого», то есть, если вы объявите ascii-строку, то все символы будут однобайтовыми, однако, если вы решите добавить к строке один знак из кириллицы, все символы уже будут двух-байтными. Всего возможны три варианта: 1, 2 и 4 байта на символ.

Строковый тип (PyUnicodeObject) объявлен следующим образом:

/* Strings allocated through PyUnicode_FromUnicode(NULL, len) use the
   PyUnicodeObject structure. The actual string data is initially in the wstr
   block, and copied into the data block using _PyUnicode_Ready. */
typedef struct {
    PyCompactUnicodeObject _base;
    union {
        void *any;
        Py_UCS1 *latin1;
        Py_UCS2 *ucs2;
        Py_UCS4 *ucs4;
    } data;                     /* Canonical, smallest-form Unicode buffer */
} PyUnicodeObject;

В свою очередь PyCompactUnicodeObject представляет собой следующую структуру (приводится с некоторыми упрощениями и моими комментариями):

/* Структура для не ascii строк */
typedef struct {
    PyASCIIObject _base;
    Py_ssize_t utf8_length;     /* количество байт под utf-8 буфер (без учёта
                                             терминатора)*/
    char *utf8;                 /* UTF-8 представление (с  терминатором)*/
    Py_ssize_t wstr_length;     /* Количество code point в wstr. */
} PyCompactUnicodeObject;

/* Структура для ascii строк */
typedef struct {
    PyObject_HEAD     /* стандартный заголовок (указатель на тип и счётчик ссылок) */
    Py_ssize_t length;          /* количество code point в строке */
    Py_hash_t hash;             /* Хэш или -1, если хэш ещё не установлен */
    struct {
        /*
           SSTATE_NOT_INTERNED (0)
           SSTATE_INTERNED_MORTAL (1)
           SSTATE_INTERNED_IMMORTAL (2)
         */
        unsigned int interned:2;
        /* Размер символа:
           - PyUnicode_WCHAR_KIND (0):
             * wchar_t (16 или 32 бита, в зависимости от платформы (старый формат))
           - PyUnicode_1BYTE_KIND (1):
           - PyUnicode_2BYTE_KIND (2):
           - PyUnicode_4BYTE_KIND (4):
         */
        unsigned int kind:3;
        /* Флаг компактной формы
          (если установлен, то вся структура лежит в обном блоке памяти,
	  если сброшен - то data лежит в другом). */
        unsigned int compact:1;
        /* Строка содержит только символы из диапазона U+0000-U+007F (ASCII) */
        unsigned int ascii:1;
        /* установлен, если структура полностью инициализирована,
           использовался в старых реализациях */
        unsigned int ready:1;
        unsigned int :24;
    } state;
    wchar_t *wstr;              /* wchar_t представление ( с  терминатором) */
} PyASCIIObject;

Таким образом возможны 4 представления строк:

  1. legacy string, ready
            * structure = PyUnicodeObject structure
            * Проверка на тип: !PyUnicode_IS_COMPACT(op) && kind != PyUnicode_WCHAR_KIND
            * kind = PyUnicode_1BYTE_KIND, PyUnicode_2BYTE_KIND or
             PyUnicode_4BYTE_KIND
            * compact = 0
            * ready = 1
            * data.any is not NULL
            * utf8 разделяется с data.any и utf8_length = length если ascii = 1
            * utf8_length = 0 если utf8 is NULL
            * wstr разделяется с with data.any и wstr_length = length
             если kind=PyUnicode_2BYTE_KIND and sizeof(wchar_t)=2
             or if kind=PyUnicode_4BYTE_KIND and sizeof(wchar_4)=4
            * wstr_length = 0 если wstr is NULL
        
  2. legacy string, not ready
             * structure = PyUnicodeObject
             * Проверка на тип: kind == PyUnicode_WCHAR_KIND
             * length = 0 (use wstr_length)
             * hash = -1
             * kind = PyUnicode_WCHAR_KIND
             * compact = 0
             * ascii = 0
             * ready = 0
             * interned = SSTATE_NOT_INTERNED
             * wstr is not NULL
             * data.any is NULL
             * utf8 is NULL
             * utf8_length = 0
      
  3. compact ascii
             * structure = PyASCIIObject
             * Проверка на тип: PyUnicode_IS_COMPACT_ASCII(op)
             * kind = PyUnicode_1BYTE_KIND
             * compact = 1
             * ascii = 1
             * ready = 1
             * (length — длина utf8 и wstr строк)
             * (data располагается сразу за структурой)
             * (так как ascii является подмножеством поле utf8 string и есть data)
      
  4. compact
             * structure = PyCompactUnicodeObject
             * Проверка на тип: PyUnicode_IS_COMPACT(op) && !PyUnicode_IS_ASCII(op)
             * kind = PyUnicode_1BYTE_KIND, PyUnicode_2BYTE_KIND or
               PyUnicode_4BYTE_KIND
             * compact = 1
             * ready = 1
             * ascii = 0
             * utf8 и data различны
             * utf8_length = 0 если utf8 is NULL
             * wstr разделяется с data и wstr_length=length
               если kind=PyUnicode_2BYTE_KIND and sizeof(wchar_t)=2
               or if kind=PyUnicode_4BYTE_KIND and sizeof(wchar_t)=4
             * wstr_length = 0 есди wstr is NULL
             * (data располагается сразу за структурой)
      

Следует обратить внимание, что python 3 так же поддерживает синтаксис объявление юникодных строк через префикс `u`.

>>> b = u"тут"
>>> b
'тут'

Эта возможность была добавлена для облегчения портирования кода со второй версии на третью в PEP-414 в python 3.3 только в феврале 2012 года, напомню, что python 3 вышел в декабре 2008, да никто не спешил с переходом.

Вооружившись этим знанием и стандартным модулем ctypes, мы можем получить доступ к внутренним полям строки.

import ctypes
import enum
import sys


class Interned(enum.Enum):
    #            SSTATE_NOT_INTERNED (0)
    #            SSTATE_INTERNED_MORTAL (1)
    #            SSTATE_INTERNED_IMMORTAL (2)
    #            If interned != SSTATE_NOT_INTERNED, the two references from the
    #            dictionary to this object are *not* counted in ob_refcnt.
    SSTATE_NOT_INTERNED = 0
    SSTATE_INTERNED_MORTAL = 1
    SSTATE_INTERNED_IMMORTAL = 2


class Kind(enum.Enum):
    PyUnicode_WCHAR_KIND = 0
    PyUnicode_1BYTE_KIND = 1
    PyUnicode_2BYTE_KIND = 2
    PyUnicode_4BYTE_KIND = 4


# PyUnicodeObject
class StrStruct(ctypes.Structure):
    _fields_ = [("refcnt", ctypes.c_long),
                ("type", ctypes.c_void_p),
                ("length", ctypes.c_long),
                ("hash", ctypes.c_void_p),  # ascii fields
                ("_interned", ctypes.c_uint, 2),
                ("_kind", ctypes.c_uint, 3),
                ("compact", ctypes.c_uint, 1),
                ("ascii", ctypes.c_uint, 1),
                ("ready", ctypes.c_uint, 1),
                ("_rest_state", ctypes.c_uint, 16),  # for future use
                ("wstr", ctypes.c_wchar_p),
                # PyCompactUnicodeObject
                ("utf8_length", ctypes.c_ssize_t),  # Number of bytes in utf8, excluding the terminating .
                ("utf8", ctypes.c_char_p),
                ("wstr_length", ctypes.c_ssize_t),  # Number of code points
                ("data", ctypes.c_void_p)  # canonical, smallest-form Unicode buffer
                ]
    _printable_fields = ("refcnt", "length", "hash", "interned", "kind", "compact", "ascii", "ready", "wstr",
                         "utf8_length", "utf8", "wstr_length", "data")

    @property
    def interned(self):
        return Interned(self._interned)

    @property
    def kind(self):
        return Kind(self._kind)

    def __repr__(self):
        new_line = 'n'  # f-string expression part cannot include a backslash
        return f"StrStruct({new_line.join(f'{key}={getattr(self, key)}' for key in self._printable_fields)})"


if __name__ == '__main__':
    string = sys.argv[1]
    s = StrStruct.from_address(id(string))
    print(s)

И даже «сломать» интерпретатор, как сделали это в предыдущей части.

DISCLAIMER: Следующий код поставляется as is, автор не несёт никакой ответственности и не может гарантировать состояние интерпретатора, а также душевное здоровье вас и ваших коллег, после запуска этого кода. Код протестирован на версии cpython 3.7 и, к сожалению, не работает с ascii строками.

Для этого надо изменить код, описанный вверху, на:

def make_some_magic(str1, str2):
    s1 = StrStruct.from_address(id(str1))
    s2 = StrStruct.from_address(id(str2))
    s2.data = s1.data


if __name__ == '__main__':
    string = "비빔밥"
    string2 = "háč"
    print(string == string2)          # False
    make_some_magic(string, string2)
    print(string == string2)          # True

В данных примерах используется интерполяция строк, добавленная в python 3.6. Python далеко не сразу пришёл к данному способу вывода строк: были испробованны %-синтаксис, format, что-то perl подобное (более подробное описание с примерами можно найти здесь).
Пожалуй это изменение для своего времени (до python 3.8 с его `:=` оператором) было самым противоречивым. Обсуждение (и осуждение) велось как на reddit и даже в виде PEP. Высказывались идеи улучшения/исправления в виде добавления i-строк, для которых пользователи смогли бы писать свои парсеры, для лучшего контроля и во избежание SQL-инъекций и прочих проблем. Однако данное изменение было отложено, для того, чтобы люди привыкли к f-строкам и выявили проблемы, если они есть.

У f-строк, есть одна особенность(недостаток): в них нельзя указывать спец символы со слэшами, например, 'n' 't'. Однако, это легко обойти, объявив отдельную строку, содержащую спец-символы и передав её в f-строку, что и было проделано в примере выше, зато можно использовать вложенные скобки.

>>> number = 2
>>> precision = 3
>>>  f"{number:.{precision}f}"
2.000
# впрочем, format тоже позволяет такие приёмы
>>> "{:{}f}".format(number, precision)
2.000    

Как вы могли заметить, строки хранят свой хэш, было предложение использовать это значение для сравнения строк, исходя из простого правила: если строки одинаковы — то у них одинаков и кеш, а из этого следует, что строки с разным хэшем не одинаковы. Однако оно осталось не реализованным.

При сравнении двух строк проверяется, ссылаются ли указатели на строки на один адрес, если нет — то запускается посимвольное сравнение или memcmp в случаях, когда это допустимо.

int
PyUnicode_Compare(PyObject *left, PyObject *right)
{
    if (PyUnicode_Check(left) && PyUnicode_Check(right)) {
        if (PyUnicode_READY(left) == -1 ||
            PyUnicode_READY(right) == -1)
            return -1;

        /* переданы две идентичные строки */
        if (left == right)
            return 0;

        return unicode_compare(left, right);  // посимвольное сравнение или memcmp
    }
    Генерация ошибки
}

Однако, косвенным образом значение хэша влияет на сравнение. Дело в том, что в cpython строки интернируются, то есть хранятся в одном словаре. Это верно не для всех строк, интернируются все константы, ключи словарей, поля и переменные и ascii строки длиной меньше 20.

if __name__ == '__main__':
    string = sys.argv[1]
    string2 = sys.argv[2]
    print(id(string) == id(string2))
$ python check_interned.py a a
True
$ python check_interned.py 비빔밥 비빔밥
False
$ python check_interned.py aaaaaaaaaaaaaaaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaaaaaaaaaaaaaaa
False

А пустая строка вообще синглтон

static PyUnicodeObject *
_PyUnicode_New(Py_ssize_t length)
{
    PyUnicodeObject *unicode;
    size_t new_size;

    /* Optimization for empty strings */
    if (length == 0 && unicode_empty != NULL) {
        Py_INCREF(unicode_empty);
        return (PyUnicodeObject*)unicode_empty;
    }
    ...
}

Как мы видим, cpython смог создать простую, но в то же время, эффективную реализацию строкового типа. Удалось сократить используемую память и ускорить операции в некоторых случаях, благодаря функциям memcmp, memcpy, вместо посимвольных операций. Как видим строковый тип совсем не так прост в реализации, как может показаться с первого раза. Но разработчики cpython достаточно умело подошли к своему делу и поэтому мы можем им пользоваться и даже не задумываться, что там «под капотом».

Автор: LinearLeopard

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js