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-строка лежит в отдельной структуре с более коротким заголовком. Дальше в статье «четыре» значит именно это — четыре случая раскладки компактной строки, а не четыре ширины:

2. ЧЕТЫРЕ ПРЕДСТАВЛЕНИЯ: БАЙТ НА СИМВОЛ И ЗАГОЛОВОК
---------------------------------------------------
  представление  байт/символ  заголовок   что помещается в эту ширину
  ASCII                    1         40   латиница, цифры, знаки — U+0000…U+007F
  Latin-1                  1         56   буквы с надстрочными знаками, U+0080…U+00FF
  UCS-2                    2         56   кириллица, греческий, иврит — до U+FFFF
  UCS-4                    4         56   эмодзи и всё остальное выше U+FFFF

  getsizeof('')        =   41 Б — один заголовок ASCII и ноль
  getsizeof('x' * 10)  =   51 Б — заголовок 40, десять байт, ноль
  getsizeof('é' * 10)  =   67 Б — байт на символ тот же, заголовок 56
  разница заголовков   =   16 Б — поля под кешированное
                              представление в UTF-8, которых у ASCII нет

  А вот одиночная 'é' даёт 61 Б, а не 58: это статическая
  строка, у которой представление в UTF-8 уже посчитано и лежит рядом.
  Мерить заголовок по строке в один символ поэтому нельзя.

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 совпали.

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

Миллион коротких строк — обычный масштаб для журнала, кеша или разобранного файла. Вот что там значит выбор ширины:

5. ЧТО ИЗ ЭТОГО СЛЕДУЕТ ДЛЯ МИЛЛИОНА КОРОТКИХ СТРОК
---------------------------------------------------
  1000000 строк по 16 символов, только сами строки, без контейнера:
  ASCII            57 Б на строку →     54.4 МиБ
  Latin-1          73 Б на строку →     69.6 МиБ
  UCS-2            90 Б на строку →     85.8 МиБ
  UCS-4           124 Б на строку →    118.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 три из этих строк дают другое состояние — почему, разобрано ниже:

2. ЧТО ИНТЕРНИРУЕТСЯ САМО
-------------------------
  строка                          состояние поля   sys._is_interned
  литерал, похожий на имя        1 интернирована        да
  литерал с пробелами и знаком   0 не интернирована    нет
  литерал из цифр                1 интернирована        да
  пустая строка                  3 статическая          да
  один символ из Latin-1         3 статическая          да
  литерал в 4096 символов        1 интернирована        да
  собрана join во время работы   0 не интернирована    нет

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

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

6. «ОДИН СИМВОЛ» — ЭТО НЕ ЛЮБОЙ СИМВОЛ
--------------------------------------
  Статические одиночные строки заведены не на все символы, а только
  на однобайтовые: U+0000…U+00FF. Проверяется это построением во время
  работы — chr(n) и join, — чтобы ни литерал, ни свёртка константы не
  подменили ответ.
  символ          chr is chr  join is chr  состояние  _is_interned  _is_immortal
  U+0061     'a'        True         True          3            да             —
  U+00E9     'é'        True         True          3            да             —
  U+00FF     'ÿ'        True         True          3            да             —
  U+0100     'Ā'       False        False          0           нет             —
  U+03B1     'α'       False        False          0           нет             —
  U+0416     'Ж'       False        False          0           нет             —
  U+1F600     '😀'       False        False          0           нет             —

Последняя колонка пуста не случайно: 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. ГДЕ ЭТО ЛОМАЕТСЯ НЕЗАМЕТНО
-----------------------------
  Одна и та же по смыслу строка, полученная двумя путями:
    из исходника:      состояние 1
    из декодирования:  состояние 0
    равны:       True
    тождественны: False

Код, который сравнивает строки через 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, которая ничего не вычисляет и никуда не передаётся. Прогон печатает опкоды всех четырёх функций после прогрева, и у этих двух он ОДИН И ТОТ ЖЕ:

3. ПОЧЕМУ ТАК: ДВА УСЛОВИЯ, А НЕ ОДНО
-------------------------------------
  Первое условие — форма кода. Специализация выбирается по тому, что
  стоит СЛЕДУЮЩЕЙ инструкцией, и видна в разобранном байт-коде:
  функция                        опкод сложения после прогрева
  local_var                      BINARY_OP_INPLACE_ADD_UNICODE
  extra_reference                BINARY_OP_INPLACE_ADD_UNICODE
  list_slot                      BINARY_OP_ADD_UNICODE
  attribute                      BINARY_OP_ADD_UNICODE

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

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

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

2. ЦЕНА ОДНОГО ШАГА: ЗАВИСИТ ЛИ ОНА ОТ УЖЕ НАКОПЛЕННОГО
-------------------------------------------------------
  Вот в чём разница на самом деле. У линейного накопления цена одного
  шага постоянна; у квадратичного каждый шаг копирует всё накопленное,
  и потому дорожает вместе с длиной.
  N        локальная: мс   нс на шаг    элемент списка: мс   нс на шаг
  2500              0.11          43                  0.50         201
  5000              0.21          42                  2.21         442
  10000             0.40          40                  9.62         962
  20000             0.79          39                 92.88        4644

Левая колонка «нс на шаг» не меняется: длина накопителя на цену шага не влияет вовсе. Правая растёт вместе с 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 байт; те же двадцать с одной é — 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