Functions
Functions - Функції⚑
Що таке функція у Python [🔥35/100]⚑
Summary
Функція - це блок коду, який виконується лише тоді, коли його викликають. Функції використовуються для організації коду та повторного використовуваним. Функцію оголошують за допомогою ключового слова
def, за яким слідує ім'я функції та параметри у круглих дужках.
Тіло функції - це блок коду, який виконується при виклику функції. Він може містити різні операції та вказівки. Функція може повертати значення за допомогою ключового слова return. Це значення може бути використане у коді, який викликав функцію.
Для виклику функції використовується її ім'я та передаються аргументи у круглих дужках. Параметри - це значення, які функція отримує при виклику. Аргументи - це конкретні значення, передані функції при її виклику.
Параметри завжди передаються однаково - присвоєнням імені тому самому об'єкту (call by object reference); копій при виклику не створюється. Різниця лише в наслідках:
- незмінний об'єкт (int, str, tuple) неможливо змінити на місці, тож зміни всередині функції його не зачіпають - виглядає як "по значенню"
- змінний об'єкт (list, dict) можна змінити на місці, і ці зміни видно в коді, що викликав функцію - виглядає як "по посиланню"
В Python функція - це об'єкт. Все що є за межами функцій і класів - глобальні змінні.
Що таке args, kwargs і в яких випадках вони потрібні [💡19/100]⚑
Вирази *args і **kwargs оголошуються в сигнатурі функції. Вони означають, що всередині функції будуть доступні змінні з іменами args і kwargs (без зірочок). Можна використовувати інші імена, але це вважається поганим стилем.
args - це кортеж, який накопичує позиційні аргументи. kwargs - словник іменованих аргументів, де ключ - це ім'я параметра, а значення - значення параметра.
Якщо в функцію не передано жодних параметрів, змінні будуть відповідно пустим кортежем і пустим словником, а не None.
Позиційні та іменовані параметри: / і * у сигнатурі⚑
Summary
Розділювач
/робить параметри ліворуч від нього тільки позиційними (не можна передати за іменем), а*робить параметри праворуч тільки іменованими (не можна передати позиційно). Це закріплює контракт виклику.
За замовчуванням параметр можна передати як позиційно, так і за іменем. Два спеціальні маркери в сигнатурі обмежують спосіб передачі:
/(PEP 570, з Python 3.8) - усе ліворуч від нього передається лише позиційно.*(PEP 3102) - усе праворуч від нього передається лише за іменем.
def f(pos_only, /, normal, *, kw_only):
...
f(1, 2, kw_only=3) # OK
f(1, normal=2, kw_only=3) # OK
f(pos_only=1, ...) # TypeError: pos_only is positional-only
f(1, 2, 3) # TypeError: kw_only is keyword-only
Призначення
- Тільки позиційні - дозволяють вільно перейменовувати параметр у майбутньому, не ламаючи код викликів за іменем; так оголошені більшість вбудованих функцій (наприклад,
len(obj, /)). Також звільняють ім'я, щоб його можна було прийняти у**kwargs. - Тільки іменовані - примушують писати ім'я в місці виклику для аргументів, чий сенс без імені неочевидний (часто прапорці), що робить виклик читабельнішим і захищає від випадкового передавання за позицією.
*args теж робить наступні параметри тільки іменованими, тож окремий * потрібен лише коли решта позиційних аргументів не збирається в кортеж.
Чому використання змінних об'єктів як параметрів за замовчуванням погана практика [🔥37/100]⚑
Функція створюється один раз під час завантаження модуля. Іменовані параметри і їх значення за замовчуванням також створюються один раз і зберігаються в одному з полів об'єкта-функції (__defaults__). Це стосується списків, множин і словників.
У даному прикладі bar має значення порожнього списку. Список - це змінний об'єкт, тому значення bar може змінюватися від виклику до виклику.
def foo(bar=[]):
bar.append(1)
return bar
foo()
>>> [1]
foo()
[1, 1]
foo()
>>> [1, 1, 1]
foo.__defaults__
>>> ([1, 1, 1],)
Хорошим тоном вважається вказувати для параметра пусте незмінне значення, наприклад 0, None, '', False. В тілі функції перевіряти на False/None і створювати нову колекцію:
def foo(bar=None):
if bar is None:
bar = []
bar.append(1)
return bar
foo()
>>> [1]
foo()
>>> [1]
foo()
>>> [1]
foo.__defaults__
>>> (None,)
Sentinel-об'єкт: відрізнити "аргумент не передано" від None⚑
Summary
Коли
None- допустиме значення аргументу, його не можна використати як маркер "значення не передано". Тоді створюють унікальний вартовий-об'єкт_MISSING = object()і перевіряють його черезis: розпізнавання за ідентичністю, а не рівністю, гарантує, що жодне передане значення випадково з ним не збігається.
Типовий фікс змінного значення за замовчуванням бере None як маркер відсутності (див. секцію "Чому використання змінних об'єктів як параметрів за замовчуванням погана практика" вище). Прийом ламається, якщо None - легітимне значення, яке свідомо передає клієнтський код: функція не відрізнить явне f(x=None) від f(). Розв'язання - власний вартовий-об'єкт (sentinel), створений лише для цієї функції:
_MISSING = object() # unique sentinel, distinct from every other object
def update(value=_MISSING):
if value is _MISSING: # identity check: the argument was truly omitted
... # no value supplied
else:
... # value supplied - even if it is None
object() повертає мінімальний екземпляр, не рівний == жодному іншому об'єкту, окрім себе, тож value is _MISSING ніколи не дає хибне спрацювання. Визначальна риса патерну: вартового розпізнають за ідентичністю (is), а не рівністю (==). Порівняння через == - це вже патерн "sentinel value" (значення-маркер), а не "sentinel object".
Вбудований sentinel() (Python 3.15, PEP 661). Ідіому object() стандартизували: Python 3.15 додає вбудований sentinel('NAME'), що повертає унікальний об'єкт, розпізнаваний через is, як і None. На відміну від голого object(), він має читабельний repr (переданий рядок), коректно переживає copy/pickle (повертає той самий об'єкт за іменем модуля) і може стояти прямо в анотації типів:
MISSING = sentinel('MISSING') # PEP 661 builtin; repr is 'MISSING', picklable, identity-checked
def read_value(default: int | MISSING = MISSING) -> int:
if default is MISSING: # identity check, like None
...
Важлива відмінність: sentinel-об'єкт - truthy (як Ellipsis), на відміну від "falsy" None, тому перевіряти треба саме is MISSING, а не if not default. До появи цього вбудованого засобу (і в коді, що підтримує старіші версії) ту саму роль виконує _MISSING = object().
Sentinel value проти sentinel object. Маркером може бути і звичайне значення, тип якого обумовлено заздалегідь - як str.find(), що повертає -1 для "не знайдено" замість винятку (на відміну від str.index()). Небезпека значення-маркера: його легко випадково використати як звичайні дані - повернутий -1 як індекс дасть некоректний результат. Окремий object() такої колізії не має, бо не є валідним значенням у жодному домені.
У стандартній бібліотеці. functools.lru_cache тримає у замиканні власний sentinel = object() і передає його дефолтом у dict.get(key, sentinel), щоб відрізнити "ключа немає в кеші" від "ключ є, а його кешоване значення - None". Так само влаштовані configparser._UNSET і глобальний _sentinel у bz2.
cache = {}
_MISS = object()
def get_cached(key):
value = cache.get(key, _MISS) # _MISS distinguishes "absent" from a stored None
if value is _MISS:
value = cache[key] = compute(key)
return value
Null Object - споріднений варіант. Замість маркера порожнечі підставляють повноцінний об'єкт, що представляє "нічого", але має той самий інтерфейс, тож код-споживач працює з ним без окремої перевірки на None. Канонічний приклад - logging.NullHandler: drop-in заглушка, яку бібліотека додає у свій логер, щоб той нічого не виводив і не падав за відсутності налаштованих обробників. Пов'язане: python/data_types.md (патерн None/True/False).
Links
Чи можна передавати функцію як аргумент іншій функції [💡14/100]⚑
Так, Функції Python є об'єктами першого класу. Їх можна присвоювати змінним, зберігати в структурах даних, передавати як аргументи іншим функціям, і повертати як значення з інших функцій.
Як повернути кілька значень з функції⚑
Summary
Технічно функція завжди повертає один об'єкт. Кілька значень "повертаються" як кортеж:
return a, b, c- синтаксичний цукор дляreturn (a, b, c). На місці виклику використовується tuple-unpacking:x, y, z = func(). Для двох-трьох значень - звичайний кортеж; для більших або іменованих наборів -NamedTupleабоdataclass.
Кортеж + unpacking
def min_max(items):
return min(items), max(items) # Implicit tuple
lo, hi = min_max([3, 1, 4, 1, 5, 9, 2, 6])
# lo = 1, hi = 9
Дужки навколо return min(items), max(items) опціональні: компілятор бачить кому і збирає кортеж автоматично. Це той самий механізм, що й у a, b = 1, 2.
Ігнорування непотрібних значень
Якщо частина повертається, але не потрібна, прийнятна конвенція - _:
_, hi = min_max([3, 1, 4]) # Only need the max
*_, last = (1, 2, 3, 4) # Star-unpacking for "everything but last"
Іменовані результати: NamedTuple або dataclass
Як тільки повертається 3+ значень, читабельність на місці виклику падає - що означає result[2]? Тоді кортеж замінюють на іменовану структуру:
from typing import NamedTuple
class Statistics(NamedTuple):
mean: float
median: float
stdev: float
def compute_stats(items: list[float]) -> Statistics:
...
return Statistics(mean=m, median=md, stdev=sd)
stats = compute_stats(data)
print(stats.mean, stats.stdev) # Self-documenting access
mean, _, _ = stats # Still unpacks like a tuple
NamedTuple - незмінний, легкий, indexable і unpackable одночасно. @dataclass (див. розділ у class_and_object.md) - коли потрібна мутабельність або кастомні методи.
Словник для динамічного набору
Якщо набір полів варіюється або частина може бути відсутньою:
Для public-API краще TypedDict для типобезпеки на статичному аналізі.
Чого не робити
return a; return b- другийreturnніколи не виконається. Якщо потрібно повертати один із двох - умовний оператор:return a if cond else b.- Глобальні змінні як "додаткові повернення" - функція стає прихованим side-effect-кодом, тестувати і композувати її складно.
- Кортежі з 5+ елементів без іменування - заміна на
NamedTuple/dataclassмайже завжди виграш у читабельності і гнучкості (новий field не ламає позиційні розпаковки на сайтах виклику).
Чи можна оголошувати функцію всередині іншої функції. Де вона буде видима [💡23/100]⚑
Так, можна. Така функція буде видимою лише всередині першої функції.
Що таке лямбди. Які їх особливості [🔥34/100]⚑
Лямбда (lambda) - це анонімна функція, яка не резервує ім'я в просторі імен.
Лямбда може приймати необмежену кількість аргументів та мати будь-яку кількість параметрів. Однак лямбда-функція може мати лише один вираз або інструкцію, і вона завжди повертає результат цього виразу.
Лямбди використовується в ситуаціях, коли потрібна анонімна функція на короткий період часу. Наприклад передати в map, reduce, filter, або для сортування.
У Python лямбди можуть складатися лише з одного виразу. Використовуючи синтаксис дужок, можна оформити тіло лямбди у декілька рядків.
Не можна використовувати крапку з комою для розділення операторів.
a = lambda x, y: x + y
print(a(5, 6)) # 11
students = [{"name": "Alice", "age": 25}, {"name": "Bob", "age": 20}]
sorted(students, key=lambda x: x["age"]) # [{"name": "Bob", "age": 20}, {"name": "Alice", "age": 25}]
Наступні вирази при завантаженні модуля викличуть SyntaxError. Тіло лямбди може містити лише вирази. pass і raise є операторами.
Пастка пізнього зв'язування (late binding) у циклі
Класична особливість лямбд у циклі: лямбда - це замикання, а замикання захоплює саму змінну (за іменем), а не її значення на момент створення. Тому всі лямбди, створені в циклі, бачать останнє значення змінної циклу, а не те, що було на відповідній ітерації:
Усі три лямбди посилаються на ту саму змінну i, яка після циклу дорівнює 2. Щоб зафіксувати значення кожної ітерації, його прив'язують через аргумент за замовчуванням - він обчислюється в момент визначення функції:
Це той самий механізм захоплення за посиланням, що й у секції про замикання (Closure) нижче, - тут він лише найчастіше проявляється з лямбдами.
Як передаються значення аргументів у функцію або метод (by value or reference)? [💡13/100]⚑
Summary
- Незмінні (імутабельні) об'єкти передаються "по значенню" (цілі числа, рядки).
- Змінювані об'єкти такі, як списки, словники, передаються у вигляді посилань на об'єкти.
У мовах програмування, таких як C++, існують змінні, які зберігаються у стеку та динамічній пам'яті. При виклику функції ми поміщаємо всі аргументи у стек, після чого передаємо керування функції. Функція знає розміри та зміщення змінних у стеку та може вірно їх інтерпретувати.
При цьому у нас є два варіанти: скопіювати пам'ять змінної на стек або помістити посилання на об'єкт у динамічній пам'яті (або на більш високому рівні стеку). Очевидно, що при зміні значень на стеці функції, значення в динамічній пам'яті не зміняться, а при зміні області пам'яті за посиланням, ми змінюємо спільну пам'ять, відповідно всі посилання на цю ж область пам'яті "побачать" нове значення.
У Python відмовилися від подібного механізму, заміною є механізм присвоєння (assignment) імені змінної до об'єкту, наприклад, при створенні змінної: var = "john" інтерпретатор створює об'єкт "john" та "ім'я" var, а потім зв'язує об'єкт з даним іменем. При виклику функції, нових об'єктів не створюється, замість цього в її області видимості створюється ім'я, яке зв'язується з існуючим об'єктом.
Проте в Python є змінні і незмінні типи даних. До других, наприклад, належать числа: при арифметичних операціях існуючі об'єкти не змінюються, а створюється новий об'єкт, з яким потім зв'язується існуюче ім'я. Якщо після цього зі старим об'єктом не зв'язано жодного імені, воно буде видалено за допомогою механізму підрахунку посилань. Якщо ж ім'я зв'язано зі змінною змінного типу, то при операціях з нею змінюється пам'ять об'єкта, відповідно всі імена, зв'язані з даною областю пам'яті, "побачать" зміни.
Links
- How do I write a function with output parameters (call by reference)?
- Интересности и полезности python. Часть 3
Що таке замикання (Closure) [💡29/100]⚑
Summary
Замикання (closure) – це вкладена функція, яка дозволяє отримати доступ до змінних зовнішньої функції навіть після завершення виконання зовнішньої функції.
Замикання — це конструкція, яка дозволяє функції "запам'ятовувати" значення змінних з її зовнішнього оточення, навіть після того, як ці змінні вийшли з області видимості. Це досягається завдяки вкладеним функціям, які можуть мати доступ до змінних з оточуючої функції.
Синтаксично це виглядає як функція, що повністю знаходиться у тілі іншої функції. При цьому вкладена внутрішня функція містить посилання на локальні змінні зовнішньої функції. Коли зовнішня функція завершує виконання, контекст її виконання не зникає повністю. Замість цього, він зберігається у вигляді замикання.
def counter():
count = 0
def increment():
nonlocal count
count += 1
print(f"Current count: {count}")
return increment
counter1 = counter()
counter1() # "Current count: 1"
counter1() # "Current count: 2"
print(counter1.__closure__) # return tuple with cell objects
Під капотом використовується атрибут функції, який називається __closure__, який повертає кортеж об'єктів cell, що зберігають значення з області видимості функції, в якій була створена вкладена функція. Кожен елемент кортежу являє собою значення однієї із змінних, що використовуються в замиканні.
Сценарії застосування
- Реалізація декораторів. Декоратор - це майже завжди замикання: внутрішня функція
wrapperпосилається на функцію-аргументfuncзовнішньої функції-декоратора. - Фабрики функцій. Створення функцій із зафіксованими параметрами:
def power_of(exponent):
def raise_to(base):
return base ** exponent
return raise_to
square = power_of(2)
cube = power_of(3)
square(5) # 25
cube(5) # 125
- Інкапсуляція стану без створення класу - "приватні" змінні живуть у замиканні, доступні лише через інтерфейс внутрішньої функції (як у прикладі з
counter). - Функціональне програмування - передача поведінки з зафіксованим контекстом замість об'єктів зі станом.
Пастка з присвоюванням
Замикання захоплює змінну зовнішнього scope, лише якщо вона не присвоюється у внутрішній функції. Присвоювання робить ім'я локальним і ламає захоплення:
def make_counter():
count = 0
def inc():
count += 1 # SyntaxError-ish at runtime: UnboundLocalError
return count
return inc
Тут Python бачить count = ... всередині inc і вважає count локальною змінною з самого початку функції; count += 1 намагається зчитати неіснуючу локальну. Щоб змінювати захоплену змінну, потрібен nonlocal:
Для зміни глобальної змінної - аналогічно, але global замість nonlocal. Якщо замикання лише читає змінну (не присвоює), ніяких ключових слів не потрібно.
Чиста функція (pure function) [💡10/100]⚑
Summary
Чиста функція - функція, яка задовольняє дві умови: (1) детермінованість - однакові вхідні дані завжди дають однаковий результат; (2) відсутність побічних ефектів - не змінює стан поза собою (глобальні змінні, mutable-аргументи, файли, БД, мережа, time, random). Виклик можна замінити на результат без зміни поведінки програми (referential transparency).
Приклади
Чиста:
Не чисті - кожна порушує одну з умов:
counter = 0
def bump():
global counter
counter += 1 # Side effect: mutates global
return counter
def now():
return datetime.now() # Non-deterministic: depends on system time
def append_log(item, log=[]):
log.append(item) # Mutates argument (and shared default)
return log
def fetch_user(uid):
return db.query(uid) # I/O - non-deterministic, side-effect of DB read
Чому це важливо
- Тестування: пряма перевірка
assert add(2, 3) == 5без mock, fixture, cleanup. Не потрібно ізолювати глобальний стан між тестами. - Мемоізація:
functools.cacheкоректний тільки для чистих функцій. Для не чистих кеш стає bug-ом -now()повертатиме перший виклик завжди. - Паралелізм: чисті функції тривіально розпаралелюються - ніяких race conditions, тому що нема спільного стану.
- Композиція: чисті функції безпечно комбінувати через
f(g(x))- результат не залежить від порядку допоміжних операцій. - Reasoning: код стає локальним; можна зрозуміти функцію за її сигнатурою без читання того, що навколо.
Прагматичний компроміс
100% чиста програма не може робити нічого корисного - їй ніколи не побачити введення користувача чи записати в БД. На практиці:
- ядро бізнес-логіки тримається чистим - функції обчислюють, не торкаються I/O;
- I/O-операції (БД, мережа, файли, час) виштовхуються на межі програми - composition root, controller, adapter;
- mutability дозволена локально в межах функції (накопичувач у циклі), але не виходить за її межі.
Цей патерн відомий як functional core, imperative shell (Gary Bernhardt, "Boundaries", 2012).
Зв'язок з ФП і референційною прозорістю
В чисто-функціональних мовах (Haskell, Elm) чистота - властивість типу: побічні ефекти явно позначені монадами (IO). У Python чистота - конвенція, дотримання якої залежить від дисципліни команди. Детальніше про ФП-підхід - у functional_programming.md.
Links
- Gary Bernhardt: Boundaries (PyCon 2013) - functional core, imperative shell
- Wikipedia: Pure function