Skip to content

Functions

Functions - Функції

Що таке функція у Python

Summary

Функція - це блок коду, який виконується лише тоді, коли його викликають. Функції використовуються для організації коду та повторного використовуваним. Функцію оголошують за допомогою ключового слова def, за яким слідує ім'я функції та параметри у круглих дужках.

Тіло функції - це блок коду, який виконується при виклику функції. Він може містити різні операції та вказівки. Функція може повертати значення за допомогою ключового слова return. Це значення може бути використане у коді, який викликав функцію.

Для виклику функції використовується її ім'я та передаються аргументи у круглих дужках. Параметри - це значення, які функція отримує при виклику. Аргументи - це конкретні значення, передані функції при її виклику.

Параметри передаються

  • по значенню - immutable створюються копії
  • по посиланню - по reference - mutable - створюється посилання
def greet(name):
    return "Hello, " + name

message = greet("Alice")
print(message)  # Hello, Alice

В Python функція - це об'єкт. Все що є за межами функцій і класів - глобальні змінні.

Що таке args, kwargs і в яких випадках вони потрібні

Вирази *args і **kwargs оголошуються в сигнатурі функції. Вони означають, що всередині функції будуть доступні змінні з іменами args і kwargs (без зірочок). Можна використовувати інші імена, але це вважається поганим стилем.

args - це кортеж, який накопичує позиційні аргументи. kwargs - словник іменованих аргументів, де ключ - це ім'я параметра, а значення - значення параметра.

Якщо в функцію не передано жодних параметрів, змінні будуть відповідно пустим кортежем і пустим словником, а не None.

def func(*args, **kwargs):
    print(f"args: {args}, kwargs: {kwargs}")

func()  # args: (), kwargs: {}

Чому використання змінних об'єктів як параметрів за замовчуванням погана практика

Функція створюється один раз під час завантаження модуля. Іменовані параметри і їх значення за замовчуванням також створюються один раз і зберігаються в одному з полів об'єкта-функції (__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,)

Чи можна передавати функцію як аргумент іншій функції

Так, Функції 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) - коли потрібна мутабельність або кастомні методи.

Словник для динамічного набору

Якщо набір полів варіюється або частина може бути відсутньою:

def fetch_user(uid: int) -> dict:
    return {"id": uid, "name": "...", "email": "..."}

Для public-API краще TypedDict для типобезпеки на статичному аналізі.

Чого не робити

  • return a; return b - другий return ніколи не виконається. Якщо потрібно повертати один із двох - умовний оператор: return a if cond else b.
  • Глобальні змінні як "додаткові повернення" - функція стає прихованим side-effect-кодом, тестувати і композувати її складно.
  • Кортежі з 5+ елементів без іменування - заміна на NamedTuple / dataclass майже завжди виграш у читабельності і гнучкості (новий field не ламає позиційні розпаковки на сайтах виклику).

Чи можна оголошувати функцію всередині іншої функції. Де вона буде видима

Так, можна. Така функція буде видимою лише всередині першої функції.

Що таке лямбди. Які їх особливості

Лямбда (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 є операторами.

nope = lambda: pass
riser = lambda x: raise Exception(x)

Як передаються значення аргументів у функцію або метод (by value or reference)?

Summary - Незмінні (імутабельні) об'єкти передаються "по значенню" (цілі числа, рядки). - Змінювані об'єкти такі, як списки, словники, передаються у вигляді посилань на об'єкти.

У мовах програмування, таких як C++, існують змінні, які зберігаються у стеку та динамічній пам'яті. При виклику функції ми поміщаємо всі аргументи у стек, після чого передаємо керування функції. Функція знає розміри та зміщення змінних у стеку та може вірно їх інтерпретувати.

При цьому у нас є два варіанти: скопіювати пам'ять змінної на стек або помістити посилання на об'єкт у динамічній пам'яті (або на більш високому рівні стеку). Очевидно, що при зміні значень на стеці функції, значення в динамічній пам'яті не зміняться, а при зміні області пам'яті за посиланням, ми змінюємо спільну пам'ять, відповідно всі посилання на цю ж область пам'яті "побачать" нове значення.

У Python відмовилися від подібного механізму, заміною є механізм присвоєння (assignment) імені змінної до об'єкту, наприклад, при створенні змінної: var = "john" інтерпретатор створює об'єкт "john" та "ім'я" var, а потім зв'язує об'єкт з даним іменем. При виклику функції, нових об'єктів не створюється, замість цього в її області видимості створюється ім'я, яке зв'язується з існуючим об'єктом.

Проте в Python є змінні і незмінні типи даних. До других, наприклад, належать числа: при арифметичних операціях існуючі об'єкти не змінюються, а створюється новий об'єкт, з яким потім зв'язується існуюче ім'я. Якщо після цього зі старим об'єктом не зв'язано жодного імені, воно буде видалено за допомогою механізму підрахунку посилань. Якщо ж ім'я зв'язано зі змінною змінного типу, то при операціях з нею змінюється пам'ять об'єкта, відповідно всі імена, зв'язані з даною областю пам'яті, "побачать" зміни.

def inc(a):
    a += 1
    return a

a = 5
print(inc(a))
print(a) # -> 5

Links

Що таке замикання (Closure)

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:

def make_counter():
    count = 0
    def inc():
        nonlocal count
        count += 1
        return count
    return inc

Для зміни глобальної змінної - аналогічно, але global замість nonlocal. Якщо замикання лише читає змінну (не присвоює), ніяких ключових слів не потрібно.

Чиста функція (pure function)

Summary

Чиста функція - функція, яка задовольняє дві умови: (1) детермінованість - однакові вхідні дані завжди дають однаковий результат; (2) відсутність побічних ефектів - не змінює стан поза собою (глобальні змінні, mutable-аргументи, файли, БД, мережа, time, random). Виклик можна замінити на результат без зміни поведінки програми (referential transparency).

Приклади

Чиста:

def add(a, b):
    return a + b              # Deterministic, no side effects

Не чисті - кожна порушує одну з умов:

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