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))