$ sudo teach IT
МОДУЛЬ 4 · УРОК 2

Duck typing — утиная типизация

В прошлом уроке мы освоили полиморфизм через наследование. Сегодня открываем кое-что ещё более мощное — утиную типизацию. Это особый способ мышления, который делает Python невероятно гибким языком. Готовьтесь: после этого урока вы будете смотреть на код совсем иначе.

⏱ ~40 минут 🎓 Для новичков 🐍 Python 3

🦆 История одной фразы про утку

Начнём с истории. В XIX веке американский поэт Джеймс Уитком Райли написал что-то вроде такой фразы, которая со временем превратилась в поговорку:

«Если оно ходит как утка, крякает как утка — то это утка.»

— "Duck Test", народная поговорка

Изначально фраза использовалась в философии и политике: если нечто ведёт себя как X — то для практических целей оно и есть X, неважно, как оно называется официально.

В мир программирования этот принцип перенёс Алекс Мартелли — один из авторов книги «Python в двух словах». В 2000-м году он написал в рассылке Python: «Вам не нужно проверять, что объект является уткой — просто попробуйте позвать его кряканием». Так выражение «утиная типизация» (duck typing) вошло в словарь каждого Python-разработчика.

💡 Суть принципа для программирования: Python не спрашивает «какого ты типа?» перед тем как вызвать метод. Python просто пробует вызвать нужный метод. Есть метод — работает. Нет метода — ошибка. Тип объекта вообще не важен.

🔍 Как Python «проверяет» объекты

Давайте сначала поймём разницу на примере из жизни. Вы — руководитель проекта. Вам нужен человек, умеющий рисовать схемы. Вы можете поступить двумя способами:

📋

Способ 1: проверить документы

«Покажи диплом дизайнера, свидетельство о квалификации и паспорт». Только после этого допускаем к работе.

Так работает статическая типизация (Java, C#)

✏️

Способ 2: дать карандаш

«Нарисуй схему». Если нарисовал — берём. Документы не нужны, главное — умеет делать нужное.

Так работает утиная типизация (Python)

Python работает как второй руководитель. Он не требует доказательств того, кем является объект, — он просто пробует попросить объект сделать нужное действие.

Посмотрите на простейший пример. Напишем функцию, которая «заставляет говорить» любой объект:

# Функция make_it_quack не знает и не проверяет тип объекта
# Она просто вызывает метод quack() — и всё!
def make_it_quack(duck):
    duck.quack()


# Создадим несколько классов — НИКАК не связанных друг с другом
class RealDuck:
    def quack(self):
        print("Кря-кря! 🦆 (настоящая утка)")


class RubberDuck:
    def quack(self):
        print("Пи-пи! 🟡 (резиновая уточка для ванны)")


class Person:
    def quack(self):
        print("Кря! Я притворяюсь уткой 🙋 (человек)")


# Вызываем функцию с объектами разных классов
# Никакого общего родителя нет — но всё работает!
make_it_quack(RealDuck())    # Кря-кря! 🦆 (настоящая утка)
make_it_quack(RubberDuck())  # Пи-пи! 🟡 (резиновая уточка для ванны)
make_it_quack(Person())      # Кря! Я притворяюсь уткой 🙋 (человек)

🎯 Ключевой момент: Классы RealDuck, RubberDuck и Person — абсолютно независимые классы. У них нет общего родителя, нет наследования. Но функция make_it_quack работает со всеми ними одинаково, потому что у всех есть метод quack(). Это и есть утиная типизация.

⚖️ Python vs Java: два взгляда на один и тот же код

Чтобы по-настоящему оценить утиную типизацию, взглянем на разницу между Python и статически типизированными языками — Java и C#. Там перед тем как передать объект в функцию, вы обязаны объявить его тип.

Допустим, у нас есть функция, которая читает данные из некоего источника. В Java это выглядело бы так (примерно, чтобы понять идею):

// Java — статическая типизация
// Метод принимает ТОЛЬКО объект типа FileReader — ничего другого!
public void processData(FileReader reader) {
    String line = reader.readLine();
    System.out.println(line);
}

// Хочешь передать StringReader (строка как файл)?
// Java скажет: ОШИБКА компиляции! Неправильный тип!
// Придётся создавать общий интерфейс, делать классы его реализующими...

А теперь посмотрите, как та же идея выглядит в Python:

# Python — утиная типизация
# Функция принимает ЛЮБОЙ объект, у которого есть метод read()
def process_data(reader):
    data = reader.read()
    print(data)


# Реальный файл с диска
with open("data.txt", "w") as f:
    f.write("Привет из файла!")

with open("data.txt", "r") as file_reader:
    process_data(file_reader)     # Работает! ✅


# StringIO — строка, которая "притворяется" файлом
import io
string_reader = io.StringIO("Привет из строки!")
process_data(string_reader)       # Работает! ✅


# BytesIO — байты, которые "притворяются" файлом
bytes_reader = io.BytesIO(b"Hello from bytes!")
process_data(bytes_reader)        # Работает! ✅


# Свой класс с методом read() — тоже работает!
class DatabaseReader:
    def read(self):
        return "Данные из базы данных"

db_reader = DatabaseReader()
process_data(db_reader)           # Работает! ✅

🧠 Что такое StringIO и BytesIO? Это объекты из стандартной библиотеки Python, которые работают точно как файлы, но данные хранятся не на диске, а в памяти. У них есть методы read(), write(), seek() — всё как у настоящего файла. Это классический пример утиной типизации из самого Python.

Функция process_data не знает и не проверяет: это реальный файл, виртуальный файл в памяти или вообще кастомный класс. Главное — у объекта есть .read(). Есть метод — работает. В Java для этого пришлось бы создавать интерфейс и заставлять все классы его реализовывать. В Python — просто пишем код.

✨ Почему duck typing — это здорово

Утиная типизация даёт программисту несколько очень важных преимуществ. Давайте разберём каждое.

🧩

Гибкость

Функция работает с любым объектом, у которого есть нужный метод. Не нужно строить иерархию классов только ради передачи в функцию.

📉

Меньше кода

Не нужны интерфейсы, абстрактные классы и декларации типов. Пишешь только то, что нужно по делу.

🧪

Лёгкое тестирование

Вместо реального объекта (например, базы данных) можно передать простую «заглушку» с нужным методом. Идеально для тестов.

🔌

Расширяемость

Можно добавить поддержку нового типа объектов, просто реализовав нужный метод — без изменения уже написанного кода.

Особенно ощутима выгода при тестировании. Представьте функцию, которая отправляет письма через почтовый сервер:

def send_notification(mailer, message):
    """Отправляет уведомление через mailer.
    mailer должен иметь метод send(message).
    """
    mailer.send(message)


# Настоящий почтовый клиент — требует SMTP, интернет и т.д.
class SmtpMailer:
    def send(self, message):
        # Здесь был бы реальный код отправки по SMTP
        print(f"📧 Отправляю через SMTP: {message}")


# Заглушка для тестов — никакой сети не нужно!
class FakeMailer:
    def __init__(self):
        self.sent_messages = []

    def send(self, message):
        self.sent_messages.append(message)
        print(f"🧪 [ТЕСТ] Сохранил сообщение: {message}")


# В продакшене используем настоящий мейлер
send_notification(SmtpMailer(), "Ваш заказ готов!")

# В тестах используем заглушку
fake = FakeMailer()
send_notification(fake, "Ваш заказ готов!")
print(f"Сообщений сохранено: {len(fake.sent_messages)}")  # 1

🎯 Функция send_notification вообще не знает о существовании FakeMailer. Она просто вызывает mailer.send(). Это делает тестирование невероятно лёгким — и именно поэтому Python так популярен в разработке через тестирование (TDD).

⚠️ Риски утиной типизации — ошибки в рантайме

Было бы нечестно рассказывать только о плюсах. У утиной типизации есть серьёзный недостаток, о котором важно знать с самого начала.

В статически типизированных языках, если вы передадите объект неправильного типа, компилятор поймает ошибку до запуска программы — ещё когда вы пишете код. В Python ошибка обнаружится только в момент выполнения (runtime), когда Python попытается вызвать метод и не найдёт его.

def make_it_quack(duck):
    duck.quack()


class Dog:
    def bark(self):
        print("Гав! 🐶")


# Передаём объект без метода quack()
# Python НЕ предупредит об этом заранее!
doggo = Dog()
make_it_quack(doggo)

# AttributeError: 'Dog' object has no attribute 'quack'
# Эта ошибка возникнет только в момент запуска этой строки!

🚨 Что это значит на практике?

Допустим, функция с ошибкой находится глубоко в коде и вызывается редко — только в особых условиях. Программа работает неделями без проблем, а потом в один прекрасный день — бам! — ошибка. Именно поэтому в Python очень важно:

  • Писать тесты, которые проверяют вызовы функций с разными типами объектов
  • Читать и документировать, какие методы ожидает функция
  • Использовать аннотации типов (type hints) как подсказки — о них в следующем модуле

Это не повод бояться утиной типизации. Это повод писать тесты и хорошую документацию. Python-разработчики принимают такой компромисс: чуть больше ответственности за правильное использование — в обмен на огромную гибкость кода.

🙏 Принцип EAFP — проще попросить прощения

В Python-сообществе есть устоявшийся принцип работы с ошибками, который тесно связан с утиной типизацией. Он называется EAFP — сокращение от:

EAFP — Easier to Ask Forgiveness than Permission

«Проще попросить прощения, чем разрешения»

Аналогия из жизни: вы хотите взять ручку с чужого стола. Есть два подхода:

  • Спросить разрешения: «Можно я возьму ручку? А вдруг она нужна тебе? А вдруг ты против?» — долго, много проверок.
  • Просто взять и попросить прощения если что: берёте ручку, а если хозяин против — извиняетесь и кладёте обратно. Быстро и просто.

В программировании EAFP означает: просто пробуй делать то, что нужно, а если не получилось — обработай исключение. Вот как это выглядит в коде:

# EAFP стиль — просто пробуем и ловим исключение
def process(obj):
    try:
        result = obj.calculate()
        print(f"Результат: {result}")
    except AttributeError:
        print("У объекта нет метода calculate() — пропускаем")
    except Exception as e:
        print(f"Произошла другая ошибка: {e}")


class Calculator:
    def calculate(self):
        return 42


class NotACalculator:
    def do_something_else(self):
        return "я не умею считать"


process(Calculator())          # Результат: 42
process(NotACalculator())      # У объекта нет метода calculate() — пропускаем
process("просто строка")       # У объекта нет метода calculate() — пропускаем

Код не проверяет тип заранее. Он просто пробует — и обрабатывает ситуацию, когда что-то пошло не так. Это очень питоновский стиль мышления.

🔄 LBYL vs EAFP — два стиля, когда что использовать

Наряду с EAFP существует противоположный подход — LBYL (Look Before You Leap): «Смотри, куда прыгаешь». Это означает: сначала проверь условия, потом действуй.

LBYL — проверяем перед вызовом

«Смотри куда прыгаешь» — сначала проверяем, есть ли нужный метод, и только потом вызываем.

def process_lbyl(obj):
    # hasattr проверяет, есть ли атрибут/метод
    if hasattr(obj, 'calculate'):
        result = obj.calculate()
        print(f"Результат: {result}")
    else:
        print("Нет метода calculate")

EAFP — пробуем и ловим ошибку

«Проще попросить прощения» — просто вызываем, а если не получилось — обрабатываем.

def process_eafp(obj):
    try:
        result = obj.calculate()
        print(f"Результат: {result}")
    except AttributeError:
        print("Нет метода calculate")

Результат одинаковый — стиль разный. Когда какой использовать?

Когда лучше LBYL:

  • Когда проверка простая и дешёвая
  • Когда код читается как инструкция: «если есть — делаем»
  • В многопоточном коде — хотя и тут EAFP часто лучше
  • Пример: if key in dict: вместо try

Когда лучше EAFP:

  • Когда ошибка — исключительный, редкий случай
  • Когда проверка дублирует работу — сначала проверяем, потом делаем то же самое
  • Работа с файлами, сетью, базами данных
  • Питоновский стиль предпочитает EAFP

Посмотрим на классический пример — открытие файла:

import os

# LBYL — проверяем перед открытием
def read_file_lbyl(filename):
    if os.path.exists(filename):           # Проверка 1: файл существует?
        if os.access(filename, os.R_OK):   # Проверка 2: есть права на чтение?
            with open(filename) as f:
                return f.read()
        else:
            return "Нет прав на чтение"
    else:
        return "Файл не найден"


# EAFP — просто открываем и ловим ошибки
def read_file_eafp(filename):
    try:
        with open(filename) as f:
            return f.read()
    except FileNotFoundError:
        return "Файл не найден"
    except PermissionError:
        return "Нет прав на чтение"


# Оба варианта работают одинаково
# Но у LBYL есть скрытая проблема: между проверкой и открытием
# файл может быть удалён другим процессом! EAFP защищён от этого.
print(read_file_lbyl("data.txt"))
print(read_file_eafp("data.txt"))

💡 Интересная деталь у LBYL: в примере с файлом — между моментом проверки os.path.exists() и моментом открытия open() другая программа может удалить файл. EAFP защищён от этой «гонки»: мы просто открываем файл и обрабатываем ошибку, если она произошла. Это делает EAFP не только питоничнее, но и надёжнее.

🧩 Всё вместе: настоящий пример утиной типизации

Давайте напишем небольшую систему сохранения данных, которая демонстрирует всё, о чём мы говорили. Функция сохранения не знает куда именно сохраняются данные — это определяет переданный объект.

import json
import io


# Функция сохранения — ей всё равно, куда сохранять!
# Главное: у объекта destination должен быть метод write()
def save_data(destination, data: dict):
    """Сохраняет данные в destination.
    destination должен поддерживать метод write(text).
    """
    json_text = json.dumps(data, ensure_ascii=False, indent=2)
    try:
        destination.write(json_text)
        print("✅ Данные сохранены успешно")
    except AttributeError:
        print("❌ Объект не поддерживает запись (нет метода write)")
    except Exception as e:
        print(f"❌ Ошибка при сохранении: {e}")


# 1. Сохраняем в файл на диске
with open("/tmp/output.json", "w", encoding="utf-8") as file:
    save_data(file, {"name": "Алиса", "age": 30})
    # ✅ Данные сохранены успешно


# 2. Сохраняем в StringIO (файл в памяти — удобно для тестов!)
buffer = io.StringIO()
save_data(buffer, {"name": "Боб", "age": 25})
print("В буфере:", buffer.getvalue()[:30], "...")
# ✅ Данные сохранены успешно
# В буфере: {
#   "name": "Боб", ...


# 3. Свой класс — например, «отправка» в базу данных
class DatabaseWriter:
    def __init__(self):
        self.records = []

    def write(self, text):
        self.records.append(text)
        print(f"💾 Записано в БД: {len(text)} символов")


db = DatabaseWriter()
save_data(db, {"product": "курс Python", "price": 990})
# ✅ Данные сохранены успешно
# 💾 Записано в БД: 47 символов

print(f"Всего записей в БД: {len(db.records)}")  # 1


# 4. Объект без метода write — EAFP поймает ошибку
save_data(42, {"key": "value"})
# ❌ Объект не поддерживает запись (нет метода write)

🎯 Посмотрите, как легко менять реализацию: мы передаём разные объекты (файл, буфер в памяти, свою БД) в одну функцию — и всё работает без изменения кода функции. Хотите написать тест? Передайте StringIO. Хотите сохранить в реальный файл? Передайте open(...). Вот это и есть сила утиной типизации.

✅ Итоги урока

  • Duck typing («утиная типизация») — Python не проверяет тип объекта, а просто пробует вызвать нужный метод. Если метод есть — всё работает.
  • Фраза происходит из поговорки: «Если ходит как утка и крякает как утка — это утка».
  • В статических языках (Java, C#) тип нужно объявлять явно. В Python — нет: главное, что у объекта есть нужный метод.
  • Объекты StringIO и BytesIO — классический пример: они «притворяются» файлами и работают везде, где ожидается метод .read()/.write().
  • Преимущества: гибкость, меньше кода, лёгкое тестирование через заглушки.
  • Риск: ошибки обнаруживаются только в runtime — поэтому важны тесты и документация.
  • EAFP (Easier to Ask Forgiveness than Permission) — питоновский стиль: просто пробуем, ловим AttributeError если нужного метода нет.
  • LBYL (Look Before You Leap) — проверяем заранее через hasattr() или isinstance(). Хорош для простых проверок, но EAFP считается более питоновским.

👉 В следующем уроке мы познакомимся с абстрактными классами и ABC (Abstract Base Classes) — инструментом, который позволяет сочетать гибкость утиной типизации с явными контрактами: задать, какие методы обязан реализовать любой подкласс.