Інкапсуляція, Керування Доступом та Властивості
Інкапсуляція та керування доступом
Вступ: коли «відкритий» клас стає небезпечним
Уявіть банківський сервіс. Клас Account зберігає баланс у публічному атрибуті balance. Перший день у production:
# ❌ Катастрофа в production
account = Account("Олена", 50_000.0)
# Хтось з команди вирішив «швидко» виправити баг:
account.balance = -999_999.0 # від'ємний баланс — фінансовий крах
account.balance = "заморожено" # str замість float — AttributeError о 3 ночі
# Або підрядник отримав доступ до внутрішнього кешу:
account._cache["approved_limit"] = 10_000_000 # обхід перевірок!
Жодного з цих сценаріїв не було б, якби клас контролював доступ до своїх даних. Саме це і вирішує інкапсуляція.
Захист даних
Стабільний API
float до Decimal, зміна структури кешу. Зовнішній код цього не відчує, бо взаємодіє лише через публічний інтерфейс.Менше зв'язності
Самодокументованість
Частина I: Філософія доступу в Python
«Ми всі тут дорослі люди»
У C++, Java, C# компілятор фізично забороняє доступ до private-членів — код просто не скомпілюється. Python обрав інший шлях. Творець мови Гвідо ван Россум сформулював принцип:
«We are all consenting adults here»(«Ми всі тут дорослі люди, що діють за згодою»)
Мова не ставить жорстких бар'єрів. Якщо розробник хоче залізти у внутрішні деталі класу — він може. Але відповідальність за зламаний код повністю на ньому. Замість заборон Python використовує угоди про іменування (naming conventions).
Частина II: Публічні, захищені та приватні атрибути
public — за замовчуванням
Усі імена в класі без підкреслень є публічними. Вони є частиною стабільного публічного API і можуть змінюватись ззовні.
class Point:
def __init__(self, x: float, y: float):
self.x = x # публічний — змінювати можна і потрібно
self.y = y # публічний
p = Point(3.0, 4.0)
p.x = 10.0 # ✅ цілком нормально
_protected — угода про внутрішнє використання
Один символ підкреслення перед іменем — сигнал: «це деталь реалізації, не призначена для прямого використання ззовні». Python це не забороняє, але весь інструментарій — IDE, linters, рецензенти коду — реагує на це попередженням.
# bank_account.py
class BankAccount:
def __init__(self, owner: str, initial_balance: float):
self.owner = owner # публічний ✅
self._balance = initial_balance # захищений — для внутрішнього використання
self._transaction_log: list = [] # захищений — деталь реалізації
def deposit(self, amount: float) -> None:
if amount <= 0:
raise ValueError("Сума депозиту має бути додатною")
self._balance += amount
self._transaction_log.append(f"+{amount}")
def withdraw(self, amount: float) -> None:
if amount > self._balance:
raise ValueError("Недостатньо коштів")
self._balance -= amount
self._transaction_log.append(f"-{amount}")
def get_balance(self) -> float:
return self._balance
account = BankAccount("Денис", 1000.0)
# ⚠️ Технічно працює, але порушує угоду!
print(account._balance) # 1000.0 — IDE покаже попередження
account._balance = -9999.0 # Баланс зламано — без жодних перевірок!
Підтримка IDE
_protected атрибути в автодоповненні або виділяють їх попередженням при зверненні ззовні класу.Статичні лінтери
pylint (W0212), flake8-bugbear та інші видають попередження при доступі до захищених членів ззовні їхньої ієрархії.Нестабільний API
_ може бути перейменований або видалений у будь-якому мінорному релізі бібліотеки. Використовуючи його — ви приймаєте ризик поломки при оновленні.__private — Name Mangling
Два підкреслення перед іменем активують механізм викривлення імен (Name Mangling). Компілятор Python автоматично перейменовує такий атрибут за шаблоном:
Це не шифрування і не справжня приватність — це лише захист від випадкових колізій імен.
# secure_wallet.py
class SecureWallet:
def __init__(self, owner: str, initial_funds: float):
self.owner = owner
self.__funds = initial_funds # → _SecureWallet__funds у пам'яті
def add_funds(self, amount: float) -> None:
if amount > 0:
self.__funds += amount
def get_funds(self) -> float:
return self.__funds
wallet = SecureWallet("Марія", 500.0)
# Прямий доступ — AttributeError
try:
print(wallet.__funds)
except AttributeError as e:
print(f"AttributeError: {e}")
# Досліджуємо __dict__ — бачимо викривлене ім'я
print(wallet.__dict__)
# Доступ через викривлене ім'я — все ще можливий!
print(wallet._SecureWallet__funds)
wallet._SecureWallet__funds = -1000.0
print(wallet.get_funds())
wallet._SecureWallet__funds є абсолютно легальним Python-кодом. Механізм призначений виключно для запобігання випадковим колізіям імен у ієрархіях наслідування, а не для захисту від навмисного злому.Справжнє призначення Name Mangling: захист від колізій при наслідуванні
Ось реальна проблема, яку вирішує подвійне підкреслення. Без Name Mangling підклас міг би випадково перезаписати внутрішній атрибут батьківського класу:
# collision_demo.py
class BaseConnector:
def __init__(self):
self.__timeout = 30 # → _BaseConnector__timeout
def connect(self):
print(f"Базовий таймаут: {self.__timeout}с")
class CustomConnector(BaseConnector):
def __init__(self):
super().__init__()
self.__timeout = 90 # → _CustomConnector__timeout (інше поле!)
def show_custom(self):
print(f"Кастомний таймаут: {self.__timeout}с")
conn = CustomConnector()
conn.connect() # Базовий таймаут: 30с ✅ НЕ перезаписано!
conn.show_custom() # Кастомний таймаут: 90с ✅
# Два окремих поля у __dict__:
print(conn.__dict__)
# {'_BaseConnector__timeout': 30, '_CustomConnector__timeout': 90}
__private (подвійне підкреслення) тільки коли проектуєте базовий клас бібліотеки і хочете захистити критичні внутрішні атрибути від випадкового перезапису у підкласах. Для всіх інших ситуацій — _protected (одне підкреслення) є достатнім і кращим вибором.Порівняльна таблиця рівнів доступу
| Конвенція | Приклад | Значення | Доступ ззовні | Name Mangling |
|---|---|---|---|---|
| Без підкреслення | self.name | Публічний API | ✅ Заохочується | ❌ |
| Одне підкреслення | self._balance | Деталь реалізації | ⚠️ Угода (не заборонено) | ❌ |
| Два підкреслення | self.__secret | Захист від колізій | 🔒 Лише через _Class__secret | ✅ |
| Dunder | self.__init__ | Магічний метод | ✅ Частина протоколу | ❌ (виняток) |
Частина III: @property — Pythonic-шлях до валідації
Проблема Java-style геттерів/сеттерів
Розробники, що прийшли з Java або C#, часто несуть звичку обгортати кожне поле парою getX() / setX():
# ❌ Антипатерн: Java-style у Python
class UnpythonicAccount:
def __init__(self, balance: float):
self._balance = balance
def get_balance(self) -> float: # зайвий шум
return self._balance
def set_balance(self, value: float): # зайвий шум
if value < 0:
raise ValueError("Баланс не може бути від'ємним")
self._balance = value
account = UnpythonicAccount(1000.0)
account.set_balance(account.get_balance() + 500) # некрасиво!
Pythonic-підхід: починайте з простого публічного атрибута. Якщо пізніше знадобиться валідація — перетворіть його на @property. Зовнішній код при цьому не потребує змін:
# ✅ Правильно: починаємо просто
class PythonicAccount:
def __init__(self, balance: float):
self.balance = balance # просто публічний атрибут
# Якщо згодом потрібна валідація — перетворюємо на @property.
# account.balance = 1500 — цей зовнішній код НЕ ЗМІНИТЬСЯ!
Декоратор @property: обчислювальні властивості (read-only)
@property перетворює метод на атрибут — зчитування відбувається без дужок (). За замовчуванням властивість є лише для читання.
# circle.py
import math
class Circle:
def __init__(self, radius: float):
if radius <= 0:
raise ValueError("Радіус має бути додатним")
self.radius = radius
@property
def area(self) -> float:
"""Площа кола — обчислюється з radius при кожному зверненні."""
return math.pi * self.radius ** 2
@property
def circumference(self) -> float:
"""Довжина кола."""
return 2 * math.pi * self.radius
@property
def diameter(self) -> float:
"""Діаметр — обчислюється з radius."""
return self.radius * 2
c = Circle(5.0)
# Звертаємось як до атрибута — БЕЗ дужок!
print(f"Площа: {c.area:.4f}")
print(f"Периметр: {c.circumference:.4f}")
print(f"Діаметр: {c.diameter:.4f}")
# Можна змінити radius — всі властивості автоматично оновляться
c.radius = 10.0
print(f"Нова площа: {c.area:.4f}")
# Спроба записати у read-only властивість:
try:
c.area = 999.0
except AttributeError as e:
print(f"AttributeError: {e}")
Геттери, Сеттери та Делітери
Для запису та видалення використовуються декоратори .setter та .deleter. Ім'я методу завжди збігається з іменем властивості.
# temperature.py
class Temperature:
"""
Зберігає температуру в Цельсіях, але дозволяє
читати/писати через Фаренгейти з автоматичною конвертацією.
"""
def __init__(self, celsius: float):
self.celsius = celsius # ← виклик сеттера (валідація вже тут!)
# ── Цельсій ───────────────────────────────────────────────────────────────
@property
def celsius(self) -> float:
"""Геттер: повертає температуру в Цельсіях."""
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
"""Сеттер: валідує діапазон перед збереженням."""
if not isinstance(value, (int, float)):
raise TypeError(f"Очікується число, отримано: {type(value).__name__}")
if value < -273.15:
raise ValueError(f"Температура {value}°C нижча за абсолютний нуль (-273.15°C)!")
self._celsius = float(value)
# ── Фаренгейт ─────────────────────────────────────────────────────────────
@property
def fahrenheit(self) -> float:
"""Геттер: конвертує Цельсій у Фаренгейт."""
return (self._celsius * 9 / 5) + 32
@fahrenheit.setter
def fahrenheit(self, value: float) -> None:
"""Сеттер: конвертує Фаренгейт у Цельсій і записує через celsius.setter."""
self.celsius = (value - 32) * 5 / 9 # ← делегуємо валідацію celsius.setter!
@fahrenheit.deleter
def fahrenheit(self) -> None:
"""Делітер: очищає внутрішній стан."""
print("Видалення температурних даних...")
del self._celsius
# ── Кельвін (read-only) ───────────────────────────────────────────────────
@property
def kelvin(self) -> float:
"""Кельвін — лише читання, конвертується з Цельсія."""
return self._celsius + 273.15
# Демонстрація
t = Temperature(25.0)
print(f"Цельсій: {t.celsius}°C")
print(f"Фаренгейт: {t.fahrenheit}°F")
print(f"Кельвін: {t.kelvin}K")
# Зміна через Фаренгейт — автоматична конвертація
t.fahrenheit = 32.0
print(f"\nПісля t.fahrenheit = 32.0:")
print(f"Цельсій: {t.celsius}°C")
# Валідація — нижче абсолютного нуля
try:
t.celsius = -300.0
except ValueError as e:
print(f"\nValueError: {e}")
# Валідація типу
try:
t.celsius = "гаряче"
except TypeError as e:
print(f"TypeError: {e}")
# Делітер
del t.fahrenheit
try:
_ = t.celsius
except AttributeError as e:
print(f"\nAttributeError після del: {e}")
Критична пастка: нескінченна рекурсія
Найпоширеніша помилка при написанні властивостей — збіг імені атрибута та назви сеттера:
# ❌ НІКОЛИ ТАК НЕ РОБІТЬ
class BadTemperature:
def __init__(self, celsius):
self.celsius = celsius # викликає setter
@property
def celsius(self):
return self.celsius # ← рекурсія! викликає сам себе
@celsius.setter
def celsius(self, value):
self.celsius = value # ← рекурсія! викликає сам себе
Правило: внутрішній атрибут для зберігання даних завжди відрізняється від імені властивості префіксом _:
# ✅ Правильно: властивість celsius, сховище _celsius
@property
def celsius(self) -> float:
return self._celsius # читаємо з _celsius
@celsius.setter
def celsius(self, value: float) -> None:
self._celsius = value # пишемо у _celsius
Декоратори vs функція property()
class ModernAccount:
def __init__(self, balance: float):
self._balance = balance
@property
def balance(self) -> float:
"""Поточний баланс."""
return self._balance
@balance.setter
def balance(self, value: float) -> None:
if value < 0:
raise ValueError("Баланс не може бути від'ємним")
self._balance = value
class ClassicAccount:
def __init__(self, balance: float):
self._balance = balance
def _get_balance(self) -> float:
return self._balance
def _set_balance(self, value: float) -> None:
if value < 0:
raise ValueError("Баланс не може бути від'ємним")
self._balance = value
# Зайві методи залишаються у просторі імен класу!
balance = property(fget=_get_balance, fset=_set_balance,
doc="Поточний баланс.")
self, повертає значення властивості.self та нове значення. Якщо не вказано — властивість є read-only.self. Викликається при del obj.prop.fget.__doc__.Чому декоратори кращі:
_get_balanceта_set_balanceне засмічують публічний інтерфейс класу- Геттер і сеттер розташовані поряд — одразу зрозуміло, що вони пов'язані
- Документаційний рядок природно прикріплений до
@property-методу
Частина IV: Під капотом — Протокол Дескрипторів
Що таке дескриптор
@property — це не магія. Це об'єкт класу property, що реалізує протокол дескрипторів — набір спеціальних методів для перехоплення доступу до атрибутів.
instance — об'єкт-власник (або None при зверненні через клас). owner — клас-власник.del.Алгоритм пошуку атрибутів (Attribute Lookup)
Коли Python виконує obj.attr, він не просто шукає у obj.__dict__. Алгоритм суворо визначений:
Крок 1: Пошук у класі та MRO
Python шукає attr у type(obj).__dict__ та класах по ланцюжку MRO.
Крок 2: Data Descriptor має найвищий пріоритет
Якщо знайдено атрибут, що реалізує __get__ та __set__ (або __delete__) — це data descriptor. Викликається descriptor.__get__(obj, type(obj)). Значення в obj.__dict__ повністю ігнорується.
Крок 3: Пошук у obj.__dict__
Якщо data descriptor не знайдено — шукаємо у власному словнику екземпляра obj.__dict__.
Крок 4: Non-data Descriptor або атрибут класу
Якщо знайдено атрибут з __get__ але без __set__ — це non-data descriptor (наприклад, звичайна функція). Викликається __get__. Якщо __get__ немає — повертається атрибут класу.
Крок 5: __getattr__
Якщо нічого не знайдено — викликається __getattr__, якщо він визначений.
Саме тому obj.area = 100 на read-only @property кидає AttributeError — property є data descriptor (має __set__, що кидає помилку без сеттера), і він перехоплює запис до того, як Python дивиться у obj.__dict__.
Sequence Diagram: читання та запис через дескриптор
Симуляція property на чистому Python
Щоб остаточно демістифікувати @property, напишемо власну реалізацію:
class SimplifiedProperty:
"""
Спрощена реалізація вбудованого property на чистому Python.
Демонструє протокол дескрипторів у дії.
"""
def __init__(self, fget=None, fset=None, fdel=None, doc=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
self.__doc__ = doc or (fget.__doc__ if fget else None)
def __get__(self, instance, owner):
# Звернення через клас (наприклад, Temperature.celsius)
if instance is None:
return self
if self.fget is None:
raise AttributeError("Не можна зчитати: геттер не визначено")
return self.fget(instance)
def __set__(self, instance, value):
if self.fset is None:
raise AttributeError("Не можна записати: це read-only властивість")
self.fset(instance, value)
def __delete__(self, instance):
if self.fdel is None:
raise AttributeError("Не можна видалити: делітер не визначений")
self.fdel(instance)
# Методи для підтримки синтаксису @prop.setter / @prop.deleter
def setter(self, fset):
"""Повертає НОВИЙ об'єкт property з доданим сеттером."""
return SimplifiedProperty(self.fget, fset, self.fdel, self.__doc__)
def deleter(self, fdel):
"""Повертає НОВИЙ об'єкт property з доданим делітером."""
return SimplifiedProperty(self.fget, self.fset, fdel, self.__doc__)
# Тест: використання нашого SimplifiedProperty замість вбудованого
class TemperatureCustom:
def __init__(self, celsius: float):
self._celsius = celsius
@SimplifiedProperty
def celsius(self) -> float:
"""Температура в Цельсіях."""
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
if value < -273.15:
raise ValueError("Нижче абсолютного нуля!")
self._celsius = value
t = TemperatureCustom(100.0)
print(t.celsius) # 100.0 ✅
t.celsius = 25.0
print(t.celsius) # 25.0 ✅
try:
t.celsius = -300
except ValueError as e:
print(f"ValueError: {e}") # ✅ валідація спрацювала
Незмінність об'єктів property: чому .setter створює новий об'єкт
Об'єкт property є immutable-like: виклик .setter(func) не модифікує існуючий об'єкт, а повертає новий з скопійованими fget та fdel і доданим fset. Доведемо практично:
class Demo:
@property
def value(self) -> int:
return self._value
print(f"Після @property: id={id(value)}") # id об'єкта 1
@value.setter
def value(self, v: int) -> None:
self._value = v
print(f"Після @value.setter: id={id(value)}") # id об'єкта 2 — інший!
.setter мутував існуючий об'єкт — можна було б випадково «зіпсувати» геттер, що вже використовується де-інде у коді. Immutability гарантує безпечне ланцюгування.Частина V: Кастомні дескриптори для перевикористання валідації
Проблема: десятки однакових @property
Уявіть ігровий клас з десятком числових атрибутів, кожен з яким потребує валідації діапазону:
# ❌ Антипатерн: дублювання логіки у кожному @property
class Character:
@property
def health(self) -> int:
return self._health
@health.setter
def health(self, v: int) -> None:
if not isinstance(v, int): raise TypeError(...)
if not 0 <= v <= 100: raise ValueError(...)
self._health = v
@property
def mana(self) -> int: # ← та сама логіка
return self._mana
@mana.setter
def mana(self, v: int) -> None: # ← та сама логіка
if not isinstance(v, int): raise TypeError(...)
if not 0 <= v <= 50: raise ValueError(...)
self._mana = v
# ... ще 8 атрибутів з тією ж логікою
Рішення: власний клас-дескриптор, що реалізує логіку один раз і перевикористовується для всіх атрибутів.
__set_name__: автоматичне отримання імені атрибута (Python 3.6+)
До Python 3.6 дескриптор не знав, під яким ім'ям його збережено в класі. Треба було передавати ім'я вручну:
class LegacyIntegerRange:
def __init__(self, storage_name: str, min_val: int, max_val: int):
self.storage_name = storage_name # дублювання!
self.min_val = min_val
self.max_val = max_val
class LegacyCharacter:
# Жахливе дублювання: ім'я вказується двічі
health = LegacyIntegerRange("_health", 0, 100)
mana = LegacyIntegerRange("_mana", 0, 50)
class IntegerRange:
def __init__(self, min_val: int, max_val: int):
self.min_val = min_val
self.max_val = max_val
def __set_name__(self, owner, name):
# Python сам повідомляє дескриптору його ім'я!
# owner = Character, name = "health"
self.public_name = name # "health"
self.private_name = f"_{name}" # "_health"
class Character:
# Жодного дублювання!
health = IntegerRange(0, 100)
mana = IntegerRange(0, 50)
Повна реалізація IntegerRange дескриптора
# descriptors_validation.py
class IntegerRange:
"""
Data Descriptor для валідації цілочислових атрибутів у діапазоні.
Автоматично отримує ім'я атрибута через __set_name__ (Python 3.6+).
"""
def __init__(self, min_value: int, max_value: int):
self.min_value = min_value
self.max_value = max_value
# Ці поля будуть заповнені __set_name__
self.public_name = None
self.private_name = None
def __set_name__(self, owner: type, name: str) -> None:
"""Викликається Python при парсингу класу-власника."""
self.public_name = name
self.private_name = f"_{name}"
def __get__(self, instance, owner: type):
if instance is None:
return self # доступ через клас → повертаємо дескриптор
return getattr(instance, self.private_name, 0)
def __set__(self, instance, value: int) -> None:
# 1. Перевірка типу
if not isinstance(value, int):
raise TypeError(
f"'{self.public_name}' має бути int, "
f"отримано {type(value).__name__!r}"
)
# 2. Перевірка діапазону
if not (self.min_value <= value <= self.max_value):
raise ValueError(
f"'{self.public_name}' має бути у [{self.min_value}, {self.max_value}], "
f"отримано {value}"
)
# 3. Запис у захищений атрибут екземпляра
setattr(instance, self.private_name, value)
def __repr__(self) -> str:
return (
f"IntegerRange({self.min_value!r}, {self.max_value!r}) "
f"→ '{self.public_name}'"
)
class Character:
"""Ігровий персонаж з валідованими атрибутами через дескриптори."""
health = IntegerRange(0, 100)
mana = IntegerRange(0, 50)
level = IntegerRange(1, 80)
armor = IntegerRange(0, 500)
def __init__(self, name: str, health: int, mana: int, level: int, armor: int = 0):
self.name = name
self.health = health # → IntegerRange.__set__ з валідацією
self.mana = mana
self.level = level
self.armor = armor
def __repr__(self) -> str:
return (
f"Character({self.name!r}, HP={self.health}, "
f"MP={self.mana}, LVL={self.level}, ARM={self.armor})"
)
# ── Тести ─────────────────────────────────────────────────────────────────────
hero = Character("Арагорн", 100, 30, 5)
print(hero)
hero.health = 75
print(f"HP після удару: {hero.health}")
# Дескриптори у __dict__ класу — не у __dict__ екземпляра!
print(f"\nCharacter.__dict__ keys: {[k for k in Character.__dict__ if not k.startswith('__')]}")
print(f"hero.__dict__: {hero.__dict__}")
# Метаінформація дескриптора
print(f"\nОпис health: {Character.health}")
# Помилки
tests = [
(lambda: setattr(hero, 'health', 150), "health=150 (>100)"),
(lambda: setattr(hero, 'mana', -1), "mana=-1 (<0)"),
(lambda: setattr(hero, 'level', "max"), "level='max' (не int)"),
]
for fn, label in tests:
try:
fn()
except (ValueError, TypeError) as e:
print(f"[{label}] {type(e).__name__}: {e}")
hero.__dict__: значення зберігаються з префіксом _ (_health, _mana), а не під іменем дескриптора. Самі дескриптори (health, mana, level, armor) зберігаються у Character.__dict__ — на рівні класу, а не екземпляра.Частина VI: Практичні завдання
Рівень 1 (Базовий): Прямокутник з властивостями
Закріпіть синтаксис @property, .setter та захищених атрибутів.
# rectangle.py
class Rectangle:
"""
Прямокутник з валідацією розмірів через @property.
Площа та периметр — обчислювальні властивості (read-only).
"""
def __init__(self, width: float, height: float):
# Ці виклики проходять через сеттери — валідація відбувається вже тут
self.width = width
self.height = height
@property
def width(self) -> float:
return self._width
@width.setter
def width(self, value: float) -> None:
if not isinstance(value, (int, float)):
raise TypeError(f"width: очікується число, отримано {type(value).__name__}")
if value <= 0:
raise ValueError(f"width має бути > 0, отримано {value}")
self._width = float(value)
@property
def height(self) -> float:
return self._height
@height.setter
def height(self, value: float) -> None:
if not isinstance(value, (int, float)):
raise TypeError(f"height: очікується число, отримано {type(value).__name__}")
if value <= 0:
raise ValueError(f"height має бути > 0, отримано {value}")
self._height = float(value)
@property
def area(self) -> float:
"""Площа (read-only, обчислюється)."""
return self._width * self._height
@property
def perimeter(self) -> float:
"""Периметр (read-only, обчислюється)."""
return 2 * (self._width + self._height)
@property
def is_square(self) -> bool:
"""Чи є прямокутник квадратом."""
return self._width == self._height
def __repr__(self) -> str:
return f"Rectangle(width={self._width}, height={self._height})"
# Тести
r = Rectangle(4, 6)
print(r)
print(f"Площа: {r.area}")
print(f"Периметр: {r.perimeter}")
print(f"Квадрат: {r.is_square}")
r.width = 6.0
print(f"\nПісля width=6: {r}")
print(f"Квадрат тепер: {r.is_square}")
for bad_value, label in [(-1, "від'ємна"), (0, "нуль"), ("широко", "рядок")]:
try:
r.width = bad_value
except (ValueError, TypeError) as e:
print(f"[{label}] {type(e).__name__}: {e}")
Рівень 2 (Середній): Система профілів користувачів
Production-сценарій: комбінація @property та перевикористовуваних дескрипторів.
Рівень 3 (Advanced): Універсальний дескриптор з декларативними правилами
Реалізуйте ValidatedField — дескриптор загального призначення, що приймає список функцій-валідаторів. Це архітектура, що використовується у спрощених ORM-системах (Django fields, SQLAlchemy columns).
# validated_field.py
from typing import Any, Callable
# Тип: функція-валідатор повертає None (успіх) або рядок (повідомлення про помилку)
Validator = Callable[[Any], str | None]
def min_value(minimum: float) -> Validator:
def validate(value) -> str | None:
if value < minimum:
return f"Значення {value} менше мінімуму {minimum}"
return validate
def max_value(maximum: float) -> Validator:
def validate(value) -> str | None:
if value > maximum:
return f"Значення {value} перевищує максимум {maximum}"
return validate
def is_type(*types) -> Validator:
def validate(value) -> str | None:
if not isinstance(value, types):
names = " | ".join(t.__name__ for t in types)
return f"Очікується {names}, отримано {type(value).__name__}"
return validate
def not_empty() -> Validator:
def validate(value) -> str | None:
if hasattr(value, '__len__') and len(value) == 0:
return "Значення не може бути порожнім"
if isinstance(value, str) and not value.strip():
return "Рядок не може складатися лише з пробілів"
return validate
def regex_match(pattern: str) -> Validator:
import re
compiled = re.compile(pattern)
def validate(value) -> str | None:
if not compiled.match(str(value)):
return f"Значення '{value}' не відповідає шаблону '{pattern}'"
return validate
class ValidatedField:
"""
Універсальний дескриптор з декларативними правилами валідації.
Приймає довільну кількість функцій-валідаторів.
"""
def __init__(self, *validators: Validator, default=None):
self.validators = validators
self.default = default
self.public_name = None
self.private_name = None
def __set_name__(self, owner: type, name: str) -> None:
self.public_name = name
self.private_name = f"_{name}"
def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.private_name, self.default)
def __set__(self, instance, value) -> None:
errors = []
for validator in self.validators:
error = validator(value)
if error is not None:
errors.append(error)
if errors:
raise ValueError(
f"Поле '{self.public_name}': " + "; ".join(errors)
)
setattr(instance, self.private_name, value)
def __repr__(self) -> str:
return (
f"ValidatedField({len(self.validators)} validators"
f"{', default=' + repr(self.default) if self.default is not None else ''})"
)
# ── Застосування: декларативна модель продукту ────────────────────────────────
class Product:
name = ValidatedField(
is_type(str),
not_empty(),
)
price = ValidatedField(
is_type(int, float),
min_value(0.01),
max_value(1_000_000),
)
sku = ValidatedField(
is_type(str),
regex_match(r"^[A-Z]{2,4}-\d{4,8}$"),
)
stock = ValidatedField(
is_type(int),
min_value(0),
default=0,
)
def __init__(self, name: str, price: float, sku: str, stock: int = 0):
self.name = name
self.price = price
self.sku = sku
self.stock = stock
def __repr__(self) -> str:
return f"Product({self.name!r}, price={self.price}, sku={self.sku!r})"
# Тести
print("=== Валідний продукт ===")
p = Product("MacBook Pro", 89999.0, "MBP-20242", stock=5)
print(p)
print(f"stock (default worked): {p.stock}")
print("\n=== Тести валідації ===")
errors_tests = [
(lambda: setattr(p, 'price', -1), "price=-1"),
(lambda: setattr(p, 'price', 2_000_000),"price=2M (>max)"),
(lambda: setattr(p, 'sku', "invalid"), "sku='invalid'"),
(lambda: setattr(p, 'stock', -5), "stock=-5"),
(lambda: setattr(p, 'name', ""), "name=''"),
]
for fn, label in errors_tests:
try:
fn()
except ValueError as e:
print(f"[{label}]\n {e}")
Практична лабораторія: E-Commerce кошик від А до Я
Це комплексний приклад, що поєднує всі концепції статті в одній системі:
| Концепція | Де застосовано |
|---|---|
_protected | Product._stock, Cart._items — внутрішні деталі |
__private (Name Mangling) | Cart.__discount_code — захист від колізій у підкласах |
@property (геттер) | Cart.total, Cart.tax, Cart.final_price — обчислювальні |
@property (сеттер + валідація) | Product.price, Product.stock — захищений запис |
@property (делітер) | Cart.coupon — очищення знижки |
| Кастомний дескриптор | PositiveFloat, NonNegativeInt — перевикористання валідації |
| Name Mangling при наслідуванні | PremiumCart — __discount_code не конфліктує |
Архітектура системи
Реалізація
Підсумки та найкращі практики
Починайте просто
@property лише коли з'являється реальна потреба в валідації або обчисленні. Рання оптимізація — корінь усього зла.Угода _protected
_single_underscore є достатнім. __double_underscore (Name Mangling) застосовуйте виключно у базових класах бібліотек для захисту від колізій у підкласах.Pythonic @property
get_balance() та set_balance(). Використовуйте @property — зовнішній код буде мати синтаксис account.balance = 1000 замість account.set_balance(1000).DRY з дескрипторами
IntegerRange або NonEmptyString і використовувати декларативно.Продуктивність
@property додає незначний overhead (~50–100 нс). Не варто оптимізувати передчасно. Але якщо властивість виконує важкий запит — оформіть її як метод get_data(), щоб виклик був явним.Consenting Adults
_private та __mangled. Поважайте чужі угоди та не лізьте під капот сторонніх об'єктів без крайньої потреби. Кожне порушення — ваш технічний борг.Класи та Об'єкти
Глибоке дослідження механізму класів і об'єктів у Python — від фундаментальних принципів інстанціювання до CPython internals, різниці між __init__ та __new__, оптимізації пам'яті через __slots__ та природи self як неявного першого аргументу.
Наслідування, MRO та суперсила super()
Глибоке дослідження механізмів наслідування в Python — від одиночного наслідування та перевизначення методів до cooperative multiple inheritance, внутрішнього влаштування super() як проксі-об'єкта, детального математичного аналізу C3-лінеаризації, CPython Internals MRO та патерну Mixins.