
Привет, Хабр! Приближается релиз Python 3.14, который несет нам множество нововведений. Среди них — новый способ форматирования строк. Давайте посмотрим, что из себя представляют t-строки, на что они годятся и как устроены внутри. Фича действительно мощная, будет интересно.
Что мы имеем сейчас
Прежде чем начать, предлагаю вспомнить, какие способы отформатировать текст в Python у нас уже есть:
Первый — максимально классический, используя +:
a = 1
b = "hello"
print("I want to say: " + b + " (" + str(a) + ")")Думаю, все (редко ли, часто ли) прибегают к такому способу. Минусы очевидны: необходимо следить за типами переменных, явно приводить все к строке с помощью str(). А еще тут очень много лишних символов, и понять, что написано в такой строке, очень сложно. Плюс на каждую операцию конкатенации создается новый объект строки, что бьёт по производительности.
Второй способ — %:
a = 1
b = "hello"
print("I want to say: %s (%s)" % (a, b))Очевидны многие плюсы по сравнению с предыдущим: «форматирование процентом» более лаконично. Для большего понимания того, что где будет вставляться, можно использовать имена:
print("I want to say: %(text)s (%(number)s)" % {"text": a, "number": b})Также % позволяет делать дополнительные операции с текстом: выравнивание, заполнение (например нулями), формат чисел и фиксирование количества цифр после запятой.
Третий способ — str.format/str.format_map:
a = 1
b = "hello"
print("I want to say {} ({})".format(a, b))Имеет все те же плюсы, что и %, но в дополнение к этому имеет более мощный язык спецификации форматирования (ссылка). Тоже поддерживает именованную замену
Четвертый способ — f-строки:
a = 1
b = "hello"
print(f"I want to say {a} ({b})")По своей сути, это тот же str.format, но моментальный: в скобках записываются вычисляемые выражения, результат которых будет туда подставлен. Поддерживается весь арсенал str.format плюс к этому — специальный символ =, который работает так:
a = 1
print(f"{a=}")>>> a=1Важное отличие в том, что такую строку (в отличие от двух предыдущих способов) нельзя заранее подготовить, а отформатировать когда-нибудь потом: все выражения eagerly-evaluated.
Пятый способ — довольно забытый string.Template:
from string import Template
a = 1
b = "hello"
t = Template("I want to say $a ($b)")
print(t.substitute({"a": a, "b": b}))Он подходит для простой замены и не поддерживает дополнительные функции вроде форматирования. Зато их можно отлично использовать каких-нибудь шаблонных сообщениях в разных форматах благодаря возможности настраивать разделитель — выбирать любой другой символ вместо $.
Синтаксис и поведение t-строк
Теперь посмотрим на синтаксис t-строк. По факту, он никак не отличается от синтаксиса f-строк, кроме, собственно, префикса. Выражения в скобках имеют такую же структуру: {выражение[!конверсия][:формат]}. Выражения все так же не ленивые.
Однако, t-строки достаточно сильно отличаются в плане поведения форматирования. Если мы попробуем написать что-то вроде print(t"Hello, {name}"), то увидим не строку, а какой-то объект Template. Давайте разбираться.
Template
Объекты этого класса создаются в результате вычисления t-строки. Что внутри имеет Template?
dir(t"")
>>> ['__add__', '__class__', '__class_getitem__', '__delattr__', 
     '__dir__', '__doc__', '__eq__', '__format__', '__ge__', 
     '__getattribute__', '__getstate__', '__gt__', '__hash__', 
     '__init__', '__init_subclass__', '__iter__', '__le__', '__lt__', 
     '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', 
     '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 
     'interpolations', 'strings', 'values']Из всех этих атрибутов нас интересуют только interpolations, strings, values и __iter__.
В атрибуте strings лежат все строковые значения, которые лежат между интерполяциями (интерполяция в нашем случае — {выражение}). Например, t"Hello, {name}!".strings == ('Hello, ', '!', '').
В атрибуте values лежат все значения интерполяций. Например, t"{1}".values == (1,).
В атрибуте interpolations лежат все интерполяции, представленные объектами Interpolation. Он содержит в себе поля:
- value— само посчитанное значение интерполяции;
- expression— строка с исходным кодом выражения;
- conversion— тип конверсии с помощью- !(- !r,- !aили- !s). Если конверсия не указана, то содержит пустую строку;
- format_spec— спецификация форматирования с помощью- :. Если спецификация не указана, то содержит пустую строку.
Например, t"{1+2!a:ffff}".interpolations вернет (Interpolation(3, '1+2', 'a', 'ffff'),).
Наконец, __iter__ позволяет нам итерироваться по t-строке. Во время итерации мы сможем по порядку перебирать все ее элементы: строки и интерполяции.
В чем соль?
«В чем же собственно фишка t-строк?» — спросите вы. Главная фишка в том, что форматирование происходит не сразу, а вместо этого вам отдается объект, который содержит полную информацию о строке, чтобы вы потом отформатировали эту строку сами ровно так, как пожелаете. То есть, вся суть — кастомизация поведения форматирования.
Для примера давайте попробуем сделать f-строки на t-строках. Сделаем функцию f:
from string.templatelib import Interpolation
def f(template):
    string = []
    for part in template:
        match part:
	        case str():
                string.append(part)
            case Interpolation(value, _, conversion, format_spec):
                if conversion == "a":
                    value = ascii(value)
                elif conversion == "s":
                    value = str(value)
                elif conversion == "r":
                    value = repr(value)
                value = format(value, format_spec)
                string.append(value)
    return "".join(string)Не самый красивый код, но это всего лишь пример. Мы просто проходим по всем частям, если надо — форматируем, а затем добавляем к строке.
Теперь давайте рассмотрим, чем эта штуковина может быть нам полезна. Способы применения я условно поделил на 3 группы. Некоторые из сфер применения могут быть сомнительными, пишите свое мнение в комментарии, будет интересно прочитать)
Санитизация
Один из основных аргументов «ЗА» PEP750, который привнес нам эти t-строки — упрощение защиты от XSS и SQL injection атак. Каким образом? Если раньше мы попробовали бы сделать что-то такое:
sql.execute(f"SELECT * FROM users WHERE username = '{username}';")то нам бы закономерно на ревью дали по шапке. Ведь если у нас придет пользователь с юзернеймом '; DROP TABLE users; --, то будет очень и очень больно. Но если мы будем использовать t-строки, то наш гипотетический sql.execute сможет пройтись по всем строковым значениям в интерполяциях и сам их экранировать:
# вымышленный коннектор к бд
import my_awesome_sql_library
def execute(query):
	...
	my_awesome_sql_library.execute(sanitize_sql(query))
	...
def sanitize_sql(query):
	string = []
    for part in template:
        match part:
	        case str():
                string.append(part)
            case Interpolation(value, _, conversion, format_spec):
                if conversion == "a":
                    value = ascii(value)
                elif conversion == "s":
                    value = str(value)
                elif conversion == "r":
                    value = repr(value)
                value = format(value, format_spec)
                string.append(my_awesome_sql_library.sanitize(value))
    return "".join(string)Мнение автора
Не совсем ясно, насколько такой подход лучше уже существующего, когда мы передаем коннектору строку с плейсхолдерами типа % и значения, а БД их сама экранирует и заменяет.
Шаблонизация
Ровно то же самое применимо и к HTML. Во-первых, это помогает разобраться с XSS: если мы собираем какую-то страничку с помощью f-строк, например:
# вымышленный веб-фреймворк
def post_html(post):
	return f"""
		<div class="post">
			<h3>{post.title}</h3>
			<small>{post.author}</small>
			<a href="/posts/{post.id}">See</a>
		</div>
	"""
@get("/posts")
def posts_page(request):
	posts = get_all_posts()
	return f"""
		<h1>Posts</h1>
		{"".join(map(post_html, posts))}
	"""и какой-нибудь особо любопытный пользователь сделает пост с заголовком <script>alert("There I am!")</script> то мы получим XSS. Но мы можем написать все то же самое с использованием t-строк, а фреймворк будет просто делать html.escape(...) для всех интерполяций, таким образом избегая инъекции HTML кода.
Во-вторых, мы можем делать кучу разных дополнительных манипуляций с HTML кодом. Например, парсить его прямо во время форматирования. Давайте взглянем на несколько примеров, которые написал Дейв Пек — один из авторов PEP750:
text = 'Hello, "world!"'
element = html(t'<p class="greeting">{text}</p>')
expected = Element("p", {"class": "greeting"}, ['Hello, "world!"'])
assert element == expectedили такое:
def Magic(attributes, children):
	"""A simple, but extremely magical, component."""
	magic_attributes = {**attributes, "data-magic": "yes"}
	magic_children = [*children, "Magic!"]
	return Element("div", magic_attributes, magic_children)
element = html(t'<{Magic} id="wow"><b>FUN!</b>')
expected = Element(
	"div",
	{"id": "wow", "data-magic": "yes"},
	[Element("b", {}, ["FUN!"]), "Magic!"],
)
assert element == expectedВ эфире на тему PEP750 его создатели (Джим Бейкер, Дейв Пек, Пол Эверитт) обсуждали много разных вещей, для которых он нужен. Среди прочего, интересна такая цитата (перевод):
… В то время бытовало мнение, что HTML довольно сильно отличается от программ …
… вам хотелось разделять людей, работающих с шаблонами, от людей, пишущих ПО …
… мир так больше не работает …
… это то, в чем я изначально был заинтересован …
… мы хотим DSL-ы, которые ближе к Python, чтобы вы могли использовать mypy для них, свой форматтер для них, подсказки своей среды разработки для них …
… и, с точки зрения HTML, мы хотим перейти из этого разделения в новый мир.
По сути, они предлагают новый способ шаблонизации. Цитата с переводом:
— … новые способы шаблонизации, например jinja3 или что-нибудь еще, могут появиться и развить эти идеи, используя что-то отсюда …
— Да, абсолютно точно, и я надеюсь, что мы увидим бум таких вещей после выхода Python 3.14 …
Мнение автора
Эти идеи кажутся интересными, возможно даже и действительно начнут какое-то новое движение в SSR в мире Python, но пока об этом рано говорить до выхода 3.14 в октябре и появления первых инструментов на основе t-строк. Да и нужно проводить тщательные сравнения и бенчмарки.
Структурированное логирование
Структурированное логирование позволяет делать логи в машиночитаемом формате. Традиционный подход — использование structlog/loguru (иногда даже можно использовать обычный встроенный logging). Все так или иначе сводится к тому, что у нас есть какой-то словарик значений, который мы куда-то передаем и он становится частью структурированного сообщения в логе.
T-строки позволяют сделать структурный логгинг фактически бесплатным. Предлагаю такой пример:
logger.info(t"Received request {request['id']:id} from user {request['user']:user}")
# string formatting
>>> Received request 123 from user 0000-0000
# json formatting
>>> {
  "id": 123, 
  "user": "0000-0000", 
  "message": "Received request 123 from user 0000-0000", 
  "type": "received_request_from_user", 
  "time": "2025-05-21T13:19:14"
}Давайте разбирать все по порядку. Во-первых, мы используем кастомную спецификацию форматирования для того, чтобы определить, под каким ключом будет лежать значение в структуре. В целом, можно применить немного иной подход, например, использовать строковое представление кода интерполяции, но тогда придется там держать только имена, т. е.:
id = request['id']
user = request['user']
logger.info(t"Received request {id} from user {user}")Затем мы добавляем поле time — время создания сообщения и message — само сообщение. Тут необходимо еще отметить то, что для структурного логгинга также нужно, чтобы сообщения имели какой-то идентификатор, который бы позволял различать события. Здесь же этим идентификатором служит строка, в которой все интерполяции заменены на ?. Потом, где-нибудь в дашборде, где мы смотрим все логи, мы сможем сделать запрос вида: (loki)  {app=my_app,type=received_request_from_user}.
Что можно сделать еще? Продолжить расширять формат-спецификацию. Так как мы заняли ее место, надо добавить возможность дописывать обычную format spec. Это можно сделать, например, так:
x = 13.00000000000004
logger.info(t"Some number: {x:my_field+.4f}")
# string formatting
>>> Some number: 13.0000
# json formatting
>>> {..., "my_field": 13.00000000000004, ...}Можем продолжить расширять формат-спеку. Допустим, можно добавить возможность указывать форматирование для дат (хотя, скорее это уже перебор):
logger.info(t"Current hour: {datetime.now():now+%Y-%m-%d %H}")То, как происходит форматирование таких логов под капотом, можно посмотреть в репозитории с примерами.
Мнение автора
Как по мне, то это наиболее перспективное направление, в котором можно использовать t-строки. Совокупность факторов: лаконичность (не надо ссылаться на один и тот же объект и в сообщении, и в словарике со значениями) и бесплатные автогенерируемые идентификаторы типа сообщения — дают некоторое преимущество над стандартным подходом вида
logger.info(
  "Request {id} from {user} received", 
  {"id": request.id, "user": request.user, "type": "received_request_from_user"}
)Тем не менее, не стоит забывать, что t-строки заметно медленнее, чем стандартный str.format, да и с существующим подходом все замечательно пока живут, хотя с t-строками и получается сильно красивее.
Что внутри?
Для полноты картины нам не хватает только части с разбором того, что находится под капотом у t-строк.
Давайте посмотрим байткод для такого выражения:
t"a {1} b {2!s:fmt} c"LOAD_CONST               4 (('a ', ' b ', ' c'))
LOAD_SMALL_INT           1
LOAD_CONST               1 ('1')
BUILD_INTERPOLATION      2
LOAD_SMALL_INT           2
LOAD_CONST               2 ('2')
LOAD_CONST               3 ('fmt')
BUILD_INTERPOLATION      7
BUILD_TUPLE              2
BUILD_TEMPLATEПервым делом видим создание кортежа из чистых строк, которые находятся между интерполяциями. Затем видим загрузку на стек значения первой интерполяции и строки "1" — ее исходного кода, после чего опкод BUILD_INTERPOLATION создает объект Interpolation.
После видим, как загружается вторая интерполяция, ее исходный код, но теперь еще и строка "fmt", которую мы указали в качестве спецификации форматирования. А затем опять вызывается BUILD_INTERPOLATION, но уже с другим оп-аргументом. Если мы посмотрим на код:
inst(BUILD_INTERPOLATION, (value, str, format[oparg & 1] -- interpolation)) {  
    PyObject *value_o = PyStackRef_AsPyObjectBorrow(value);  
    PyObject *str_o = PyStackRef_AsPyObjectBorrow(str);  
    int conversion = oparg >> 2;  
    PyObject *format_o;  
    if (oparg & 1) format_o = PyStackRef_AsPyObjectBorrow(format[0]);  
    else format_o = &_Py_STR(empty); 
    PyObject *interpolation_o = _PyInterpolation_Build(value_o, str_o, conversion, format_o); 
    ...
    interpolation = PyStackRef_FromPyObjectSteal(interpolation_o);  
}
то увидим, что оп-аргумент контроллирует наличие/отсутствие спецификации форматирования, а также конверсии (!a, !s или !r) с помощью булевых флагов. Здесь же видно, что при отсутствии спецификации форматирования, она принимает дефолтное значение пустой строки.
Следующий опкод — BUILD_TUPLE 2 — создает кортеж из двух значений на стеке. Эти значения — сформированные интерполяции. После этого опкод BUILD_TEMPLATE просто собирает вместе кортежи чистых строк и интерполяций в шаблон.
inst(BUILD_TEMPLATE, (strings, interpolations -- template)) {  
    PyObject *strings_o = PyStackRef_AsPyObjectBorrow(strings);  
    PyObject *interpolations_o = PyStackRef_AsPyObjectBorrow(interpolations);  
    PyObject *template_o = _PyTemplate_Build(strings_o, interpolations_o);  
    ...
    template = PyStackRef_FromPyObjectSteal(template_o);  
}Строки и интерполяции чередуются, т.е., например, кортеж чистых строк у шаблона t"{a}{b}" будет содержать ("", "", "").
Заключение
T-строки — крайне мощный инструмент, который вот-вот (уже в октябре) будет доступен в стабильном виде в Python 3.14. Хотя нам и только предстоит увидеть их в действии в реальных юзкейсах, уже сейчас можно сказать, что они определенно точно привнесут кое-что новое и удобное в мир шаблонизации и структурного логирования.
Спасибо Никите Соболеву (CPython Core Developer) за пруфридинг статьи и особенно секции с внутренностями t‑строк. Еще больше приколов про внутрянку CPython и свежие новости про новые фичи вы можете узнать у него на канале «Находки в опенсорсе».
Комментарии (58)
 - Octagon7726.05.2025 17:27- Я привередлив. Это кому-то надо, кому-то нет. Значит, должно быть вне языка и импортироваться. Хотя бы потому, что медленнее. - С другой стороны, есть f-строки и всё выглядит красиво и нет никаких причин почему бы ни быть и t-cтрокам, и d-строкам, и q-строкам, и r-строкам... Значит, должно быть на уровне языка. Точно так, как и сделано. - Раз я привередлив - я бы сделал возможность писать пакеты добавляющие строки на любые буквы. Да, так сложнее, но с огдядки на "сложнее" и начинается деградация.  - lorc26.05.2025 17:27- r-строки уже давно есть, кстати. Наверное чуть ли не с первой версии.  - tapeline Автор26.05.2025 17:27- Более того, на discuss.python.org недавно предложили d-строки, но это вообще уже, как по мне, шиза. Суть в том, что они автоматом будут убирать кучу отступов в начале строки при, например, таком использовании: - def f(): print( """ Hello, World! This is a second line """- Как по мне, так это либо надо изначально было делать (как поступили, например, в Java), либо сейчас уже просто жить с - textwrap.dedentи не плодить всякое в язык.
 
  - tapeline Автор26.05.2025 17:27- С какой-то стороны с вами согласен, но это уже скорее философия конкретного языка. - Сейчас точно не вспомню, в дискассе под каким пепом это читал, но там был комментарий от Core Developer в духе: - Необязательно, чтобы то, что добавляет PEP, было полезно всем. Python использует большое количество людей, эта фича будет полезной определенной части комьюнити. - А насчет пакетов на добавление строк на любые буквы: у нас и так есть PEP263 и порт f-строк на Python 2 с помощью него, хотя это скорее грязный хак, чем задуманная фича. 
 
 - lorc26.05.2025 17:27- Я понимаю что статья не о том, но - Не совсем ясно, насколько такой подход лучше уже существующего, когда мы передаем коннектору строку с плейсхолдерами типа - %и значения, а он их сам экранирует и заменяет.- Не надо вводить людей в заблуждение. Все нормальные движки БД давно поддерживают параметры. Поэтому значения в параметризированный запрос не подставляются никогда. Опять же, парсеру запросов проще один раз распарсить, оптимизировать и закешировать `select * from users where name = $1` чем парсить тысячи разных запросов, которые отличаются только параметром. Я уж не говорю о том что это исключает SQL injection полностью и навсегда.  - tapeline Автор26.05.2025 17:27- Да, вы правы, я не совсем правильно выразился. Исправил, чтобы не продолжать вводить в заблуждение. Спасибо! 
  - funca26.05.2025 17:27- Обычно DB разрешают использовать параметры лишь вместо значений. Но довольно часто приходится параметризовывать части самого запроса: названия схем, таблиц, полей и т.п. Здесь серверная интерпретация не работает.  - lorc26.05.2025 17:27- Ну если вы параметризируете эти части кусками из user input, то у меня для вас плохие новости...  - n0isy26.05.2025 17:27- Ну почему же. Если экранировать то все хорошо. Если вы говорите про антипаттерн, то я напомню, что like '%text%' не так просто записать параметром  - Stawros26.05.2025 17:27- like '%text%' не так просто записать параметром - Смотря где, в оракле можно без проблем сделать - like '%'||:P_SUBSTR||'%'
  - ipatiev26.05.2025 17:27- Если экранировать то все хорошо. - Если экранировать, то всё плохо. Очень плохо. Само по себе "экранирование" не защищает вообще ни от чего. - я напомню, что like '%text%' не так просто записать параметром - С каких пор запись обычного строкового значения перестала быть элементарной операцией, не говоря уже о каком-то "не так просто"? 
 
 
 
 
 
           
 


sobolevn
Крутая статья, спасибо!