Кратко

Концепции ООП

Разбор концепций ООП для подготовки к собеседованиям

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

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

Инкапсуляция

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

Преимущество заключается в предсказуемости. Когда класс Account владеет полем balance и позволяет менять его только через deposit() и withdraw(), вы можете обеспечивать выполнение правил в этих методах. Вы можете предотвратить появление отрицательного баланса, логировать транзакции, обновлять связанное состояние. Если бы баланс был публичным и кто угодно мог бы записывать в него напрямую, у вас не было бы никаких гарантий того, что эти правила соблюдаются.

На собеседованиях инкапсуляция выступает как базовая проверка гигиены кода. Открывают ли ваши классы свои поля напрямую, или они предоставляют методы? Возвращаете ли вы ссылки на изменяемые внутренние коллекции, которые вызывающий код может модифицировать, или вы возвращаете копии?

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

PythonПлохое решение: нет инкапсуляции
class ParkingSpot:
    def occupy(self, vehicle: "Vehicle") -> None:
        ...


class Vehicle:
    def __init__(self, type_: str):
        self.type = type_


class ParkingLot:
    def __init__(self):
        self.spots: list[ParkingSpot] = []  # public, mutable

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

PythonХорошее решение: правильная инкапсуляция
from typing import Optional


class ParkingSpot:
    def occupy(self, vehicle: "Vehicle") -> None:
        ...


class Vehicle:
    def __init__(self, type_: str):
        self.type = type_


class ParkingLot:
    def __init__(self):
        self._spots: list[ParkingSpot] = []

    def park_vehicle(self, vehicle: Vehicle) -> bool:
        spot = self._find_available_spot(vehicle)
        if spot is None:
            return False
        spot.occupy(vehicle)
        return True

    def _find_available_spot(self, vehicle: Vehicle) -> Optional[ParkingSpot]:
        return self._spots[0] if self._spots else None

    @property
    def spots(self) -> list[ParkingSpot]:
        return list(self._spots)

Если вы проектируете класс и сомневаетесь, стоит ли открывать поле или написать геттер, пишите геттер. Если вам нужно вернуть коллекцию, возвращайте неизменяемое представление или копию.

Абстракция

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

Преимущество заключается в упрощении. Абстракция скрывает сложность. Когда код обработки заказов зависит от интерфейса PaymentMethod, а не от конкретных классов таких как CardProcessor или SBPProcessor, вы можете заменять реализации, не меняя код, который ими пользуется. Вызывающему коду не нужно знать, обращаетесь ли вы к API Yookassa или сохраняете платежные токены в базу данных. Он просто вызывает process() и получает результат.

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

В этом примере OrderService жестко связан с реализацией YookassaAPI, поэтому изменения в платежной системе потребуют менять класс OrderService.

PythonПлохое решение: нет абстракции
class Order:
    def __init__(self, total: float, credit_card: str):
        self.total = total
        self.credit_card = credit_card


class YooKassaAPI:
    def set_api_key(self, key: str) -> None:
        ...

    def create_charge(self, amount: float, card: str) -> None:
        ...


class OrderService:
    def __init__(self, api_key: str):
        self.api_key = api_key

    def checkout(self, order: Order) -> None:
        yookassa = YooKassaAPI()
        yookassa.set_api_key(self.api_key)
        yookassa.create_charge(order.total, order.credit_card)

Намного лучше полагаться на абстракцию, такую как PaymentMethod, для обработки различных способов оплаты.

PythonХорошее решение: правильная абстракция
from abc import ABC, abstractmethod


class PaymentMethod(ABC):
    @abstractmethod
    def process(self, amount: float) -> bool:
        ...


class CardPayment(PaymentMethod):
    def process(self, amount: float) -> bool:
        return True


class SBPPayment(PaymentMethod):
    def process(self, amount: float) -> bool:
        return True


class Order:
    def __init__(self, total: float, credit_card: str):
        self.total = total
        self.credit_card = credit_card


class OrderService:
    def __init__(self, payment_method: PaymentMethod):
        self.payment_method = payment_method

    def checkout(self, order: Order) -> None:
        self.payment_method.process(order.total)

Интерфейс определяет контракт process(amount), и каждая реализация обрабатывает детали. Классу OrderService не важно, какую именно из них он получает.

Самое сложное - выбрать правильный уровень абстракции. Слишком абстрактный подход приведет к тому, что ваш интерфейс станет бессмысленным (doWork() или handleRequest(). Слишком конкретная абстракция ничего не абстрагирует. Думайте о том, какие операции нужны вызывающему коду, а не о том, как эти операции выполняются внутри.

Полиморфизм

Полиморфизм заменяет проверки вида if (type == "credit") или switch (vehicleType). Вместо проверки типов вы вызываете один и тот же метод, а каждый объект обрабатывает вызов самостоятельно. Разные объекты реагируют на одно и то же действие по-своему.

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

Полиморфизм естественным образом вытекает из абстракции. Как только вы определяете интерфейс, такой как PaymentMethod или Vehicle, каждая реализация может предоставить свое собственное поведение. Когда вы вызываете метод интерфейса, фактическая реализация, которая будет выполнена, зависит от конкретного типа, с которым вы работаете. Каждый тип знает, как обрабатывать свои операции и проверка типов не требуется.

PythonПлохое решение: нет полиморфизма
from typing import Optional


class ParkingSpot:
    pass


class Vehicle:
    def __init__(self, type_: str):
        self.type = type_


class ParkingLot:
    def park_vehicle(self, vehicle: Vehicle) -> bool:
        if vehicle.type == "car":
            spot = self._find_spot_by_size("regular")
            return spot is not None
        elif vehicle.type == "motorcycle":
            spot = self._find_spot_by_size("motorcycle")
            return spot is not None
        elif vehicle.type == "truck":
            spot = self._find_spot_by_size("large")
            return spot is not None
        return False

    def _find_spot_by_size(self, size: str) -> Optional[ParkingSpot]:
        return None
PythonХорошее решение: используется полиморфизм
from enum import Enum
from typing import Optional


class SpotSize(Enum):
    REGULAR = "regular"
    MOTORCYCLE = "motorcycle"
    LARGE = "large"


class ParkingSpot:
    pass


class Vehicle:
    def get_required_spot_size(self) -> SpotSize:
        raise NotImplementedError


class Car(Vehicle):
    def get_required_spot_size(self) -> SpotSize:
        return SpotSize.REGULAR


class Motorcycle(Vehicle):
    def get_required_spot_size(self) -> SpotSize:
        return SpotSize.MOTORCYCLE


class Truck(Vehicle):
    def get_required_spot_size(self) -> SpotSize:
        return SpotSize.LARGE


class ParkingLot:
    def park_vehicle(self, vehicle: Vehicle) -> bool:
        required = vehicle.get_required_spot_size()
        spot = self._find_spot_by_size(required)
        return spot is not None

    def _find_spot_by_size(self, size: SpotSize) -> Optional[ParkingSpot]:
        return None

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

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

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

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

Когда подкласс наследует поля и методы родителя, любое изменение в родителе может сломать каждого потомка. Это проблема "хрупкого базового класса", и именно поэтому наследование часто создает больше проблем, чем решает.

Более безопасная альтернатива - композиция и интерфейсы. Интерфейс определяет поведение, и каждый класс реализует его независимо. Вы по-прежнему получаете абстракцию и полиморфизм, но без принуждения классов к отношениям "родитель-потомок" или совместного использования состояния, которого у них быть не должно.

Когда наследование подходит

Наследование имеет смысл, когда у вас есть стабильная общая реализация, которая действительно нужна нескольким подклассам. Банковские счета - хороший пример. И SavingsAccount, и CheckingAccount отслеживают балансы, обрабатывают зачисление и снятие средств, а также ведут историю транзакций. Эта логика идентична во всех типах счетов.

PythonХорошее решение: наследование общей реализации
class BankAccount:
    def __init__(self):
        self.balance = 0.0

    def deposit(self, amount: float) -> None:
        self.balance += amount

    def withdraw(self, amount: float) -> bool:
        if self.balance < amount:
            return False
        self.balance -= amount
        return True

    def get_balance(self) -> float:
        return self.balance


class SavingsAccount(BankAccount):
    def __init__(self, interest_rate: float):
        super().__init__()
        self.interest_rate = interest_rate


class CheckingAccount(BankAccount):
    def __init__(self, overdraft_limit: int):
        super().__init__()
        self.overdraft_limit = overdraft_limit

Здесь общая реализация стабильна и имеет смысл. Оба подкласса действительно являются формами BankAccount, и им не нужно переопределять унаследованное поведение таким образом, который нарушает контракт родителя. Поэтому наследование здесь хорошо подходит.

Когда наследование не подходит

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

PythonПлохое решение: наследование для различающегося поведения
from abc import ABC, abstractmethod


class Drivetrain(ABC):
    @abstractmethod
    def start(self) -> None:
        ...


class GasEngine(Drivetrain):
    def start(self) -> None:
        # gas engine startup logic
        ...


class ElectricMotor(Drivetrain):
    def start(self) -> None:
        # electric motor startup logic
        ...


class Car:
    def __init__(self, drivetrain: Drivetrain):
        self.drivetrain = drivetrain

    def start(self) -> None:
        self.drivetrain.start()

У электромобилей нет двигателей внутреннего сгорания. Они не имеют общей логики запуска двигателя. Мы помещаем различие в поведении в иерархию классов, и это создает хрупкий код. Когда мы добавим гибридный автомобиль, от чего он должен наследоваться: от Car или ElectricCar? Ни один вариант не выглядит хорошим.

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

PythonХорошее решение: композиция для различающегося поведения
from abc import ABC, abstractmethod


class Drivetrain(ABC):
    @abstractmethod
    def start(self) -> None:
        ...


class GasEngine(Drivetrain):
    def start(self) -> None:
        # gas engine startup logic
        ...


class ElectricMotor(Drivetrain):
    def start(self) -> None:
        # electric motor startup logic
        ...


class Car:
    def __init__(self, drivetrain: Drivetrain):
        self.drivetrain = drivetrain

    def start(self) -> None:
        self.drivetrain.start()

Теперь вы можете моделировать любой автомобиль, не ломая иерархию и не переопределяя логику неестественным образом. Нужен гибрид? Дайте ему два компонента Drivetrain. Нужен роторно-лопастной двигатель? Добавьте новую реализацию Drivetrain. Класс с автомобилем при этом не меняется.

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

Собираем все вместе

Точно так же, как и с принципами проектирования, важно не знание названий концепций, а умение их применять. Когда вы видите требования, такие как "поддержать несколько способов оплаты", вы понимаете, что нужно определить интерфейс. Когда вы проектируете класс, вы понимаете, что нужно держать поля приватными и предоставлять методы для доступа. Когда замечаете, что пишете проверки типов, вы понимаете, что стоит использовать полиморфизм.

Концепции проявляются в том, как вы проектируете, а не в том, как вы это называете. Сфокусируйтесь на их естественном применении:

  • Инкапсуляция: скрывайте состояние, открывайте поведение. Делайте поля приватными, предоставляйте методы для доступа к ним.
  • Абстракция: определяйте интерфейсы для вариантов поведения. Несколько способов оплаты? Различные типы транспортных средств? Создайте интерфейс.
  • Полиморфизм: позволяйте объектам обрабатывать себя самим. Без проверок типов и без switch по типам.
  • Наследование: компонуйте поведение, а не наследуйте его. В первую очередь обращайтесь к интерфейсам, используйте наследование только для переиспользования стабильной реализации.
Войдите чтобы отмечать прогресс