Deep Engineering

ЗАМЕР

bench/strfmt/basics.py

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

Цитируется в статье
/ru/interview/python/strings
Как запустить
python3.11 bench/strfmt/basics.py
python3.12 bench/strfmt/basics.py
python3.13 bench/strfmt/basics.py
python3.14 bench/strfmt/basics.py

python3.13 bench/strfmt/cost.py
python3.14 bench/strfmt/cost.py

python3.11 bench/strfmt/practice.py
python3.12 bench/strfmt/practice.py
python3.13 bench/strfmt/practice.py
python3.14 bench/strfmt/practice.py

python3.11 bench/strfmt/example.py      # и 3.12, 3.13, 3.14
python3.11 bench/strfmt/fastpath.py     # и 3.12, 3.13, 3.14

Запись прогона

Замеры для урока «Строки и форматирование»

Скрипт Что делает
basics.py наблюдения без времени: неизменяемость и +=, срезы, методы split/join/find/index/isdigit, спецификации формата и округление к чётному, str и bytes, что компилируется на разных версиях (кавычки внутри f-строки, t-строки)
cost.py цена склейки n кусков: join, s += x, s += x при второй ссылке, io.StringIO; цена одного форматирования: f-строка, str.format, %, сложение
practice.py источник ответов двух практических задач урока
example.py полный короткий пример из «Базы»: копируется в пустой каталог и запускается как есть (python3 example.py)
fastpath.py когда быстрый путь s += x срабатывает: три формы цикла (в том числе s += x; k += 1 в одну строку), один шаг на строке в миллион символов при второй ссылке, посчитанном хеше, sys.intern, подклассе str, символе вне ASCII; опкод += после прогрева
python3.11 bench/strfmt/basics.py
python3.12 bench/strfmt/basics.py
python3.13 bench/strfmt/basics.py
python3.14 bench/strfmt/basics.py

python3.13 bench/strfmt/cost.py
python3.14 bench/strfmt/cost.py

python3.11 bench/strfmt/practice.py
python3.12 bench/strfmt/practice.py
python3.13 bench/strfmt/practice.py
python3.14 bench/strfmt/practice.py

python3.11 bench/strfmt/example.py      # и 3.12, 3.13, 3.14
python3.11 bench/strfmt/fastpath.py     # и 3.12, 3.13, 3.14

Записи в runs/: без суффикса — CPython 3.13.7, остальные — с суффиксом версии (3.11.15, 3.12.3, 3.14.7). Все прогоны — Linux x86_64, одна машина (2 ядра); сверено 14.10.2026. Числа cost.py и fastpath.py — наблюдения этой конфигурации: на другой машине и версии меняются и числа, и отношения, и, как показал fastpath.py, даже набор условий быстрого пути, — мерить у себя. Раздел 7 basics.py и скрипты example.py, fastpath.py добавлены по внешнему аудиту урока 14.10.2026; разделы 1–6 basics.py при перезапуске не изменились.

Что получилось

Наблюдения (разделы 1–5 одинаковы на 3.11–3.14): s[0] = 'П' — TypeError; после t = s; s += '!' имя t видит старую строку; p[10:20] — '', p[10] — IndexError; '-'.join([1, 2]) — TypeError; find — −1, index — ValueError; '-42'.isdigit() — False; f'{2.5:.0f}' — '2', round(2.5) — 2, а Decimal с ROUND_HALF_UP — 3; f'{2.675:.2f}' и round(2.675, 2) — 2.67, потому что за литералом хранится 2.67499…; 'привет' — 6 символов и 12 байт в UTF-8, '👍' — 1 и 4.

Раздел 7 (одинаков на 3.11–3.14): p[:100] — 'Python', p[4:100] — 'on': границы среза урезаются, результат не обязательно пуст. len считает кодовые точки: é одной точкой — 1, буквой с комбинирующим знаком U+0301 — 2, после NFC — снова 1; палец с оттенком кожи — 2, эмодзи «семья» — 7; развёрнутая 'cafe\u0301' начинается с отрывшегося знака. p.strip() is p, p.replace('z', 'y') is p, p[:100] is p — True, p.upper() is p.upper() — False: совпавший результат CPython вернул тем же объектом (наблюдение реализации, не контракт).

Быстрый путь (fastpath.py, 3.11–3.14): шаг s += x на строке в миллион символов — 0,1 мкс без помех и 240–690 мкс со второй ссылкой, посчитанным hash(s), после sys.intern, у подкласса str; ASCII-строка плюс кусок с 'é' — 101–116 мкс, строка из 'é' плюс ASCII — 0,1–0,2. Цикл s += x; k += 1 в одну строку: ×1,0 и ×1,1 на 3.11/3.12, ×32,3 на 3.13.7 и ×35,5 на 3.14.7 при ×10 кусков — компилятор 3.13 сливает запись и чтение в STORE_FAST_LOAD_FAST, и += специализируется в BINARY_OP_ADD_UNICODE вместо BINARY_OP_INPLACE_ADD_UNICODE.

Версии (раздел 6): f"{d["k"]}" на 3.11 — SyntaxError: f-string: unmatched '[', с 3.12 — '1'; t"x={1 + 1}" до 3.13 включительно — SyntaxError, на 3.14 — объект Template, выражение в котором уже вычислено (2).

Склейка (медианы, 3.13.7, нс на кусок при 2 000 / 20 000 кусков по десять символов): join 49,8 / 35,5; s += x 82,9 / 52,5; s += x со второй ссылкой 215,7 / 2 942,0 — рост в 13,6 раза при ×10 кусков; io.StringIO 42,0 / 49,4. На 3.14.7 — та же картина, рост 9,9.

Форматирование (медианы, 3.13.7, нс): f-строка 167,1, str.format 355,9, % 271,5, сложение через str() 190,5. На 3.14.7 сложение (131,6) быстрее f-строки (152,2); f-строка быстрее format и % на обеих версиях.

Скрипт

180 строк
"""Строки: неизменяемость, срезы, методы, форматирование, str и bytes.

Времени здесь нет — только наблюдения. Цена склейки и форматирования — в
cost.py.

ШЕСТЬ СЮЖЕТОВ:

1. НЕИЗМЕНЯЕМОСТЬ. Символ строки не заменить; методы возвращают новую
   строку; `s += x` связывает s с новой строкой, а старая, на которую
   ссылается другое имя, не меняется.

2. СРЕЗЫ. Отрицательные индексы, шаг, разворот. Срез за пределами строки
   даёт пустую строку, а индекс за пределами — IndexError.

3. МЕТОДЫ, О КОТОРЫХ СПРАШИВАЮТ. split с разделителем и без, join, strip,
   find против index, replace со счётчиком.

4. ФОРМАТИРОВАНИЕ. Спецификации формата в f-строке, отладочное `=`,
   `!r`, str.format и %. И округление при `.0f` — к чётному.

5. STR И BYTES. Длина строки — в символах, длина байтов — в байтах.

6. ВЕРСИИ. Кавычки того же вида внутри f-строки (можно с 3.12) и t-строки
   (появились в 3.14) — проверяются компиляцией, чтобы скрипт работал на
   всех версиях.

Запускать: python3.11 / 3.12 / 3.13 / 3.14.
"""
import sys

print("PY", sys.version.split()[0])
print()


def err(fn):
    try:
        return repr(fn())
    except Exception as e:  # noqa: BLE001 — нам нужен текст
        return f"{type(e).__name__}: {e}"


# ── 1 ─────────────────────────────────────────────────────────────────────
print("1) неизменяемость")
s = "привет"


def assign_char():
    s[0] = "П"


print("   s[0] = 'П'          ->", err(assign_char))
upper = s.upper()
print("   s.upper()           ->", repr(upper), "| s ->", repr(s))
t = s
s += "!"
print("   t = s; s += '!'     -> s =", repr(s), "| t =", repr(t), "| s is t:", s is t)
print()

# ── 2 ─────────────────────────────────────────────────────────────────────
print("2) срезы")
p = "Python"
for expr in ["p[0]", "p[-1]", "p[1:4]", "p[:2]", "p[-3:]", "p[::2]", "p[::-1]", "p[10:20]", "p[10]"]:
    print(f"   {expr:<10}->", err(lambda expr=expr: eval(expr)))
print()

# ── 3 ─────────────────────────────────────────────────────────────────────
print("3) методы")
cases = [
    ("'a,b,,c'.split(',')", lambda: "a,b,,c".split(",")),
    ("'  a  b '.split()", lambda: "  a  b ".split()),
    ("'-'.join(['a', 'b', 'c'])", lambda: "-".join(["a", "b", "c"])),
    ("'-'.join([1, 2])", lambda: "-".join([1, 2])),
    ("'  hi  '.strip()", lambda: "  hi  ".strip()),
    ("'abc'.find('z')", lambda: "abc".find("z")),
    ("'abc'.index('z')", lambda: "abc".index("z")),
    ("'hello'.replace('l', 'L', 1)", lambda: "hello".replace("l", "L", 1)),
    ("'hello world'.title()", lambda: "hello world".title()),
    ("'42'.isdigit(), '-42'.isdigit()", lambda: ("42".isdigit(), "-42".isdigit())),
]
for label, fn in cases:
    print(f"   {label:<34}->", err(fn))
print()

# ── 4 ─────────────────────────────────────────────────────────────────────
print("4) форматирование")
x = 3.14159
n = 1234567
name = "Аня"
print("   f'{x:.2f}'          ->", repr(f"{x:.2f}"))
print("   f'{n:,}'            ->", repr(f"{n:,}"))
print("   f'{n:_}'            ->", repr(f"{n:_}"))
print("   f'{name:>6}|'       ->", repr(f"{name:>6}|"))
print("   f'{name:*^9}'       ->", repr(f"{name:*^9}"))
print("   f'{name!r}'         ->", repr(f"{name!r}"))
print("   f'{x=}'             ->", repr(f"{x=}"))
print("   f'{x=:.2f}'         ->", repr(f"{x=:.2f}"))
print("   f'{0.25:.0%}'       ->", repr(f"{0.25:.0%}"))
print("   '{} и {}'.format(1, 2) ->", repr("{} и {}".format(1, 2)))
print("   '%s: %d' % ('n', 5) ->", repr("%s: %d" % ("n", 5)))
print("   f'{0.5:.0f} {1.5:.0f} {2.5:.0f}' ->", repr(f"{0.5:.0f} {1.5:.0f} {2.5:.0f}"))
print("   round(2.5), round(3.5) ->", round(2.5), round(3.5))
from decimal import ROUND_HALF_UP, Decimal  # noqa: E402

print("   Decimal('2.5').quantize(Decimal('1'), rounding=ROUND_HALF_UP) ->",
      Decimal("2.5").quantize(Decimal("1"), rounding=ROUND_HALF_UP))
print("   f'{2.675:.2f}', round(2.675, 2) ->", repr(f"{2.675:.2f}"), round(2.675, 2))
print("   Decimal(2.675) ->", Decimal(2.675))
print()

# ── 5 ─────────────────────────────────────────────────────────────────────
print("5) str и bytes")
for word in ["hello", "привет", "👍"]:
    b = word.encode("utf-8")
    print(f"   {word!r:<10} len = {len(word)}, в UTF-8 байт = {len(b)}")
b = "привет".encode()
print("   'привет'.encode()[0]  ->", b[0], "— байт, это int, а не символ")
print("   b.decode()            ->", repr(b.decode()))
print("   'привет'.encode('ascii') ->", err(lambda: "привет".encode("ascii")))
print("   'a' + b'b'            ->", err(lambda: "a" + b"b"))
print()

# ── 6 ─────────────────────────────────────────────────────────────────────
print("6) версии: что компилируется")
d = {"k": 1}


def compiles(src):
    try:
        code = compile(src, "<src>", "eval")
    except SyntaxError as e:
        return f"SyntaxError: {e.msg}"
    value = eval(code)
    if type(value).__name__ == "Template":
        return f"Template, значение выражения уже вычислено: {value.interpolations[0].value}"
    return repr(value)


print("   f\"{d[\"k\"]}\"   ->", compiles('f"{d["k"]}"'))
print("   f\"{d['k']}\"   ->", compiles("f\"{d['k']}\""))
print("   t\"x={1 + 1}\" ->", compiles('t"x={1 + 1}"'))
print()

# ── 7 ─────────────────────────────────────────────────────────────────────
# Добавлен по внешнему аудиту урока (14.10.2026). Разделы 1–6 выше не менялись.
#
# ТРИ УТОЧНЕНИЯ. Срез за границей не обязательно пуст: границы урезаются до
# длины строки. len считает кодовые точки Unicode, а не видимые символы:
# 'é', записанная буквой e и комбинирующим знаком U+0301, — две точки; и
# разворот [::-1] переставляет точки, отрывая знак от буквы. А «метод
# возвращает новую строку» — неточно: неизменяемость обещает только, что
# исходная не изменится; CPython может вернуть тот же объект, если результат
# с ним совпал. Входы с не-ASCII символами записаны escape-последовательностями
# и печатаются через ascii(), чтобы строка вывода читалась одинаково везде.
print("7) границы среза, кодовые точки, тот же объект")
import unicodedata  # noqa: E402

p = "Python"
for expr in ["p[:100]", "p[4:100]", "p[-100:2]"]:
    print(f"   {expr:<10}->", err(lambda expr=expr: eval(expr)))
FAMILY = "\U0001F468\u200d\U0001F469\u200d\U0001F467\u200d\U0001F466"
print("   FAMILY =", ascii(FAMILY))
uni = [
    ("len('\\u00e9')", lambda: len("\u00e9")),
    ("len('e\\u0301')", lambda: len("e\u0301")),
    ("len(unicodedata.normalize('NFC', 'e\\u0301'))", lambda: len(unicodedata.normalize("NFC", "e\u0301"))),
    ("len('\\U0001F44D\\U0001F3FD')", lambda: len("\U0001F44D\U0001F3FD")),
    ("len(FAMILY)", lambda: len(FAMILY)),
    ("ascii('cafe\\u0301'[::-1])", lambda: ascii("cafe\u0301"[::-1])),
]
for label, fn in uni:
    print(f"   {label:<46}->", fn())
same = [
    ("p.strip() is p", lambda: p.strip() is p),
    ("p.replace('z', 'y') is p", lambda: p.replace("z", "y") is p),
    ("p[:100] is p", lambda: p[:100] is p),
    ("p.upper() is p.upper()", lambda: p.upper() is p.upper()),
]
for label, fn in same:
    print(f"   {label:<46}->", fn())