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

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

Начнём с небольшого экскурса в историю. 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 буфер (без учёта
                                            \0 терминатора)*/
    char *utf8;                 /* UTF-8 представление (с \0 терминатором)*/
    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 представление ( с \0 терминатором) */
} 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 \0.
                ("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 достаточно умело подошли к своему делу и поэтому мы можем им пользоваться и даже не задумываться, что там «под капотом».

© Habrahabr.ru