Об’єктно-орієнтоване програмування (ООП) на Python

pic

У цій статті ми зануримося у світ об'єктно-орієнтованого програмування (OOP), створюючи практичну систему управління книжковим магазином. Перед тим, як почати, переконайтесь, що ви знайомі з основними концепціями програмування, такими як змінні, функції, оператори умови (if), та цикли (for). Якщо ви маєте досвід роботи з цими концепціями в будь-якій мові програмування, ви готові слідувати далі!

Повний код цього проекту доступний у моєму репозиторії на GitHub.

Вступ до OOP у Python

Тут ми визначили змінні для book1, book1_price, book1_quantity і book1_total_price. Щоб полегшити асоціацію цих змінних для людини, ми додали префікс book1, щоб вказати, що вони стосуються однієї і тієї ж книги.

book1 = "Learning Python"  
book1_price = 10  
book1_quantity = 5  
book1_total_price = book1_price * book1_quantity

Розглянемо типи даних цих змінних:

print(type(book1)) # Output:   
print(type(book1_price)) # Output:   
print(type(book1_quantity)) # Output:   
print(type(book1_total_price)) # Output: 

Як бачите, для Python ці змінні є чотирма окремими змінними зі своїми власними типами даних. book1 є рядком, а book1_price, book1_quantity і book1_total_price — цілими числами.

Зверніть увагу, що для кожного типу результат містить ключове слово class. Це означає, що ці типи даних фактично є екземплярами своїх відповідних класів.

Ключова ідея: Все є об'єктом

У Python кожен тип даних є об'єктом, створеним з попередньо визначеного класу. Наприклад:

  • Змінна book1 є об'єктом, створеним із класу рядка.
  • Точно так само book1_price, book1_quantity і book1_total_price є об'єктами, створеними з класу цілих чисел.

Це підкреслює одну з найпотужніших особливостей Python: всі типи даних є об'єктами, створеними на основі класів. Клас фактично визначає шаблон для створення об'єктів, а самі об'єкти є екземплярами цих класів.

Визначення класів у Python

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

Створення класу

Давайте визначимо клас з іменем Book. Спочатку ми не додаватимемо жодної функціональності, тому використаємо оператор pass, щоб уникнути помилок:

class Book:  
 pass

Створення екземплярів

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

class Book:  
 pass  

book1 = Book()

Присвоєння атрибутів класу

Атрибути дозволяють зберігати конкретні деталі про екземпляр. Ми можемо створювати їх за допомогою оператора крапка (.) після імені екземпляра. Давайте визначимо кілька атрибутів, таких як title, price і quantity:

class Book:  
 pass  

book1 = Book()  
book1.title = "Learning Python"  
book1.price = 10  
book1.quantity = 5

Тепер ці атрибути пов'язані з одним екземпляром класу. Це створює чітке відношення між атрибутами (title, price, quantity) та об'єктом (book1). Давайте перевіримо це, розглянувши типи як екземпляра, так і його атрибутів:

print(type(book1)) # Output:   
print(type(book1.title)) # Output:   
print(type(book1.price)) # Output:   
print(type(book1.quantity)) # Output: 

Тип даних book1 відрізняється від окремих змінних, які ми створювали раніше.
Тип даних `вказує, щоbook1є екземпляром (instance) власного класуBook`, що означає, що ми фактично створили власний тип даних.

Методи в класах Python

У Python методи — це функції, визначені всередині класу, які призначені для роботи з екземплярами цього класу. Їх можна викликати за допомогою синтаксису instance.method_name(). Це з'єднання дозволяє методам взаємодіяти з даними, що містяться в екземплярі, і змінювати їх.

Параметр self

Коли ви визначаєте метод всередині класу, Python вимагає, щоб він приймав хоча б один параметр. Цей параметр, зазвичай званий self, представляє екземпляр класу, який викликає метод, і дозволяє доступ до атрибутів екземпляра та інших методів.

Хоча технічно ви могли б використати будь-яке ім'я для цього параметра, дотримання конвенції self забезпечує узгодженість і зручність для читання в проектах на Python.

Наприклад, розглянемо наступний метод:

class Book:  
 def calculate_total_price(self):  
 pass

Тут параметр self дозволяє методу доступати до атрибутів і методів екземпляра, коли він буде реалізований. Якщо ви видалите self і спробуєте викликати метод:

book1 = Book()  
book1.calculate_total_price()

Python виведе помилку:

TypeError: Book.calculate_total_price() takes 0 positional arguments but 1 was given

Ця помилка виникає тому, що Python автоматично передає екземпляр (book1) як перший аргумент методу. Без self в оголошенні методу немає параметра, який міг би отримати цей аргумент.

Визначення та використання методів

Давайте створимо метод для обчислення загальної ціни книг на основі ціни та кількості. Назвемо його calculate_total_price і визначимо його для прийому двох параметрів — x та y, що представляють ціну та кількість відповідно. Метод буде повертати їх добуток:

class Book:  
 def calculate_total_price(self, x, y):  
 return x * y

Тепер ми можемо створювати екземпляри класу Book і використовувати цей метод:

book1 = Book()  
book1.title = "Learning Python"  
book1.price = 10  
book1.quantity = 5  
print(book1.calculate_total_price(book1.price, book1.quantity)) # Output: 50  

book2 = Book()  
book2.title = "Two Scoops of Django"  
book2.price = 30  
book2.quantity = 10  
print(book2.calculate_total_price(book2.price, book2.quantity)) # Output: 300

Метод init

Вищезгаданий підхід працює, але є неефективним, оскільки потрібно вручну визначати атрибути для кожного екземпляра. Ми можемо виправити це, використовуючи метод __init__, також відомий як конструктор.

Метод __init__ є спеціальним методом в Python, який виконується автоматично щоразу, коли створюється екземпляр класу. Це дозволяє ініціалізувати атрибути безпосередньо під час створення екземпляра.

Ось як ми визначаємо метод __init__:

class Book:  
 def __init__(self):  
 print(f"A new book has been added.")  

book1 = Book()  
book2 = Book()

Коли ми виконуємо код, повідомлення "A new book has been added." буде надруковано двічі — один раз для кожного екземпляра (book1 та book2). Це відбувається тому, що Python автоматично викликає метод __init__ кожного разу, коли ми створюємо новий екземпляр класу.

Покращення ініціалізації

Давайте покращимо метод __init__ для нашої системи управління книжковим магазином.
Замість того, щоб вручну встановлювати атрибути, як-от title, price та quantity, для кожного екземпляра після його створення, ми можемо передавати ці атрибути як параметри методу __init__.

Параметр self в методі __init__ дозволяє нам динамічно призначати ці атрибути екземплярам, що гарантує їх правильну ініціалізацію під час створення об'єкта.

Ось як ми можемо цього досягти:

class Book:  
 def __init__(self, title, price, quantity):  
 self.title = title  
 self.price = price  
 self.quantity = quantity  

 def calculate_total_price(self, x, y):  
 return x * y  


book1 = Book("Learning Python", 10, 5)  
book2 = Book("Two Scoops of Django", 30, 10)  

print(book1.title) # Output: Learning Python  
print(book1.price) # Output: 10  
print(book1.quantity) # Output: 5  

print(book2.title) # Output: Two Scoops of Django  
print(book2.price) # Output: 30  
print(book2.quantity) # Output: 10

Метод __init__ використовується для визначення атрибутів, які об'єкт має мати, коли він створюється. Переміщуючи title, price та quantity в метод __init__, ми забезпечуємо, що кожен об'єкт Book ініціалізується з цими атрибутами.

Уникнення надмірних параметрів у методах

У Python кожен метод в класі автоматично отримує екземпляр, на якому він викликається, як перший аргумент, зазвичай званий self. Це означає, що метод calculate_total_price вже має доступ до атрибутів price та quantity через self.

Ось оновлена реалізація класу Book:

class Book:  
 def __init__(self, title, price, quantity):  
 self.title = title  
 self.price = price  
 self.quantity = quantity  

 def calculate_total_price(self):  
 return self.price * self.quantity  

book1 = Book("Learning Python", 10, 5)  
book2 = Book("Two Scoops of Django", 30, 10)  

print(book1.calculate_total_price()) # Output: 50  
print(book2.calculate_total_price()) # Output: 300

Звертаючись до self.price і self.quantity, немає потреби явно передавати значення x та y при виклику методу. Це демонструє важливу особливість методів екземплярів і роль конструкторів в класах.

Використання значень за замовчуванням у методах

Методи можуть включати як обов'язкові, так і необов'язкові параметри, використовуючи значення за замовчуванням. Наприклад, якщо кількість книги спочатку невідома, ми можемо встановити значення за замовчуванням для параметра quantity, рівне 0:

class Book:  
 def __init__(self, title, price, quantity=0):  
 self.title = title  
 self.price = price  
 self.quantity = quantity  

 def calculate_total_price(self):  
 return self.price * self.quantity  


book1 = Book("Learning Python", 10)  
book2 = Book("Two Scoops of Django", 30, 10)  

print(book1.calculate_total_price()) # Output: 0  
print(book2.calculate_total_price()) # Output: 300

Значення за замовчуванням, як-от quantity=0, роблять ваш код більш гнучким. Вони дозволяють обробляти неповні дані без проблем, гарантуючи, що ваша програма не зламається, якщо деякі атрибути будуть відсутні під час створення об'єкта.

Додавання атрибутів до екземплярів динамічно

Класи Python дозволяють динамічно призначати атрибути конкретним екземплярам без необхідності визначати їх у конструкторі. Наприклад, щоб відстежувати, чи є книга частиною спеціальної колекції, ви можете безпосередньо додати цей атрибут до екземпляра:

book1.special_edition = True

Обробка помилок в ООП

Розглянемо, що станеться, якщо передати некоректний тип даних у клас. Наприклад, якщо випадково передати рядок замість числа:

book = Book("The Great Gatsby", "10", 3)  
print(book.calculate_total_price())

Замість помилки, ви можете спостерігати, як Python поводиться неочікувано.
У цьому випадку Python трактує множення як повторення рядка, що призводить до такого виводу:

101010

Це підкреслює важливість перевірки типів даних для значень, які ми передаємо в наші класи.

Додавання підказок типів (Type Hints)

Підказки типів (Type Hints) дозволяють нам вказати очікувані типи для параметрів, що покращує зрозумілість коду та зменшує ризик помилок під час виконання. Наприклад, ми можемо оголосити, що title повинен бути рядком (str), price — числом з плаваючою точкою (float), а quantity може мати тип за замовчуванням — ціле число (int):

class Book:  
 def __init__(self, title: str, price: float, quantity: int = 0):  
 self.title = title  
 self.price = price  
 self.quantity = quantity

Забезпечення валідації даних

Хоча підказки типів (Type Hints) корисні, вони не забезпечують перевірку коректності під час виконання. Якщо буде передано несумісний тип даних, інструменти Python, такі як лінтери або IDE, можуть виявити проблему, але програма все одно запуститься, якщо не буде додаткової перевірки.

Щоб забезпечити правильність типів, ви можете додати ручні перевірки в конструктор класу:

class Book:  
 def __init__(self, title: str, price: float, quantity: int = 0):  
 assert price >= 0, f"Price {price} is not greater or equal to zero"  
 assert quantity >= 0, f"Quantity {quantity} is not greater or equal to zero"  

 self.title = title  
 self.price = price  
 self.quantity = quantity

З такими перевірками, якщо price або quantity буде від'ємним, виникне помилка AssertionError з чітким повідомленням. Це допоможе виявити помилки на ранніх етапах виконання програми:

book = Book("The Great Gatsby", 10, -1)  
print(book.calculate_total_price())

Давайте подивимось, як працює вдосконалений клас:

AssertionError: Quantity -1 is not greater or equal to zero

Розуміння атрибутів

У Python атрибути, які асоціюються з об'єктами, поділяються на два типи:

  • Атрибути екземпляра (Instance Attributes): Вони є унікальними для кожного екземпляра класу. Кожен об'єкт має свою копію, що дозволяє налаштовувати атрибути індивідуально для кожного екземпляра.
  • Атрибути класу (Class Attributes): Вони спільні для всіх екземплярів класу. Вони визначені на рівні класу і залишаються однаковими для всіх екземплярів, якщо їх не змінити вручну.

У наведеному прикладі discount_rate є атрибутом класу. Він доступний для всіх екземплярів класу Book. При зверненні через екземпляр Python спочатку перевіряє атрибути екземпляра, і якщо їх там не знаходить, шукає серед атрибутів класу.

class Book:  
 discount_rate = 0.2  

 def __init__(self, title: str, price: float, quantity: int = 0):  
 assert price >= 0, f"Price {price} is not greater or equal to zero"  
 assert quantity >= 0, f"Quantity {quantity} is not greater or equal to zero"  

 self.title = title  
 self.price = price  
 self.quantity = quantity  

 def calculate_total_price(self):  
 return self.price * self.quantity  

book1 = Book("Learning Python", 10, 5)  
book2 = Book("Two Scoops of Django", 30, 10)

Звернення до атрибута класу безпосередньо через клас:

print(Book.discount_rate) # Output: 0.2

Звернення до атрибута класу через екземпляр:

print(book1.discount_rate) # Output: 0.2  
print(book2.discount_rate) # Output: 0.2

Перегляд усіх атрибутів класу:

print(Book.__dict__) # Output: All attributes defined at the class level, including discount_rate

Перегляд атрибутів екземплярів для конкретних об'єктів:

print(book1.__dict__) # Output: {'title': 'Learning Python', 'price': 10, 'quantity': 5}  
print(book2.__dict__) # Output: {'title': 'Two Scoops of Django', 'price': 30, 'quantity': 10}

Використання атрибутів класу в методах

Ми хочемо створити метод apply_discount, який змінює ціну книги, застосовуючи ставку знижки.
discount_rate визначено як атрибут класу, оскільки він спільний для всіх екземплярів.

class Book:  
 discount_rate = 0.2  

 def __init__(self, title: str, price: float, quantity: int = 0):  
 self.title = title  
 self.price = price  
 self.quantity = quantity  

 def apply_discount(self):  
 self.price = self.price * (1 - Book.discount_rate)

Тут discount_rate доступний через ім'я класу Book.discount_rate. Це гарантує, що всі екземпляри використовують одну й ту саму ставку знижки. Наприклад:

book1 = Book("Learning Python", 10, 5)  
book1.apply_discount()  
print(book1.price) # Output: 8.0

Метод apply_discount працює як очікується, застосовуючи знижку 20%.

Перезаписування атрибутів класу

Якщо ми хочемо використати іншу ставку знижки для конкретної книги, наприклад, застосувати знижку 10% до Two Scoops of Django, залишаючи знижку 20% для Learning Python, зміна атрибута класу discount_rate не підходить, оскільки це вплине на всі книги. Натомість, ми можемо призначити індивідуальний атрибут екземпляра:

book2 = Book("Two Scoops of Django", 30, 10)  
book2.discount_rate = 0.1  
book2.apply_discount()  
print(book2.price) # Output: 24.0

Однак, у початковій реалізації методу apply_discount, метод використовує Book.discount_rate, що завжди звертається до атрибута класу. Як результат, атрибут екземпляра discount_rate буде проігноровано. Щоб це виправити, ми повинні використовувати self.discount_rate:

class Book:  
 def apply_discount(self):  
 self.price = self.price * (1 - self.discount_rate)

Тепер, якщо ми не перезаписуємо discount_rate для book1, він за замовчуванням використовує атрибут класу. З іншого боку, для book2 атрибут екземпляра discount_rate (який встановлений на 0.1) перезаписує атрибут класу. Коли ми виконуємо код:

book1 = Book("Learning Python", 10, 5)  
book2 = Book("Two Scoops of Django", 30, 10)  

book1.apply_discount()  
print(book1.price) # Output: 8.0  

book2.discount_rate = 0.1  
book2.apply_discount()  
print(book2.price) # Output: 27.0

Для book1 буде застосована знижка 20%, а для book2 — знижка 10%.

Відстеження та управління всіма екземплярами класу

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

Давайте створимо більше екземплярів класу Book:

book1 = Book("Learning Python", 10, 5)  
book2 = Book("Two Scoops of Django", 30, 10)  
book3 = Book("Clean Code", 25, 7)  
book4 = Book("Design Patterns in Python", 40, 3)  
book5 = Book("Fluent Python", 60, 2)

Ми можемо ввести атрибут класу для зберігання списку всіх створених екземплярів. Спочатку визначимо атрибут all як порожній список на рівні класу:

class Book:  
 all = []

Далі, оновимо метод __init__, щоб додавати кожен новий екземпляр до списку all:

class Book:  
 ...  
 all = []  

 def __init__(self, title: str, price: float, quantity: int = 0):  
 ...  
 Book.all.append(self)

Тепер, коли створюється новий екземпляр класу Book, він автоматично додається до списку Book.all. Для перевірки можемо вивести список Book.all:

print(Book.all)

Коли ви виконаєте код, ви побачите, що список Book.all містить всі екземпляри:

[<__main__.Book object at 0x00000279F66CA720>, <__main__.Book object at 0x00000279F66C81A0>, <__main__.Book object at 0x00000279F66CA750>, <__main__.Book object at 0x00000279F66CA7B0>, <__main__.Book object at 0x00000279F66CA8A0>]

Покращення представлення екземплярів

Хоча вищенаведений вивід технічно правильний, він не надає багато корисної інформації. Щоб зробити представлення кожного екземпляра більш зручним для читання, ми можемо визначити метод __repr__ в класі Book.
Цей метод контролює, як екземпляри класу представляються у вигляді рядків:

def __repr__(self):  
 return f"Book('{self.title}', {self.price}, {self.quantity})"

Після додавання цього методу, виведення Book.all дасть такий результат:

[Book('Learning Python', 10, 5), Book('Two Scoops of Django', 30, 10), Book('Clean Code', 25, 7), Book('Design Patterns in Python', 40, 3), Book('Fluent Python', 60, 2)]

Практичне використання атрибута all

Атрибут all дозволяє легко маніпулювати всіма екземплярами. Наприклад, щоб вивести назви всіх книг:

for instance in Book.all:  
 print(instance.title)

Результат буде таким:

Learning Python  
Two Scoops of Django  
Clean Code  
Design Patterns in Python  
Fluent Python

Робота з даними за допомогою CSV файлів

До цього часу ми зберігали дані безпосередньо у файлі main.py, створюючи кілька елементів вручну. Однак, коли програма зростатиме, зберігання коду і даних в одному файлі ускладнить додавання нових функцій.

Ми могли б використати базу даних для керування даними, але для цілей цього уроку ми залишимо все простіше і використаємо CSV файл. CSV (Comma-Separated Values) — це формат, у якому дані зберігаються у табличній структурі, де кожен рядок представляє запис, а кожен стовпчик — поле.

Створення CSV файлу

Ми створимо файл з ім'ям books.csv, щоб зберігати наші дані у такому форматі:

Title,Price,Quantity  
"Learning Python",10,5  
"Two Scoops of Django",30,10  
"Clean Code",25,7  
"Design Patterns in Python",40,3  
"Fluent Python",60,2

Перший рядок містить заголовки стовпців: Title, Price та Quantity, а наступні рядки містять значення даних, розділені комами.

Читання даних з CSV

Для роботи з CSV файлами в Python ми будемо використовувати вбудовану бібліотеку csv. Почнемо з її імпорту:

import csv

Створення методу класу

Далі ми створимо метод для завантаження даних з файлу books.csv. Цей метод спеціально призначений для створення екземплярів класу, тому його не можна викликати на екземплярі класу. Замість цього, він повинен бути доступний на рівні класу, ось так:

Book.instantiate_from_csv()

Щоб цього досягти, потрібно перетворити метод на метод класу. Додавши декоратор @classmethod перед нашим методом, ми вказуємо, що цей метод належить самому класу, а не конкретному екземпляру класу. Метод автоматично отримує клас як перший параметр, який за умовою називається cls замість self.

Ось приклад реалізації:

class Book:  
 @classmethod  
 def instantiate_from_csv(cls):  
 pass  

Book.instantiate_from_csv()

Читання CSV файлу та створення об'єктів

Ось приклад методу класу, який читає CSV файл і створює об'єкти на основі його вмісту. У цьому прикладі Python файл (main.py) і CSV файл (books.csv) знаходяться в одній директорії.

@classmethod  
def instantiate_from_csv(cls):  
 with open("books.csv", "r") as f:  
 reader = csv.DictReader(f)  
 books = list(reader)  

 for book in books:  
 print(book)

У цьому коді ми відкриваємо CSV файл і читаємо його вміст як словник. Потім перетворюємо цей словник на список, оскільки списки дозволяють ітеруватися через елементи і виводити їх для перегляду структури.

При виконанні коду ви побачите, що кожен рядок з CSV файлу буде виведений як словник:

{'Title': 'Learning Python', 'Price': '10', 'Quantity': '5'}  
{'Title': 'Two Scoops of Django', 'Price': '30', 'Quantity': '10'}  
{'Title': 'Clean Code', 'Price': '25', 'Quantity': '7'}  
{'Title': 'Design Patterns in Python', 'Price': '40', 'Quantity': '3'}  
{'Title': 'Fluent Python', 'Price': '60', 'Quantity': '2'}

Замість того, щоб виводити кожну книгу, ми можемо створювати об'єкти, передаючи значення зі словника як аргументи.
Наприклад:

for book in books:  
 Book(  
 title=book.get("Title"),  
 price=float(book.get("Price")),  
 quantity=int(book.get("Quantity")),  
 )

Необхідно перетворити Price і Quantity на float і int, оскільки ці значення зчитуються з CSV файлу як рядки. Без цього перетворення виникне помилка при спробі використати ці значення як числа.

Статичні методи

Статичні методи працюють як звичайні окремі функції, що належать класу, але приймають тільки параметри, явно визначені в їх сигнатурі, що робить їх ідеальними для функціональності, спільної для всього класу, яка не потребує доступу до даних на рівні екземпляра або класу.

Для визначення статичного методу використовується декоратор @staticmethod, що гарантує, що метод не отримає посилання на екземпляр (self) або клас (cls) як перший аргумент.

@staticmethod  
def is_integer(num):  
 if isinstance(num, float):  
 return num.is_integer()  
 elif isinstance(num, int):  
 return True  
 else:  
 return False

У цьому прикладі статичний метод is_integer перевіряє, чи є задане число цілим або числом типу float, яке виглядає як ціле. Ось як він працює з різними вхідними даними:

print(Book.is_integer(7)) # True  
print(Book.is_integer(7.0)) # True  
print(Book.is_integer(7.5)) # False

Метод повертає True для дійсних цілих чисел або чисел типу float, які виглядають як цілі, і False в іншому випадку.

Наслідування в Python

Ми створимо два екземпляри класу Book та додамо новий атрибут damaged_copies для відстеження кількості пошкоджених друкованих книжок. Це реалістичне доповнення, яке допоможе нам слідкувати за запасами:

book1 = Book("Introduction to Algorithms", 45.0, 10)   
book1.damaged_copies = 2   

book2 = Book("Advanced Data Structures", 60.0, 5)   
book2.damaged_copies = 1

Зараз атрибут damaged_copies не інтегрований належним чином у клас Book, оскільки він не прив'язаний до self.

Додавати метод для обчислення кількості непошкоджених копій безпосередньо в класі Book не є ідеальним, оскільки ця функціональність є специфічною для друкованих книг і не є релевантною для інших типів. Тому ми створимо клас PrintedBook.

Дитячий та батьківський клас

Коли ми хочемо створити новий клас, подібний до класу Book, з додатковою функціональністю, ми можемо використати наслідування, щоб уникнути дублювання методів і атрибутів класу Book.

Дитячий клас успадковує від батьківського класу. Успадковуючи від батьківського класу, дитячий клас отримує всі методи та атрибути батька, водночас дозволяючи додавати нові функціональності, специфічні для дитячого класу.

class PrintedBook(Book):  
 all = []  

 def __init__(self, title: str, price: float, quantity=0, damaged_copies=0):  
 super().__init__(title, price, quantity)  

 assert (  
 damaged_copies >= 0  
 ), f"Damaged copies {damaged_copies} is not greater or equal to zero"  

 self.damaged_copies = damaged_copies  

 PrintedBook.all.append(self)

Тут клас PrintedBook успадковує від класу Book, тому методи, як-от calculate_total_price, доступні для екземплярів PrintedBook.

Клас PrintedBook вводить додатковий атрибут damaged_copies, який є специфічним для класу PrintedBook.

Покращення методу представлення

Створимо кілька екземплярів класу PrintedBook, а потім виведемо всі екземпляри як класу Book, так і PrintedBook:

printed_book1 = PrintedBook("Introduction to Algorithms", 25, 7, 1)  
printed_book2 = PrintedBook("Advanced Data Structures", 40, 3, 2)  

print(Book.all)  
print(PrintedBook.all)

Після виконання програми, результат виглядатиме так:

[Book('Introduction to Algorithms', 25, 7), Book('Advanced Data Structures', 40, 3)]  
[Book('Introduction to Algorithms', 25, 7), Book('Advanced Data Structures', 40, 3)]

Виводяться екземпляри класу Book, оскільки ми ще не реалізували метод __repr__ у класі Book.
Щоб виправити це та динамічно відображати назву класу, ми можемо використати спеціальний магічний атрибут __class__.__name__ в методі __repr__:

def __repr__(self):  
 return f"{self.__class__.__name__}('{self.title}', {self.price}, {self.quantity})"

Коли ми знову запустимо програму з цією зміною, результат стане таким:

[PrintedBook('Introduction to Algorithms', 25, 7), PrintedBook('Advanced Data Structures', 40, 3)]  
[PrintedBook('Introduction to Algorithms', 25, 7), PrintedBook('Advanced Data Structures', 40, 3)]

Доступ до атрибутів і методів батьківського класу

Функція super() є необхідною при роботі з наслідуванням. Вона дозволяє дочірньому класу доступати атрибути та методи батьківського класу без дублювання коду. Це гарантує, що будь-які зміни в батьківському класі автоматично поширюватимуться на дочірні класи. Наприклад:

super().__init__(title, price, quantity)

Якщо ми видалимо наступні рядки з класу PrintedBook і знову запустимо програму, результат залишиться таким самим:

all = []  
PrintedBook.all.append(self)

Це демонструє, що PrintedBook безшовно успадковує функціональність від класу Book, забезпечуючи чистий та підтримуваний код.

Організація коду в кількох файлах

Якщо наш проект росте, управління всім в одному файлі стає складним. Для вирішення цієї проблеми ми можемо розділити код на кілька файлів. Наприклад, можна створити окремі файли для класів Book та PrintedBook, а також файл main.py, який буде відповідати за створення та управління екземплярами цих класів.

Ось як ми організуємо нові файли Python:

  • book.py (перемістити клас Book та його імпорт сюди)
  • printed_book.py (перемістити клас PrintedBook та імпортувати Book сюди)

Оновимо main.py, щоб створювати екземпляри та працювати з даними, ось так:

from book import Book  
from printed_book import PrintedBook  

Book.instantiate_from_csv()  
print(Book.all)

Коли ви запустите main.py, все працюватиме як очікується.

Геттери та Сеттери

Переваги використання геттерів і сеттерів полягають у тому, щоб надавати публічний доступ до даних і уникати порушення зовнішнього коду, коли ви змінюєте внутрішні поля. Розглянемо наступний код:

class Book:  
 def __init__(self, title: str):  
 self.title = title  


book1 = Book("AAA")  
book1.title = "BBB"  

print(book1.title) # Output: BBB

Тут атрибут title можна змінити після ініціалізації. Однак, ми можемо розглядати title як критичний атрибут, тому нам може бути потрібно обмежити цю поведінку, щоб його можна було змінювати лише під час ініціалізації.

Геттери

Однією з проблем, яку ми маємо, є те, що атрибут title потрібно зробити доступним лише для читання. Використовуючи декоратор @property, ми можемо визначити атрибут, який буде тільки доступним і не змінюваним.

Створення атрибута тільки для читання

Щоб зробити атрибут title доступним лише для читання, ми можемо визначити @property у файлі book.py таким чином:

class Book:  
 def __init__(self, title: str):  
 self.title = title  

 @property  
 def read_only_title(self):  
 return "AAA"

Тепер, в main.py, якщо ми спробуємо доступити або змінити read_only_title, ми побачимо таку поведінку:

from book import Book  

book1 = Book("Introduction to Algorithms")  
book1.title = "Algorithms"  

print(book1.read_only_title) # Output: AAA  
book1.read_only_title = "BBB" # Raises AttributeError

Як показано, атрибут read_only_title зроблений доступним лише для читання. Це означає, що ви можете лише прочитати його значення, і спроба змінити його викликає AttributeError.

Використання приватного атрибута для доступу тільки для читання

Декоратор @property контролює тільки поведінку атрибута read_only_title самостійно, а не те, як дані за ним змінюються. Тому ми можемо ввести приватний атрибут _title в класі Book.
Ось як це виглядає:

class Book:  
 def __init__(self, title: str):  
 self._title = title # Приватний атрибут з підкресленням  

 @property  
 def title(self):  
 return self._title

Цей підхід надає метод геттера (title), щоб отримати значення атрибута _title. Ось як це працює:

from book import Book  

book1 = Book("Introduction to Algorithms")  
print(book1.title) # Output: Introduction to Algorithms  
print(book1._title) # Output: Introduction to Algorithms  

book1.title = "Algorithms"  
print(book1.title) # Raises AttributeError  

book1._title = "Algorithms"  
print(book1._title) # Output: Algorithms

Але є проблема: атрибут _title можна змінювати напряму. Це суперечить меті мати властивість лише для читання, оскільки будь-хто може обійти властивість і змінити атрибут _title безпосередньо.

Використання подвійних підкреслень для запобігання доступу

Щоб запобігти доступу або зміні атрибута ззовні класу, ми можемо додати подвійні підкреслення (__title) до атрибута. Ось оновлений клас Book:

class Book:  
 def __init__(self, title: str):  
 self.__title = title  

 @property  
 def title(self):  
 return self.__title

Тепер протестуємо це в main.py:

from book import Book  

book1 = Book("Introduction to Algorithms")  
print(book1.title) # Output: Introduction to Algorithms  

book1.title = "Algorithms"  
print(book1.__title) # Raises AttributeError

Навіть спроба доступити __title безпосередньо призводить до помилки. Це відбувається тому, що подвійні підкреслення роблять атрибут недоступним ззовні класу.

Сеттери

Раніше ми дізналися, що декоратор @property робить атрибут лише для читання. Коли ми намагаємося встановити значення для такої властивості, виникає AttributeError. Однак іноді нам потрібно дозволити змінювати атрибут, при цьому контролюючи, як і коли це можна зробити. Ось тут і допомагають сеттери.

Створення сеттера

Щоб створити сеттер для атрибута, спочатку визначеного як тільки для читання, ми визначаємо новий метод з такою самою назвою, як у властивості, і використовуємо декоратор @property_name.setter. Цей декоратор дозволяє призначати нові значення для атрибута, навіть якщо це властивість.

Ось як ми визначаємо сеттер для атрибута title:

class Book:  
 def __init__(self, title: str):  
 self.__title = title # Приватний атрибут для збереження title  

 @property  
 def title(self):  
 return self.__title  

 @title.setter  
 def title(self, value):  
 self.__title = value

Давайте подивимося, що трапиться, коли ми спробуємо встановити нове значення для атрибута title у main.py:

from book import Book  

book1 = Book("myitem")  
book1.title = "otherthing"  

print(book1.title) # Output: otherthing

Тепер ми можемо не тільки встановлювати значення для приватного атрибута _title через конструктор, але й призначати нове значення для атрибута title пізніше, використовуючи сеттер.

Додавання валідації за допомогою сеттерів

Сеттери не лише для призначення нових значень; вони також можуть включати логіку для валідації або обмеження значень, які призначаються.
Наприклад, обмежимо атрибут title максимальним розміром 10 символів:

class Book:  
 def __init__(self, title: str):  
 self.__title = title # Приватний атрибут  

 @property  
 def title(self):  
 return self.__title  

 @title.setter  
 def title(self, value):  
 if len(value) > 10:  
 raise Exception("Заголовок занадто довгий") # Обмеження: максимальна довжина 10  
 else:  
 self.__title = value

Давайте подивимося, як ця валідація впливає на поведінку програми в main.py:

from book import Book  

book1 = Book("short") # Ініціалізуємо з коротким заголовком  
print(book1.title) # Output: short  

book1.title = "acceptable" # Новий заголовок, довжина <= 10  
print(book1.title) # Output: acceptable  

book1.title = "this_title_is_too_long" # Raises Exception: Заголовок занадто довгий

Принципи ООП

Об'єктно-орієнтоване програмування (OOP) включає чотири основні принципи, які вам слід зрозуміти для ефективного проектування великих програм. Дотримуючись цих принципів, вам буде простіше підтримувати і розвивати ваші програми. Принципи:

1. Інкапсуляція

Інкапсуляція об'єднує атрибути та методи, які працюють з даними, в єдину одиницю, зазвичай клас, і обмежує прямий доступ до певних атрибутів. Наприклад, зробивши атрибут price приватним у класі Book, ми запобігаємо його прямому зміненню. Замість цього використовуються методи, як-от apply_discount або apply_increment, для зміни ціни, забезпечуючи, щоб виконувалися тільки валідні операції.

Ось початкова реалізація:

from book import Book  

book1 = Book("Python Basics", 75)  
book1.price = -90  

print(book1)

У наведеному коді ви можете побачити, що ми можемо безпосередньо встановити атрибут price на будь-яке значення, навіть на недійсне, як-от -90.
Раніше ми використовували методи, як-от apply_discount, щоб змінити атрибут price, знижуючи його на 20%. Тепер, для кращого контролю, нам потрібно реалізувати методи, які обмежують доступ до атрибута price. Ми можемо додати методи для збільшення ціни на відсоток або застосування знижки.

Оновлена реалізація:

class Book:  
 def __init__(self, price: float):  
 self.__price = price  

 @property  
 def price(self):  
 return self.__price  

 def apply_discount(self, discount_rate: float):  
 if 0 <= discount_rate <= 1:  
 self.__price = self.__price * (1 - discount_rate)  
 else:  
 raise ValueError("Рівень знижки має бути між 0 і 1.")  

 def apply_increment(self, increment_rate: float):  
 if increment_rate > 0:  
 self.__price = self.__price * (1 + increment_rate)  
 else:  
 raise ValueError("Рівень збільшення має бути додатним.")

Зробивши атрибут price приватним (__price), ми гарантуємо, що його можна змінювати лише через контрольовані методи, такі як застосування знижки або збільшення.

book1 = Book("Python Basics", 75)  


print(book1.price) # Output: 75  

book1.apply_discount(0.2)  
print(book1.price) # Output: 60.0 (Застосовано знижку 20%)  

book1.apply_increment(0.1)  
print(book1.price) # Output: 66.0 (Застосовано збільшення 10%)

2. Абстракція

Абстракція зосереджується на наданні лише необхідних атрибутів і поведінки об'єкта, приховуючи непотрібні деталі. Це спрощує складність, надаючи спрощений інтерфейс, що робить системи легшими у використанні та розумінні.

Наприклад, розглянемо наступну програму:

from book import Book  

book1 = Book("Python Basics", 75, 6)  
book1.send_email()

У класі Book метод send_email є операцією високого рівня. Реальний процес включає кілька кроків у фоновому режимі, таких як підключення до SMTP сервера та підготовка тексту листа — ці деталі приховані від користувача. Щоб ефективно реалізувати абстракцію, ми можемо розбити ці кроки на окремі методи.

Наприклад, можемо створити метод для підключення до SMTP сервера:

class Book:   
 ...
def connect(self, smtp_server):   
 pass

І метод для підготовки тіла електронного листа з автоматичним повідомленням:

class Book:   
 ...   

 def prepare_body(self):  
 return f"""   
 Hello,   
 We have {self.quantity} of {self.title} available.   
 """

Ці методи потім комбінуються в методі send_email:

class Book:   
 ...   

 def send(self):   
 pass   

 def send_email(self):   
 self.connect("")   
 self.prepare_body()   
 self.send()

У цьому підході методи, як-от connect та prepare_body, доступні безпосередньо з екземпляра класу, що порушує принцип абстракції. Щоб приховати ці деталі, Python дозволяє нам імітувати приватні методи, додаючи до їх назв подвійні підкреслення.

Зробивши ці методи приватними, ми забезпечуємо, щоб їх можна було викликати лише зсередини самого класу:

class Book:   
 ...   

 def __connect(self, smtp_server):  
 pass  

 def __prepare_body(self):  
 return f"""   
 Hello,   
 We have {self.quantity} of {self.title} available.   
 """  

 def __send(self):  
 pass  

 def send_email(self):  
 self.__connect("")  
 self.__prepare_body()  
 self.__send()

Якщо ви спробуєте звернутися до цих приватних методів безпосередньо з екземпляра в main.py, вони не з’являться в автодоповненні або не будуть доступні. Це тому, що приватні методи призначені для використання лише всередині класу.

3. Спадкування

Спадкування дозволяє дочірньому класу успадковувати атрибути та методи від батьківського класу. Це сприяє повторному використанню коду, запобігає дублюванню і робить системи більш масштабованими та легшими для підтримки.

Наприклад, в цьому курсі ми реалізували спадкування, створивши клас PrintedBook, який є нащадком класу Book. Клас PrintedBook успадковує всі атрибути та методи класу Book:

from printed_book import PrintedBook  

printed_book1 = PrintedBook("Python Basics", 75, 3)

Тут ми передаємо ті ж аргументи, що вимагає клас Book, класу PrintedBook. Спадкування також гарантує, що дочірні класи, такі як PrintedBook, можуть отримати доступ до методів, визначених у батьківському класі.

Наприклад, метод apply_increment визначений у класі Book, але може бути використаний з екземпляром класу PrintedBook:

from printed_book import PrintedBook  

printed_book1 = PrintedBook("Python Basics", 75, 3)  
printed_book1.apply_increment(0.2)  
print(printed_book1.price)

Це демонструє, як логіка батьківського класу може бути повторно використана без переписування для кожного дочірнього класу, наприклад, для класів EBook, AudioBook або Article.

4. Поліморфізм

Поліморфізм, що означає «багато форм», дозволяє об'єктам різних класів оброблятися так, ніби вони належать до одного спільного батьківського класу. Це досягається через перевизначення методів для надання специфічної поведінки, що забезпечує гнучкість і повторне використання коду.

Наприклад, вбудована функція Python len демонструє поліморфізм, оскільки вона приймає різні типи об'єктів (як-от рядки, списки або словники) і повертає їх довжину відповідно:

name = "Sahar"  
print(len(name)) # Output: 5  

some_list = ["some", "name"]  
print(len(some_list)) # Output: 2

Тут функція len повертає кількість символів у рядку або кількість елементів у списку, демонструючи, як одна функція може обробляти кілька типів об'єктів. Поліморфізм також застосовується до власних методів класів. Розглянемо клас Book і підклас PrintedBook:

from printed_book import PrintedBook  

printed_book1 = PrintedBook("Python Basics", 75, 3)  
printed_book1.apply_discount()  
print(printed_book1.price) # Output: 60.0

Метод apply_discount визначений у класі Book, але може бути використаний класом PrintedBook, оскільки він успадковує від класу Book.
Це демонструє поліморфізм: той самий метод працює з різними об'єктами.

Тепер давайте розширимо цей приклад з підкласом EBook:

from book import Book  

class EBook(Book):  
 discount_rate = 0.5  

 def __init__(self, title: str, price: float, quantity=0):  
 super().__init__(title, price, quantity)

Запуск коду показує, що метод apply_discount в EBook працює як очікується:

from ebook import EBook  

ebook1 = EBook("Python Basics", 75, 3)  
ebook1.apply_discount()  
print(ebook1.price) # Output: 37.5

Тут метод використовує специфічну змінну discount_rate, визначену в класі EBook (discount_rate = 0.5), що показує, як дочірні класи можуть налаштовувати поведінку, використовуючи той самий метод.

Поліморфізм стає ще потужнішим, коли поєднується з абстрактними класами, які виступають як шаблони для визначення спільних інтерфейсів між класами. Хоча абстрактні класи ще більше покращують поліморфізм, навіть без них поліморфізм є необхідним для написання гнучкого та зручного для підтримки коду на Python.

Перекладено з: Python Object Oriented Programming (OOP)

Leave a Reply

Your email address will not be published. Required fields are marked *