Python · Синтаксис · Продвинутый уровень

functools: partial, lru_cache и другое

10 задач

Освойте модуль functools: `partial`, `wraps`, `lru_cache`, `cached_property`, `total_ordering` и `singledispatch`.

lru_cache, cache и cached_property детально

#
**`functools` — инструментарий функционального программирования** Модуль `functools` предоставляет функции высшего порядка — функции работающие с или возвращающие другие функции. Эти паттерны постоянно встречаются в профессиональном коде Python: кэширование, частичное применение, составление сравнений. **`@lru_cache` — мемоизация с ограничением размера** LRU (Least Recently Used) кэш хранит результаты вызовов функций. При тех же аргументах возвращает кэшированный результат: ```python from functools import lru_cache @lru_cache(maxsize=128) def fibonacci(n): if n < 2: return n return fibonacci(n - 1) + fibonacci(n - 2) fibonacci(100) fibonacci.cache_info() # CacheInfo(hits=98, misses=101, ...) fibonacci.cache_clear() # сбросить кэш ``` Ключ кэша строится из аргументов — все они должны быть хешируемыми (без списков и словарей). `maxsize=None` — неограниченный кэш. **`@cache` (Python 3.9+) — неограниченная мемоизация** ```python from functools import cache @cache def count_ways(n, k): if n == 0: return 1 if n < 0 or k == 0: return 0 return count_ways(n - k, k) + count_ways(n, k - 1) ``` **`@cached_property` — вычисление один раз на экземпляр** Обычная `@property` запускает getter каждый раз. `@cached_property` запускает один раз и сохраняет результат как атрибут экземпляра: ```python from functools import cached_property class DataSet: def __init__(self, raw): self.raw = raw @cached_property def sorted_data(self): print('computing...') return sorted(self.raw) ds = DataSet([3, 1, 2]) ds.sorted_data # 'computing...' печатается ds.sorted_data # ничего — кэшировано в ds.__dict__['sorted_data'] ```

partial, reduce, паттерн compose, cmp_to_key и @wraps

#
**`partial` — зафиксировать часть аргументов функции** `functools.partial` создаёт новый вызываемый объект с некоторыми аргументами предварительно заполненными: ```python from functools import partial def power(base, exp): return base ** exp square = partial(power, exp=2) cube = partial(power, exp=3) square(5) # 25 cube(3) # 27 import json dump_pretty = partial(json.dumps, indent=4, sort_keys=True) ``` **`reduce` — свернуть последовательность в одно значение** `reduce(func, iterable)` применяет `func` накопительно: ```python from functools import reduce from operator import mul, add numbers = [1, 2, 3, 4, 5] product = reduce(mul, numbers) # 120 total = reduce(add, numbers) # 15 # С начальным значением: reduce(add, [], 0) # 0 (избегаем TypeError на пустом списке) # reduce для построения пайплайна: def compose(*funcs): return reduce(lambda f, g: lambda x: g(f(x)), funcs) pipeline = compose(str.strip, str.lower, str.title) pipeline(' hello world ') # 'Hello World' ``` **`cmp_to_key` — адаптация старого стиля сравнения** ```python from functools import cmp_to_key def compare_versions(a, b): av = tuple(int(x) for x in a.split('.')) bv = tuple(int(x) for x in b.split('.')) return (av > bv) - (av < bv) versions = ['1.10.0', '1.9.0', '2.0.0', '1.10.1'] sorted(versions, key=cmp_to_key(compare_versions)) # ['1.9.0', '1.10.0', '1.10.1', '2.0.0'] ``` **`@wraps` — сохранение метаданных через декораторы** ```python from functools import wraps def logged(func): @wraps(func) # копирует __name__, __doc__, __annotations__ def wrapper(*args, **kwargs): print(f'calling {func.__name__}') return func(*args, **kwargs) return wrapper @logged def add(a, b): '''Add two numbers.''' return a + b add.__name__ # 'add' add.__doc__ # 'Add two numbers.' ```

total_ordering, singledispatch и полный справочник functools

#
**`@total_ordering` — определите одно сравнение, получите остальные** Если определить `__eq__` и одно из `__lt__`, `__le__`, `__gt__`, `__ge__`, `@total_ordering` заполняет оставшиеся методы сравнения: ```python from functools import total_ordering @total_ordering class Version: def __init__(self, major, minor): self.major = major self.minor = minor def __eq__(self, other): return (self.major, self.minor) == (other.major, other.minor) def __lt__(self, other): # достаточно определить только __lt__ return (self.major, self.minor) < (other.major, other.minor) v1 = Version(1, 9) v2 = Version(1, 10) v1 < v2 # True v1 > v2 # False — сгенерировано @total_ordering sorted([v2, v1]) # [Version(1,9), Version(1,10)] ``` **`@singledispatch` — перегрузка функций по типу** ```python from functools import singledispatch @singledispatch def process(value): raise NotImplementedError(f'No handler for {type(value)}') @process.register(int) def _(value): return value * 2 @process.register(str) def _(value): return value.upper() @process.register(list) def _(value): return [process(item) for item in value] process(5) # 10 process('hello') # 'HELLO' process([1, 'a', 2]) # [2, 'A', 4] ``` **Краткий справочник** | Функция | Назначение | |---|---| | `@lru_cache(maxsize=N)` | Кэш с LRU вытеснением | | `@cache` | То же но неограниченный (3.9+) | | `@cached_property` | Вычисление один раз на экземпляр | | `partial(func, *args, **kw)` | Предварительно заполнить аргументы | | `reduce(func, iterable)` | Свернуть последовательность | | `cmp_to_key(cmp_fn)` | Адаптировать старое сравнение | | `@wraps(func)` | Копировать метаданные через декораторы | | `@total_ordering` | Генерировать методы сравнения | | `@singledispatch` | Перегрузка по типу |
01

functools.partial — предварительное заполнение аргументов

#

Используйте `functools.partial` для создания двух специализированных функций из `power(base, exp)`: `square(n)` — это `power(n, 2)`, `cube(n)` — `power(n, 3)`. Также создайте `add5 = partial(operator.add, 5)`, добавляющую 5 к любому числу.

import functools
import operator

def power(base: int, exp: int) -> int:
    return base ** exp


# Создайте square, cube и add5 через partial


print(square(4))   # 16
print(cube(3))     # 27
print(add5(10))    # 15
Решение
import functools
import operator

def power(base: int, exp: int) -> int:
    return base ** exp

square = functools.partial(power, exp=2)
cube = functools.partial(power, exp=3)
add5 = functools.partial(operator.add, 5)


print(square(4))   # 16
print(cube(3))     # 27
print(add5(10))    # 15
02

lru_cache для мемоизации

#

Используйте `@functools.lru_cache(maxsize=None)` для мемоизации рекурсивной функции `fib(n)`. Выведите `fib(35)`. Затем используйте `fib.cache_info()`, чтобы показать количество попаданий vs промахов. Без кеширования `fib(35)` сделал бы ~29 миллионов вызовов.

import functools

# Добавьте декоратор lru_cache здесь
def fib(n: int) -> int:
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)


print(fib(35))          # 9227465
print(fib.cache_info()) # CacheInfo(hits=..., misses=36, ...)
Решение
import functools

@functools.lru_cache(maxsize=None)
def fib(n: int) -> int:
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)


print(fib(35))          # 9227465
print(fib.cache_info())
03

cached_property для затратных атрибутов

#

Создайте класс `Circle` с `radius: float`. Добавьте атрибут `area` через `@functools.cached_property`, вычисляющий `math.pi * radius ** 2`. Убедитесь, что обращение к `area` дважды возвращает то же значение без повторного вычисления (добавьте `print('computing...')` внутри, чтобы убедиться, что выводится только один раз).

import math
import functools

class Circle:
    def __init__(self, radius: float) -> None:
        self.radius = radius

    # Добавьте cached_property для area здесь


c = Circle(5)
print(c.area)   # computing... затем 78.539...
print(c.area)   # 78.539... (без 'computing...')
Решение
import math
import functools

class Circle:
    def __init__(self, radius: float) -> None:
        self.radius = radius

    @functools.cached_property
    def area(self) -> float:
        print('computing...')
        return math.pi * self.radius ** 2


c = Circle(5)
print(c.area)
print(c.area)
04

total_ordering — методы сравнения

#

Используйте `@functools.total_ordering` для создания класса `Version` с полями `major, minor, patch`. Определите только `__eq__` и `__lt__`. Декоратор автоматически сгенерирует `__le__`, `__gt__`, `__ge__`. Убедитесь, что все сравнения работают.

import functools

@functools.total_ordering
class Version:
    def __init__(self, major: int, minor: int, patch: int) -> None:
        self.major = major
        self.minor = minor
        self.patch = patch

    def _tuple(self):
        return (self.major, self.minor, self.patch)

    def __eq__(self, other) -> bool:
        pass

    def __lt__(self, other) -> bool:
        pass


v1 = Version(1, 2, 3)
v2 = Version(1, 3, 0)
print(v1 < v2)   # True
print(v1 > v2)   # False
print(v1 <= v1)  # True
print(v1 == Version(1, 2, 3))  # True
Решение
import functools

@functools.total_ordering
class Version:
    def __init__(self, major: int, minor: int, patch: int) -> None:
        self.major = major
        self.minor = minor
        self.patch = patch

    def _tuple(self):
        return (self.major, self.minor, self.patch)

    def __eq__(self, other) -> bool:
        return self._tuple() == other._tuple()

    def __lt__(self, other) -> bool:
        return self._tuple() < other._tuple()


v1 = Version(1, 2, 3)
v2 = Version(1, 3, 0)
print(v1 < v2)   # True
print(v1 > v2)   # False
print(v1 <= v1)  # True
print(v1 == Version(1, 2, 3))  # True
05

singledispatch — диспетчеризация по типу

#

Используйте `@functools.singledispatch` для написания функции `describe(value)` с разными реализациями для `int`, `str`, `list` и типовой для других типов. int: `f'Integer: {value}'`, str: `f'String of length {len(value)}'`, list: `f'List with {len(value)} items'`, default: `f'Unknown type: {type(value).__name__}'`.

import functools

@functools.singledispatch
def describe(value) -> str:
    pass  # реализация по умолчанию

# Зарегистрируйте реализации для int, str, list здесь


print(describe(42))          # Integer: 42
print(describe('hello'))     # String of length 5
print(describe([1, 2, 3]))   # List with 3 items
print(describe(3.14))        # Unknown type: float
Решение
import functools

@functools.singledispatch
def describe(value) -> str:
    return f'Unknown type: {type(value).__name__}'

@describe.register(int)
def _(value: int) -> str:
    return f'Integer: {value}'

@describe.register(str)
def _(value: str) -> str:
    return f'String of length {len(value)}'

@describe.register(list)
def _(value: list) -> str:
    return f'List with {len(value)} items'


print(describe(42))          # Integer: 42
print(describe('hello'))     # String of length 5
print(describe([1, 2, 3]))   # List with 3 items
print(describe(3.14))        # Unknown type: float
06

functools.reduce

#

Используйте `functools.reduce` для реализации трёх операций без встроенных функций: `product(nums)` — произведение всех чисел, `my_max(nums)` — максимальное значение, `flatten_str(words)` — объединение слов через пробел.

import functools

nums = [2, 3, 4, 5]
words = ['Привет', 'мир', 'от', 'reduce']

product = functools.reduce(lambda a, b: a * b, nums)
print(product)    # 120

my_max = functools.reduce(lambda a, b: a if a > b else b, nums)
print(my_max)     # 5

flatten_str = functools.reduce(lambda a, b: a + ' ' + b, words)
print(flatten_str)  # Привет мир от reduce
Решение
import functools

nums = [2, 3, 4, 5]
words = ['Привет', 'мир', 'от', 'reduce']

product = functools.reduce(lambda a, b: a * b, nums)
print(product)    # 120

my_max = functools.reduce(lambda a, b: a if a > b else b, nums)
print(my_max)     # 5

flatten_str = functools.reduce(lambda a, b: a + ' ' + b, words)
print(flatten_str)
07

functools.wraps в декораторе

#

Напишите декоратор `log_calls`, выводящий `'Calling <func_name>'` перед вызовом обёрнутой функции. Используйте `@functools.wraps(func)` внутри, чтобы обёртка сохраняла `__name__` и `__doc__` оригинальной функции. Убедитесь, что `decorated.__name__` является оригинальным именем, а не `'wrapper'`.

import functools

def log_calls(func):
    # Примените @functools.wraps здесь
    def wrapper(*args, **kwargs):
        print(f'Calling {func.__name__}')
        return func(*args, **kwargs)
    return wrapper


@log_calls
def greet(name: str) -> str:
    '''Поздороваться.'''
    return f'Привет, {name}!'

print(greet('Алиса'))       # Calling greet / Привет, Алиса!
print(greet.__name__)       # greet (не 'wrapper')
print(greet.__doc__)        # Поздороваться.
Решение
import functools

def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f'Calling {func.__name__}')
        return func(*args, **kwargs)
    return wrapper


@log_calls
def greet(name: str) -> str:
    '''Поздороваться.'''
    return f'Привет, {name}!'

print(greet('Алиса'))
print(greet.__name__)       # greet
print(greet.__doc__)        # Поздороваться.
08

lru_cache с ограниченным размером

#

Напишите функцию `slow_square(n)`, симулирующую медленное вычисление (выводит `f'Computing {n}^2'` и возвращает `n*n`). Примените `@lru_cache(maxsize=3)`, чтобы кешировались только 3 последних результата. Вызов: 1, 2, 3, 1 (кеш), 4 (вытесняет 2), 2 (вычисляется снова).

import functools

# Добавьте @lru_cache(maxsize=3) здесь
def slow_square(n: int) -> int:
    print(f'Computing {n}^2')
    return n * n


print(slow_square(1))  # Computing 1^2 -> 1
print(slow_square(2))  # Computing 2^2 -> 4
print(slow_square(3))  # Computing 3^2 -> 9
print(slow_square(1))  # кеш -> 1
print(slow_square(4))  # Computing 4^2, вытесняет 2 -> 16
print(slow_square(2))  # Computing 2^2 снова -> 4
Решение
import functools

@functools.lru_cache(maxsize=3)
def slow_square(n: int) -> int:
    print(f'Computing {n}^2')
    return n * n


print(slow_square(1))
print(slow_square(2))
print(slow_square(3))
print(slow_square(1))  # из кеша
print(slow_square(4))  # вытесняет 2
print(slow_square(2))  # вычисляется снова
09

partial как ключ сортировки

#

Используйте `functools.partial` для создания функции `sort_by_field(items, field)`. Затем создайте `sort_by_age = partial(sort_by_field, field='age')` и `sort_by_name = partial(sort_by_field, field='name')`. Примените оба к списку словарей.

import functools

def sort_by_field(items, field):
    return sorted(items, key=lambda x: x[field])

sort_by_age = functools.partial(sort_by_field, field='age')
sort_by_name = functools.partial(sort_by_field, field='name')


people = [
    {'name': 'Чарли', 'age': 30},
    {'name': 'Алиса', 'age': 25},
    {'name': 'Боб', 'age': 35},
]

for p in sort_by_age(people):
    print(p['name'], p['age'])

for p in sort_by_name(people):
    print(p['name'])
Решение
import functools

def sort_by_field(items, field):
    return sorted(items, key=lambda x: x[field])

sort_by_age = functools.partial(sort_by_field, field='age')
sort_by_name = functools.partial(sort_by_field, field='name')


people = [
    {'name': 'Чарли', 'age': 30},
    {'name': 'Алиса', 'age': 25},
    {'name': 'Боб', 'age': 35},
]

for p in sort_by_age(people):
    print(p['name'], p['age'])

for p in sort_by_name(people):
    print(p['name'])
10

Комбинирование инструментов functools

#

Объедините `@singledispatch` и `@lru_cache` для написания кешированной функции `process(value)`. Реализация для `int` возвращает `value * 2`, для `str` — `value.upper()`, типовая — `str(value)`. Кешируйте реализации для str и int с `maxsize=32`.

import functools

@functools.singledispatch
def process(value):
    return str(value)

@process.register(int)
@functools.lru_cache(maxsize=32)
def _(value: int):
    print(f'processing int {value}')
    return value * 2

@process.register(str)
@functools.lru_cache(maxsize=32)
def _(value: str):
    print(f'processing str {value}')
    return value.upper()


print(process(5))
print(process(5))        # из кеша
print(process('hello'))
print(process(3.14))
Решение
import functools

@functools.singledispatch
def process(value):
    return str(value)

@process.register(int)
@functools.lru_cache(maxsize=32)
def _(value: int):
    print(f'processing int {value}')
    return value * 2

@process.register(str)
@functools.lru_cache(maxsize=32)
def _(value: str):
    print(f'processing str {value}')
    return value.upper()


print(process(5))
print(process(5))        # из кеша
print(process('hello'))
print(process(3.14))