9  Об’єктно-орієнтоване програмування

Data Miorsh Ihor Miroshnychenko Youtube Monobank

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

Цього разу ми зосередимося на іншій парадигмі, і розглянемо її більш детально, а саме на об’єктно-орієнтованому програмуванні (ООП). Можливо дехто з вас вже мали досвід програмування і вивчали такі мови, як Java, які за своєю суттю є об’єктно-орієнтованими, Python дійсно дозволяє вам бути більш гнучкими, коли мова йде про те, як ви вирішуєте проблеми за допомогою коду. Але виявляється, що ООП, об’єктно-орієнтоване програмування, є досить переконливим рішенням проблем, з якими ви незмінно стикаєтесь, коли ваші програми стають довшими, більшими та складнішими.

9.1 Використання структур даних

9.1.1 Кортежі

Почнемо з простого прикладу і потрошку будемо додавати все нові і нові функції. Подивимось, чи не призведе це до труднощів.

Для початку створимо програму student.py, яка буде приймати ім’я та гуртожиток студента у світі Гарі Поттера:

Terminal
code student.py
name = input("Ім'я: ")
house = input("Гуртожиток: ")

print(f"{name} з {house}")
Terminal
python student.py
Ім'я: Гаррі
Гуртожиток: Грифіндор

Гаррі з Грифіндор

Раніше ми навчилися використовувати власні функції. Використаємо функціональний підхід і створимо функції get_name() та get_house(), які будуть повертати ім’я та гуртожиток студента відповідно:

def main():
    name = get_name()
    house = get_house()
    print(f"{name} з {house}")

def get_name():
    return input("Ім'я: ")

def get_house():
    return input("Гуртожиток: ")

if __name__ == "__main__":
    main()
Terminal
python student.py
Ім'я: Гаррі
Гуртожиток: Грифіндор

Гаррі з Грифіндор

Розвиваючи та узагальнюючи цей код, мо можемо створити функцію get_student(), яка буде повертати ім’я та гуртожиток студента:

def main():
    name, house = get_student() 
    print(f"{name} з {house}")

def get_student(): 
    name = input("Ім'я: ") 
    house = input("Гуртожиток: ") 
    return name, house # це одне значення, якє є кортежем 

if __name__ == "__main__":
    main()
Terminal
python student.py
Ім'я: Гаррі
Гуртожиток: Грифіндор

Гаррі з Грифіндор

Насправді, коли ми повертаємо значення з функції, ми повертаємо кортеж (англ. tuple) - один з типів даних Python, який дуже схожий на список, але він не може бути змінений. Це означає, що ми можемо використовувати кортежі, якщо ми хочемо повернути кілька значень з функції. І навіть записати їх у дужках, що інтуїтивно буде вказувати на використання кортежу: return (name, house).

Ми можемо не розпаковувати кортеж в окремі змінні, а замість цього використовувати індексацію, щоб отримати доступ до значень у кортежі:

def main():
    student = get_student() 
    print(f"{student[0]} з {student[1]}") 

def get_student():
    name = input("Ім'я: ")
    house = input("Гуртожиток: ")
    return name, house

if __name__ == "__main__":
    main()

Для демонстрації незмінності кортежів, спробуємо змінити значення у кортежі через умовні оператори, для цього введемо умову, що якщо ім’я студента - Падма, то змінимо її гуртожиток на Рейвенкло:

def main():
    student = get_student()
    if student[0] == "Падма": 
        student[1] = "Рейвенклов" 
    print(f"{student[0]} з {student[1]}")

def get_student():
    name = input("Ім'я: ")
    house = input("Гуртожиток: ")
    return (name, house)

if __name__ == "__main__":
    main()
Terminal
python student.py
Ім'я: Падма
Гуртожиток: Грифіндор

TypeError: 'tuple' object does not support item assignment

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

def main():
    student = get_student()
    if student[0] == "Падма":
        student[1] = "Рейвенклов"
    print(f"{student[0]} з {student[1]}")

def get_student():
    name = input("Ім'я: ")
    house = input("Гуртожиток: ")
    return [name, house] 

if __name__ == "__main__":
    main()
Terminal
python student.py
Ім'я: Падма
Гуртожиток: Грифіндор

Падма з Рейвенклов

9.1.2 Словники

Задачу з введенням ім’я та гуртожитку можна вирішити через словник:

def main():
    student = get_student()
    print(f"{student['name']} з {student['house']}")

def get_student():
    student = {} 
    student['name'] = input("Ім'я: ") 
    student['house'] = input("Гуртожиток: ") 
    return student

if __name__ == "__main__":
    main()

Нам не обов’язково створювати пустий словник, ми можемо одразу повертати словник з ім’ям та гуртожитком:

def main():
    student = get_student()
    print(f"{student['name']} з {student['house']}")

def get_student():
    return { 
        'name': input("Ім'я: "), 
        'house': input("Гуртожиток: ") 
    } 

if __name__ == "__main__":
    main()

Якщо ми захочемо враховувати можливість змінити гуртожиток, якщо ім’я студента - Падма, то ми можемо використати умовний оператор:

def main():
    student = get_student()
    if student['name'] == "Падма": 
        student['house'] = "Рейвенклов" 
    print(f"{student['name']} з {student['house']}")

def get_student():
    return {
        'name': input("Ім'я: "),
        'house': input("Гуртожиток: ")
    }

if __name__ == "__main__":
    main()

На невеликих прикладах все виглядає досить просто. Уявіть, що в перспективі з’явиться необхідність додати більше інформації про студента: патронус, магічні здібності, тощо. Було б значно зручніше, якби Python дозволяв нам створювати власні типи даних, які могли б містити інформацію про студента. І це можливо завдяки об’єктно-орієнтованому програмуванню.

9.2 Класи

Класи (англ. classes) - це щось на зразок шаблону, який дозволяє створювати власні типи даних, давати їм імена.

Для створення класу використовується ключове слово class, за яким слідує ім’я класу, а потім двокрапка. Ім’я класу повинно починатися з великої літери, а якщо ім’я складається з декількох слів, то кожне слово повинно починатися з великої літери. Наприклад, Student:

class Student:
    ...
Примітка

Документація до класів Python: https://docs.python.org/3/tutorial/classes.html

Для того, щоб наша програма почала використовувати клас, ми повинні створити екземпляр класу, тобто об’єкт. Це можна зробити, використовуючи ім’я класу, за яким слідує дужка. Наприклад, Student().

Класи мають атрибути (англ. attributes), свого роду властивості, які дозволяють вам вказувати значення всередині них. Для того, щоб вказати атрибут, використовується крапка, за якою слідує ім’я атрибуту. Наприклад, student.name:

class Student:
    ...
    
def main():
    student = get_student()
    print(f"{student.name} з {student.house}")

def get_student():
    student = Student()
    student.name = input("Ім'я: ")
    student.house = input("Гуртожиток: ")
    return student

if __name__ == "__main__":
    main()
Terminal
python student.py
Ім'я: Гаррі
Гуртожиток: Грифіндор

Гаррі з Грифіндор

Введемо ще одну термінологію. Ми створили клас за допомогою ключового слова class, але коли ми створюємо екземпляр класу, ми створюємо об’єкт (англ. object). Таким чином, об’єкт - це екземпляр класу. Якщо говорити метафорично, то клас - це план будинку, а об’єкт - це будинок, побудований за цим планом.

Давайте додамо більше функціональності нашому класу. Досить нерозважливо просто вставляти все що завгодно всередину класу. Класи можна стандартизувати - вказувати якими можуть бути атрибути і які значення вони можуть приймати. Давайте всередині функції get_student() створимо локальні змінні name та house, а потім використаємо їх для створення екземпляру класу Student:

class Student:
    ...

def main():
    student = get_student()
    print(f"{student.name} з {student.house}")

def get_student():
    name = input("Ім'я: ") 
    house = input("Гуртожиток: ") 
    student = Student(name, house) 
    return student

if __name__ == "__main__":
    main()

Зараз, коли ми готуємось до більш потужних можливостей класів та об’єктно-орієнтованого програмування в цілому. Зверніть увагу, що ми передаємо локальні змінні name та house класу Student, як аргументи функції, але він поки не знає, що з ними робити. Зараз ми стандартизуємо цей клас, щоб він знав, що робити з цими аргументами. Це дасть можливість перевіряти ці дані на помилки, які можуть виникнути, коли користувач вводить неправильні дані.

Тепер перейдемо до класу Student, який до цього часу мав три крапки. У контексті класів є ряд не тільки атрибутів або змінних екземпляра, які ви можете помістити всередину, але і методи (анг. methods). Класи поставляються з певними методами або функціями всередині, які ви можете визначити, і вони просто поводяться особливим чином за природою того, як працює Python. Ці функції дозволяють вам визначати поведінку у стандартний спосіб. Для цього використовується ключове слово def, за яким слідує ім’я методу, а потім дужки. Наприклад, __init__():

class Student:
    def __init__(self, name, house): 
        self.name = name 
        self.house = house 

def main():
    student = get_student()
    print(f"{student.name} з {student.house}")

def get_student():
    name = input("Ім'я: ")
    house = input("Гуртожиток: ")
    student = Student(name, house)
    return student

if __name__ == "__main__":
    main()

В такому випадку ми визначаємо метод __init__(), який викликається, коли ми створюємо екземпляр класу. Це називається конструктором (англ. constructor). Він викликається автоматично, коли ми створюємо екземпляр класу. Він приймає аргумент self, який вказує на екземпляр класу, а також інші аргументи, які ми передаємо, коли створюємо екземпляр класу. В нашому випадку це name та house. Ми використовуємо self.name та self.house, щоб вказати, що ці атрибути належать екземпляру класу. Це дозволяє нам використовувати ці атрибути всередині класу.

Насправді, нам не потрібна змінна student у функції get_student(), оскільки ми можемо повернути екземпляр класу Student:

class Student:
    def __init__(self, name, house):
        self.name = name
        self.house = house

def main():
    student = get_student()
    print(f"{student.name} з {student.house}")

def get_student():
    name = input("Ім'я: ")
    house = input("Гуртожиток: ")
    return Student(name, house) 

if __name__ == "__main__":
    main()

А що, якщо щось піде не так при створенні студента? Наприклад, якщо користувач не дасть нам ім’я і просто натисне клавішу Enter, коли з’явиться запит на введення імені? Або введе некоректну назву гуртожитку. Для цього ми можемо змінити метод __init__() так, щоб він перевіряв правильність введення даних. Для цього ми можемо використати ключове слово raise, яке дозволяє викидати помилки. Наприклад, raise ValueError():

class Student:
    def __init__(self, name, house):
        if not name: 
            raise ValueError("Ім'я не може бути порожнім") 
        if house not in ["Ґрифіндор", "Гафелпаф", "Рейвенклов", "Слизерин"]: 
            raise ValueError("Неправильний гуртожиток") 
        self.name = name
        self.house = house

def main():
    student = get_student()
    print(f"{student.name} з {student.house}")

def get_student():
    name = input("Ім'я: ")
    house = input("Гуртожиток: ")
    return Student(name, house)

if __name__ == "__main__":
    main()

Перевіримо роботу програми:

Terminal

```{.bash filename="Terminal"}
python student.py
Ім'я: Гаррі
Гуртожиток: Ґрифіндор

Гаррі з Ґрифіндор
Terminal
python student.py
Ім'я: Гаррі
Гуртожиток: вул. Тисова, 4

ValueError: Неправильний гуртожиток

9.3 Метод __str__

Повернемось до функції main(), де ми друкуємо ім’я студента та його гуртожиток вручну через звернення до об’єкту student.name та student.house. Було б чудово, якби ми могли просто викликати метод print() і передати об’єкт student як аргумент, і він сам вивів би ім’я та гуртожиток:

class Student:
    def __init__(self, name, house):
        if not name:
            raise ValueError("Ім'я не може бути порожнім")
        if house not in ["Ґрифіндор", "Гафелпаф", "Рейвенклов", "Слизерин"]:
            raise ValueError("Неправильний гуртожиток")
        self.name = name
        self.house = house

def main():
    student = get_student()
    print(student) 

def get_student():
    name = input("Ім'я: ")
    house = input("Гуртожиток: ")
    return Student(name, house)

if __name__ == "__main__":
    main()

Але в такому випадку ми отримаємо адресу об’єкту у пам’яті комп’ютера, а не ім’я та гуртожиток:

Terminal
python student.py
Ім'я: Гаррі
Гуртожиток: Ґрифіндор

<__main__.Student object at 0x7f9b1c0b5d30>

Але ми можемо змінити це, якщо визначимо метод __str__() у класі Student:

class Student:
    def __init__(self, name, house):
        if not name:
            raise ValueError("Ім'я не може бути порожнім")
        if house not in ["Ґрифіндор", "Гафелпаф", "Рейвенклов", "Слизерин"]:
            raise ValueError("Неправильний гуртожиток")
        self.name = name
        self.house = house

    def __str__(self): 
        return f"{self.name} з {self.house}" 

def main():
    student = get_student()
    print(student)

def get_student():
    name = input("Ім'я: ")
    house = input("Гуртожиток: ")
    return Student(name, house)

if __name__ == "__main__":
    main()
Terminal
python student.py
Ім'я: Гаррі
Гуртожиток: Ґрифіндор

Гаррі з Ґрифіндор
Terminal
python student.py
Ім'я: Драко
Гуртожиток: Слизерин

Драко з Слизерин

9.4 Власні методи

Давайте подивимося, чи можемо ми зберігати не лише ім’я студента та його гуртожиток, але й його Патронус1, тобто те, що насправді він викликає, коли використовує це закляття.

Додамо до аргументів методу __init__() ще один аргумент patronus. Поки не будемо перейматися правильністю введення даних і просто присвоїмо значення атрибуту self.patronus та запросимо його у функції main():

class Student:
    def __init__(self, name, house, patronus): 
        if not name:
            raise ValueError("Ім'я не може бути порожнім")
        if house not in ["Ґрифіндор", "Гафелпаф", "Рейвенклов", "Слизерин"]:
            raise ValueError("Неправильний гуртожиток")
        self.name = name
        self.house = house
        self.patronus = patronus 

    def __str__(self):
        return f"{self.name} з {self.house}"

def main():
    student = get_student()
    print(student)

def get_student():
    name = input("Ім'я: ")
    house = input("Гуртожиток: ")
    patronus = input("Патронус: ") 
    return Student(name, house, patronus) 

if __name__ == "__main__":
    main()

Припустимо, що тепер ми хочемо мати функціональність, а не тільки для ініціалізації студента і роздруківки студента. Якщо наш клас дійсно призначений для роботи зі студентами, ми можемо не просто запам’ятовувати інформацію про студентів. На відміну від словників, класи можуть мати не лише змінні або змінні екземплярів, так би мовити, атрибути, які ми постійно створюємо, а й вбудовані функції. Вони також можуть мати вбудовані функції, так звані методи. Коли функція знаходиться всередині класу, вона називається методом. Але це все ще просто функція.

На даний момент ми вже бачили два методи, які називаються __init__ і __str__. Але це особливі методи, тому що вони працюють, тільки якщо ви їх визначите. Python викликає їх автоматично за вас. Але якщо ви хочете створити більше функціональності для студента, наприклад, використовувати магічні здібності, то ви можете визначити власні методи. Для цього використовується ключове слово def, за яким слідує ім’я методу, а потім дужки. Наприклад, charm(). Давайте одразу реалізуємо цей метод так, щоб він повертав емодзі зображення Патронуса:

class Student:
    def __init__(self, name, house, patronus):
        if not name:
            raise ValueError("Ім'я не може бути порожнім")
        if house not in ["Ґрифіндор", "Гафелпаф", "Рейвенклов", "Слизерин"]:
            raise ValueError("Неправильний гуртожиток")
        self.name = name
        self.house = house
        self.patronus = patronus

    def __str__(self):
        return f"{self.name} з {self.house}"

    def charm(self): 
        match self.patronus: 
            case "Олень": 
                return "🦌" 
            case "Видра": 
                return "🦦" 
            case "Тер'єр": 
                return "🐕" 
            case _: 
                return "🪄" 

def main():
    student = get_student()
    print("Expecto Patronum!") 
    print(student.charm()) 

def get_student():
    name = input("Ім'я: ")
    house = input("Гуртожиток: ")
    patronus = input("Патронус: ")
    return Student(name, house, patronus)

if __name__ == "__main__":
    main()
Terminal
python student.py
Ім'я: Гаррі
Гуртожиток: Ґрифіндор
Патронус: Олень

Expecto Patronum!
🦌
Terminal
python student.py
Ім'я: Драко
Гуртожиток: Слизерин
Патронус: 

Expecto Patronum!
🪄

9.5 Властивості та декоратори

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

class Student:
    def __init__(self, name, house):
        if not name:
            raise ValueError("Ім'я не може бути порожнім")
        if house not in ["Ґрифіндор", "Гафелпаф", "Рейвенклов", "Слизерин"]:
            raise ValueError("Неправильний гуртожиток")
        self.name = name
        self.house = house

    def __str__(self):
        return f"{self.name} з {self.house}"

def main():
    student = get_student()
    print(student)

def get_student():
    name = input("Ім'я: ")
    house = input("Гуртожиток: ")
    return Student(name, house)

if __name__ == "__main__":
    main()

Не дивлячись на той факт, що ми перевіряємо правильність введення даних, наша програма все ще не надійна. Ми можемо обійти перевірку шляхом перезапису атрибутів екземпляра класу у функції main():

class Student:
    def __init__(self, name, house):
        if not name:
            raise ValueError("Ім'я не може бути порожнім")
        if house not in ["Ґрифіндор", "Гафелпаф", "Рейвенклов", "Слизерин"]:
            raise ValueError("Неправильний гуртожиток")
        self.name = name
        self.house = house

    def __str__(self):
        return f"{self.name} з {self.house}"

def main():
    student = get_student()
    student.house = "вул. Тисова, 4" 
    print(student)

def get_student():
    name = input("Ім'я: ")
    house = input("Гуртожиток: ")
    return Student(name, house)

if __name__ == "__main__":
    main()
Terminal
python student.py
Ім'я: Гаррі
Гуртожиток: Ґрифіндор

Гаррі з вул. Тисова, 4

І саме тут з’являються властивості (англ. properties). Властивості дозволяють вам виконувати додаткові дії, коли ви звертаєтесь до атрибутів екземпляра класу. Це просто атрибут, який має більше захисних механізмів. Для цього використовується ключове слово @property, яке технічно є функцією декоратора. Декоратори (англ. decorators) - це функції, які приймають іншу функцію і повертають модифіковану функцію.

Створимо два методи house() у класі Student - один для отримання значення атрибуту house, а інший для його зміни. Перший метод називається геттером (англ. getter), а другий - сеттером (англ. setter).

Геттер - це функція для класу, яка отримує деякий атрибут. Сеттер - це функція в деякому класі, яка встановлює деяке значення. Саме для того, щоб ніхто не зміг обійти перевірку правильності введення даних, ми використовуємо сеттер. В такому випадку, коли користувач напише код student.house =, Python автоматично викличе функцію-сеттер. Звідки Python знає, що саме потрібно викликати, адже назви методів однакові? Для цього і потрібні декоратори. Коли ви хочете визначити геттер, необхідно вказати декоратор @property над функцією. Для визначення сеттера використовується декоратор @house.setter. Декоратори вказують Python, що відповідна функція є геттером або сеттером для атрибуту house. Таким чином, коли ми звертаємось до атрибуту house, Python автоматично викликає геттер, а коли ми змінюємо атрибут house, Python автоматично викликає сеттер. В такому випадку, нам вже не буде потрібна перевірка правильності введення даних про гуртожиток у методі __init__(), оскільки вона буде виконуватися в сеттері:

class Student:
    def __init__(self, name, house):
        if not name:
            raise ValueError("Ім'я не може бути порожнім")
        self.house = house

    def __str__(self):
        return f"{self.name} з {self.house}"

    @preoperty # Getter 
    def house(self): 
        return self.house 

    @house.setter # Setter 
    def house(self, house): 
        if house not in ["Ґрифіндор", "Гафелпаф", "Рейвенклов", "Слизерин"]: 
            raise ValueError("Неправильний гуртожиток") 
        self.house = house 

def main():
    student = get_student()
    student.house = "вул. Тисова, 4"
    print(student)

def get_student():
    name = input("Ім'я: ")
    house = input("Гуртожиток: ")
    return Student(name, house)

if __name__ == "__main__":
    main()

Залишається остання проблема з іменами - якщо в нас є змінна екземпляра з назвою house, ми не можемо мати функції з назвою house(). Загальноприйнятий спосіб вирішити цю проблему полягає в тому, щоб сеттер не зберігав значення, яке передається в self.house, а використовував майже ідентичне ім’я, але використовував невеликий індикатор, який означає, що ви знаєте, що робите все правильно. Зазвичай, це нижнє підкреслення. Таким чином, ми змінюємо ім’я атрибуту house на _house:

class Student:
    def __init__(self, name, house):
        if not name:
            raise ValueError("Ім'я не може бути порожнім")
        self._house = house 

    def __str__(self):
        return f"{self.name} з {self.house}"

    @preoperty
    def house(self):
        return self._house 

    @house.setter
    def house(self, house):
        if house not in ["Ґрифіндор", "Гафелпаф", "Рейвенклов", "Слизерин"]:
            raise ValueError("Неправильний гуртожиток")
        self._house = house 

def main():
    student = get_student()
    student.house = "вул. Тисова, 4" 
    print(student)

def get_student():
    name = input("Ім'я: ")
    house = input("Гуртожиток: ")
    return Student(name, house)

if __name__ == "__main__":
    main()

Тепер ми можемо перевірити правильність введення даних у функції main(), за умови що користувач змінив наш код і продовжує використовувати запис student.house = "вул. Тисова, 4":

Terminal
python student.py
Ім'я: Гаррі
Гуртожиток: Ґрифіндор

ValueError: Неправильний гуртожиток

Видалимо рядок student.house = "вул. Тисова, 4" з функції main():

class Student:
    def __init__(self, name, house):
        if not name:
            raise ValueError("Ім'я не може бути порожнім")
        self._house = house

    def __str__(self):
        return f"{self.name} з {self.house}"

    @preoperty
    def house(self):
        return self._house

    @house.setter
    def house(self, house):
        if house not in ["Ґрифіндор", "Гафелпаф", "Рейвенклов", "Слизерин"]:
            raise ValueError("Неправильний гуртожиток")
        self._house = house

def main(): 
    student = get_student() 
    print(student) 

def get_student():
    name = input("Ім'я: ")
    house = input("Гуртожиток: ")
    return Student(name, house)

if __name__ == "__main__":
    main()

І перевіримо роботу програми:

Terminal
python student.py
Ім'я: Гаррі
Гуртожиток: Ґрифіндор

Гаррі з Ґрифіндор
Terminal
python student.py
Ім'я: Гаррі
Гуртожиток: вул. Тисова, 4

ValueError: Неправильний гуртожиток

Давайте додамо перевірку імені студента через геттер і сеттер:

class Student:
    def __init__(self, name, house):
        self.name = name 
        self._house = house

    def __str__(self):
        return f"{self.name} з {self.house}"

    @preoperty 
    def name(self): 
        return self._name 

    @name.setter 
    def name(self, name): 
        if not name: 
            raise ValueError("Ім'я не може бути порожнім") 
        self._name = name 

    @preoperty
    def house(self):
        return self._house

    @house.setter
    def house(self, house):
        if house not in ["Ґрифіндор", "Гафелпаф", "Рейвенклов", "Слизерин"]:
            raise ValueError("Неправильний гуртожиток")
        self._house = house


def main():
    student = get_student()
    print(student)

def get_student():
    name = input("Ім'я: ")
    house = input("Гуртожиток: ")
    return Student(name, house)

if __name__ == "__main__":
    main()
Terminal
python student.py
Ім'я: 
Гуртожиток: Ґрифіндор

ValueError: Ім'я не може бути порожнім

На жаль, Python фокусується на певних домовленостях, а не жорстких обмеженнях, тому ви можете обійти перевірку правильності введення даних, якщо ви звернетесь до атрибуту _name напряму:

class Student:
    def __init__(self, name, house):
        self.name = name
        self._house = house

    def __str__(self):
        return f"{self.name} з {self.house}"

    @preoperty
    def name(self):
        return self._name

    @name.setter
    def name(self, name):
        if not name:
            raise ValueError("Ім'я не може бути порожнім")
        self._name = name

    @preoperty
    def house(self):
        return self._house

    @house.setter
    def house(self, house):
        if house not in ["Ґрифіндор", "Гафелпаф", "Рейвенклов", "Слизерин"]:
            raise ValueError("Неправильний гуртожиток")
        self._house = house


def main():
    student = get_student()
    student._house = "вул. Тисова, 4" 
    print(student)

def get_student():
    name = input("Ім'я: ")
    house = input("Гуртожиток: ")
    return Student(name, house)

if __name__ == "__main__":
    main()
Terminal
python student.py
Ім'я: Гаррі
Гуртожиток: Ґрифіндор

Гаррі з вул. Тисова, 4

Ну, на відміну від мов на кшталт Java, які просто не дозволяють вам робити подібні речі, Python сам дозволяє вам вказати, що певні змінні екземпляра можуть бути загальнодоступними і доступними для будь-чийого коду або захищеними чи приватними, що означає, що ніхто інший не повинен мати змоги змінювати ці значення. У світі Python це просто система домовленостей. У самій мові не закладено поняття видимості, публічності чи приватності. І, як правило, якщо змінна екземпляра починається з символу підкреслення, це означає, що розробник просить не чіпати її. Символ підкреслення позначає угоду про те, що ця змінна має бути приватною. Іноді, ви можете зустріти і два підкреслення, це ще більше зусиль програмістів, щоб сказати: “Не чіпайте це”. Але з технічної точки зору, ніщо не заважає вам обійти всі ці геттери та сеттери. Зрештою, ми просто дотримуємося системи домовленостей, щоб не робити цього, коли бачимо змінні екземпляра з префіксом з одним або навіть двома підкресленнями.

Підказка

Якщо подивитися на документацію Python до цілих чисел (int), рядків (str), списків (list), тощо - це все класи, які мають власні методи.

9.6 Методи класу

Досі я навмисно називав усі змінні змінними екземплярів, а всі методи - методами екземплярів. Виявляється, існують інші типи змінних та методів. І один з них називається методами класу (англ. class methods).

Не обов’язково і не розумно пов’язувати функцію з об’єктами класу, а скоріше з самим класом. Екземпляр або об’єкт класу - це дуже специфічне його втілення. Іноді нам потрібна певна функціональність, яка була б пов’язана з самим класом, незалежно від того, якими є власні значення конкретного об’єкта або змінні екземпляра. І для цього у нас є ключове слово @classmethod. Це декоратор, який вказує Python, що функція, яку ви визначаєте, є методом класу.

Створимо нову програму hat.py, яка буде виконувати функції Сортувального капелюха з книги “Гаррі Поттер і філософський камінь”:

Terminal
code hat.py

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

import random


class Hat:
    def __init__(self):
        self.houses = ["Ґрифіндор", "Гафелпаф", "Рейвенклов", "Слизерин"]

    def sort(self, name):
        print(name, "з", random.choice(self.houses))


hat = Hat()
hat.sort("Гаррі")
Гаррі з Рейвенклов

На скільки нам відомо, у світі Гаррі Поттера є тільки один Сортувальний капелюх, який вибирає факультет для учнів. Тому нам не потрібно створювати екземпляр класу Hat. Для цього ми можемо створити змінну класу, яка буде зберігати список гуртожитків. І тоді ми можемо викликати метод sort(), як метод класу, а не метод екземпляру. Для цього використовується декоратор @classmethod. І відповідно, в першому аргументі методу sort() ми не будемо передавати self, але будемо передавати cls, який вказує на клас, а не на екземпляр класу. І вже в самому методі ми будемо використовувати cls.houses замість self.houses:

import random


class Hat:
    houses = ["Ґрифіндор", "Гафелпаф", "Рейвенклов", "Слизерин"] 

    @classmethod 
    def sort(cls, name): 
        print(name, "з", random.choice(cls.houses)) 


Hat.sort("Гаррі") 
Гаррі з Рейвенклов

Повернемося до класу Student у спрощеному вигляді:

class Student:
    def __init__(self, name, house):
        self.name = name
        self._house = house

    def __str__(self):
        return f"{self.name} з {self.house}"


def main():
    student = get_student()
    print(student)

def get_student():
    name = input("Ім'я: ")
    house = input("Гуртожиток: ")
    return Student(name, house)

if __name__ == "__main__":
    main()

Зверніть увагу, що запит імені та гуртожитку відбувається за межами класу Student в окремій функції get_student(). Це виглядає досить дивно, оскільки ім’я та гуртожиток - це атрибути екземпляра класу Student. І вони повинні бути визначені в методі __init__(). Але в такому випадку, ми не зможемо використовувати функцію get_student() для створення екземплярів класу Student в інших програмах. Тому ми можемо визначити метод get_student() як метод класу, а не метод екземпляру. Для цього використовується декоратор @classmethod. І відповідно, в першому аргументі методу get_student() ми не будемо передавати self, але будемо передавати cls, який вказує на клас, а не на екземпляр класу. І вже в самому методі ми будемо використовувати cls.houses замість self.houses:

class Student:
    def __init__(self, name, house):
        self.name = name
        self._house = house

    def __str__(self):
        return f"{self.name} з {self.house}"

    @classmethod 
    def get_student(cls): 
        name = input("Ім'я: ")
        house = input("Гуртожиток: ")
        return cls(name, house) 


def main():
    student = Student.get_student() 
    print(student)

if __name__ == "__main__":
    main()

9.7 Успадкування

У Python є й інші типи методів, які можна використовувати у класах. Їх зазвичай називають статичними методами (англ. static methods). І вони також мають інший декоратор, який називається @staticmethod, що є кролячою норою, в яку ми не будемо заглиблюватися, але усвідомлюємо, що існує ще одна функціональність, яку ви можете використовувати за допомогою об’єктно-орієнтованого програмування.

Але ми вирішили зосередитись на деяких основних функціях, які можна знайти не тільки в Python, але й в інших мовах. І, мабуть, однією з найпривабливіших особливостей об’єктно-орієнтованого програмування, яку ми ще не використовували явно є поняття успадкування (англ. inheritance).

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

Створимо нову програму wizard.py:

Terminal
code wizard.py

У цій програмі створимо два класи для студентів та професорів школи чарівників та чаклунів Хогвартс. Студенти будуть мати ім’я та гуртожиток, а професори - ім’я та предмет, який вони викладають:

class Student:
    def __init__(self, name, house):
        self.name = name
        self.house = house

    ...


class Professor:
    def __init__(self, name, subject):
        self.name = name
        self.subject = subject

    ...

Обидва класи мають спільну змінну name і робити перевірку введення даних окремо для кожного класу недоцільно. Тому ми можемо використовувати успадкування - визначити спільний клас Wizard, який буде містити спільні атрибути та методи для класів Student та Professor. Для успадкування використовується ключове слово class з ім’ям класу, який успадковується, а в дужках вказується ім’я класу, який успадковує. Таким чином, клас Student успадковує клас Wizard, і клас Professor успадковує клас Wizard. Для цього використовується ключове слово super(), яке вказує на батьківський клас. І вже в методі __init__() ми можемо викликати метод __init__() батьківського класу за допомогою super().__init__(). Таким чином, ми успадковуємо атрибути та методи класу Wizard:

class Wizard: 
    def __init__(self, name): 
        if not name: 
            raise ValueError("Ім'я не може бути порожнім") 
        self.name = name 

    ...


class Student(Wizard): 
    def __init__(self, name, house):
        super().__init__(name) 
        self.house = house

    ...


class Professor(Wizard): 
    def __init__(self, name, subject):
        super().__init__(name) 
        self.subject = subject

    ...


wizrd = Wizard("Альбус") 
student = Student("Гаррі", "Ґрифіндор") 
professor = Professor("Снейп", "Захист від темних мистецтв") 

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

BaseException
 ├── KeyboardInterrupt
 └── Exception
      ├── ArithmeticError
      │    └── ZeroDivisionError
      ├── AssertionError
      ├── AttributeError
      ├── EOFError
      ├── ImportError
      │    └── ModuleNotFoundError
      ├── LookupError
      │    └── KeyError
      ├── NameError
      ├── SyntaxError
      │    └── IndentationError
      ├── ValueError
    ...

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

Так, наприклад, внизу наведеного списку знаходиться помилка ValueError, яких ми бачили досить багато. І якщо ви підніметеся вгору по лінії на цій діаграмі у форматі ASCII, то побачите, що помилка значення має батьківський клас або суперклас, який називається Exception. А клас Exception, у свою чергу, має батьківський клас, який називається BaseException.

Коли використовуєте ключові слова try та accept у Python, загалом, ви намагаєтесь перехопити дуже специфічні винятки. Але технічно, ви можете перехопити батьківське або навіть прабатьківське виключення для даного виключення.

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

9.8 Перевантаження операторів

Є ще одна особливість об’єктно-орієнтованого програмування яка, можливо, відкриє вам очі на те, що ви можете робити тепер, коли у вашому розпорядженні є класи. Python та деякі інші мови підтримують це поняття перевантаження операторів (англ. operator overloading), коли ви можете взяти дуже поширені символи, такі як + чи - або інший подібний синтаксис на клавіатурі, і реалізувати їх власну інтерпретацію.

Давайте створимо програму vault.py, яка буде реалізовувати ідею сховища Ґрінґотса - банку, заснований гобліном Ґрінготом у 1474 році, в якому чарівники зберігають свої заощадження:

Terminal
code vault.py

І тип грошей, який існує у світі Гаррі Поттера - це монети, які називаються ґалеони, серпики і кнати3. У сховищі може бути ціла купа монет, золотих, срібних і бронзових. Тож як нам реалізувати ідею сховища, щоб ми могли зберігати, наприклад, для Гаррі Поттера стільки монет, скільки є в сімейному сховищі?

Для початку створимо клас Vault, який має представляти банківське сховище, в якому зберігаються монети. Створимо екземпляр класу Vault для Гаррі Поттера, який має 100 ґалеонів, 50 серпиків і 25 кнатів:

class Vault:
    def __init__(self, galleons=0, sickles=0, knuts=0):
        self.galleons = galleons
        self.sickles = sickles
        self.knuts = knuts

potter = Vault(100, 50, 25)
print(potter)
<__main__.Vault object at 0x0000020864CA4FD0>

Все працює, але вивід не дуже зрозумілий. Давайте визначимо метод __str__() для класу Vault, який буде виводити кількість монет кожного типу:

class Vault:
    def __init__(self, galleons=0, sickles=0, knuts=0):
        self.galleons = galleons
        self.sickles = sickles
        self.knuts = knuts

    def __str__(self): 
        return f"Ґалеони: {self.galleons}, Серпики: {self.sickles}, Кнати: {self.knuts}" 

potter = Vault(100, 50, 25)
print(potter)
Ґалеони: 100, Серпики: 50, Кнати: 25

Тепер давайте додамо можливість додавати монети до сховища. Припустимо, що Гаррі Поттер та Рон Вісли вирішили зберегти свої монети разом:

wesley = Vault(25, 50, 100)

total = Vault(
    potter.galleons + wesley.galleons,
    potter.sickles + wesley.sickles,
    potter.knuts + wesley.knuts
)
print(total)
Ґалеони: 125, Серпики: 100, Кнати: 125

Тепер згадаємо про перевантаження операторів. Якщо ви хочете додати два об’єкти класу Vault, ви можете визначити метод __add__() для класу Vault, який буде викликатися, коли ви використовуєте оператор + для об’єктів класу Vault. І вже в самому методі __add__() ви можете визначити, як саме ви хочете додати два об’єкти класу Vault:

class Vault:
    def __init__(self, galleons=0, sickles=0, knuts=0):
        self.galleons = galleons
        self.sickles = sickles
        self.knuts = knuts

    def __str__(self):
        return f"Ґалеони: {self.galleons}, Серпики: {self.sickles}, Кнати: {self.knuts}"

    def __add__(self, other): 
        return Vault( 
            self.galleons + other.galleons, 
            self.sickles + other.sickles, 
            self.knuts + other.knuts 
        ) 

potter = Vault(100, 50, 25)
wesley = Vault(25, 50, 100)

total = potter + wesley
print(total)
Ґалеони: 125, Серпики: 100, Кнати: 125
Примітка

Більше про спеціальні методи можна дізнатися з офіційної документації Python за посиланням https://docs.python.org/3/reference/datamodel.html#special-method-names.


  1. Патронус (англ. Patronus, від лат. Patronus - захисник) - магічна сутність, яка викликається заклинанням.↩︎

  2. Повний перелік винятків в Python можна знайти за посиланням https://docs.python.org/3/library/exceptions.html#exception-hierarchy.↩︎

  3. Більш детально про гроші у світі Гаррі Поттера можна дізнатися за посиланням https://harrypotter.fandom.com/uk/wiki/Чаклунські_гроші.↩︎

Data Miorsh Ihor Miroshnychenko Youtube Monobank