Код инженера данных это в основном функции: прочитать, преобразовать, записать. Классы нужны реже, чем в бэкенде, но без них не обойтись: коннекторы, конфиги, клиенты 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")) # Truefrozen=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.
Практика
Класс BatchStats копит статистику по батчам загрузки. Метод add(rows, errors) учитывает один батч: число строк и число ошибок. Метод summary() возвращает строку вида "батчей: 3, строк: 250, ошибок: 4". Заполните __init__ и методы: вся статистика хранится в атрибутах экземпляра через self, у каждого экземпляра своя.
| Ввод | Ваш вывод | Ожидаемый вывод | |
|---|---|---|---|
| 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 | |||