Python

Інкапсуляція, Керування Доступом та Властивості

Глибоке дослідження інкапсуляції в Python — від філософії «дорослих людей» та угод про іменування до механізму Name Mangling, обчислювальних властивостей з @property, кастомних дескрипторів та валідації даних через геттери й сеттери.

Інкапсуляція та керування доступом

Вступ: коли «відкритий» клас стає небезпечним

Уявіть банківський сервіс. Клас 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, зміна структури кешу. Зовнішній код цього не відчує, бо взаємодіє лише через публічний інтерфейс.

Менше зв'язності

Коли код взаємодіє через чітко визначений API — зміна одного класу не ламає десятки інших. Зв'язність (coupling) мінімальна.

Самодокументованість

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

Частина I: Філософія доступу в Python

«Ми всі тут дорослі люди»

У C++, Java, C# компілятор фізично забороняє доступ до private-членів — код просто не скомпілюється. Python обрав інший шлях. Творець мови Гвідо ван Россум сформулював принцип:

«We are all consenting adults here»(«Ми всі тут дорослі люди, що діють за згодою»)

Мова не ставить жорстких бар'єрів. Якщо розробник хоче залізти у внутрішні деталі класу — він може. Але відповідальність за зламаний код повністю на ньому. Замість заборон Python використовує угоди про іменування (naming conventions).

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam ArrowColor #6366f1

package "Об'єкт класу (Екземпляр)" #f3f4f6 {
    rectangle "Внутрішній стан\n[_balance, __secret, _cache]\n(Деталі реалізації)" as Internal #fee2e2
    rectangle "Публічний інтерфейс (API)\n[deposit(), withdraw(), balance]" as Public #d1fae5

    Public -down-> Internal : "безпечно модифікує\nта зчитує"
}

actor "Зовнішній код" as Client

Client -right-> Public : "1. Викликає методи ✅"
Client --x Internal : "2. Прямий доступ ❌\n(порушення угоди)"
@enduml

Частина 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    # Баланс зламано — без жодних перевірок!
pylint bank_account.py
$ pylint --disable=all --enable=W0212 main.py
main.py:8:6: W0212: Access to a protected member _balance of a client class (protected-access)
main.py:9:0: W0212: Access to a protected member _balance of a client class (protected-access)

Підтримка IDE

VS Code і PyCharm приховують _protected атрибути в автодоповненні або виділяють їх попередженням при зверненні ззовні класу.

Статичні лінтери

pylint (W0212), flake8-bugbear та інші видають попередження при доступі до захищених членів ззовні їхньої ієрархії.

Нестабільний API

Атрибут з _ може бути перейменований або видалений у будь-якому мінорному релізі бібліотеки. Використовуючи його — ви приймаєте ризик поломки при оновленні.

__private — Name Mangling

Два підкреслення перед іменем активують механізм викривлення імен (Name Mangling). Компілятор Python автоматично перейменовує такий атрибут за шаблоном:

Завантаження...
_ClassName__attribute

Це не шифрування і не справжня приватність — це лише захист від випадкових колізій імен.

# 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())
python secure_wallet.py
$ python secure_wallet.py
AttributeError: 'SecureWallet' object has no attribute '__funds'
{'owner': 'Марія', '_SecureWallet__funds': 500.0}
500.0 # доступ через викривлене ім'я — все ще працює!
-1000.0 # успішно перезаписано через mangled name
Name Mangling — це не засіб безпеки. Він не шифрує дані і не захищає їх у пам'яті. 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}
python collision_demo.py
$ python collision_demo.py
Базовий таймаут: 30с # НЕ перезаписано підкласом!
Кастомний таймаут: 90с
{'_BaseConnector__timeout': 30, '_CustomConnector__timeout': 90}
Використовуйте __private (подвійне підкреслення) тільки коли проектуєте базовий клас бібліотеки і хочете захистити критичні внутрішні атрибути від випадкового перезапису у підкласах. Для всіх інших ситуацій — _protected (одне підкреслення) є достатнім і кращим вибором.

Порівняльна таблиця рівнів доступу

КонвенціяПрикладЗначенняДоступ ззовніName Mangling
Без підкресленняself.nameПублічний API✅ Заохочується
Одне підкресленняself._balanceДеталь реалізації⚠️ Угода (не заборонено)
Два підкресленняself.__secretЗахист від колізій🔒 Лише через _Class__secret
Dunderself.__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}")
python circle.py
$ python circle.py
Площа: 78.5398
Периметр: 31.4159
Діаметр: 10.0000
Нова площа: 314.1593
AttributeError: property 'area' of 'Circle' object has no setter

Геттери, Сеттери та Делітери

Для запису та видалення використовуються декоратори .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}")
python temperature.py
$ python temperature.py
Цельсій: 25.0°C
Фаренгейт: 77.0°F
Кельвін: 298.15K
Після t.fahrenheit = 32.0:
Цельсій: 0.0°C
ValueError: Температура -300.0°C нижча за абсолютний нуль (-273.15°C)!
TypeError: Очікується число, отримано: str
Видалення температурних даних...
AttributeError після del: 'Temperature' object has no attribute '_celsius'

Критична пастка: нескінченна рекурсія

Найпоширеніша помилка при написанні властивостей — збіг імені атрибута та назви сеттера:

# ❌ НІКОЛИ ТАК НЕ РОБІТЬ
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     # ← рекурсія! викликає сам себе
Що станеться при запуску
$ python bad_temperature.py
RecursionError: maximum recursion depth exceeded while calling a Python object
# Python вийде з ладу через ~1000 вкладених викликів

Правило: внутрішній атрибут для зберігання даних завжди відрізняється від імені властивості префіксом _:

# ✅ Правильно: властивість 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
fget
callable | None = None
Функція-геттер. Приймає лише self, повертає значення властивості.
fset
callable | None = None
Функція-сеттер. Приймає self та нове значення. Якщо не вказано — властивість є read-only.
fdel
callable | None = None
Функція-делітер. Приймає лише self. Викликається при del obj.prop.
doc
str | None = None
Документаційний рядок. Якщо не вказано — береться з fget.__doc__.

Чому декоратори кращі:

  1. _get_balance та _set_balance не засмічують публічний інтерфейс класу
  2. Геттер і сеттер розташовані поряд — одразу зрозуміло, що вони пов'язані
  3. Документаційний рядок природно прикріплений до @property-методу

Частина IV: Під капотом — Протокол Дескрипторів

Що таке дескриптор

@property — це не магія. Це об'єкт класу property, що реалізує протокол дескрипторів — набір спеціальних методів для перехоплення доступу до атрибутів.

__get__(self, instance, owner)
descriptor method
Викликається при зчитуванні атрибута. instance — об'єкт-власник (або None при зверненні через клас). owner — клас-власник.
__set__(self, instance, value)
descriptor method
Викликається при записі значення в атрибут. Наявність цього методу робить дескриптор «data descriptor».
__delete__(self, instance)
descriptor method
Викликається при видаленні атрибута через del.
__set_name__(self, owner, name)
descriptor method (Python 3.6+)
Викликається один раз під час парсингу класу. Дозволяє дескриптору дізнатися ім'я атрибута, якому він призначений. Замінює необхідність вручну передавати ім'я у конструктор.

Алгоритм пошуку атрибутів (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__, якщо він визначений.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam ArrowColor #6366f1

start

:obj.attr;

:Пошук attr у type(obj).__mro__;

if (Знайдено data descriptor\n(__get__ + __set__)?) then (Так)
    :Викликати descriptor.__get__(obj, type)\n**найвищий пріоритет**;
    stop
else (Ні)
    if (Є в obj.__dict__?) then (Так)
        :Повернути obj.__dict__['attr'];
        stop
    else (Ні)
        if (Знайдено non-data descriptor\n(тільки __get__)?) then (Так)
            :Викликати descriptor.__get__(obj, type);
            stop
        else (Ні)
            if (__getattr__ визначено?) then (Так)
                :Викликати __getattr__(obj, 'attr');
                stop
            else (Ні)
                :raise AttributeError;
                stop
            endif
        endif
    endif
endif
@enduml

Саме тому obj.area = 100 на read-only @property кидає AttributeErrorproperty є data descriptor (має __set__, що кидає помилку без сеттера), і він перехоплює запис до того, як Python дивиться у obj.__dict__.

Sequence Diagram: читання та запис через дескриптор

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam ArrowColor #6366f1
skinparam sequence {
    ParticipantBackgroundColor #f3f4f6
    ParticipantBorderColor #d1d5db
}

actor "Клієнтський код" as Client
control "Python Runtime\n(Attribute Lookup)" as Runtime
participant "celsius : property\n(Descriptor)" as Descriptor
entity "instance : Temperature" as Instance

== Зчитування: instance.celsius ==
Client -> Runtime : instance.celsius
Runtime -> Descriptor : data descriptor знайдено!\n__get__(instance, Temperature)
Descriptor -> Instance : fget(instance) → читає _celsius
Instance --> Descriptor : 25.0
Descriptor --> Runtime : 25.0
Runtime --> Client : 25.0

== Запис: instance.celsius = -300 ==
Client -> Runtime : instance.celsius = -300
Runtime -> Descriptor : __set__(instance, -300)
Descriptor -> Descriptor : Валідація: -300 < -273.15 → True
Descriptor --> Runtime : raise ValueError
Runtime --> Client : ValueError!
@enduml

Симуляція 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}")  # ✅ валідація спрацювала
python simplified_property_demo.py
$ python simplified_property_demo.py
100.0
25.0
ValueError: Нижче абсолютного нуля!

Незмінність об'єктів 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 — інший!
python property_immutability.py
$ python property_immutability.py
Після @property: id=4373777600 # перший об'єкт
Після @value.setter: id=4373777680 # новий об'єкт! адреса відрізняється
Ця незмінність є навмисним дизайнерським рішенням. Якщо б .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)

Повна реалізація 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}")
python descriptors_validation.py
$ python descriptors_validation.py
Character('Арагорн', HP=100, MP=30, LVL=5, ARM=0)
HP після удару: 75
Character.__dict__ keys: ['health', 'mana', 'level', 'armor'] # дескриптори тут!
hero.__dict__: {'name': 'Арагорн', '_health': 75, '_mana': 30, '_level': 5, '_armor': 0}
Опис health: IntegerRange(0, 100) → 'health'
[health=150 (>100)] ValueError: 'health' має бути у [0, 100], отримано 150
[mana=-1 (<0)] ValueError: 'mana' має бути у [0, 50], отримано -1
[level='max'] TypeError: 'level' має бути int, отримано 'str'
Зверніть увагу на 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}")
python rectangle.py
$ python rectangle.py
Rectangle(width=4.0, height=6.0)
Площа: 24.0
Периметр: 20.0
Квадрат: False
Після width=6: Rectangle(width=6.0, height=6.0)
Квадрат тепер: True
[від'ємна] ValueError: width має бути > 0, отримано -1
[нуль] ValueError: width має бути > 0, отримано 0
[рядок] TypeError: width: очікується число, отримано str

Рівень 2 (Середній): Система профілів користувачів

Production-сценарій: комбінація @property та перевикористовуваних дескрипторів.

python main.py
$ python main.py
=== Створення валідного профілю ===
UserProfile(username='arakviel', email='denys@example.com', balance=150.5)
display_name: Денис Арквіель (@arakviel)
is_verified: True
first_name (trimmed): 'Денис' # пробіли прибрані!
email (normalized): 'denys@example.com' # lowercase!
=== Перевірка валідації ===
[username=пробіли] ValueError: 'username' не може бути порожнім
[email=невалідний] ValueError: 'email': email має містити рівно один '@'
[phone без '+'] ValueError: 'phone': має починатися з '+'
[balance=-10] ValueError: balance не може бути від'ємним, отримано -10.0

Рівень 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}")
python validated_field.py
$ python validated_field.py
=== Валідний продукт ===
Product('MacBook Pro', price=89999.0, sku='MBP-20242')
stock (default worked): 5
=== Тести валідації ===
[price=-1]
Поле 'price': Значення -1 менше мінімуму 0.01
[price=2M (>max)]
Поле 'price': Значення 2000000 перевищує максимум 1000000
[sku='invalid']
Поле 'sku': Значення 'invalid' не відповідає шаблону '^[A-Z]{2,4}-\d{4,8}$'
[stock=-5]
Поле 'stock': Значення -5 менше мінімуму 0
[name='']
Поле 'name': Рядок не може складатися лише з пробілів

Практична лабораторія: E-Commerce кошик від А до Я

Це комплексний приклад, що поєднує всі концепції статті в одній системі:

КонцепціяДе застосовано
_protectedProduct._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 не конфліктує

Архітектура системи

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
skinparam ArrowColor #6366f1
skinparam class {
    BackgroundColor #f3f4f6
    BorderColor #d1d5db
}

class PositiveFloat {
    + __set_name__(owner, name)
    + __get__(instance, owner)
    + __set__(instance, value)
}

class NonNegativeInt {
    + __set_name__(owner, name)
    + __get__(instance, owner)
    + __set__(instance, value)
}

class Product {
    + name : str
    + price : float <<PositiveFloat>>
    + stock : int <<NonNegativeInt>>
    - _category : str
    + is_available : bool <<property>>
    + reserve(qty) : void
    + release(qty) : void
}

class Cart {
    - _items : dict
    - __discount_code : str | None
    - __discount_percent : float
    + subtotal : float <<property>>
    + tax : float <<property>>
    + discount_amount : float <<property>>
    + total : float <<property>>
    + coupon : str | None <<property>>
    + add(product, qty) : void
    + remove(product) : void
    + checkout() : Receipt
}

class PremiumCart {
    - __discount_code : str | None
    + loyalty_discount : float <<property>>
    + total : float <<property, override>>
}

class Receipt {
    + items : list
    + subtotal : float
    + tax : float
    + discount : float
    + total : float
    + to_text() : str
}

Product "1" --o "0..*" Cart : contains
Cart <|-- PremiumCart
Cart --> Receipt : creates
PositiveFloat ..> Product : validates price
NonNegativeInt ..> Product : validates stock
@enduml

Реалізація

python main.py
$ python main.py
==================================================
E-COMMERCE СИСТЕМА: ПОВНА ДЕМОНСТРАЦІЯ
==================================================
📦 Каталог товарів:
Product('MacBook Pro 14', price=89999.00 грн, stock=3 ✅)
Product('iPhone 15 Pro', price=44999.00 грн, stock=10 ✅)
Product('AirPods Pro', price=9999.00 грн, stock=25 ✅)
Product('USB-C кабель', price=499.00 грн, stock=0 ❌)
🔒 Валідація Product:
[price=-1] ValueError: 'price' має бути > 0, отримано -1
[stock=-5] ValueError: 'stock' має бути >= 0, отримано -5
[stock=1.5] TypeError: 'stock' має бути int, отримано 'float'
🛒 Звичайний кошик:
+ MacBook Pro 14 × 1 (89999.00 грн/шт)
+ iPhone 15 Pro × 2 (44999.00 грн/шт)
+ AirPods Pro × 3 (9999.00 грн/шт)
Cart('Іван Петренко', items=6, total=209993.00 грн)
subtotal: 209993.00 грн
ПДВ: 34998.83 грн
🎟️ Купони:
Купон 'FAKE99' не знайдено або вже не діє.
✅ Купон 'SALE10' активовано: знижка 10%
Знижка: 20999.30 грн
До оплати: 188993.70 грн
🗑️ Купон 'SALE10' скасовано
💳 Checkout (VIP20 — 20%):
==========================================
ЧАРТЕР ЗАМОВЛЕННЯ
==========================================
MacBook Pro 14 1 × 89999.00 грн
iPhone 15 Pro 2 × 44999.00 грн
AirPods Pro 3 × 9999.00 грн
------------------------------------------
Сума без ПДВ: 174994.17 грн
ПДВ (20%): 34998.83 грн
Знижка (VIP20): -41998.60 грн
==========================================
РАЗОМ: 167994.40 грн
==========================================
👑 PremiumCart (Name Mangling демонстрація):
✅ Купон 'WELCOME5' активовано: знижка 5%
PremiumCart('Олена VIP', loyalty=15%, total=93894.37 грн)
_Cart__discount_code: 'WELCOME5' # купон (з Cart)
_PremiumCart__discount_code: 'LOYALTY' # loyalty (з PremiumCart)
(два різних поля — Name Mangling захищає від колізій!)
❌ Недоступний товар:
ValueError: Товар 'USB-C кабель' недоступний

Підсумки та найкращі практики

Починайте просто

Починайте з публічних атрибутів. Переходьте до @property лише коли з'являється реальна потреба в валідації або обчисленні. Рання оптимізація — корінь усього зла.

Угода _protected

Для більшості випадків _single_underscore є достатнім. __double_underscore (Name Mangling) застосовуйте виключно у базових класах бібліотек для захисту від колізій у підкласах.

Pythonic @property

Не пишіть get_balance() та set_balance(). Використовуйте @property — зовнішній код буде мати синтаксис account.balance = 1000 замість account.set_balance(1000).

DRY з дескрипторами

Якщо одна й та сама валідаційна логіка потрібна у 3+ атрибутах — виносьте у дескриптор. Один раз написати IntegerRange або NonEmptyString і використовувати декларативно.

Продуктивність

@property додає незначний overhead (~50–100 нс). Не варто оптимізувати передчасно. Але якщо властивість виконує важкий запит — оформіть її як метод get_data(), щоб виклик був явним.

Consenting Adults

Python не забороняє доступ до _private та __mangled. Поважайте чужі угоди та не лізьте під капот сторонніх об'єктів без крайньої потреби. Кожне порушення — ваш технічний борг.
Copyright © 2026