Классы и ООП в Python: init, self, наследование, *args и **kwargs

Классы и ООП в Python: init, self, наследование, *args и **kwargs

У многих Junior Python-программистов возникают проблемы с классами. Сегодня подробно разберём применение классов в Python, а также затронем тему *args и **kwargs. 

Почему эти темы трудные? На начальном уровне программирования нам не требуется строить громоздкие конструкции. Обычно мы ограничиваемся скриптами без собственных классов. Или же создаем объекты классов, не задумываясь о том, что внутри. 

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

Что такое ООП в Python простыми словами

Прежде чем писать классы, разберёмся зачем они нужны. ООП (объектно-ориентированное программирование) - подход, при котором программа строится из объектов. Объект объединяет данные и действия над ними в одном месте.

Представьте кота. У него есть данные (имя, возраст, цвет) и поведение (мяукать, спать, есть). В ООП Python мы описываем это классом - шаблоном, по которому создаются объекты-коты.

ООП в Python держится на четырёх принципах:

  • Инкапсуляция - данные и методы спрятаны внутри объекта. Снаружи доступно только то, что мы разрешили.
  • Наследование - один класс может перенять свойства другого. Кот и собака - оба животные, общее берём из родителя.
  • Полиморфизм - объекты разных классов отвечают на один вызов по-своему. Команда "издать звук" у кота даст "мяу", у собаки "гав".
  • Абстракция - прячем сложность за простым интерфейсом. Вызываем cat.feed(), не вникая как именно внутри устроено кормление.

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

Дальше разберём как всё это устроено в Python: классы, объекты, self, init, наследование и продвинутые приёмы.

Классы в Python: основы ООП

Что потребуется:

  1. Компьютер или любая другая ЭВМ
  2. Редактор кода
  3. Python версии 3.9 и выше
  4. Соединение с интернетом (впрочем можно и без него)

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

Например, мы взаимодействуем с базой данных. Нам приходится выполнять ряд однотипных действий:

  1. Подключиться к базе
  2. Найти нужную нам таблицу
  3. Сделать поиск значений по каким-то параметрам
  4. Вывести полученные значения.

Таких действий может быть и больше. Скоро нам надоест руками перебирать все функции для работы с БД. Тут и вступают в игру классы. 

Рассмотрим на примере кода:

class Person:
    def __init__(self, sex: str):
        self.sex = sex
        self.gender = 'heterosexual'
        self.age = 0
        print('I was born')

    def change_gender(self, preferred_gender: str):
        self.gender = preferred_gender

    def describe_the_action(self, action: str):
        print('I do ', action)

    def __del__(self):
        print('I am dead')

Сразу уточним, что функции в рамках класса будут называться методами, а переменные - полями. Обратите внимание - в Python принято записывать имя класса с большой буквы, а имена методов - с маленькой.

Метод __init__ - это конструктор, он автоматически вызывается при создании объекта. Как только мы создали новый объект, нам выведется ‘I was born’. 

Все методы принимают обязательный элемент self - он указывает, что мы обращаемся именно к этому объекту, а не к какому-то другому. 
Для пояснения self есть красочный пример со stackoverflow:

У нашей персоны будет его пол, гендер (стандартный при рождении) и возраст (изначально он будет равен 0). Дальше передаем аргументы.

Теперь немного о полях: они обозначаются обращением к self через точку и название поля.

Метод __del__ - это деконструктор, который срабатывает когда объект уничтожается (вызов del object или завершение скрипта).

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

Создаем объект на основе класса и передаем его пол "male":

person = Person('male')

Давайте посмотрим что выведется и попробуем изменить пол:

print('gender:', person.gender)
person.change_gender('cat')
print('gender:', person.gender)

Выводы в консоль:

I was born

gender: heterosexual

gender: cat

I am dead

Тут мы видим, что все работает корректно. Предлагаю вам самостоятельно поиграться с классом персон.

Атрибуты класса и экземпляра в Python

У классов Python есть два вида атрибутов (полей), и новички часто их путают.

Атрибуты экземпляра - принадлежат конкретному объекту. Задаются обычно в init через self:

class Cat:
    def __init__(self, name):
        self.name = name   # атрибут экземпляра

cat1 = Cat("Барсик")
cat2 = Cat("Мурка")
print(cat1.name)  # Барсик
print(cat2.name)  # Мурка

У каждого кота своё имя - это атрибут экземпляра.

Атрибуты класса - общие для всех объектов. Объявляются прямо в теле класса, вне методов:

class Cat:
    species = "кошка"   # атрибут класса, общий для всех

    def __init__(self, name):
        self.name = name

print(Cat.species)        # кошка
print(Cat("Барсик").species)  # кошка

species один на всех котов. Если поменять Cat.species, изменится у всех экземпляров сразу.

Правило простое: данные уникальные для объекта (имя, возраст) - в self через init. Данные общие для всего класса (вид, константы) - атрибут класса.

Методы классов в Python: обычные, статические и классовые

Метод - это функция внутри класса. В Python есть три вида методов, у каждого своя роль.

Обычный метод - работает с конкретным объектом, первым аргументом принимает self:

class Cat:
    def __init__(self, name):
        self.name = name

    def meow(self):
        print(f"{self.name} говорит: Мяу!")

Статический метод (@staticmethod) - не зависит от объекта и класса, просто логически связан с классом. Без self:

class Cat:
    @staticmethod
    def is_cat_sound(sound):
        return sound.lower() in ("мяу", "meow")

print(Cat.is_cat_sound("Мяу"))  # True

Классовый метод (@classmethod) - работает с самим классом, первым аргументом принимает cls. Часто используется для альтернативных способов создания объекта:

class Cat:
    def __init__(self, name):
        self.name = name

    @classmethod
    def from_string(cls, data):
        return cls(data.split(",")[0])

cat = Cat.from_string("Барсик,3,рыжий")
print(cat.name)  # Барсик

Отдельно стоит property - метод, который ведёт себя как атрибут. Удобно для вычисляемых значений и контроля доступа:

class Cat:
    def __init__(self, birth_year):
        self.birth_year = birth_year

    @property
    def age(self):
        return 2026 - self.birth_year

cat = Cat(2020)
print(cat.age)  # 6 - обращаемся как к атрибуту, без скобок

Магические методы Python (dunder-методы)

Магические методы (их ещё зовут dunder - double underscore) - специальные методы с двойными подчёркиваниями. Python вызывает их автоматически в определённых ситуациях. Один из них вы уже знаете - это init.

Самые полезные магические методы:

init - конструктор, вызывается при создании объекта (мы его уже разбирали).

str - что показывать при print() объекта. Без него увидите невнятное <__main__.Cat object at 0x...>:

class Cat:
    def __init__(self, name):
        self.name = name

    def __str__(self):
        return f"Кот по имени {self.name}"

print(Cat("Барсик"))  # Кот по имени Барсик

repr - техническое представление объекта, для отладки. Показывается в консоли и логах.

eq - как сравнивать объекты через ==:

class Cat:
    def __init__(self, name):
        self.name = name

    def __eq__(self, other):
        return self.name == other.name

print(Cat("Барсик") == Cat("Барсик"))  # True

len - что возвращает len(объект). getitem - доступ по индексу объект[i]. call - позволяет вызывать объект как функцию.

Магические методы делают ваши классы "родными" для Python - они начинают работать со встроенными функциями (print, len, ==) как обычные типы данных.

Наследование классов в Python

Итак, мы хотим создать новый класс на базе Person. Для этого после названия класса в скобках пишем класс, от которого хотим наследоваться: 

class Ivan(Person):
    def __init__(self, sex: str, name: str):
        super().__init__(sex)
        self.name = name

Класс Ivan унаследует все методы и поля, которые есть у родителя Person. 

[TGBLOCK]

Также, надо сделать инициализацию класса родителя. С этим нам поможет  super().__init__(sex). После чего добавляем имя для Ivan, чего не было у Person. Теперь можем создать объект "man" и провести с ним некоторые операции:

man = Ivan('male', 'Ivan')
print('gender:', man.gender)
man.change_gender('cat')
print('gender:', man.gender)

Вывод в консоль:

I was born

gender: heterosexual

gender: cat

I am dead

Не забывайте, что обращаться к полям класса можно через точку:

print(man.name)

Вывод в консоль:

Ivan

Теперь вы убедились, что  Ivan унаследовал все методы от Person + к ним добавилось поле "name".

super() и порядок наследования в Python

При наследовании классов в Python часто нужно вызвать метод родителя. Для этого есть функция super().

Типичный случай - дочерний класс расширяет init родителя:

class Animal:
    def __init__(self, name):
        self.name = name

class Cat(Animal):
    def __init__(self, name, color):
        super().__init__(name)   # вызываем __init__ родителя
        self.color = color       # добавляем своё поле

cat = Cat("Барсик", "рыжий")
print(cat.name, cat.color)  # Барсик рыжий

super().__init__(name) берёт логику из родителя, не дублируя код. Это и есть сила наследования - общее пишем один раз.

Когда классов в иерархии много, Python определяет порядок поиска методов через MRO (Method Resolution Order). Посмотреть его можно так:

print(Cat.__mro__)
# (<class 'Cat'>, <class 'Animal'>, <class 'object'>)

Python ищет метод сначала в самом классе, потом у родителей по цепочке, и в конце - в базовом object. Знание MRO спасает при множественном наследовании, когда классов-родителей несколько.

Инкапсуляция: приватные поля в Python

Инкапсуляция - сокрытие внутренних данных объекта. В Python нет строгих private как в Java, но есть соглашения.

Одно подчёркивание _name - сигнал "это внутреннее, снаружи не трогай". Технически доступ остаётся, но по договорённости лезть не стоит:

class Cat:
    def __init__(self, name):
        self._secret = name   # внутреннее поле

Два подчёркивания __name - Python запутывает имя (name mangling), доступ снаружи усложняется:

class Cat:
    def __init__(self):
        self.__chip_id = 12345   # почти приватное

cat = Cat()
# print(cat.__chip_id)  # AttributeError
print(cat._Cat__chip_id)  # 12345 - доступ только так

Зачем это нужно: инкапсуляция защищает данные от случайных изменений и прячет детали реализации. Снаружи работаем через публичные методы и property, а внутреннюю кухню класса не трогаем. Это делает код надёжнее.

dataclasses: современный способ писать классы

Если класс нужен в основном для хранения данных, писать init вручную утомительно. С Python 3.7 есть dataclasses - они генерируют init, repr и eq автоматически.

Сравните. Обычный класс:

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Point(x={self.x}, y={self.y})"

То же самое через dataclass:

from dataclasses import dataclass

@dataclass
class Point:
    x: int
    y: int

p = Point(1, 2)
print(p)          # Point(x=1, y=2) - __repr__ уже есть
print(p == Point(1, 2))  # True - __eq__ тоже

Декоратор @dataclass сам создаёт конструктор по объявленным полям. Меньше шаблонного кода, меньше ошибок. Для классов-хранилищ данных (конфиги, DTO, точки, записи) - идеальный выбор в современном Python.

Секреты *args и **kwargs

Что это за звёздочки и зачем они нужны? Начнём с *args.

Неважно какое будет название у аргумента, важен сам оператор - звёздочка. В нашем примере звёздочка сообщает Python, что если пользователь укажет больше двух аргументов, все лишние аргументы передадутся в кортеж под названием other: 

def arguments(first, second, *other):
    print('first:', first)
    print('second:', second)
    print('other:', other)
    print(type(other))

arguments(1, 2, 3, 4, 5, 6, 7, 8)

Давайте посмотрим что выведется в консоль:

first: 1

second: 2

other: (3, 4, 5, 6, 7, 8)

<class 'tuple'>

Первые аргументы, поступающие в функцию, имеют свои названия - first и second. Все остальные аргументы попали в кортеж (tuple). К other нужно обращаться по индексу, как и к обычному кортежу (подробнее о tuple тут).

Что будет, если мы не передадим в other никаких аргументов?

arguments(1, 2)

Вывод в консоль:

first: 1

second: 2

other: ()

<class 'tuple'>

Мы просто получим пустой кортеж - никаких ошибок не будет.

Теперь разберёмся с **kwargs.

def keyword_arguments(first, second, **other):
    print('first:', first)
    print('second:', second)
    print('other:', other)
    print(type(other))
    
keyword_arguments(1, 2, nums=[1, 2, 3, 3], string='text', dict={'key': 'value'})

У ваc может возникнуть вопрос - что за новые аргументы nums, string и dict? 
Давайте запустим и посмотрим:

first: 1

second: 2

other: {'nums': [1, 2, 3, 3], 'string': 'text', 'dict': {'key': 'value'}}

<class 'dict'> 

Удивительно, но other стал словарем с ключами в виде названий аргументов. Как мы видим, туда можно передавать любое количество аргументов с названием и значением - они будут доступны в виде словаря.

Если мы задаём всего 2 аргумента - словарь просто останется пустым:

keyword_arguments(1, 2)

Вывод в консоль:

first: 1

second: 2

other: {}

<class 'dict'> 

Как видите, ошибок нет, мы получили пустой словарь.

FAQ: классы и ООП в Python

Что такое self в Python?

self - ссылка на текущий объект внутри методов класса. Через self обращаются к атрибутам и методам конкретного экземпляра. Python передаёт self автоматически при вызове метода, в самом вызове его писать не нужно.

Зачем нужен init в Python?

init - конструктор класса, метод который вызывается автоматически при создании объекта. В нём задают начальные значения атрибутов через self. Например def __init__(self, name): self.name = name.

Чем отличается атрибут класса от атрибута экземпляра?

Атрибут экземпляра уникален для каждого объекта (задаётся через self в init). Атрибут класса общий для всех объектов (объявляется в теле класса). Имя кота - атрибут экземпляра, вид "кошка" - атрибут класса.

Что такое ООП в Python?

ООП - объектно-ориентированное программирование, подход где код строится из объектов, объединяющих данные и поведение. Держится на четырёх принципах: инкапсуляция, наследование, полиморфизм, абстракция. В Python всё является объектом.

Когда использовать args и *kwargs?

args - когда метод принимает произвольное число позиционных аргументов, *kwargs - именованных. Часто применяются при наследовании, чтобы передать аргументы родителю через super() без жёсткой привязки к их числу.

Что лучше: обычный класс или dataclass?

Если класс в основном хранит данные - берите dataclass, он экономит код (сам генерирует init, repr, eq). Если в классе много логики и методов - обычный класс гибче.

Заключение

Темы классов и аргументов не просты в освоении. Надеюсь, мне удалось пролить больше света на эти удивительные возможности Python. 

Чем крупнее будут становиться проекты, тем больше вам придётся иметь дело с классами. Без них просто невозможно управлять сложными объектами с большим количеством функций. 

Ну а если вы не знаете заранее какие аргументы и в каком количестве вам нужны для функции, на помощь всегда придут *args и **kwargs. 

Чтобы понять и закрепить материал, я рекомендую самостоятельно поиграться с классами, с *args и **kwargs. Лучше, если вы примените их на реальном проекте, чтобы как следует разобраться и усвоить детали.