Deep Engineering
Продвинутый·Опубликовано·3.12 · 3.13 · 3.14·35 МИН

Строки изнутри: три ширины, четыре случая раскладки и цена склейки

У строки размер объекта при одинаковой длине определяется самым старшим кодом символа в ней: одна кириллическая буква среди латиницы удваивает место под каждый символ, один эмодзи — учетверяет. Отсюда же и два других вопроса: почему `is` для строк иногда срабатывает и почему `s += x` в цикле то линеен, то квадратичен — условий у быстрого пути два, и второго в коде не видно.

Полное техническое изложение

У строки размер объекта при одинаковой длине определяется тем, что в ней лежит, — и определяется систематически: самым старшим кодом символа, а не историей объекта.

Это расходится с тем, к чему приучают другие контейнеры, и потому легко ускользает от внимания. Строка из двадцати символов: двадцать латинских букв — это 61 байт, те же двадцать с одной кириллической — 98, с одним эмодзи — 140. Ни одна буква, кроме заменённой, не изменилась.

Строка при этом не единственный такой тип, и сказать это стоит сразу, чтобы дальше не путать разные причины. У целого размер зависит от величины числа: 0 занимает 28 байт, 2**1000 — 160. У списка при одинаковой длине он зависит от того, как список построили: list(range(100)) — 856 байт, а тот же по длине, собранный через append, — 920. Разница в том, что у строки это не история и не запас, а прямое следствие содержимого: те же двадцать символов при том же алфавите всегда дают то же число.

У статьи три части про устройство и четвёртая — задачи. Каждая из трёх отвечает на свой вопрос: почему одинаковые по длине строки занимают разное место; почему is для строк иногда срабатывает — и почему на это нельзя опираться; почему s += x в цикле то линеен, то квадратичен (условий у быстрого пути два, и второе в коде не видно вовсе).

Часть I. Три ширины и четыре случая раскладки

Ширина выбирается по самому старшему символу

PEP 393 назвал и задачу, и решение одной фразой:

The Unicode string type is changed to support multiple internal representations, depending on the character with the largest Unicode ordinal (1, 2, or 4 bytes)

перевод

Тип строки изменён так, чтобы поддерживать несколько внутренних представлений в зависимости от символа с наибольшим кодом (1, 2 или 4 байта)

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

«Учетверяет» при этом относится к буферу символов, а не к размеру объекта целиком: в getsizeof входит ещё заголовок и завершающий ноль, поэтому у короткой строки итог вырастает не ровно вчетверо — 61 байт против 140 на двадцати символах.

Длина во всех четырёх положениях переключателя одна и та же — двадцать. Меняется один символ из двадцати, и вместе с ним меняется ширина всех.

Четвёртый случай — и он не ширина

Ширин в PEP 393 три: один, два и четыре байта на символ. А случаев, которые видно по размеру объекта, четыре, и четвёртый — не ширина: ASCII-строка лежит в отдельной структуре с более коротким заголовком. Дальше в статье «четыре» значит именно это — четыре случая раскладки компактной строки, а не четыре ширины. Замер bench/strings/layout.py; кроме таблицы, он печатает и абсолютные размеры: пустая строка — 41 байт, 'x' * 10 — 51, 'é' * 10 — 67.

представлениебайт на символзаголовокчто помещается в эту ширину
ASCII140латиница, цифры, знаки — U+0000…U+007F
Latin-1156буквы с надстрочными знаками, U+0080…U+00FF
UCS-2256кириллица, греческий, иврит — до U+FFFF
UCS-4456эмодзи и всё остальное выше U+FFFF

ASCII и Latin-1 отличаются не шириной символа — она у обеих один байт, — а заголовком: 40 против 56. Шестнадцать байт разницы — это два поля под кешированное представление в UTF-8, которых у ASCII-строки нет, потому что её UTF-8 совпадает с ней самой байт в байт.

Здесь стоит развести две разные вещи, которые легко спутать. Каноническое представление — то, в котором строка хранится и по которому считается len, — это один, два или четыре байта на символ. Кеш UTF-8 — отдельное представление той же строки, которое может лежать рядом, если его кто-то запрашивал. Один байт на символ в первом смысле не означает «строка хранится в UTF-8»: у Latin-1 канонический байт и байт UTF-8 — разные вещи, и именно поэтому у неё есть поля под кеш.

Отсюда и то, что удивляет в первую очередь: одна é в строке из двадцати латинских букв увеличивает её с 61 до 77 байт, не меняя байтов на символ. Выросло не содержимое, а заголовок.

Заодно прогон предупреждает о ловушке, в которую легко попасть при самостоятельной проверке: мерить заголовок по строке в один символ нельзя. Одиночная é даёт не 58 байт, а 61, потому что это статическая строка, у которой представление в UTF-8 уже посчитано и лежит рядом.

контракт языка

Три ширины и правило «по самому старшему символу» — часть PEP 393, принятого и реализованного с 3.3. Это можно считать свойством языка в той мере, в какой PEP служит нормативным документом.

Чего PEP не задаёт — конкретных 40 и 56 байт. Это размеры структур в измеренной сборке: CPython, 64 бита — прогон печатает разрядность первой строкой. На другой разрядности или в другой сборке числа будут другими, а вот то, что у ASCII заголовок короче на два поля, останется.

контракт языка

И ещё одна граница, о которой стоит знать заранее: всё, что здесь меряется, живёт в кодовых позициях Unicode, а не в том, что читатель видит глазами. len("é") равен единице, если это одна кодовая позиция, и двум, если то же самое записано буквой и комбинирующим знаком; видимый символ — это графемный кластер, и ни len, ни PEP 393 с ним не работают.

Как ширина определена в замере

Скрипт не полагается на одно лишь чтение поля структуры: смещение поля — предположение, а неверное число выглядит точно так же, как верное. Поэтому ширина считается дважды и оба ответа печатаются рядом.

Первый способ — измерение, никакого знания о внутренностях не требует:

PYTHON
per_char = sys.getsizeof(ch * 11) - sys.getsizeof(ch * 10)

Второй — чтение поля kind в заголовке объекта через ctypes. Прогон печатает оба и отдельной строкой говорит, совпали они или нет. На 3.12.3, 3.13.7 и 3.14.7 совпали.

Во что это обходится

Миллион коротких строк — обычный масштаб для журнала, кеша или разобранного файла. Вот что там значит выбор ширины — миллион строк по 16 символов, только сами строки, без контейнера; тот же прогон bench/strings/layout.py:

представлениебайт на строкувсего
ASCII5754,4 МиБ
Latin-17369,6 МиБ
UCS-29085,8 МиБ
UCS-4124118,3 МиБ

Первая и последняя строки — это одна и та же информация, записанная четырьмя разными ширинами, и между ними больше чем вдвое. И уменьшить эту разницу оптимизацией кода нельзя — её задают сами данные: строка с единственным эмодзи в конце стоит как строка из одних эмодзи.

Часть II. Интернирование

Четыре состояния, а не два

«Интернирована или нет» — упрощение, из-за которого наблюдения не складываются в одну картину. Состояний четыре, и они перечислены в InternalDocs/string_interning.md — единственном документе InternalDocs, который есть и в 3.13, и в 3.14:

значение полячто означает
0не интернирована
1интернирована, смертна
2интернирована и бессмертна
3статическая: заведена при старте интерпретатора

Проверить состояние можно двумя способами: вызовом приватной sys._is_interned (появилась в 3.13) и чтением двух младших битов поля state в заголовке объекта. Прогон печатает оба.

Три состояния из четырёх называют одним словом «интернирована», и от этого теряется главное: интернирование и бессмертие — разные вещи. Документация sys.intern предупреждает об этом прямо:

Interned strings are not immortal; you must keep a reference to the return value of intern() around to benefit from it

перевод

Интернированные строки не бессмертны; чтобы получить от intern() пользу, надо держать ссылку на возвращённое значение

То есть состояние 1 — «интернирована и смертна»: строка лежит в таблице, но исчезнет вместе с последней ссылкой. Состояние 2 — «интернирована и бессмертна». Состояние 3 — «статическая», заведена при старте интерпретатора и не принадлежит никакому коду. Начиная с 3.14 первые два различает и отдельная функция sys._is_immortal; до неё различить их можно было только по полю.

Что интернируется само

Прогон bench/strings/interning.py на 3.13.7; на 3.12.3 три из этих строк дают другое состояние — почему, разобрано ниже:

строкасостояние поляsys._is_interned
литерал, похожий на имя1да
литерал с пробелами и знаком0нет
литерал из цифр1да
пустая строка3да
один символ из Latin-13да
литерал в 4096 символов1да
собрана join во время работы0нет

Что отсюда видно — не правило языка, а поведение этой сборки: интернируется литерал, похожий на имя. Пробел или восклицательный знак — и строка остаётся обычной. Пустая строка в одном экземпляре заведена при старте интерпретатора.

А вот «любой одиночный символ существует в одном экземпляре» — неверно, и граница лежит не там, где её ожидают. Статические одиночные строки заведены только на однобайтовые символы. Прогон строит символы во время работы, чтобы литерал и свёртка константы не подменили ответ. Таблица снята на 3.13.7; на 3.12.3 обе колонки с is те же, а поле state читается нулём у всех семи символов — состояние «статическая» у одиночных строк появилось в 3.13. Блок 6 bench/strings/interning.py:

символchr(n) is chr(n)join is chrсостояние поляsys._is_interned
U+0061 'a'TrueTrue3да
U+00E9 'é'TrueTrue3да
U+00FF 'ÿ'TrueTrue3да
U+0100 'Ā'FalseFalse0нет
U+03B1 'α'FalseFalse0нет
U+0416 'Ж'FalseFalse0нет
U+1F600 '😀'FalseFalse0нет

Колонки sys._is_immortal, которую прогон тоже печатает, в таблице нет: она пуста, потому что эта функция появилась только в 3.14, а прогон снят на 3.13.7.

Граница ровно между U+00FF и U+0100. Кириллическая Ж, построенная дважды, — два разных объекта, и is на ней даёт False. То есть «одиночный символ» — это про Latin-1, а не про Unicode.

И главное: строка, собранная во время работы, не интернировалась ни в одном из проверенных случаев. Отсюда разница, на которой ломается код:

PYTHON
literal = "some_attribute_name"
built = "".join(["some_attribute", "_name"])
 
literal == built      # True
literal is built      # False

Где is ломается незаметно

Опасность не в том, что is даёт неверный ответ. Опасность в том, что он даёт верный ответ всё время, пока строки берутся из исходника, и перестаёт — как только они приходят откуда-то ещё. Блок 4 того же прогона bench/strings/interning.py — одна и та же по смыслу строка, полученная двумя путями; строки равны, но не тождественны:

откуда строкасостояние поля
литерал в исходнике1
"user_id".encode().decode()0

Код, который сравнивает строки через is, пройдёт все тесты, написанные на литералах, и сломается на первой строке, пришедшей из файла, из сети или из базы. Ошибка при этом не выглядит ошибкой: сравнение просто отвечает «не равны».

Язык на эту тему не обещает ничего. Он говорит только, что is сравнивает тождественность:

The operators is and is not test for an object's identity: x is y is true if and only if x and y are the same object

перевод

Операторы is и is not проверяют тождественность объекта: x is y истинно тогда и только тогда, когда x и y — один и тот же объект

Ни слова о том, что равные литералы дают один объект. Всё, что об этом наблюдается, — поведение реализации, и оно менялось.

Одно и то же, а состояния разные

Вот прямое доказательство того, что границы «интернировано» двигают между версиями. Литерал "some_attribute_name", тот же самый:

версиясостояние поля interned
3.12.32 — интернирована и бессмертна
3.13.71 — интернирована, смертна
3.14.71 — интернирована, смертна

Для кода из этого не следует ничего: строка интернирована в обоих случаях, и результат is для двух таких литералов от версии не зависит. Следует другое: «интернировано» — не одно состояние, а семейство, и его устройство — не то, на чём стоит строить сравнение.

деталь реализации · 3.13

Ни одна версия не обещает, какие строки интернируются сами. Обещано только то, что делает sys.intern, и обещано там скромно: Interning strings is useful to gain a little performance on dictionary lookup (Интернирование строк полезно, чтобы немного выиграть на поиске в словаре). Всё остальное в этой части — наблюдение на трёх сборках, и формулировать его надо так: «на 3.13.7 эта строка не интернировалась», а не «такие строки не интернируются».

Часть III. Склейка

Два условия, а не одно

«Строки неизменяемы, поэтому склейка в цикле квадратична» — верное рассуждение с неверным следствием: обычный цикл со s += x работает линейно. Причина — специализация BINARY_OP_INPLACE_ADD_UNICODE, и условий у неё два.

Сразу о границах этого раздела. Всё, что здесь описано, — оптимизация CPython, а не свойство строк: неизменяемость никуда не делась, менять строку на месте позволено ровно потому, что на неё больше никто не смотрит. Ни одна версия такого поведения не обещает, а условия у него узкие: конкретная форма кода, конкретное число ссылок и конкретное имя специализации, которое между выпусками меняется. Числа ниже сняты на 3.13.7, опкоды прочитаны на ней же.

Первое условие — форма кода. Специализация выбирается по тому, что стоит следующей инструкцией, и записано это в Python/bytecodes.c прямо:

C
tier1 op(_BINARY_OP_INPLACE_ADD_UNICODE, (left, right --)) {
    assert(next_instr->op.code == STORE_FAST);
    PyObject **target_local = &GETLOCAL(next_instr->op.arg);
    DEOPT_IF(*target_local != left);

То есть накопитель обязан быть локальной переменной функции, и результат обязан присваиваться обратно в неё же. Накопитель в элементе списка или в атрибуте объекта этому условию не удовлетворяет, и там выбирается обычное сложение.

Второе условие проверяется уже во время работы, и цель его названа в том же файле:

If left has only two references remaining (one from the stack, one in the locals), DECREFing left leaves only the locals reference, so PyUnicode_Append knows that the string is safe to mutate.

перевод

Если у left осталось ровно две ссылки (одна со стека, одна в локальных переменных), то уменьшение счётчика оставляет только локальную ссылку — и PyUnicode_Append знает, что строку можно менять на месте

Одна строка кода, которая ничего не делает

Второе условие — то, что показывает второе положение переключателя на схеме выше. Две функции:

PYTHON
def local_var():
    s = ""
    for _ in range(N):
        s += X
    return s
 
 
def extra_reference():
    s = ""
    for _ in range(N):
        keep = s
        s += X
    return s

Разница — строка keep = s, которая ничего не вычисляет и никуда не передаётся. Прогон bench/strings/concat.py печатает опкоды всех четырёх функций после прогрева, и у этих двух он один и тот же:

функцияопкод сложения после прогрева
накопитель в локальной переменнойBINARY_OP_INPLACE_ADD_UNICODE
то же, плюс keep = sBINARY_OP_INPLACE_ADD_UNICODE
накопитель в элементе спискаBINARY_OP_ADD_UNICODE
накопитель в атрибутеBINARY_OP_ADD_UNICODE

А время — нет. В том же прогоне первая функция проходит двадцать тысяч шагов за 0,8 миллисекунды, вторая — за 140,7.

Зависит ли цена одного шага от уже накопленного

Проверять квадратичность отношением времени на этой машине нельзя: отношение между соседними размерами гуляет от 4,4 до 9,7, и на длинах в сотни килобайт в него, скорее всего, входит ещё и смена стратегии выделения памяти — замер её не отделяет. Зато прекрасно проверяется другое — цена одного шага, тот же прогон bench/strings/concat.py:

Nлокальная, мснс на шагэлемент списка, мснс на шаг
25000,11430,50201
50000,21422,21442
100000,40409,62962
200000,793992,884644

Левая колонка «нс на шаг» не меняется: длина накопителя на цену шага не влияет вовсе. Правая растёт вместе с N — каждый шаг копирует всё, что уже накоплено. Так разница между «строка выросла на месте» и «строка скопирована целиком» не просто названа, а показана на числах.

наблюдение замера

Числа этого раздела сняты одним запуском 3.13.7 и сравниваются только между собой. Насколько именно быстро растёт правая колонка, замер не утверждает: на больших длинах в неё входит ещё и смена стратегии выделения памяти, а она здесь не отделена.

Что из этого делать

Комментарий в исходнике сам называет, ради чего оптимизация сделана: This attempts to avoid quadratic behavior when one neglects to use str.join() (Это попытка избежать квадратичного поведения, когда str.join() забыли применить). То есть быстрый путь — подстраховка на случай, когда код написан не лучшим способом, а не разрешение писать так всегда.

Числа из того же прогона:

способдвадцать тысяч кусков
s += X, s — локальная0,8 мс
"".join(список)0,6 мс
StringIO.write0,7 мс

В этом замере join выигрывает мало, и дело не в выигрыше: 0,6 мс против 0,8 — один порядок. На других данных отношение получится другим: куски другой длины, другое их число, другой алфавит — и разрыв может уйти в любую сторону, поэтому отсюда не следует ни «join быстрее», ни «join медленнее».

Следует другое: join надёжнее. Его время не зависит ни от того, где лежит накопитель, ни от того, сохранил ли кто-то ссылку на строку, ни от того, вынесут ли завтра этот код в метод класса. Склейка по месту зависит от всего перечисленного и ломается молча.

Часть IV. Практика

Практика · что напечатает

Три равные строки, полученные тремя способами, и два размера. Что напечатает этот код на 3.13?
import sys

left = "ab"
right = "cd"

one = "abcd"
two = left + right
three = "ab" + "cd"

print(one == two, one is two)
print(one == three, one is three)
print(sys.getsizeof("ab"), sys.getsizeof("αβ"))

Практика · оцените

Строка из двадцати латинских букв — и она же, в которой одна буква заменена на эмодзи. Во сколько раз вторая больше первой?
раза

История версий

ВерсияИзменениеЧто это значит для кода
3.12Литерал, похожий на имя, интернируется в состояние 2 — «интернирована и бессмертна». Узнать состояние из Python нечем: sys._is_interned ещё нет, остаётся только читать поле в заголовке объекта.
3.13Появляется sys._is_interned — приватная, но официальный ответ интерпретатора. Тот же литерал интернируется теперь в состояние 1, «интернирована и смертна»: у интернированных строк снова есть счётчик ссылок. Для кода не меняется ничего — меняется то, что «интернировано» перестаёт быть одним состоянием.
3.14Состояния те же, что в 3.13. Раскладка во всех четырёх случаях на трёх проверенных версиях одна и та же: вывод bench/strings/layout.py на 3.12.3, 3.13.7 и 3.14.7 совпадает побайтово, кроме первых трёх строк, где стоят версия, компилятор и разрядность.

Чем измерено

Числа этой статьи получены этими скриптами. Каждый открывается прямо отсюда — вместе с записью прогона.

Байты и состояния — сравнимы между версиями, записи на всех трёх:

Время — одна запись, одна сборка:

Опора под задачи раздела «Практика»:

Python 3.12.3 (GCC 13.3.0), 3.13.7 (Clang 20.1.4), 3.14.7 (Clang 22.1.3); Intel Xeon 2,80 ГГц, 2 vCPU. Байты между версиями сравнимы — это раскладка объекта. Время нет: у сборок разные компиляторы и разные флаги.

Фрагменты Python/bytecodes.c приводятся по тегу v3.13.7 дословно.

Унести в работу

  • Не оценивать память строки по её длине: ширина выбирается по самому старшему символу и применяется ко всем сразу — двадцать латинских букв это 61 байт, те же двадцать с одной кириллической — 98.
  • Не сравнивать строки через is: он работает на литералах и ломается на первой же строке, прочитанной из файла.
  • Не рассчитывать на быструю склейку s += x: второго её условия — что на строку нет лишних ссылок — в коде не видно, и строка keep = s рядом даёт тот же опкод и в сто семьдесят семь раз большее время.
  • Собирать строку через "".join(...): в этом замере он не быстрее, но его время не зависит ни от одного из невидимых условий.

Частые заблуждения

Утверждение

Размер строки пропорционален её длине

На самом деле

Он пропорционален длине, умноженной на ширину, а ширину задаёт самый старший символ строки. Двадцать латинских букв — 61 байт; те же двадцать с одной é — 77; с одной кириллической — 98; с одним эмодзи — 140. Длина во всех четырёх случаях равна двадцати. Практическое следствие: строка с единственным эмодзи в конце занимает столько же, сколько строка из одних эмодзи той же длины.

Утверждение

Случаев раскладки столько же, сколько ширин, — три

На самом деле

Ширин действительно три: один, два и четыре байта на символ. А случаев, которые видно по размеру объекта, четыре: ASCII вынесено в отдельную структуру с укороченным заголовком — 40 байт против 56 в этой 64-битной сборке, — потому что у ASCII-строки её представление в UTF-8 совпадает с ней самой и кешировать нечего. Отсюда то, что удивляет первым: одна é увеличивает строку из двадцати латинских букв с 61 до 77 байт, не меняя числа байтов на символ. Выросло не содержимое, а заголовок.

Утверждение

Одинаковые строковые литералы всегда дают один объект, поэтому is для них работает

На самом деле

Для литералов, похожих на имена, на трёх проверенных сборках — да, и именно поэтому такой код проходит все тесты. Стоит строке прийти не из исходника, и всё меняется: "user_id" из файла или из сети имеет состояние 0, не интернирована, и is с литералом даёт False при равенстве True. Язык на эту тему не обещает ничего: The operators is and is not test for an object's identity: x is y is true if and only if x and y are the same object (Операторы is и is not проверяют тождественность объекта: x is y истинно тогда и только тогда, когда x и y — один и тот же объект) — и ни слова о литералах.

Утверждение

"ab" + "cd" is "abcd" даёт True, потому что результат интернируется

На самом деле

Даёт True по другой причине: сложение двух литералов выполняет компилятор, а не интерпретатор, и в байт-коде остаётся одна константа "abcd". Это свёртка констант; интернирование только доводит дело до конца. Проверяется заменой литералов на переменные: с left + right при тех же значениях is даёт False.

Утверждение

Строка либо интернирована, либо нет

На самом деле

Состояний четыре, и они перечислены в InternalDocs/string_interning.md: 0 — не интернирована, 1 — интернирована и смертна, 2 — интернирована и бессмертна, 3 — статическая. Границы между ними двигают: литерал "some_attribute_name" на 3.12.3 имеет состояние 2, а на 3.13.7 и 3.14.7 — состояние 1. Для кода из этого не следует ничего, и в этом всё дело: устройство интернирования — не то, на чём стоит строить сравнение.

Утверждение

Склейка через s += x в цикле всегда квадратична

На самом деле

В обычном цикле — нет: цена одного шага не меняется вовсе (43, 42, 40, 39 нс при росте N от 2500 до 20 000), потому что строка растёт на месте. Оптимизация названа в Python/bytecodes.c прямо: This attempts to avoid quadratic behavior when one neglects to use str.join() (Это попытка избежать квадратичного поведения, когда str.join() забыли применить). Там, где быстрый путь не сработал, цена шага растёт вместе с длиной: 201, 442, 962, 4644 нс — каждый шаг копирует всё накопленное. Насколько быстро она растёт, замер не утверждает.

Утверждение

Быстрый путь склейки включается, когда накопитель — локальная переменная

На самом деле

Это только первое из двух условий. Второе проверяется во время работы — сколько ссылок на строку, — и ломается независимо от первого. Функция, в которой в цикле добавлена одна ничего не делающая строка keep = s, получает тот же опкод BINARY_OP_INPLACE_ADD_UNICODE, а работает 140,7 мс против 0,8 мс. Ни по коду, ни по разобранному байт-коду разницы не видно.

Утверждение

"".join() нужен потому, что он быстрее склейки

На самом деле

Он не быстрее: в том же прогоне 0,6 мс против 0,8 мс — то есть примерно столько же. Зато он надёжнее. Время join не зависит ни от того, где лежит накопитель, ни от того, сохранил ли кто-то ссылку на строку, ни от того, вынесут ли завтра этот код в метод класса. Склейка по месту зависит от всего перечисленного и ломается молча — без ошибки, без предупреждения, с тем же самым байт-кодом.

Проверьте себя

Вопрос 1 из 6

Строка из ста символов, все латинские, кроме последнего — эмодзи. Сколько байт на символ она тратит?

Источники и что читать дальше

5 ИСТОЧНИКОВ

  1. PEP 393 — Flexible String RepresentationPEP. Документ, которым введены три ширины. Формулирует и задачу, и решение: «The Unicode string type is changed to support multiple internal representations, depending on the character with the largest Unicode ordinal (1, 2, or 4 bytes)» (Тип строки изменён так, чтобы поддерживать несколько внутренних представлений в зависимости от символа с наибольшим кодом — 1, 2 или 4 байта). Там же перечислены четыре структуры — PyASCIIObject, PyCompactUnicodeObject и две некомпактные формы — и сказано, чем ASCII отличается от прочих однобайтовых строк. Статус Final с 3.3.https://peps.python.org/pep-0393/
  2. InternalDocs/string_interning.mdИсходный код CPython. Единственный документ InternalDocs, который есть и в 3.13, и в 3.14. Перечисляет четыре значения поля interned, которые читаются в прогоне: 0 — не интернирована, 1 — интернирована и смертна, 2 — интернирована и бессмертна, 3 — статическая. Без этого списка два бита в заголовке объекта читались бы как «да/нет», и расхождение между 3.12 и 3.13 выглядело бы шумом.https://github.com/python/cpython/blob/v3.13.7/InternalDocs/string_interning.md
  3. Python/bytecodes.c — специализация BINARY_OP_INPLACE_ADD_UNICODEИсходный код CPython. Оба условия быстрой склейки, дословно. Первое — форма кода: assert(next_instr->op.code == STORE_FAST); и DEOPT_IF(*target_local != left);. Второе — комментарий про число ссылок: «If left has only two references remaining (one from the stack, one in the locals), DECREFing left leaves only the locals reference, so PyUnicode_Append knows that the string is safe to mutate» (Если у left осталось ровно две ссылки — одна со стека, одна в локальных переменных, — то уменьшение счётчика оставляет только локальную ссылку, и PyUnicode_Append знает, что строку можно менять на месте). Там же названа и цель оптимизации: «This attempts to avoid quadratic behavior when one neglects to use str.join()» (Это попытка избежать квадратичного поведения, когда str.join() забыли применить). Читалось по тегу v3.13.7.https://github.com/python/cpython/blob/v3.13.7/Python/bytecodes.c
  4. sys.intern — что обещано и что нетОфициальная документация. Обещано ускорение поиска: «Interning strings is useful to gain a little performance on dictionary lookup» (Интернирование строк полезно, чтобы немного выиграть на поиске в словаре). Не обещано ничего про то, какие строки интернируются сами, — и это ровно тот вопрос, ответ на который меняли между версиями. Отсюда правило статьи: наблюдаемое поведение описывается с указанием версий, а не как правило языка.https://docs.python.org/3/library/sys.html#sys.intern
  5. Модель данных: оператор isОфициальная документация. Приводится как источник того, чего в языке НЕТ: гарантии, что равные строки тождественны. Язык говорит только, что is сравнивает тождественность объектов — «The operators is and is not test for an object's identity: x is y is true if and only if x and y are the same object» (Операторы is и is not проверяют тождественность объекта: x is y истинно тогда и только тогда, когда x и y — один и тот же объект). Ни слова о том, что литералы с одинаковым содержимым дают один объект; всё, что об этом наблюдается, — поведение реализации.https://docs.python.org/3/reference/expressions.html#is