Уроки курса
1 Введение в Python и философия дзен
30 мин
2 Переменные и динамическая типизация
30 мин
3 Базовые типы данных: числа, строки и булевы значения
30 мин
4 Изменяемые и неизменяемые объекты (Mutable vs Immutable)
30 мин
5 Форматирование строк и f-строки
30 мин
6 Углубленная работа со списками
30 мин
7 Кортежи и их особенности
30 мин
8 Словари под капотом
30 мин
9 Множества и математические операции
30 мин
10 Генераторы списков (List Comprehensions)
30 мин
11 Генераторы словарей и множеств
30 мин
12 Встроенные функции для коллекций
30 мин
13 Условные операторы и логические выражения
30 мин
14 Циклы while и управление потоком
30 мин
15 Итерация с циклом for
30 мин
16 Конструкции for...else и while...else
30 мин
17 Функции enumerate и zip
30 мин
18 Создание собственных функций (def)
30 мин
19 Позиционные и именованные аргументы
30 мин
20 Проблема изменяемых аргументов по умолчанию
30 мин
21 Произвольное число аргументов (*args и **kwargs)
30 мин
22 Область видимости переменных (LEGB)
30 мин
23 Анонимные функции (lambda)
30 мин
24 Функции высшего порядка
30 мин
25 Замыкания (Closures)
30 мин
26 Введение в объектно-ориентированное программирование
30 мин
27 Атрибуты классов и экземпляров
30 мин
28 Магический метод __init__
30 мин
29 Методы экземпляра
30 мин
30 Инкапсуляция и сокрытие данных
30 мин
31 Декоратор @property
30 мин
32 Наследование классов
30 мин
33 Переопределение методов и функция super()
30 мин
34 Полиморфизм в Python
30 мин
35 Магические методы строк (__str__ и __repr__)
30 мин
36 Обработка исключений (try-except)
30 мин
37 Блоки else и finally
30 мин
38 Генерация собственных исключений (raise)
30 мин
39 Открытие и чтение файлов
30 мин
40 Запись данных в файлы
30 мин
41 Контекстные менеджеры (with)
30 мин
42 Работа с форматом JSON
30 мин
43 Модули и импорты
30 мин
44 Полезные модули стандартной библиотеки
30 мин
45 Модуль datetime
30 мин
46 Модуль collections
30 мин
47 Виртуальные окружения (venv)
30 мин
48 Установка сторонних пакетов через pip
30 мин
49 Организация структуры Python-проекта
30 мин
50 Финальный проект: создание приложения
30 мин

Введение в объектно-ориентированное программирование

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

Прогресс урока: 0%

Добро пожаловать в один из самых важных и трансформационных уроков в вашем пути программиста. До этого момента вы, вероятнее всего, писали программы в так называемом процедурном стиле. Процедурное программирование — это парадигма, в которой программа рассматривается как набор последовательных инструкций или функций, которые манипулируют данными. Вы создавали переменные (данные) и писали функции (действия), которые принимали эти данные в качестве аргументов, изменяли их и возвращали результат. Это отлично работает для небольших скриптов, автоматизации простых задач или решения алгоритмических головоломок. Однако, по мере того как ваши программы будут становиться все больше, сложнее и масштабнее, вы неизбежно столкнетесь с тем, что называется 'программным кризисом' или 'спагетти-кодом'. Когда в вашей программе сотни функций и тысячи переменных, становится невероятно сложно отслеживать, какая функция изменяет какие данные, и как изменения в одной части системы повлияют на другую. Именно здесь на сцену выходит Объектно-Ориентированное Программирование (ООП). ООП — это не просто новый синтаксис, это совершенно другой способ мышления. В ООП мы перестаем разделять данные и функции. Вместо этого мы объединяем их в единые сущности, которые называются объектами. Представьте себе реальный мир. Мир состоит из объектов: машин, деревьев, людей, банковских счетов, книг. Каждый из этих объектов имеет две фундаментальные характеристики. Во-первых, у него есть состояние (данные, характеристики, атрибуты). Например, у машины есть цвет, марка, текущая скорость, количество топлива. Во-вторых, у объекта есть поведение (действия, методы). Машина может завестись, поехать, остановиться, посигналить. В процедурном программировании вы бы создали отдельную переменную для скорости машины и отдельную функцию для ее изменения. В ООП вы создаете программный объект 'Машина', внутри которого уже 'зашиты' и данные о ее скорости, и механизмы изменения этой скорости. Это позволяет создавать модульный, переиспользуемый и очень надежный код. Концепция ООП была предложена еще в 1960-х годах, но именно в современных языках, таких как Python, она достигла невероятной элегантности. Python — это полностью объектно-ориентированный язык. Вы уже пользовались объектами, даже не подозревая об этом. Строки, списки, словари, целые числа, даже сами функции в Python — все это объекты под капотом. Когда вы вызывали метод `append()` у списка, вы обращались к встроенному поведению объекта списка. В этом уроке мы научимся создавать свои собственные типы данных, свои собственные объекты, проектировать их архитектуру и управлять ими.

В чем заключается главное отличие процедурного программирования от объектно-ориентированного?

Чтобы начать создавать свои собственные объекты, нам необходимо понять разницу между Классом (Class) и Объектом (Object) (или экземпляром класса). Эта концепция вызывает больше всего путаницы у новичков, поэтому давайте разберем ее максимально подробно на примерах из реальной жизни. Представьте, что вы архитектор, который проектирует жилой дом. Вы рисуете подробный чертеж. На этом чертеже указано, где будут находиться стены, сколько будет окон, где пройдут трубы водоснабжения и электропроводка. Этот чертеж идеален, он содержит всю необходимую информацию о доме. Но можете ли вы жить в чертеже? Можете ли вы открыть дверь на бумаге или включить там свет? Разумеется, нет. Чертеж — это лишь абстрактная модель, инструкция, шаблон. В программировании Класс — это чертеж. Это шаблон, который описывает, из каких данных будет состоять будущий объект и какие действия он сможет выполнять. Теперь представьте, что строительная компания берет ваш чертеж и строит по нему реальный дом. Этот дом материален. В нем можно жить, можно покрасить стены в синий цвет, можно поставить мебель. Более того, компания может взять этот же самый чертеж и построить целый район из ста абсолютно одинаковых домов. Каждый из этих домов будет иметь свой собственный адрес, свои собственные координаты на карте. В одном доме могут жить люди, другой может пустовать. В одном доме стены покрасят в белый, в другом в желтый. Эти реальные дома в терминах ООП называются Объектами или Экземплярами класса (Instances). Класс существует в программе в единственном экземпляре (как чертеж в архиве), а объектов, созданных на основе этого класса, может быть бесконечно много. Каждый объект является независимым, у него своя область памяти. Изменение состояния одного объекта никак не влияет на состояние других объектов, созданных из того же класса, если только это не было задумано специально. Когда мы пишем код на Python, создание класса начинается с ключевого слова class, после которого следует имя класса (принято писать с заглавной буквы в стиле CamelCase). Создание же объекта происходит путем вызова имени класса со скобками, как будто мы вызываем обычную функцию. Этот процесс называется инстанцированием (от слова instance).

Какое ключевое слово используется в Python для объявления пользовательского типа данных (шаблона)?

Давайте посмотрим, как выглядит создание простейшего класса и его объектов в коде. Мы создадим класс Robot. На первом этапе мы не будем добавлять ему никаких сложных данных или поведения, мы просто создадим пустой 'чертеж', используя ключевое слово pass, которое говорит интерпретатору Python: 'здесь пока ничего нет, просто иди дальше'. Несмотря на то, что класс пуст, он уже является полноценным типом данных в нашей программе. После того как мы определили класс, мы можем создать объекты этого класса. Мы создадим двух роботов и присвоим их переменным r1 и r2. Очень важно понимать, что происходит в памяти компьютера в этот момент. Когда интерпретатор доходит до строки r1 = Robot(), он выделяет небольшой участок оперативной памяти. В этот участок записывается информация о том, что это объект типа Robot. Затем адрес этого участка памяти (ссылка) помещается в переменную r1. То же самое происходит со второй переменной. У нас появляются два совершенно разных, независимых участка памяти, хотя оба они были созданы по одному шаблону. Если вы попробуете вывести эти объекты с помощью функции print(), вы не увидите красивого описания. Вместо этого Python выведет что-то вроде <__main__.Robot object at 0x7f8b9c2a3e10>. Эта загадочная строка на самом деле очень информативна. Она говорит нам: 1) Это объект, 2) Он принадлежит классу Robot, который находится в текущем модуле (__main__), 3) Самое главное — он находится в памяти по определенному шестнадцатеричному адресу (0x...). Адреса объектов r1 и r2 будут гарантированно разными. Это доказывает, что мы создали две независимые сущности. На данном этапе наши роботы пусты, как чистые листы. Но в отличие от строгих языков программирования, динамическая природа Python позволяет нам добавлять атрибуты (свойства) к объектам прямо 'на лету', даже если они не были заранее прописаны в классе. Мы можем написать r1.name = 'Валли' и r1.color = 'Желтый'. Теперь у первого объекта появились свои личные данные. Если мы попробуем обратиться к r2.name, программа выдаст ошибку AttributeError, потому что мы добавили имя только первому роботу. Хотя такая динамическая привязка возможна, в профессиональном ООП так делать категорически не рекомендуется. Вся структура объекта должна быть четко определена внутри класса, чтобы другие разработчики точно знали, какие данные есть у объекта, а каких нет. Для этого используется метод инициализации.

python
class Robot:
    pass # Пустой класс, просто чертеж без деталей

r1 = Robot() # Создаем первый объект (экземпляр)
r2 = Robot() # Создаем второй объект

print(r1)
# Вывод: <__main__.Robot object at 0x000001>
print(r2)
# Вывод: <__main__.Robot object at 0x000002>

# Динамическое добавление атрибутов (плохая практика, но возможная в Python)
r1.name = 'Валли'
r1.weight = 100

print(r1.name) # Выведет: Валли
# print(r2.name) # Вызовет AttributeError, так как у r2 нет такого атрибута

Что выведет интерпретатор Python, если попытаться распечатать объект пользовательского класса с помощью print() (без переопределения специальных методов)?

Теперь давайте перейдем к правильному, профессиональному способу добавления данных в объекты. Как мы выяснили, добавлять атрибуты 'на лету' — это плохой тон, потому что мы теряем смысл класса как надежного чертежа. Чтобы гарантировать, что каждый объект, созданный из нашего класса, будет иметь определенный набор атрибутов с самого начала, мы должны использовать специальный метод инициализации, который в Python называется __init__. Обратите внимание на двойные подчеркивания слева и справа. В Python методы с такими именами называются магическими методами (dunder methods — от 'double underscore'). Они вызываются интерпретатором автоматически при наступлении определенных событий. Метод __init__ часто называют конструктором (хотя строго технически конструктором является метод __new__, а __init__ — это инициализатор состояния). Этот метод запускается автоматически в то самое мгновение, когда вы создаете новый объект (пишете Robot()). Его главная задача — настроить начальное состояние объекта, 'прикрутить' к нему необходимые переменные. Давайте заглянем внутрь этого метода. Самая важная и, пожалуй, самая необычная для новичков вещь — это первый параметр этого метода, который принято называть self. Ключевое слово self — это краеугольный камень ООП в Python. Когда вы создаете метод внутри класса, Python всегда неявно и автоматически передает в этот метод ссылку на тот конкретный объект, с которым вы сейчас работаете. Вы можете представить, что self переводится как 'я сам'. Когда вы пишете self.name = 'Валли' внутри метода __init__, вы буквально говорите: 'взять меня самого, найти внутри меня ячейку с названием name и положить туда значение Валли'. Без self переменная name стала бы просто локальной переменной внутри функции __init__, и она исчезла бы из памяти, как только функция завершила бы работу. Добавляя self., мы прикрепляем переменную непосредственно к объекту, делаем ее долгоживущей. Имя self не является зарезервированным словом языка, вы могли бы назвать этот параметр this, me или как угодно еще, но использование именно self является строжайшим стандартом PEP-8, и любой другой программист на Python ожидает увидеть именно его. Помимо self, метод __init__ может принимать любые другие аргументы, точно так же, как обычная функция. Это позволяет нам передавать уникальные данные в момент создания объекта. Например, def __init__(self, name, year): позволяет нам при создании писать Robot('Терминатор', 2029). Обратите внимание: хотя в определении метода три параметра (self, name, year), при вызове мы передаем только два ('Терминатор', 2029). Python сам 'подставит' свежесозданный объект на место первого аргумента self. Это автоматическое действие интерпретатора избавляет нас от огромного количества рутинного кода.

python
class User:
    # Метод инициализации (конструктор)
    def __init__(self, username, email, age):
        # self указывает на конкретный объект
        # Мы создаем атрибуты объекта и присваиваем им переданные значения
        self.username = username 
        self.email = email
        self.age = age
        self.is_active = True # Атрибут по умолчанию (не передается при создании)

# Создаем объекты. Обратите внимание: self передавать НЕ НУЖНО!
# Python автоматически подставит user1 на место self.
user1 = User("alice_wonder", "alice@mail.com", 25)
user2 = User("bob_builder", "bob@build.com", 30)

# Обращаемся к атрибутам объектов
print(f"Пользователь: {user1.username}, Активен: {user1.is_active}")
print(f"Пользователь: {user2.username}, Возраст: {user2.age}")

Если метод определен как `def __init__(self, title, price):`, сколько аргументов нужно передать при создании объекта `item = Item(...)`?

Мы научились наделять объекты состоянием с помощью атрибутов. Теперь пришло время поговорить о поведении. Поведение в ООП реализуется через методы. В сущности, метод — это обычная функция, но с одной важной оговоркой: она 'живет' внутри класса и предназначена для работы с данными того объекта, к которому она принадлежит. Представьте, что у нас есть класс BankAccount (банковский счет). У него есть атрибуты: имя владельца и текущий баланс. Нам нужны действия, которые можно совершать со счетом: пополнить баланс, снять деньги, вывести информацию на экран. Все эти действия логично упаковать внутрь самого класса. Чтобы создать метод, мы просто пишем конструкцию def имя_метода(self, параметры): внутри тела класса (на том же уровне отступа, что и __init__). Ключевое слово self здесь играет решающую роль. Когда вы вызываете метод для конкретного объекта, например account1.deposit(500), интерпретатор берет объект account1 и передает его в метод в качестве аргумента self. Благодаря этому внутри метода мы имеем полный доступ ко всем атрибутам этого конкретного объекта. Мы можем написать self.balance += 500. Если мы вызовем тот же метод для другого счета account2.deposit(1000), то внутри метода self будет указывать уже на account2, и изменится баланс второго счета, а не первого. Это гарантирует, что объекты изолированы друг от друга, и их методы оперируют только собственными данными. Более того, методы могут вызывать другие методы того же объекта! Для этого тоже используется self. Например, внутри метода снятия денег мы можем вызвать метод проверки лимита: if self.check_limit(amount): .... Важно понимать разницу между терминами функция и метод, так как на собеседованиях этот вопрос задают очень часто. Функция — это независимый блок кода, определенный на уровне модуля (файла) с помощью def. Она принимает данные через аргументы и возвращает результат. Метод — это функция, которая привязана к объекту. Метод всегда вызывается в контексте конкретного объекта через точечную нотацию (object.method()) и неявно получает ссылку на этот объект. Использование методов позволяет нам реализовать мощную парадигму инкапсуляции логики: внешний код не должен знать, по какой сложной формуле рассчитывается банковская комиссия, он просто вызывает метод account.withdraw(money), а вся сложная математика надежно спрятана внутри метода класса.

Как называется переменная, которая всегда должна быть первым параметром любого метода экземпляра в классе (согласно стандарту PEP-8), чтобы метод получил ссылку на сам объект?

python
class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance

    # Метод для пополнения счета
    def deposit(self, amount):
        if amount > 0:
            self.balance += amount
            print(f"{self.owner}: Счет пополнен на {amount}. Новый баланс: {self.balance}")
        else:
            print("Сумма пополнения должна быть положительной!")

    # Метод для снятия денег
    def withdraw(self, amount):
        # Проверяем, достаточно ли средств на счету
        if amount > self.balance:
            print(f"{self.owner}: Недостаточно средств! Баланс: {self.balance}")
        else:
            self.balance -= amount
            print(f"{self.owner}: Снято {amount}. Остаток: {self.balance}")

# Создаем два разных счета
acc1 = BankAccount("Иван", 1000)
acc2 = BankAccount("Анна", 5000)

# Вызываем методы. Обратите внимание, как они меняют состояние конкретных объектов
acc1.deposit(200)   # Иван: Счет пополнен на 200. Новый баланс: 1200
acc2.withdraw(1000) # Анна: Снято 1000. Остаток: 4000
acc1.withdraw(5000) # Иван: Недостаточно средств! Баланс: 1200

Теперь мы подошли к одной из самых глубоких и частых ошибок, которые допускают программисты, переходящие на уровень Intermediate (и даже опытные разработчики иногда попадают в эту ловушку). Эта проблема известна как Проблема изменяемого аргумента по умолчанию (Mutable Default Argument). В архивах нашего курса сохранился классический диалог между Junior и Senior разработчиками. Джуниор пытался создать класс или функцию, где в качестве значения по умолчанию использовался пустой список (например, def __init__(self, name, marks=[]):). Ожидалось, что если список не передан, то создастся новый пустой список. Но на деле происходило нечто странное: при создании нескольких объектов без явной передачи списка, все они начинали 'делить' между собой один и тот же список! Когда добавляли оценку первому студенту, она мистическим образом появлялась и у второго студента. Почему это происходит? Разгадка кроется в том, когда интерпретатор Python вычисляет значения по умолчанию. Это происходит не каждый раз при вызове функции или создании объекта, а ровно один раз — в момент, когда Python читает файл и компилирует определение класса (или функции). Если вы указали список [], Python создает объект списка в памяти и 'привязывает' его к сигнатуре метода __init__ навсегда. Каждый раз, когда вы создаете новый экземпляр класса, не передавая список, self.marks получает ссылку на этот единственный, однажды созданный список. Поскольку списки являются изменяемыми (mutable) объектами, изменения списка через один объект (добавление элемента) мгновенно отражаются на всех остальных объектах, которые ссылаются на ту же область памяти. Как же правильно решить эту проблему? Senior разработчик ответил бы так: 'Никогда не используйте изменяемые типы данных (списки, словари, множества) в качестве значений по умолчанию'. Вместо этого, нужно использовать неизменяемый тип данных None. Внутри метода __init__ нужно сделать простую проверку: if marks is None: self.marks = []. В этом случае, каждый раз, когда мы заходим в эту ветку условия if, будет выполняться инструкция создания нового, свежего, независимого списка [], и каждый объект получит свою собственную область памяти для хранения данных. Понимание этого механизма — это водораздел между начинающим и уверенным Python-разработчиком.

python
# ОШИБОЧНЫЙ ПОДХОД (Антипаттерн)
class BadStudent:
    def __init__(self, name, grades=[]): # grades=[] создается один раз!
        self.name = name
        self.grades = grades

    def add_grade(self, grade):
        self.grades.append(grade)

bad_student1 = BadStudent("Олег")
bad_student2 = BadStudent("Маша")

bad_student1.add_grade(5)
print(bad_student2.grades) # Выведет [5]! Оценки Олега появились у Маши!


# ПРАВИЛЬНЫЙ ПОДХОД (Pythonic way)
class GoodStudent:
    def __init__(self, name, grades=None): # Используем None
        self.name = name
        if grades is None:
            self.grades = [] # Новый список создается для каждого объекта индивидуально!
        else:
            self.grades = grades

    def add_grade(self, grade):
        self.grades.append(grade)

good_student1 = GoodStudent("Олег")
good_student2 = GoodStudent("Маша")

good_student1.add_grade(5)
print(good_student2.grades) # Выведет []. Оценки изолированы. Все работает верно.

Выберите правильный (безопасный) способ объявления метода `__init__`, если мы хотим, чтобы у каждого нового сотрудника по умолчанию был пустой список задач.

Мы успешно научились создавать классы, наделять их атрибутами и методами. Но ООП — это не просто группировка переменных и функций под одной 'крышей'. В основе объектного подхода лежат три фундаментальных принципа: Инкапсуляция, Наследование и Полиморфизм (иногда выделяют четвертый — Абстракция). На этом уровне мы детально разберем первый и, пожалуй, самый важный в повседневной разработке принцип — Инкапсуляцию. Слово 'инкапсуляция' происходит от латинского 'capsula' (коробочка, капсула). По сути, это защита внутренних данных объекта от прямого вмешательства извне. Представьте себе сложный телевизор. У него внутри сотни проводов, микросхем, конденсаторов. Если бы все эти внутренности торчали наружу, пользователь мог бы случайно задеть провод и сжечь телевизор. Вместо этого производитель прячет всю сложную электронику внутрь прочного пластикового корпуса (инкапсулирует), оставляя снаружи только безопасный интерфейс — кнопки на пульте управления. В программировании мы преследуем ту же цель. Мы не хотим, чтобы внешний код напрямую менял внутренние атрибуты нашего объекта, потому что он может присвоить туда недопустимое значение и сломать всю логику. Например, если у нас есть объект User с атрибутом age (возраст), мы не хотим, чтобы кто-то извне написал user.age = -50 или user.age = 'двенадцать'. Чтобы предотвратить это, мы должны 'спрятать' атрибут age и заставить другие части программы взаимодействовать с ним только через специальные методы (кнопки на пульте), в которых мы сможем прописать логику проверки (валидации). В классических языках вроде Java или C++ для этого существуют жесткие модификаторы доступа: public (доступно всем), private (доступно только внутри класса), protected (доступно классу и наследникам). Интерпретатор этих языков физически блокирует попытку доступа к приватным данным. Но Python устроен иначе. Философия Python гласит: 'We are all consenting adults here' ('Мы все здесь взрослые люди по обоюдному согласию'). В Python нет строгих механизмов сокрытия данных на уровне ядра языка. Все атрибуты по умолчанию публичны. Но как же тогда реализовать инкапсуляцию? Для этого в Python существуют строгие соглашения об именовании, которые каждый уважающий себя разработчик обязан соблюдать.

Какая аналогия из реального мира лучше всего описывает принцип инкапсуляции?

Разберем систему соглашений об именовании атрибутов и методов в Python. Существует три уровня доступа, которые мы эмулируем с помощью символа подчеркивания (_). 1. Публичные атрибуты (Public). Это обычные имена, без подчеркиваний (например, self.name, def get_info(self):). Они предназначены для свободного использования кем угодно и где угодно. Это 'кнопки пульта'. 2. Защищенные атрибуты (Protected). Начинаются с одного одинарного подчеркивания (например, self._balance, def _calculate_tax(self):). Это сигнал, мощное предупреждение другим программистам: 'Внимание! Этот атрибут или метод предназначен для внутреннего использования самим классом или его наследниками. Не трогай его напрямую из внешнего кода, если не уверен на 100%'. Технически Python никак не запретит вам написать print(object._balance), код сработает штатно. Это держится исключительно на честном слове и дисциплине разработчиков (те самые 'взрослые люди'). Инструменты анализа кода (линтеры, вроде PyLint или MyPy) будут ругаться на такой доступ, но программа не упадет. 3. Приватные атрибуты (Private). Начинаются с двойного подчеркивания (например, self.__password, def __connect_db(self):). Здесь Python применяет хитрый трюк, называемый Name Mangling (искажение имен). Если вы попытаетесь обратиться к такому атрибуту напрямую извне (print(object.__password)), Python выбросит ошибку AttributeError, как будто такого атрибута не существует. Кажется, что это строгая защита! Но на самом деле интерпретатор просто переименовывает этот атрибут 'под капотом' в формат _ИмяКласса__имя_атрибута. Если вы знаете это правило, вы можете легко получить доступ к приватной переменной, написав object._User__password. Этот механизм был придуман не для безопасности (от злонамеренного хакера он не спасет), а исключительно для защиты от случайного переопределения имен при наследовании в сложных иерархиях классов. В современной Python-практике использование двойного подчеркивания считается избыточным (overengineering). В 95% случаев для инкапсуляции используется одно подчеркивание (protected). Этого сигнала достаточно для понимания архитектуры, и он не создает проблем при тестировании и отладке кода. Итак, если мы делаем данные 'приватными', как нам их изменять? Для этого создаются публичные методы-посредники: геттеры (getters, для получения значения) и сеттеры (setters, для изменения значения с проверкой).

python
class Person:
    def __init__(self, name, age):
        self.name = name # Публичный атрибут
        self._age = age  # Protected (защищенный) атрибут. Сигнал: не трогать напрямую!

    # Метод-геттер (Getter) для получения защищенных данных
    def get_age(self):
        return self._age

    # Метод-сеттер (Setter) для безопасного изменения данных с проверкой (валидацией)
    def set_age(self, new_age):
        if isinstance(new_age, int) and 0 <= new_age <= 120:
            self._age = new_age
        else:
            print("Ошибка: Недопустимый возраст!")

# Использование:
person = Person("Алексей", 30)

# Мы можем (но не должны) обратиться к _age напрямую
# print(person._age) # Сработает, но так делать не принято

# Правильный способ получения данных:
print(f"Возраст: {person.get_age()}")

# Правильный способ изменения данных (включается защита от неверных данных):
person.set_age(35) # Сработает штатно
person.set_age(-5) # Выдаст ошибку, состояние объекта защищено!

Мы разобрали классический паттерн инкапсуляции с использованием геттеров (get_age) и сеттеров (set_age). Этот подход пришел к нам из языков Java и C++. Однако в Python есть свой, гораздо более изящный и лаконичный механизм, называемый Свойствами (Properties). Pythonic way (питоновский стиль) говорит о том, что код должен быть простым и читаемым. Постоянно писать вызовы методов со скобками user.set_age(user.get_age() + 1) — это громоздко. Мы бы хотели писать просто: user.age += 1, но при этом сохранять всю мощь валидации (проверки) данных внутри сеттера. Именно это позволяют сделать декораторы @property. Декоратор — это специальная синтаксическая конструкция в Python (обозначается знаком @), которая модифицирует поведение функции. Когда мы вешаем декоратор @property над методом, который просто возвращает защищенный атрибут (геттер), мы говорим интерпретатору: 'Сделай так, чтобы к этому методу можно было обращаться как к обычной переменной, без скобок'. Давайте посмотрим, как это работает. Мы создаем метод def age(self): return self._age и ставим над ним @property. Теперь, когда кто-то напишет print(user.age) (заметьте, без скобок!), Python неявно вызовет этот метод и вернет значение _age. Внешний код даже не догадывается, что под капотом вызвалась функция! Затем мы можем создать сеттер для этого свойства. Мы берем то же самое имя метода (age) и используем декоратор @age.setter. Внутри этого метода мы пишем логику валидации (проверки на отрицательные числа). Теперь, когда внешний код попытается выполнить присваивание user.age = 50, Python перехватит эту операцию и передаст число 50 в качестве аргумента в наш метод-сеттер. Если проверка прогер попытается написать user.age = -5, сработает наша защита. Таким образом, декоратор @property позволяет нам достичь 'Святого Грааля' ООП: мы получаем строгую инкапсуляцию и защиту данных, но при этом сохраняем красивый, лаконичный синтаксис доступа через точку, как к обычным публичным атрибутам. Это один из самых важных инструментов для разработки качественных API и библиотек в Python.

Какой декоратор используется в Python над методом-геттером, чтобы к нему можно было обращаться без скобок, как к обычному атрибуту (реализация инкапсуляции в Pythonic стиле)?

python
class Temperature:
    def __init__(self, celsius):
        # При инициализации мы обращаемся к СВОЙСТВУ (setter), 
        # чтобы сразу запустить проверку валидности
        self.celsius = celsius 

    # 1. Делаем метод-геттер свойством. 
    # Имя метода будет внешним именем 'атрибута'
    @property
    def celsius(self):
        return self._celsius

    # 2. Создаем сеттер для этого свойства.
    # Имя декоратора должно быть @имя_свойства.setter
    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Температура не может быть ниже абсолютного нуля!")
        self._celsius = value # Фактически сохраняем в защищенный атрибут

# Использование. Выглядит как работа с обычными переменными!
temp = Temperature(25)
print(temp.celsius)  # Вызывается геттер -> выведет 25

temp.celsius = 30    # Вызывается сеттер с проверкой, сохранит 30
print(temp.celsius)

# temp.celsius = -300 # Если раскомментировать, сеттер выбросит ValueError!

Теперь, когда мы вооружились знаниями о классах, объектах, методах, конструкторах и инкапсуляции, пришло время объединить все это в реальный проект. В рамках нашего подхода Project-Based Learning мы реализуем Систему управления библиотекой, взятую из архива продвинутых задач. Эта задача — классический пример бизнес-логики, где объекты должны отслеживать свое состояние и менять его в зависимости от действий пользователя. Мы создадим класс Book. Суть книги в библиотеке такова: у нее есть название (title) и автор (author). Эти данные публичны, их может узнать кто угодно. Но у книги есть критически важное состояние — статус ее доступности (взята она на руки или лежит на полке). Мы не можем позволить внешнему коду (например, неопытному библиотекарю-стажеру, пишущему скрипт) просто взять и поменять book.is_borrowed = True. Почему? Потому что если книга уже выдана, ее нельзя выдать второй раз! Нужна логика проверки. Поэтому атрибут статуса должен быть инкапсулирован. Мы назовем его _is_borrowed (с одним подчеркиванием), и по умолчанию при добавлении новой книги в библиотеку он будет равен False (книга доступна). Далее мы создадим методы, моделирующие поведение: borrow_book() (взять книгу) и return_book() (вернуть книгу). Внутри borrow_book() мы реализуем проверку (условие if): если _is_borrowed уже равно True, метод выведет сообщение об ошибке и вернет False (отказ). Если книга доступна, метод поменяет флаг на True и сообщит об успехе. Метод return_book() просто сбросит флаг в False. Наконец, мы используем изученный нами декоратор @property, чтобы создать удобное свойство status. Это свойство не будет возвращать сухой булевый флаг True/False, оно будет на лету генерировать человекочитаемую строку 'Выдана' или 'Доступна'. Обратите внимание, что мы создадим только геттер (property) для статуса, но не будем создавать сеттер (status.setter). Почему? Потому что статус — это производное значение (вычисляемое на основе _is_borrowed), и мы не хотим, чтобы кто-то мог написать book.status = 'Доступна'. Статус должен меняться исключительно через методы borrow/return. Это называется свойство только для чтения (read-only property). Этот пример блестяще демонстрирует, как ООП защищает логику приложения от некорректного использования.

Задание

Разработка класса Book для системы управления библиотекой с использованием инкапсуляции и свойств.

  • Определите класс Book.
  • Создайте метод __init__, принимающий title и author. Сохраните их в публичные атрибуты.
  • Внутри __init__ создайте защищенный атрибут _is_borrowed и установите его по умолчанию в False.
  • Напишите метод borrow_book(self). Если книга уже выдана, выведите сообщение и верните False. Иначе поменяйте статус на True и верните True.
  • Напишите метод return_book(self), который меняет _is_borrowed на False.
  • Используя декоратор @property, создайте метод status(self), который возвращает строку 'Выдана' или 'Доступна' в зависимости от состояния.
10 баллов
python
class Book:
    def __init__(self, title, author):
        self.title = title
        self.author = author
        # Инкапсулируем состояние, чтобы его нельзя было изменить в обход правил
        self._is_borrowed = False

    def borrow_book(self):
        # Проверяем внутреннее состояние
        if self._is_borrowed:
            print(f"Книга '{self.title}' уже выдана читателю.")
            return False
        
        # Меняем состояние
        self._is_borrowed = True
        print(f"Успех! Вы взяли книгу '{self.title}'.")
        return True

    def return_book(self):
        self._is_borrowed = False
        print(f"Книга '{self.title}' возвращена в библиотеку.")

    # Создаем read-only свойство для удобного вывода статуса
    @property
    def status(self):
        if self._is_borrowed:
            return "Выдана"
        else:
            return "Доступна"

# Практическое использование нашей системы
b1 = Book("1984", "George Orwell")
print(f"Статус книги: {b1.status}")  # Доступна

b1.borrow_book() # Успех! Вы взяли книгу '1984'.
print(f"Статус книги: {b1.status}")  # Выдана

b1.borrow_book() # Книга '1984' уже выдана читателю. (Защита сработала!)

В классе Book из примера выше мы использовали `@property def status(self):`, но не создавали для него метод-сеттер. Что произойдет, если мы попытаемся выполнить код `b1.status = 'Доступна'`?

Мы рассмотрели атрибуты, которые привязаны к конкретному экземпляру (объекту). Это значит, что у каждой книги свой автор, свое название и свой статус. Такие атрибуты инициализируются внутри метода __init__ с помощью ключевого слова self (например, self.title). Но в ООП существует еще один уровень хранения данных — Атрибуты класса (Class Attributes). Атрибуты класса принадлежат не какому-то конкретному объекту, а самому классу, то есть 'чертежу' целиком. Они создаются прямо в теле класса, вне любых методов (обычно сразу после объявления class Name:). Зачем они нужны? Атрибуты класса используются для хранения информации, которая является общей для всех объектов этого класса. Представьте себе класс Employee (Сотрудник). У каждого сотрудника есть свое имя (self.name) и своя зарплата (self.salary) — это атрибуты экземпляра. Но компания может установить общую базовую ставку ежегодного повышения зарплаты для всех, скажем, 5%. Это число едино для всех, поэтому логично сделать его атрибутом класса: raise_amount = 1.05. Еще один классический паттерн использования атрибутов класса — это ведение статистики. Например, мы хотим знать, сколько всего сотрудников было нанято (сколько объектов класса Employee было создано в программе). Мы можем создать атрибут класса total_employees = 0. А затем внутри метода __init__ каждый раз, когда создается новый объект, мы будем обращаться к классу и увеличивать счетчик: Employee.total_employees += 1. Важно понимать разницу в доступе. К атрибуту экземпляра мы обращаемся через self (или имя переменной объекта emp1.name). К атрибуту класса мы можем обращаться как через само имя класса (Employee.raise_amount), так и через объект (emp1.raise_amount). Однако, если мы попытаемся изменить атрибут класса через объект (например, emp1.raise_amount = 1.10), Python не изменит общий атрибут класса. Вместо этого он создаст новый, локальный атрибут экземпляра специально для объекта emp1, который 'перекроет' классовый атрибут. Это частая причина трудноуловимых багов. Поэтому золотое правило гласит: если вы хотите изменить атрибут класса так, чтобы это затронуло всех, всегда делайте это через имя самого класса: Employee.raise_amount = 1.10. Понимание разницы между памятью класса и памятью экземпляра (где под капотом используются разные словари __dict__) критически важно для эффективного использования оперативной памяти при создании тысяч объектов.

python
class Employee:
    # Атрибуты класса (общие для всех)
    raise_amount = 1.05
    total_employees = 0

    def __init__(self, first_name, last_name, salary):
        # Атрибуты экземпляра (уникальные для каждого)
        self.first_name = first_name
        self.last_name = last_name
        self.salary = salary
        
        # Обращаемся к классу, чтобы увеличить общий счетчик
        Employee.total_employees += 1

    def apply_raise(self):
        # Используем атрибут класса для расчета
        self.salary = int(self.salary * Employee.raise_amount)

# Изначально счетчик равен 0
print(f"Сотрудников: {Employee.total_employees}")

emp1 = Employee("Иван", "Иванов", 50000)
emp2 = Employee("Петр", "Петров", 60000)

# Счетчик увеличился до 2
print(f"Сотрудников: {Employee.total_employees}")

# Меняем атрибут класса. Это коснется ВСЕХ будущих расчетов!
Employee.raise_amount = 1.10 

emp1.apply_raise()
print(f"Новая зарплата Ивана: {emp1.salary}")

Один из самых красивых аспектов Объектно-Ориентированного Программирования — это взаимодействие объектов друг с другом. До этого момента мы работали с объектами изолированно: создавали счет в банке и пополняли его, или брали книгу. Но в реальных архитектурах объекты постоянно 'общаются'. Они могут передаваться в методы других объектов в качестве аргументов. Возвращаясь к нашей библиотеке, логично предположить, что библиотека не работает сама по себе в вакууме. Книги берут Читатели. Давайте спроектируем систему, где класс Reader взаимодействует с классом Book. Класс Reader будет иметь имя и список borrowed_books (взятых книг на руках). Напомним из предыдущих тем: мы не будем использовать пустой список как значение по умолчанию в __init__, чтобы избежать бага изменяемого аргумента! Мы инициализируем его через None. У читателя будет метод take_book(self, book). Обратите внимание: аргумент book — это не просто строка с названием, это целый объект класса Book! Внутри этого метода читатель обращается к переданному объекту книги и вызывает ее метод: success = book.borrow_book(). Если книга ответила 'да' (метод вернул True), читатель добавляет этот объект в свой список self.borrowed_books.append(book). Эта архитектура называется Агрегацией или Композицией (в зависимости от жесткости связей). Один объект становится частью состояния другого объекта. Таким образом, мы строим сложную сеть взаимодействий из простых строительных блоков. Главное правило хорошего тона при проектировании такого взаимодействия: объекты должны общаться через публичные методы. Читатель не должен лезть внутрь книги и менять book._is_borrowed = True. Он должен вежливо попросить книгу выдать себя через метод borrow_book(). Это сохраняет целостность данных. В Python, благодаря динамической типизации, нам не нужно указывать жестко, что аргумент book должен быть именно объектом класса Book. Главное — чтобы у переданного объекта был метод borrow_book(). Это проявление так называемой 'Утиной типизации' (Duck Typing), одного из краеугольных принципов Python: 'Если это выглядит как утка, плавает как утка и крякает как утка, то это, вероятно, утка'.

Как называется принцип Python, который позволяет передавать в метод любой объект, главное чтобы у него были нужные методы (например, borrow_book()), не проверяя жестко его класс ('Если это крякает как утка...')?

python
# (Используем класс Book из предыдущего примера)

class Reader:
    def __init__(self, name):
        self.name = name
        # Инициализируем пустой список правильно!
        self.borrowed_books = []

    def take_book(self, book):
        # ВЗАИМОДЕЙСТВИЕ ОБЪЕКТОВ:
        # Вызываем метод объекта Book
        if book.borrow_book(): 
            self.borrowed_books.append(book)
            print(f"{self.name} успешно взял книгу '{book.title}'.")
        else:
            print(f"{self.name} не смог взять книгу '{book.title}'.")

    def show_my_books(self):
        if not self.borrowed_books:
            print(f"У {self.name} нет взятых книг.")
            return
        print(f"Книги на руках у {self.name}:")
        for book in self.borrowed_books:
            # Обращаемся к атрибуту объекта Book
            print(f" - {book.title} (автор: {book.author})")

# СЦЕНАРИЙ
book1 = Book("Мастер и Маргарита", "Михаил Булгаков")
book2 = Book("Преступление и наказание", "Федор Достоевский")

reader_anton = Reader("Антон")
reader_maria = Reader("Мария")

reader_anton.take_book(book1) # Антон берет книгу
reader_maria.take_book(book1) # Мария пытается взять ТУ ЖЕ книгу (Отказ!)
reader_maria.take_book(book2) # Мария берет другую книгу

reader_anton.show_my_books()

Подводя итог этому насыщенному уроку, давайте вернемся к фундаментальной Философии Python (The Zen of Python). Тим Питерс в своем знаменитом стихотворении написал: 'Явное лучше, чем неявное' (Explicit is better than implicit) и 'Простое лучше, чем сложное' (Simple is better than complex). Объектно-ориентированное программирование — это мощнейший инструмент структурирования кода, но он требует ответственности. Новички часто начинают страдать 'объектно-ориентированной архитектурной болезнью', пытаясь превратить абсолютно всё в классы, создавая глубочайшие уровни абстракции для самых простых задач. Если вам нужно просто посчитать сумму чисел в списке, вам не нужен класс ListCalculator с методом calculate(), достаточно встроенной функции sum(). Создавайте классы только тогда, когда у вас действительно появляется сущность, обладающая и состоянием (данными, которые нужно хранить между вызовами), и поведением (специфическими операциями над этими данными). ООП ради ООП — это антипаттерн в Python. Python — мультипарадигменный язык. Он позволяет элегантно комбинировать функциональное программирование, процедурный код и объекты. В этом уроке мы заложили прочный фундамент: мы научились переходить от абстрактных идей (сущностей реального мира) к конкретным программным конструкциям с помощью классов. Мы освоили инициализацию объектов, научились защищать внутреннее состояние с помощью инкапсуляции и свойств, разобрали разницу между данными класса и экземпляра, и научили объекты общаться друг с другом. В следующих модулях этот фундамент позволит нам легко освоить продвинутые техники: наследование (когда один класс расширяет другой), полиморфизм и магические методы, которые сделают наши объекты неотличимыми от встроенных типов языка. Обязательно выполните практические тесты блока Active Recall ниже, чтобы перевести знания из кратковременной памяти в долговременную, и не бойтесь экспериментировать с кодом!

Выберите утверждение, которое лучше всего отражает 'Pythonic' подход (в духе Zen of Python) к использованию ООП.