
Привет, Хабр! Приближается релиз 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 и свежие новости про новые фичи вы можете узнать у него на канале «Находки в опенсорсе».
Комментарии (37)
Octagon77
26.05.2025 17:27Я привередлив. Это кому-то надо, кому-то нет. Значит, должно быть вне языка и импортироваться. Хотя бы потому, что медленнее.
С другой стороны, есть f-строки и всё выглядит красиво и нет никаких причин почему бы ни быть и t-cтрокам, и d-строкам, и q-строкам, и r-строкам... Значит, должно быть на уровне языка. Точно так, как и сделано.
Раз я привередлив - я бы сделал возможность писать пакеты добавляющие строки на любые буквы. Да, так сложнее, но с огдядки на "сложнее" и начинается деградация.
lorc
26.05.2025 17:27r-строки уже давно есть, кстати. Наверное чуть ли не с первой версии.
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 с помощью него, хотя это скорее грязный хак, чем задуманная фича.
lorc
26.05.2025 17:27Я понимаю что статья не о том, но
Не совсем ясно, насколько такой подход лучше уже существующего, когда мы передаем коннектору строку с плейсхолдерами типа
%
и значения, а он их сам экранирует и заменяет.Не надо вводить людей в заблуждение. Все нормальные движки БД давно поддерживают параметры. Поэтому значения в параметризированный запрос не подставляются никогда. Опять же, парсеру запросов проще один раз распарсить, оптимизировать и закешировать `select * from users where name = $1` чем парсить тысячи разных запросов, которые отличаются только параметром. Я уж не говорю о том что это исключает SQL injection полностью и навсегда.
tapeline Автор
26.05.2025 17:27Да, вы правы, я не совсем правильно выразился. Исправил, чтобы не продолжать вводить в заблуждение. Спасибо!
funca
26.05.2025 17:27Обычно DB разрешают использовать параметры лишь вместо значений. Но довольно часто приходится параметризовывать части самого запроса: названия схем, таблиц, полей и т.п. Здесь серверная интерпретация не работает.
lorc
26.05.2025 17:27Ну если вы параметризируете эти части кусками из user input, то у меня для вас плохие новости...
n0isy
26.05.2025 17:27Ну почему же. Если экранировать то все хорошо. Если вы говорите про антипаттерн, то я напомню, что like '%text%' не так просто записать параметром
Stawros
26.05.2025 17:27like '%text%' не так просто записать параметром
Смотря где, в оракле можно без проблем сделать
like '%'||:P_SUBSTR||'%'
sobolevn
Крутая статья, спасибо!