Объектно-ориентированное программирование – это методология разработки программ, основанная на представлении программы как совокупности взаимодействующих объектов.
Основные принципы ООП с примерами кода
ООП в своей основе опирается на четыре принципа.
Абстракция
Выделение главных, наиболее существенных для задачи характеристик объекта, и игнорирование второстепенных.
Аналогии для понимания принципа:
- пульт от телевизора – важно, что пульт может включать и выключать телевизор, и не важно, как именно он устроен.
- модель идеального газа в физике – помогает упростить расчеты, но не учитывает все нюансы строения молекул и их взаимодействия.
# Класс - это абстракция, содержащая наиболее важные функции объекта (пульта телевизора)
class TVRemote:
def turn_on(self):
print("Телевизор включен")
def turn_off(self):
print("Телевизор выключен")
remote = TVRemote()
remote.turn_on()
remote.turn_off()
Инкапсуляция
Инкапсуляция – это механизм ограничения доступа к внутренним данным и методам объекта.
Внутреннее состояние объекта доступно только через определенные методы. Состояние закрывается с помощью приватных методов и переменных, которые нельзя вызвать напрямую.
Аналогии для понимания принципа:
- банкомат выдает деньги и позволяет посмотреть свой баланс, но не дает доступа к банковским данным и внутренним механизмам.
- клеточная мембрана инкапсулирует внутреннее содержимое клетки, и пропускает внутрь только определенные вещества.
class BankAccount:
def __init__(self, balance):
self.__balance = balance # __balance - приватная переменная, недоступная извне
def deposit(self, amount): # Позволяет пополнить баланс
self.__balance += amount
def withdraw(self, amount): # Позволяет снять деньги, если сумма не превышает баланс
if amount <= self.__balance:
self.__balance -= amount
else:
print("Недостаточно средств!")
def get_balance(self): # Получить данные о балансе (это специальный метод)
return self.__balance
account = BankAccount(1000)
account.deposit(500)
account.withdraw(300)
print(account.get_balance())
print(account.__balance) # Это выражение приведет к ошибке
Наследование
Наследование – это механизм, позволяющий создавать классы на основе уже существующих классов. Новый класс (дочерний) наследует свойства и методы родительского, и может добавлять собственные.
Аналогии для понимания принципа:
- ребенок наследует качества родителей, но имеет и свои уникальные черты
- биологическая классификация видов – класс млекопитающих наследует общие признаки и строение животных, но добавляет свои специфические особенности.
# Родительский класс
class Animal:
def speak(self):
print("Животное издает звуки")
# Дочерний класс
class Dog(Animal):
def run(self):
print("Собака быстро бегает")
# Дочерний класс
class Cat(Animal):
def jump(self):
print("Кошка хорошо прыгает")
animal = Animal()
dog = Dog()
cat = Cat()
# Наследуемый метод .speak() срабатывает при вызове из дочерних классов
animal.speak() # Животное издает звук
dog.speak() # Животное издает звук
dog.run() # Собака быстро бегает
cat.speak() # Животное издает звук
cat.jump() # Кошка хорошо прыгает
Полиморфизм
Полиморфизм позволяет одинаково обращаться с объектами разных классов через общий интерфейс. При этом каждый объект ведет себя по-разному.
Аналогия для понимания принципа:
- управление велосипедом, автомобилем, самолетом и кораблем происходит с помощью руля, но каждый вид транспорта ведет себя по-разному.
# У всех перечисленных классов есть одинаковый метод move, действующий по-разному
class Car:
def move(self):
print("Автомобиль едет по дороге")
class Boat:
def move(self):
print("Лодка плывет по воде")
class Airplane:
def move(self):
print("Самолет летит по воздуху")
vehicles = [Car(), Boat(), Airplane()]
for vehicle in vehicles:
vehicle.move()
# Автомобиль едет по дороге
# Лодка плывет по воде
# Самолет летит по воздуху
Организация структуры проекта в ООП – модули и директории
Если решено: что проект будет делаться по принципам ООП, полезно придерживаться определенной структуры проекта.
Логическое разделение на компоненты
Каждый модуль или пакет отвечает за свою конкретную задачу или функциональность.
Отдельные классы = отдельные файлы
Обычно каждый класс или группа тесно связанных классов помещаются в отдельный модуль (файл .py).
Пакеты и подпакеты
Если несколько модулей логически объединены, их помещают в пакет (директорию с файлом __init__.py).
Отдельные директории для тестов, документации, конфигураций и т.д.
Это упрощает работу с проектом и увеличивает его читаемость.
Типичная структура проекта с ООП
bookstore_project/
|-- bookstore/ # Основной пакет приложения
| |-- __init__.py
| |-- models/ # Папка с классами (моделями данных)
| | |-- __init__.py
| | |-- book.py
| | |-- author.py
| |-- services/ # Папка с бизнес-логикой
| | |-- __init__.py
| | |-- bookstore_service.py
| |-- utils/ # Вспомогательные функции и классы
| | |-- __init__.py
| | |-- helpers.py
| |-- main.py # Главный запускаемый файл приложения
|-- tests/ # Папка с юнит-тестами
| |-- __init__.py
| |-- test_models.py
|-- requirements.txt # Зависимости
|-- README.md # Документация проекта
Как части проекта связаны между собой?
Через импорт.
from bookstore.models.book import Book
from bookstore.models.author import Author
...
Как запускается проект?
Через файл main.py. Файл может содержать инструкции к вызову логики. А импорт и использование классов происходит уже в модуле с логикой. Например:
from bookstore.services.bookstore_service import BookstoreService
def main():
service = BookstoreService()
service.add_book("Война и мир", "Лев Толстой", "Россия", 500)
service.add_book("1984", "Джордж Оруэлл", "Великобритания", 450)
print("Книги в магазине:")
service.show_books()
if __name__ == "__main__":
main()
Запуск обычно делается командой в терминале из директории с проектом:
python bookstore/main.py
Что такое self?
Это специальный параметр, используемый в методах класса (см. следующий раздел) для обращения к конкретному экземпляру (объекту) класса. Это ссылка на сам объект, с которым мы в данный момент работаем.
# Экземпляр класса (объект) создается всякий раз при вызове класса.
class Car:
def __init__(self, name, age):
self.name = name
self.age = age
def get_name(self):
print(f"Car name is: {self.name}")
def get_age(self):
print(f"Car age is: {self.age}")
car1 = Car("Porche", 2) # Создаем первый экземпляр класса Car
car2 = Car("BMW", 10) # Создаем второй экземпляр класса Car
# Вызываем метод get_name() для первого экземпляра класса (за счет self)
car1.get_name()
# Вызываем метод get_name() для второго экземпляра класса, независимо от первого экземпляра (также за счет ссылки self)
car2.get_name()
car1.get_age()
car2.get_age()
# Output:
Car name is: Porche
Car name is: BMW
Car age is: 2
Car age is: 10
Self дает возможность вызывать один и тот же метод класса независимо в каждом экземпляре. Таким образом можно хранить разные свойства и состояния для разных объектов НЕЗАВИСИМО.
Важно помнить, что:
- self всегда должен передаваться первым параметром
- при вызове метода объекта аргумент self не нужно передавать, он подставляется автоматически
- self – это общее соглашение в разработке. Это обозначение можно заменить любым другим словом или набором символов, но это настоятельно не рекомендуется.
Типы методов в ООП
В ООП существует три основных типа методов:
- методы экземпляра (instance methods)
- методы класса (class methods)
- статические методы (static methods)
Методы экземпляра
Это самый распространенный тип методов в ООП.
Методы экземпляра:
- имеют доступ к состоянию конкретного объекта
- всегда принимают первым аргументом ‘self’
Применяется, когда метод должен работать с состоянием конкретного объекта (экземпляра класса).
class Car:
def __init__(self, name):
self.name = name
def get_name(self): # Это и есть типичный метод экземпляра
print(f"Car name is: {self.name}")
Методы класса
Методы класса:
- принимают первым аргументом ссылку на класс ‘cls’
- имеют доступ к самому классу и его атрибутам
- помечаются декоратором @classmethod
- могут использоваться для создания альтернативных конструкторов (методов создания объектов)
Методы класса используются, когда нужен метод, не зависящий от состояния конкретного объекта, но зависящий от состояния класса.
class Cat:
species = "Felis domesticus"
def __init__(self, name):
self.name = name
@classmethod
def get_species(cls): # Этот метод класса имеет доступ к атрибуту species
return cls.species
print(Cat.get_species())
# Output:
Felis domesticus
# Пример альтернативного конструктора
class Pizza:
def __init__(self, ingredients):
self.ingredients = ingredients
@classmethod
def margherita(cls):
return cls(["cheese", "tomatoes"])
@classmethod
def peperoni(cls):
return cls(["cheese", "tomatoes", "peperoni"])
pizza1 = Pizza.margherita()
pizza2 = Pizza.peperoni()
print(pizza1.ingredients)
print(pizza2.ingredients)
# Output
['cheese', 'tomatoes']
['cheese', 'tomatoes', 'peperoni']
Статические методы
Статические методы
- не принимают аргументов self и cls
- помечаются декоратором @staticmethod
- не имеют доступа ни к состоянию объекта, ни к состоянию класса
Используются, когда метод логически связан с классом, но НЕ зависит от состояния класса или объекта. Как правило, в таких методах зашита арифметика или другие вычисления.
class Math:
@staticmethod
def add(x, y):
return x + y
@staticmethod
def multiply(x, y):
return x * y
print(Math.add(2, 6))
print(Math.multiply(3, 12))
# Output
8
36
Паттерны проектирования в ООП
Паттерны проектирования – это проверенные временем и опытом решения типовых задач, возникающих при проектировании приложений. Они помогают делать код более понятным, гибким, расширяемым и удобным для поддержки.
Порождающие паттерны
Упрощают создание объектов. Пример – паттерн Singleton.
Singleton гарантирует, что класс имеет только один экземпляр и предоставляет к нему глобальную точку доступа. Такой паттерн применяется в конфигурации приложения, логгерах, при подключениях к базе данных.
# Класс может создать только один экземпляр, даже если вызывать его многократно
class Singleton:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
print("Создан новый экземпляр Singleton")
return cls._instance
s1 = Singleton() # Создан новый экземпляр Singleton
s2 = Singleton()
print(s1 is s2) # True
Структурные паттерны
Упрощают построение сложных структур объектов. Пример – Adapter.
Adapter позволяет объектам с несовместимыми интерфейсами работать вместе, преобразуя интерфейс одного класса к другому. Пример – есть старый класс, который не подходит под новый интерфейс, но этот старый класс необходимо использовать, не меняя исходный код.
class OldPrinter: # Старый класс
def print_text(self, text):
print(f"Old printer: {text}")
class NewPrinterInterface: # Новый интерфейс
def print(self, text):
raise NotImplementedError()
class PrinterAdapter(NewPrinterInterface): # Адаптер на основе нового класса
def __init__(self, old_printer): # Инициализатор может принимать объект старого класса
self.old_printer = old_printer
def print(self, text): # Метод, использующий функционал старого класса
self.old_printer.print_text(text)
old_printer = OldPrinter()
adapter = PrinterAdapter(old_printer)
adapter.print("Привет!!!") # Old printer: Привет!!!
Поведенческие паттерны
Управляют взаимодействием между объектами. Пример – Observer (наблюдатель).
Observer – позволяет одним объектам (наблюдателям) автоматически получать уведомления об изменении состояния других объектов (наблюдаемых). Применяется для оповещения пользователей о новых сообщениях и событиях в приложении.
class Subject:
def __init__(self):
self.observers = []
def register(self, observer):
self.observers.append(observer)
def notify(self, message):
for observer in self.observers:
observer.update(message)
class Observer:
def __init__(self, name):
self.name = name
def update(self, message):
print(f"{self.name} получил сообщение: {message}")
# Использование
subject = Subject()
observer1 = Observer("Наблюдатель 1")
observer2 = Observer("Наблюдатель 2")
subject.register(observer1)
subject.register(observer2)
subject.notify("Произошло событие 1!!!")
# Output
# Наблюдатель 1 получил сообщение: Произошло событие 1!!!
# Наблюдатель 2 получил сообщение: Произошло событие 1!!!
Важно отметить, что применение паттернов не всегда оправдано. Они полезны для задач, для которых предназначены. Если такая задача встречается в проекте, применение соответствующего паттерна будет хорошей практикой.
Возможные недостатки ООП
Избыточная сложность
В некоторых ситуациях использование ООП избыточно.
Сложность поддержки иерархий классов
При использовании классов с вложенным наследованием (глубокие и разветвленные иерархии) могут возникнуть трудности с поддержкой и доработкой кода. Сложность может привести к непредсказуемым последствиям в работе кода.
Проблема ромбовидного наследования
Когда два разных класса наследуются от одного родительского класса, а затем оба становятся родителем еще одного класса, это может привести к неоднозначности в поведении последнего.
class A:
def method(self):
print("Class A method")
class B(A):
def method(self):
print("Class B method")
class C(A):
def method(self):
print("Class C method")
class D(B, C):
pass
obj = D()
obj.method() # Какой метод будет вызван? Из класса B или С?
В сложных случаях это может привести к путанице и ошибкам.
Состояние и изменяемость
ООП часто подразумевает наличие состояний данных внутри объектов. Это может быть какая-либо переменная, меняющаяся при работе кода (инкремент, баланс счета и т.д.). Если к этой переменной можно получить доступ и напрямую влиять на ее значение, это усложняет отладку и тестирование кода, может привести к ошибкам.
class Counter:
def __init__(self):
self.count = 0
def increment(self):
self.count += 1
counter = Counter()
counter.increment()
counter.count = 999 # Это прямой доступ к состоянию объекта!!!
Слабая производительность
Сложные программы с ООП, как правило, требуют больше памяти и процессорных мощностей для создания и управления объектами. Создание большого количества мелких объектов (экземпляров класса) может существенно повлиять на производительность программы.
Злоупотребление шаблонами проектирования
Применение сложных шаблонов без реальной необходимости может сильно усложнить программу и затруднить ее поддержку. Шаблоны следует использовать только при реальной необходимости.
Когда применять ООП?
ООП подходит не для всех задач. Часто можно обойтись и без него.
Вот наиболее явные признаки задач, для которых подходит ООП.
В задаче явно выделяются объекты с состоянием и поведением
- Состояние – характеристики, переменные состояния.
- Поведение – методы, действия, операции.
Например, в приложении для учета книг в библиотеке можно выделить такие сущности:
- Объекты: книга, автор, читатель, библиотека.
- Состояние: название книги, автор, год издания, количество экземпляров.
- Поведение: выдать книгу, вернуть книгу, добавить нового читателя.
Есть необходимость повторного использования кода
Если планируется повторное использование частей кода и его расширение, однозначно, следует использовать подход ООП.
Пример: создание разных форм геометрических фигур, каждая из которых имеет свою формулу площади.
Легко прослеживаются отношения между объектами
В задаче просматриваются иерархии и взаимосвязи между объектами.
- Наследование – кошка является животным, автомобиль – техникой.
- Композиция (содержит) – автомобиль включает в себя двигатель, колеса и т.д.
Задача требует расширяемости и масштабируемости
В этом случае организация проекта по принципам ООП позволит добавлять новые объекты и функции, не меняя общий код.
Например, в программе для моделирования видов транспорта можно добавить новый вид транспорта, не меняя существующий код.
В задаче есть необходимость инкапсулировать и защищать данные
Если важно ограничить доступ к внутреннему состоянию объектов и обеспечить их безопасность, ООП будет хорошим вариантом.
Когда ООП не нужно
Простые задачи, состоящие из небольшого числа шагов.
Однократные скрипты для автоматизации мелких задач (например, скрипт для разовой обработки текстового файла).
Задачи, в которых нет очевидных сущностей и состояний. Например, простые расчеты или обработка данных в виде последовательных шагов, не требующая хранения состояний.