PEP 498 ввів новий механізм форматування рядків, відомий як Інтерполяція Рядкових Літералів (Literal String Interpolation) або, частіше, як F-рядки (через символ f, що передує рядковому літералу). F-рядки забезпечують стислий і зручний спосіб вбудовування виразів Python усередину рядкових літералів для форматування.
In [2]: import math
In [3]: radius = 10
In [4]: pi = math.pi
In [5]: f'Circumference of a circle with radius {radius}: {2*pi*radius}'
Out[5]: 'Circumference of a circle with radius 10: 62.83185307179586'
Ми можемо виконувати функції всередині f-рядків:
In [6]: import math
In [7]: radius = 10
In [8]: def area_of_circle(radius):
...: return 2*math.pi*radius
...:
In [9]: f'Area of a circle with radius {radius}:{area_of_circle(radius=radius)}'
Out[9]: 'Area of a circle with radius 10:62.83185307179586'
F-рядки швидші за %-форматування
та str.format()
— два найчастіше використовуваних механізми форматування рядків:
In [11]: a = 1
In [12]: b = 2
In [13]: timeit f'{a} + {b} = {a + b}'
12.4 ns ± 0.17 ns per loop (mean ± std. dev. of 7 runs, 100000000 loops each)
In [14]: timeit '{} + {} = {}'.format(a, b, a + b)
510 ns ± 4.19 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
In [15]: timeit '%s + %s = %s' % (a, b, a + b)
366 ns ± 4.05 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
Але чому f-рядки такі швидкі та як вони насправді працюють? PEP 498 дає відповідь на це питання:
F-рядки надають спосіб вбудовування виразів усередину рядкових літералів, використовуючи мінімальний синтаксис. Слід зазначити, що f-рядок — це вираз, який обчислюється під час виконання, а не постійне значення. У вихідному коді Python f-рядок є літеральним рядком з попередньою позначкою 'f', який містить вирази всередині фігурних дужок. Вирази замінюються їх значеннями.
Ключовим моментом тут є те, що f-рядок дійсно вираз, який обчислюється під час виконання, а не постійне значення. По суті, це означає, що вирази всередині f-рядків обчислюються так само, як і інші вирази Python у межах області їх появи. Компілятор CPython робить складну роботу протягом етапу парсингу, щоб розділити f-рядки на рядкові літерали та вирази для генерації відповідного Абстрактного Синтаксичного Дерева (Abstract Syntax Tree):
In [10]: import ast
In [11]: ast.dump(ast.parse('a + b'))
Out[11]: "Module(body=[Expr(value=BinOp(left=Name(id='a', ctx=Load()), op=Add(), right=Name(id='b', ctx=Load())))])"
In [12]: ast.dump(ast.parse('''f"{a + b}"'''))
Out[12]: "Module(body=[Expr(value=FormattedValue(value=BinOp(left=Name(id='a', ctx=Load()), op=Add(), right=Name(id='b', ctx=Load())), conversion=-1, format_spec=None))])"
Ми використовуємо модуль ast для перегляду абстрактного синтаксичного дерева, пов'язаного з простим виразом a + b
всередині та за межами f-рядка. Можна побачити, що вираз a + b
всередині f-рядка f{a + b}
розкладається в просту бінарну операцію так само, як і за його межами.
Більш того — навіть на рівні байт-коду вирази f-рядків обчислюються так само як і інші вирази Python:
In [14]: import dis
In [15]: def add_two():
...: a = 1
...: b = 2
...: return a + b
...:
In [16]: def add_two_fstring():
...: a = 1
...: b = 2
...: return f'{a + b}'
...:
In [17]: dis.dis(add_two)
2 0 LOAD_CONST 1 (1)
2 STORE_FAST 0 (a)
3 4 LOAD_CONST 2 (2)
6 STORE_FAST 1 (b)
4 8 LOAD_FAST 0 (a)
10 LOAD_FAST 1 (b)
12 BINARY_ADD
14 RETURN_VALUE
In [18]: dis.dis(add_two_fstring)
2 0 LOAD_CONST 1 (1)
2 STORE_FAST 0 (a)
3 4 LOAD_CONST 2 (2)
6 STORE_FAST 1 (b)
4 8 LOAD_FAST 0 (a)
10 LOAD_FAST 1 (b)
12 BINARY_ADD
14 FORMAT_VALUE 0
16 RETURN_VALUE
Функція add_two
просто підсумовує локальні змінні a і b та повертає результат. Функція add_two_fstring
робить те саме, але додавання відбувається всередині f-рядка. Крім інструкції FORMAT_VALUE
у дизасембльованому байт-коді функції add_two_fstring
(ця інструкція тут тому, що f-рядку треба конвертувати результат доданого виразу у рядок), байт-код інструкції для обчислення a + b
всередині та за межами f-рядка однакові.
Обробка f-рядків просто розбивається на обчислення виразу, вкладеного у фігурні дужки, і поєднання його з частиною рядкового літерала f-рядка, щоб повернути значення кінцевого рядка. Нема потреби в додатковій обробці часу виконання. Це робить f-рядки досить швидкими та ефективними.
Чому str.format()
набагато повільніше, ніж f-рядки? Відповідь стане зрозумілою, як тільки ми розглянемо дизасембльований байт-код для функції, що використовує str.format()
:
In [20]: import dis
In [21]: def add_two_string_format():
...: a = 1
...: b = 2
...: return '{}'.format(a + b)
...:
In [24]: dis.dis(add_two_string_format)
2 0 LOAD_CONST 1 (1)
2 STORE_FAST 0 (a)
3 4 LOAD_CONST 2 (2)
6 STORE_FAST 1 (b)
4 8 LOAD_CONST 3 ('{}')
10 LOAD_ATTR 0 (format)
12 LOAD_FAST 0 (a)
14 LOAD_FAST 1 (b)
16 BINARY_ADD
18 CALL_FUNCTION 1
20 RETURN_VALUE
Із дизасембльованого байт-коду одразу випливають дві інструкції: LOAD_ATTR
і CALL_FUNCTION
. Коли ми використовуємо str.format()
, функція форматування спершу потребує пошуку у глобальному середовищі. Це робиться за допомогою байт-код інструкції LOAD_ATTR
. Глобальний пошук змінних складається з кількох кроків. Як тільки функція форматування виявлена, викликається операція двійкового додавання (BINARY_ADD
) для підсумовування змінних a і b. А потім, за допомогою байт-код інструкції CALL_FUNCTION
, виконується функція форматування і повертаються рядкові результати. У str.format()
додатковий час витрачається на LOAD_ATTR
і CALL_FUNCTION
,
це і є те, через що str.format()
набагато повільніший, ніж f-рядки.
Як щодо форматування %-string
? Ми бачили, що воно швидше за str.format()
, але все-таки повільніше, ніж f-рядки. Знову ж таки, розгляньмо дизасембльований байт-код для функції, яка використовує форматування %-string
:
In [32]: import dis
In [33]: def add_two_percent_string_format():
...: a = 1
...: b = 2
...: return '%s' % a + b
...:
In [34]: dis.dis(add_two_percent_string_format)
2 0 LOAD_CONST 1 (1)
2 STORE_FAST 0 (a)
3 4 LOAD_CONST 2 (2)
6 STORE_FAST 1 (b)
4 8 LOAD_CONST 3 ('%s')
10 LOAD_FAST 0 (a)
12 BINARY_MODULO
14 LOAD_FAST 1 (b)
16 BINARY_ADD
18 RETURN_VALUE
Спочатку ми не бачимо інструкції LOAD_ATTR
і CALL_FUNCTION
— так форматування %-string
уникає накладних витрат на пошук глобального атрибута та виклику функції Python. Це пояснює, чому воно швидше, ніж str.format()
. Але чому %-рядки все-таки повільніше за f-рядки? Одне потенційне місце, в якому %-string може витрачати додатковий час, вказано в інструкції BINARY_MODULO
. Дивлячись на вихідний код CPython, можна зрозуміти, чому виникає невелика кількість накладних витрат, пов'язаних із викликом BINARY_MODULO
:
// Python/ceval.c
TARGET(BINARY_MODULO) {
PyObject *divisor = POP();
PyObject *dividend = TOP();
PyObject *res;
if (PyUnicode_CheckExact(dividend) && (
!PyUnicode_Check(divisor) || PyUnicode_CheckExact(divisor))) {
res = PyUnicode_Format(dividend, divisor);
} else {
res = PyNumber_Remainder(dividend, divisor);
}
Py_DECREF(divisor);
Py_DECREF(dividend);
SET_TOP(res);
if (res == NULL)
goto error;
DISPATCH();
}
З приведеного вище вихідного коду Python C видно, що операція BINARY_MODULO
перевантажена. Під час кожного її виклику, їй потрібно перевірити тип її операндів (7-13 у наведеному вище фрагменті коду) для визначення, є вони рядковими об'єктами чи ні. Якщо так, тоді оператор ділення по модулю виконує операції форматування рядків. В іншому випадку, він обчислює звичайний модуль (повертає залишок від ділення першого аргументу на другий). Ця перевірка типу виконується з накладними витратами, яких і уникають f-рядки.
Сподіваюсь, цей пост допоміг пролити світло на те, чому f-рядки виділяються з натовпу, коли мова заходить про форматування рядків. F-рядки швидкі, прості у використанні, практичні та роблять код більш чистим. Використовуйте їх!
Ще немає коментарів