Объектно-ориентированное программирование в Python: четыре столпа ООП

Курс посвящён систематическому изучению фундаментальных концепций объектно-ориентированного программирования на языке Python. Слушатели последовательно освоят четыре столпа ООП — инкапсуляцию, наследование, полиморфизм и абстракцию — с теоретическим обоснованием и примерами синтаксиса. Материал предназначен для новиков и изложен в академическом стиле с чёткими определениями.

1. Основы классов и объектов в Python: определение и создание

Основы классов и объектов в Python: определение и создание

Представьте, что вы проектируете каталог товаров для интернет-магазина. У каждого товара есть название, цена и количество на складе. Если бы вы описывали каждый товар отдельно переменными, вам пришлось бы создавать name_1, price_1, stock_1, затем name_2, price_2, stock_2 — и так для тысяч позиций. Такой подход моментально становится неуправляемым. Именно для решения этой проблемы в программировании существует понятие класса — шаблона, который описывает структуру и поведение однотипных сущностей.

Класс как чертёж

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

В Python класс объявляется ключевым словом class, за которым следует имя класса и двоеточие. По соглашению имена классов записываются в стиле PascalCase — каждое слово с заглавной буквы без разделителей.

Этот минимальный класс ничего не делает, но уже является валидным. Ключевое слово pass здесь означает «тело пусто» — Python требует, чтобы после двоеточия был хотя бы один оператор.

Конструктор __init__ и атрибуты экземпляра

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

Параметр self — это ссылка на текущий экземпляр класса. Через self конструктор привязывает переданные значения к конкретному объекту. Значение по умолчанию stock = 0 означает, что если количество не указано, товар считается отсутствующим на складе.

Объект как экземпляр класса

Объект (или экземпляр) — это конкретная реализация класса с заполненными данными. Если класс — чертёж, то объект — это построенный по чертежу дом с реальными стенами и мебелью.

Каждый объект хранит свои собственные значения атрибутов. Обратиться к ним можно через точечную нотацию:

Атрибуты можно изменять после создания объекта — Python не накладывает ограничений на запись, если только вы явно не используете механизмы инкапсуляции, которые будут рассмотрены в следующей статье.

Методы: поведение объектов

Если атрибуты описывают состояние объекта, то методы описывают его поведение. Метод — это функция, определённая внутри класса и принимающая первым параметром self.

Метод is_available возвращает булево значение — есть ли товар в наличии. Метод apply_discount уменьшает цену на заданный процент, а restock пополняет запас. Все три метода работают с атрибутами конкретного экземпляра через self.

Строковое представление: __str__ и __repr__

По умолчанию вывод объекта на экран даёт непонятную информацию вроде <__main__.Product object at 0x7f8b1c>. Чтобы это исправить, определяют специальные методы __str__ и __repr__.

Метод __str__ вызывается при использовании print() и предназначен для человека. Метод __repr__ вызывается в отладочных контекстах и должен однозначно описывать объект — в идеале так, чтобы его можно было воссоздать.

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

Помимо атрибутов, привязанных к конкретному объекту через self, существуют атрибуты класса — они определяются непосредственно в теле класса и общие для всех экземпляров.

Обратиться к атрибуту класса можно как через имя класса, так и через экземпляр:

Однако стоит быть осторожным: если через экземпляр присвоить значение атрибуту с тем же именем, будет создан атрибут экземпляра, который «перекроет» атрибут класса для данного объекта. Остальные экземпляры продолжат видеть исходное значение.

Статические и классовые методы

Иногда методу не нужен доступ ни к конкретному экземпляру, ни к самому классу. Для таких случаев существует декоратор @staticmethod. А @classmethod принимает первым параметром сам класс (cls), а не экземпляр.

Классовый метод from_dict — это фабричный метод: он создаёт экземпляр из словаря, что удобно при работе с данными из JSON или базы данных. Статический метод validate_price проверяет корректность цены, не привязываясь к конкретному объекту.

Встроенные проверки типов

Python — язык с динамической типизацией, поэтому атрибутам можно присваивать значения любого типа. Чтобы контролировать это, используют аннотации типов (как в примерах выше) и библиотеку dataclasses, которая автоматически генерирует __init__, __repr__ и другие методы.

Этот код эквивалентен ручному определению класса с __init__ и __repr__, но значительно компактнее. Аннотации типов не накладывают жёстких ограничений в рантайме, но позволяют статическим анализаторам (например, mypy) находить ошибки до запуска программы.

Классы и объекты — это фундамент, на котором строятся все четыре столпа объектно-ориентированного программирования. Понимание того, как устроены конструкторы, методы и атрибуты, необходимо перед тем, как переходить к контролю доступа к данным внутри объектов.

2. Инкапсуляция и управление доступом: уровни видимости

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

Представьте банковский счёт: у него есть баланс, и вы хотите, чтобы клиент мог пополнять его и снимать деньги, но не мог напрямую изменить баланс на произвольное значение. Если бы атрибут баланса был публичным, ничто не мешало бы написать account.balance = 1000000 — и миллион появился бы из ниоткуда. Инкапсуляция решает именно эту проблему: она позволяет скрыть внутреннее состояние объекта и предоставить контролируемый доступ через заранее определённые методы.

Суть инкапсуляции

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

В Python инкапсуляция реализуется не через жёсткие модификаторы доступа (как private в Java или C++), а через соглашение об именовании — механизм, основанный на культуре языка и поддерживаемый инструментами.

Три уровня доступа в Python

Python формально различает три уровня видимости атрибутов и методов:

| Уровень | Синтаксис | Смысл | |---------|-----------|-------| | Публичный | self.name | Доступен откуда угодно | | Защищённый | self._name | Сигнал «не трогай извне» | | Приватный | self.__name | Имя искажается (name mangling) |

Разберём каждый уровень подробно.

Публичные атрибуты

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

Проблема очевидна: ничто не мешает сделать account.balance = -5000 или account.balance = "много". Публичные атрибуты уместны, когда данные действительно должны быть открыты — например, имя владельца.

Защищённые атрибуты

Атрибут, имя которого начинается с одного подчёркивания (_), по соглашению считается защищённым. Это означает: «этот атрибут является частью внутренней реализации, не используйте его напрямую извне класса».

Важно понимать: одно подчёркивание — это именно соглашение, а не техническое ограничение. Python не запретит обратиться к account._balance извне. Однако статические анализаторы и IDE (например, PyCharm) подсвечивают такие обращения как потенциально ошибочные, а опытные разработчики рассматривают нарушение этого соглашения как признак плохого кода.

Приватные атрибуты и name mangling

Двойное подчёркивание в начале имени (__) запускает механизм name mangling — автоматического переименования атрибута. Python преобразует __balance в _Account__balance, что затрудняет (но не делает невозможным) случайный доступ извне.

Name mangling полезен прежде всего при наследовании: он предотвращает случайное перекрытие атрибутов в дочерних классах. Если базовый класс использует __balance, а дочерний класс тоже определит __balance, они не столкнутся — внутренние имена будут разными.

Свойства: @property

Прямые геттеры и сеттеры (get_balance, set_balance) работают, но выглядят громоздко. Python предлагает элегантную альтернативу — свойства через декоратор @property. Свойство выглядит как обычный атрибут при чтении, но за кулисами выполняет произвольную логику.

Обратите внимание на паттерн: публичное свойство balance оборачивает защищённый атрибут _balance. Внешний код обращается к acc.balance, а внутри — строгая валидация. Это и есть инкапсуляция в действии.

Свойство может быть доступно только для чтения — достаточно определить @property без соответствующего @balance.setter:

Попытка присвоить значение acc.created_at = "..." вызовет AttributeError.

Вычисляемые свойства

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

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

Декоратор @property.deleter

Существует и третий декоратор — @balance.deleter, который управляет поведением оператора del. Это используется редко, но бывает полезно для освобождения ресурсов или сброса состояния.

Практический паттерн: валидация через свойства

Рассмотрим полноценный пример, где инкапсуляция защищает данные от некорректного состояния.

Здесь оба свойства (celsius и fahrenheit) работают через единый защищённый атрибут _celsius. Установка значения в Фаренгейтах автоматически пересчитывается в Цельсии, и валидация срабатывает в обоих направлениях.

Когда какой уровень использовать

Выбор уровня доступа — это проектное решение, но есть практические ориентиры:

  • Публичный — данные, которые являются частью контракта класса и могут свободно читаться внешним кодом (например, owner).
  • Защищённый (_) — внутренние данные, которые технически доступны, но не должны использоваться напрямую. Это самый распространённый уровень в Python.
  • Приватный (__) — данные, которые нужно защитить от перекрытия при наследовании. Используется реже и только когда есть реальная конфликтная ситуация в иерархии классов.
  • Свойство (@property) — когда доступ к атрибуту требует валидации, вычислений или побочных эффектов.
  • Инкапсуляция — это не просто сокрытие данных ради сокрытия. Это инструмент, который гарантирует, что объект всегда остаётся в корректном состоянии, а внешний код не может нарушить внутренние инварианты. Следующий шаг — понять, как инкапсуляция взаимодействует с механизмом наследования, когда один класс расширяет функциональность другого.

    3. Наследование и иерархия классов: механизм расширения

    Наследование и иерархия классов: механизм расширения

    Допустим, вы описали класс Animal с атрибутами name и age и методом speak. Теперь вам нужны классы Dog, Cat и Parrot — у каждого свои особенности, но общая база: все они животные с именем, возрастом и способностью издавать звук. Без наследования вам пришлось бы копировать один и тот же код в три класса. Наследование позволяет создать общий базовый класс и расширять его в дочерних, избегая дублирования.

    Базовый и дочерний классы

    Наследование — это механизм ООП, при котором новый класс (дочерний, subclass) получает атрибуты и методы существующего класса (базового, superclass) и может добавлять собственные или переопределять унаследованные.

    В Python наследование объявляется в круглых скобках после имени класса:

    Класс Dog наследует всё от Animal: атрибуты name и age, метод describe. Но метод speak переопределён — собака говорит «Гав!», а не «...». Метод fetch — собственное дополнение, которого нет у произвольного животного.

    Функция isinstance проверяет принадлежность объекта к классу или его предкам:

    Вызов конструктора базового класса: super()

    Если дочерний класс определяет свой __init__, он перекрывает родительский. Чтобы не потерять инициализацию базового класса, используют функцию super().

    super() возвращает прокси-объект, который делегирует вызовы методов следующему классу в цепочке разрешения методов (MRO — Method Resolution Order). Это критически важно в множественном наследовании, но полезно и при простом — вы гарантируете, что базовая инициализация выполнится.

    Множественное наследование

    Python поддерживает множественное наследование — класс может наследовать от нескольких родителей одновременно.

    Утка наследует поведение от трёх классов сразу. Порядок разрешения методов определяется алгоритмом C3 linearization — Python строит линейный список предков (MRO), который можно проверить:

    Когда вызывается метод, Python ищет его в классах MRO слева направо и останавливается на первом найденном.

    Проблема ромба и её решение

    Множественное наследование порождает классическую проблему «ромба» (diamond problem): если два родительских класса наследуют от одного общего предка, какой конструктор или метод вызвать?

    Благодаря C3 linearization конструктор A вызывается ровно один раз, а порядок — D → B → C → A. Если бы super() не использовался, и каждый класс вызывал бы A.__init__() напрямую, базовая инициализация выполнилась бы дважды.

    Абстрактные базовые классы

    Иногда базовый класс не должен создаваться сам по себе — он существует только как шаблон для дочерних. Такой класс называется абстрактным. В Python для этого используется модуль abc.

    Абстрактный метод — это метод без реализации, помеченный декоратором @abstractmethod. Любой дочерний класс обязан реализовать все абстрактные методы, иначе Python откажется создавать его экземпляр.

    Обратите внимание: description в базовом классе — это конкретный метод, который использует абстрактные area и perimeter. Это мощный приём: базовый класс определяет алгоритм, а дочерние классы предоставляют шаги.

    Паттерн «Шаблонный метод»

    Именно на абстрактных классах строится паттерн Template Method — базовый класс задаёт скелет алгоритма, а конкретные шаги делегируются дочерним классам.

    Метод generate определён один раз в базовом классе и работает одинаково для любого отчёта. Конкретные классы меняют только три шага: загрузку, обработку и форматирование.

    Наследование от встроенных типов

    Python позволяет наследовать от встроенных типов — list, dict, str и других. Это полезно, когда нужно расширить стандартное поведение.

    ValidatedList ведёт себя как обычный список, но гарантирует, что все элементы — числа.

    Когда наследование — не лучший выбор

    Наследование создаёт тесную связь между классами: дочерний класс зависит от реализации родителя. Если базовый класс изменится, все дочерние могут сломаться. Поэтому существует принцип: предпочитай композицию наследованию (favor composition over inheritance).

    Композиция означает, что один объект содержит другой как атрибут, а не наследует от него:

    Здесь Car не является двигателем — он использует двигатель. Это более гибкая связь: можно заменить Engine на ElectricEngine, не меняя иерархию классов.

    Наследование уместно, когда между классами действительно отношение «является» (is-a): собака является животным, круг является фигурой. Если отношение скорее «использует» или «состоит из» — лучше композиция.

    Наследование даёт мощный инструмент для построения иерархий и переиспользования кода. Но его истинная сила раскрывается в сочетании с полиморфизмом — когда разные дочерние классы обрабатываются единообразно через общий интерфейс базового класса.

    4. Полиморфизм и переопределение методов: единый интерфейс

    Полиморфизм и переопределение методов: единый интерфейс

    Представьте систему оплаты: клиент может расплатиться банковской картой, электронным кошельком или криптовалютой. Каждый способ оплаты имеет свою внутреннюю логику, но для системы оформления заказа все они выглядят одинаково — «выполнить платёж на сумму X». Если бы пришлось для каждого способа писать отдельную ветку кода с проверками типа, система стала бы хрупкой и трудноподдерживаемой. Полиморфизм решает эту задачу: он позволяет работать с объектами разных классов через единый интерфейс, не зная их конкретного типа.

    Что такое полиморфизм

    Полиморфизм (от греч. polys — много, morphe — форма) — это способность объектов разных классов обрабатываться единообразным образом через общий интерфейс. Каждый класс реализует интерфейс по-своему, но вызывающий код не обязан знать детали.

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

    Переопределение методов

    Фундамент полиморфизма — переопределение методов (method overriding). Дочерний класс определяет метод с тем же именем, что и в родительском, но с собственной реализацией.

    Оба дочерних класса переопределяют pay и describe, но реализация принципиально разная.

    Полиморфизм в действии

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

    Функция process_payment не содержит ни одной проверки типа — она просто вызывает pay и describe. Если завтра появится новый способ оплаты (например, SBPPayment), функция process_payment не изменится вообще.

    Утиная типизация без наследования

    Python не требует, чтобы классы наследовали общий базовый класс для полиморфизма. Достаточно, чтобы у объектов был метод с нужным именем.

    Класс Cash не наследует PaymentMethod, но у него есть метод pay с той же сигнатурой. Функция process_payment будет работать и с ним — при условии, что у объекта есть метод describe. Если его нет, Python вызовет AttributeError в рантайме.

    Чтобы Cash стал полностью совместим, достаточно добавить метод describe:

    Это и есть утиная типизация: объекту не нужен формальный контракт — достаточно реализовать ожидаемое поведение.

    Полиморфизм встроенных операций

    Полиморфизм в Python работает не только с методами, но и со встроенными операторами. Функция len() работает со строками, списками, словарями и любым объектом, у которого определён метод __len__. Аналогично + вызывает __add__, а str()__str__.

    Оператор + полиморфен: для чисел он складывает, для строк — конкатенирует, для списков — объединяет, а для Vector — выполняет векторное сложение. Python не проверяет тип операнда заранее — он просто вызывает соответствующий магический метод.

    Полиморфизм через протоколы

    Начиная с Python 3.8, модуль typing предоставляет протоколы — формальный способ описать ожидаемый интерфейс без наследования. Протокол похож на интерфейс из Java или TypeScript.

    Любой класс, у которого есть методы pay и describe с нужными сигнатурами, автоматически считается реализацией протокола Payable — даже без явного наследования. Статический анализатор mypy проверяет это на этапе разработки.

    Полиморфизм в коллекциях

    Одно из самых практических применений полиморфизма — обработка коллекции разнотипных объектов единообразно.

    Цикл не знает и не должен знать, какой конкретно тип элемента. Он полагается только на наличие метода pay. Добавление нового способа оплаты не требует изменения цикла — достаточно создать новый класс.

    Различие переопределения и перегрузки

    В языках со статической типизацией (Java, C++) существует перегрузка (overloading) — несколько методов с одним именем, но разными сигнатурами. Python не поддерживает перегрузку в классическом смысле: повторное определение метода с тем же именем просто перезаписывает предыдущее.

    Однако Python позволяет имитировать перегрузку через аргументы по умолчанию и *args:

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

    Полиморфизм и Liskov Substitution Principle

    Принцип подстановки Лисков (LSP) — один из пяти принципов SOLID — гласит: объекты базового класса должны быть заменяемы объектами дочернего класса без нарушения корректности программы. Иными словами, дочерний класс не должен ломать контракт родителя.

    Нарушение LSP:

    Если функция ожидает Bird и вызывает fly, передача Penguin приведёт к исключению. Это нарушение LSP. Корректное решение — пересмотреть иерархию: выделить FlyingBird и FlightlessBird или использовать композицию.

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

    5. Абстракция и интерфейсы: проектирование систем

    Абстракция и интерфейсы: проектирование систем

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

    Абстракция как принцип проектирования

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

    Абстракция отличается от инкапсуляции по направленности: инкапсуляция скрывает данные внутри объекта, абстракция скрывает сложность реализации за простым интерфейсом. Инкапсуляция — это «чёрный ящик» на уровне атрибутов, абстракция — на уровне системы в целом.

    Абстрактные классы в Python

    Модуль abc предоставляет базовый класс ABC и декоратор abstractmethod. Абстрактный класс — это класс, который содержит хотя бы один абстрактный метод и не может быть инстанцирован напрямую.

    Абстрактный класс Storage определяет контракт хранилища: три обязательных метода (save, load, delete) и один конкретный (exists), который строится на абстрактных. Любой класс, наследующий Storage, обязан реализовать все три абстрактных метода.

    Реализация абстракции: конкретные классы

    MemoryStorage хранит данные в словаре, FileStorage — в файлах. Внутренние механизмы完全不同, но интерфейс единообразен.

    Код, зависящий от абстракции

    Истинная ценность абстракции раскрывается, когда бизнес-логика работает с абстрактным типом, а не с конкретной реализацией.

    SessionManager принимает любой объект типа Storage — он не знает и не должен знать, где хранятся данные. Это позволяет менять хранилище без изменения бизнес-логики:

    Это и есть принцип инверсии зависимостей (Dependency Inversion Principle): высокоуровневый модуль (SessionManager) не зависит от низкоуровневого (FileStorage), оба зависят от абстракции (Storage).

    Протоколы как структурная типизация

    Абстрактные классы требуют явного наследования. Но Python предлагает более гибкий механизм — протоколы из модуля typing. Протокол описывает ожидаемую структуру без требования наследования.

    Любой класс с методами to_json и from_json автоматически удовлетворяет протоколу Serializable — без явного наследования. Это структурная типизация в противовес номинативной (где важна цепочка наследования).

    Множественная абстракция через протоколы

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

    Любой объект с методами read и write удовлетворяет ReadWritable. Это моделирует, например, файловые объекты, сетевые сокеты или буферы в памяти.

    Абстракция в архитектуре: слои и границы

    На уровне архитектуры приложения абстракция определяет границы между слоями. Классическая трёхуровневая архитектура:

    | Слой | Ответственность | Зависит от | |------|-----------------|------------| | Представление | UI, API-эндпоинты | Бизнес-логика | | Бизнес-логика | Правила, валидация, workflows | Абстракции данных | | Данные | База данных, файлы, API | Абстракции данных |

    Ключевой момент: бизнес-логика зависит не от конкретной базы данных, а от абстракции репозитория. Это позволяет менять PostgreSQL на MongoDB или тестировать логику с мок-хранилищем без изменения кода бизнес-правил.

    Бизнес-логика работает с UserRepository, а конкретная реализация подставляется при конфигурировании приложения — в продакшене PostgresUserRepository, в тестах InMemoryUserRepository.

    Абстракция и полиморфизм: два лица одной монеты

    Абстракция определяет контракт — набор методов и их сигнатур. Полиморфизм позволяет использовать разные реализации этого контракта единообразно. Вместе они дают открытость для расширения: вы можете добавить RedisStorage или S3Storage без изменения SessionManager.

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

    Когда абстракция избыточна

    Абстракция — это не бесплатная. Каждый абстрактный слой добавляет cognitive load: разработчику нужно понимать не только конкретную реализацию, но и контракт. Если в системе существует только одна реализация интерфейса и не предвидится альтернатив, абстракция может быть преждевременной.

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

    Абстракция завершает картину четырёх столпов ООП. Инкапсуляция защищает внутреннее состояние, наследование строит иерархии, полиморфизм обеспечивает единообразную обработку, а абстракция определяет контракты, через которые все эти механизмы работают вместе. Четыре столпа — это не четыре отдельных инструмента, а единая система проектирования, где каждый элемент усиливает остальные.