deшка
Урок 3.8

Классы в пайплайне: self, super() и принципы ООП без религии

Что такое self и зачем он нужен, как работает super(), dataclass вместо бойлерплейта, принципы ООП так, как их спрашивают, и где в коде загрузки класс уместен, а где достаточно функции.

Код инженера данных это в основном функции: прочитать, преобразовать, записать. Классы нужны реже, чем в бэкенде, но без них не обойтись: коннекторы, конфиги, клиенты API, операторы в Airflow. А на собеседовании «назовите принципы ООП» и «что такое self» спрашивают независимо от того, сколько классов вы напишете на работе. Урок про механику классов в Python и про то, когда класс действительно нужен.

Класс и экземпляр

Класс описывает, какие данные и методы есть у объектов. Экземпляр это конкретный объект с конкретными данными.

class Source:
    def __init__(self, name):
        self.name = name
        self.rows_read = 0
 
    def read(self):
        raise NotImplementedError
 
    def describe(self):
        return f"{self.name}: прочитано {self.rows_read}"
 
src = Source("orders")
print(src.describe())      # orders: прочитано 0

__init__ вызывается при создании экземпляра и заполняет его атрибуты. Это не конструктор в смысле C++: объект уже создан, __init__ его инициализирует.

self

Метод это обычная функция, которая лежит в классе. Когда вы пишете src.describe(), Python превращает это в Source.describe(src): экземпляр подставляется первым аргументом. Этот первый аргумент и называется self. Имя это соглашение, можно назвать иначе, но никто так не делает.

print(Source.describe(src))   # то же самое: orders: прочитано 0

Через self метод обращается к данным именно этого экземпляра: self.name, self.rows_read. Без self метод не знал бы, с каким объектом работает. Атрибуты экземпляра живут в его словаре src.__dict__, и self.x = 1 внутри метода это запись в этот словарь.

Два частых вопроса. «Почему self надо писать в сигнатуре, а при вызове не надо?» Потому что подставляет его Python при вызове через экземпляр. «Чем отличается атрибут класса от атрибута экземпляра?» Атрибут класса общий для всех экземпляров и задаётся в теле класса; атрибут экземпляра свой у каждого и задаётся через self. Изменяемый атрибут класса (rows = [] в теле класса) это та же ловушка, что изменяемое значение по умолчанию у функции: один список на всех.

Наследование и super()

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

class CsvSource(Source):
    def __init__(self, name, path):
        super().__init__(name)      # name и rows_read заполнит родитель
        self.path = path
 
    def read(self):
        with open(self.path, encoding="utf-8") as f:
            for line in f:
                self.rows_read += 1
                yield line.rstrip("\n").split(",")
 
    def describe(self):
        return super().describe() + f" из {self.path}"

Без super().__init__(name) у экземпляра CsvSource не было бы атрибутов name и rows_read, и describe упал бы с AttributeError. Это самая частая ошибка при наследовании: переопределили __init__ и забыли вызвать родительский.

super() без аргументов внутри метода означает «следующий класс в порядке разрешения методов после текущего». При одиночном наследовании это просто родитель. При множественном порядок задаёт MRO (Cls.__mro__), и super() обходит его по цепочке, поэтому в сложных иерархиях super().__init__() обязан вызываться в каждом классе, иначе цепочка рвётся.

Наследование в коде загрузок уместно для одного: общий интерфейс и общие куски поведения у семейства источников, операторов, проверок. Иерархии в три уровня глубиной это уже плохой знак. Если дочерний класс переопределяет половину методов родителя, им не стоило быть родственниками.

dataclass

Большая часть классов в пайплайне это «запись с полями»: конфиг подключения, строка из API, результат проверки. Писать для них __init__, __repr__ и __eq__ руками долго и ошибкоопасно. dataclass генерирует их по аннотациям:

from dataclasses import dataclass, field
 
@dataclass(frozen=True)
class DbConfig:
    host: str
    port: int = 5432
    database: str = "dwh"
    options: dict = field(default_factory=dict)
 
cfg = DbConfig("db.internal", database="stg")
print(cfg)                                   # DbConfig(host='db.internal', port=5432, database='stg', options={})
print(cfg == DbConfig("db.internal", database="stg"))   # True

frozen=True запрещает менять поля после создания и делает экземпляр хешируемым, то есть пригодным в ключи словаря. field(default_factory=dict) вместо options: dict = {}: dataclass сам не даст поставить изменяемое значение по умолчанию и выбросит ошибку, та же ловушка, что у функций. slots=True экономит память, если экземпляров миллионы.

Для данных, которые приходят снаружи и требуют проверки типов, следующий шаг это pydantic: модель с валидацией на входе. Об этом в блоке про работу с API.

Принципы ООП так, как о них спрашивают

На собеседовании ждут четыре слова и по предложению на каждое. С привязкой к Python это выглядит так.

Инкапсуляция. Данные и методы работы с ними живут вместе, а внутренности скрыты за интерфейсом. В Python нет закрытых полей: подчёркивание в self._conn это договорённость «не трогать снаружи», двойное подчёркивание включает переименование имени, но не защиту. Инкапсуляция здесь это дисциплина, а не механизм языка.

Наследование. Класс получает поведение родителя и дополняет его. В Python множественное, с порядком MRO.

Полиморфизм. Один вызов работает с объектами разных классов, если у них есть нужный метод. В Python это утиная типизация: load_all(sources) вызывает src.read() у каждого, и неважно, CsvSource это, ApiSource или объект из теста, лишь бы read был.

Абстракция. Выделение общего интерфейса, за которым скрыта реализация. Source.read() с NotImplementedError или abc.ABC с @abstractmethod: класс объявляет, что умеет, и не говорит как.

Честный ответ про практику: в коде загрузок из четырёх принципов работают полиморфизм через утиную типизацию и абстракция интерфейсов. Глубокие иерархии наследования и «геттеры и сеттеры» из учебников по Java в Python выглядят чужеродно, и интервьюеры из DE обычно это знают.

Когда класс, а когда функция

Класс нужен, когда есть состояние, которое живёт между вызовами, и несколько операций над ним: соединение с базой и методы запросов, клиент API с токеном и ретраями, накопитель статистики по батчам. Признак: несколько функций принимают один и тот же набор параметров первым аргументом. Это и есть self, который просится наружу.

Функция нужна, когда операция чистая: вход, выход, без состояния. Преобразование строки, проверка записи, расчёт суммы. Класс с одним методом и __init__ это функция, которой выдали лишнюю форму.

Остальные механизмы, которые встречаются в чужом коде и стоит узнавать: @property превращает метод в атрибут только для чтения (вычисляемое поле), @staticmethod и @classmethod это функции внутри класса без self и с классом вместо self (альтернативные конструкторы вроде DbConfig.from_env()), __repr__ определяет, как объект печатается в логах, и его стоит писать у любого класса, который попадёт в traceback.

Как отвечать на собеседовании

Что такое self? Первый параметр метода, в который Python подставляет экземпляр при вызове obj.method(). Через него метод читает и пишет атрибуты именно этого объекта. Имя это соглашение, подстановка это механизм языка.

Что такое super() и зачем он нужен? Доступ к методам следующего класса в порядке MRO, обычно родителя. Используется, чтобы дополнить унаследованный метод, а не переписать его целиком; в __init__ дочернего класса вызов super().__init__() обязателен, иначе атрибуты родителя не будут созданы.

Назовите принципы ООП. Инкапсуляция, наследование, полиморфизм, абстракция. В Python инкапсуляция держится на соглашении об именах с подчёркиванием, полиморфизм реализуется утиной типизацией, абстракция через abc или просто через договорённость об интерфейсе.

Когда в пайплайне нужен класс? Когда есть состояние между вызовами и несколько операций над ним: коннектор, клиент API, конфиг. Для чистых преобразований достаточно функций, для записей с полями есть dataclass.

Практика

python 3.12 · песочницарешено 0 / 3

Класс BatchStats копит статистику по батчам загрузки. Метод add(rows, errors) учитывает один батч: число строк и число ошибок. Метод summary() возвращает строку вида "батчей: 3, строк: 250, ошибок: 4". Заполните __init__ и методы: вся статистика хранится в атрибутах экземпляра через self, у каждого экземпляра своя.

Ctrl+Enter
ТЕСТЫ
ВводВаш выводОжидаемый вывод
s = BatchStats() s.add(100) s.add(100, errors=3) s.add(50, 1) print(s.summary())—батчей: 3, строк: 250, ошибок: 4
a = BatchStats() b = BatchStats() a.add(10) print(a.summary()) print(b.summary())—батчей: 1, строк: 10, ошибок: 0 батчей: 0, строк: 0, ошибок: 0
скрытый тест 3
решённые задания сохранятся в прогресс после входа
Войти, чтобы отмечатьдальше →
3.9 →Исключения и политика плохих данных