Пильний погляд на роботу f-рядків Python

15 хв. читання

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-рядки швидкі, прості у використанні, практичні та роблять код більш чистим. Використовуйте їх!

Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 5.6K
Приєднався: 8 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація