Lecture 7 · Part 2

OOP in Python

Polymorphism, encapsulation and designing fault-tolerant banking systems

Today we will look at how to design flexible and secure architectures. With real-world examples from fintech - how to manage complex data flows, keep passwords out of logs, and orchestrate dozens of payment gateways through a single interface.

Lecture 6 Recap

Core concepts

In the previous session we laid the foundation: we learned to package state and behavior into classes, and we got acquainted with inheritance.

Before moving on to more advanced concepts - dynamic polymorphism and encapsulation - we need to lock in the terminology on basic examples. Without that foundation, it is easy to get lost in the mechanics of MRO and Name Mangling.

Recap

The purpose of classes

Let us compare two approaches to managing data, using bank account bookkeeping as an example.

Procedural approach
# data and logic are independent; # hard to scale acc1_number = "44001122" acc1_balance = 50000.0 acc2_number = "55003344" acc2_balance = 1200.0
OOP approach
class BankAccount: def __init__(self, number: str, balance: float): self.number = number self.balance = balance acc1 = BankAccount("44001122", 50000.0)

A class is a user-defined data type that binds financial state and the logic for working with it into a single structure.

Recap

Class and instance

Class

Blueprint

A programmatic description of structure. On its own it stores no specific client's data and takes up no memory for data.

Instance

Instance

A concrete object in memory - for example, a client's account with a real balance. Millions of isolated objects can be created from a single class.

Recap

Attributes and methods

1

Attributes

Variables inside an object that reflect its current state: account_number, currency, is_blocked.

2

Methods

Functions inside a class with access to the object's state: withdraw(), block_account().

💡 A well-designed class manages its own attributes independently, and external code interacts with it only through method calls.

Recap

The __init__ method and the self argument

class BankAccount: def __init__(self, account_number: str, balance: float): print(f"Memory allocated. self refers to address: {id(self)}") self.account_number = account_number self.balance = balance my_acc = BankAccount("44001122", 1000.0)
Memory allocated. self refers to address: 140237482390224

__init__ is the object's constructor, called automatically after memory is allocated. self is an explicit reference to the current instance.

Context

Python self vs Java this

Python

Explicit declaration

def deposit(self, amount: float): self.balance += amount

self is written as the first argument of every method. "Explicit is better than implicit."

Java

Implicit passing

public void deposit(double amount) { this.balance += amount; }

The JVM passes the reference automatically; inside the method it is available through this.

Recap

Isolation of object namespaces

class BankAccount: def __init__(self, owner: str, balance: float): self.owner = owner self.balance = balance alice_acc = BankAccount("Alice", 500.0) bob_acc = BankAccount("Bob", 10000.0) alice_acc.balance -= 50.0 print(f"Alice's balance: {alice_acc.balance}") print(f"Bob's balance: {bob_acc.balance}")
Alice's balance: 450.0 Bob's balance: 10000.0

Recap

The __str__ method

class BankAccount: def __init__(self, owner: str, balance: float): self.owner = owner self.balance = balance def __str__(self) -> str: return f"Client account: {self.owner} | Balance: {self.balance} USD" acc = BankAccount("Oleg", 7500.0) print(acc)
Client account: Oleg | Balance: 7500.0 USD

Recap

Method signatures

class BankAccount: def __init__(self, balance: float): self.balance = balance def transfer(self, target_acc: 'BankAccount', amount: float) -> bool: if self.balance >= amount: self.balance -= amount target_acc.balance += amount return True return False acc_a = BankAccount(100.0) acc_b = BankAccount(20.0) success = acc_a.transfer(acc_b, 50.0) print(f"Transaction status: {success} | Remaining: {acc_a.balance}")
Transaction status: True | Remaining: 50.0

Recap

Inheritance and super()

class BasicCard: def __init__(self, holder: str, number: str): self.holder = holder self.number = number class PremiumCard(BasicCard): def __init__(self, holder: str, number: str, cashback_rate: float): super().__init__(holder, number) self.cashback_rate = cashback_rate vip = PremiumCard("Asel", "4400...", 0.05) print(f"Card: {vip.holder}, Cashback: {vip.cashback_rate * 100}%")
Card: Asel, Cashback: 5.0%

Recap

OOP everywhere in Python

"card_verified".upper() # a method of a str class instance balances.append(500.0) # changing the state of a list instance df.dropna() # a method of a pandas DataFrame object api.send_payment(payload) # an HTTP-session client via requests / httpx

When working with Python, you use OOP every minute - almost every call through a dot is a method of an object.

Recap

Summary table

ConceptIn briefMeaning in architecture
Class / InstanceDescription of structure vs object in RAMProduct template and a real client account
Attribute / MethodState variable vs functionBalance (data) and the transfer method (behavior)
__init__ / selfConstructor and reference to the contextEntry point at creation and a reference to the instance
Inheritance / super()Extension without duplicationCreditAccount built on BasicAccount

Course map

Current status of OOP mastery

ConceptStatusStage
Inheritance🟢 DONESyntax of class extension and super().
Polymorphism🟡 CURRENT TOPICA single interface for different payment gateways.
Encapsulation⚪ UPCOMINGHiding critical data and validation.
Abstraction⚪ UPCOMINGSeparating business logic from implementation details.

Today

Lecture 7 plan

  • Polymorphism - method overriding, duck typing, MRO and diamond inheritance
  • Encapsulation - the _ and __ modifiers, @property, setters
  • Class-level tools - @staticmethod, @classmethod
  • Magic methods - the +, ==, len() operators for user-defined objects
  • Exceptions - custom error classes

Polymorphism

What is polymorphism?

Picture a payment terminal at a checkout. It does not care what exactly the customer presents: a UzCard plastic card, a smartphone with Humo Pay, or a QR code from Milly Bank. For the terminal there is one interface - "accept the payment." And how the charge actually happens depends on the specific payment instrument.

Polymorphism is the ability of a program to interact with different objects through a single interface, relying only on the method name and not on the object's internal structure.

Polymorphism

Two ways to implement polymorphism

1

Classical

Through inheritance and overriding. A parent class is created; descendants implement a method with the same name but their own logic.

2

Dynamic

Duck typing. No inheritance is required: if independent classes have a method with the same name, it can be called polymorphically.

Polymorphism

Polymorphism mechanisms in Python

┌──────────────────────────┐ │ POLYMORPHISM (concept) │ └────────────┬─────────────┘ │ ┌──────────────────┴──────────────────┐ ▼ ▼ ┌──────────────────────────────┐ ┌──────────────────────────────┐ │ Classical (hierarchical) │ │ Dynamic (duck typing) │ ├──────────────────────────────┤ ├──────────────────────────────┤ │ Requires a common parent │ │ No inheritance required │ │ Overrides ancestor methods │ │ Only the method must exist │ │ Classes linked in hierarchy │ │ Classes are fully independent│ └──────────────────────────────┘ └──────────────────────────────┘

Classical polymorphism

How overriding works

Overriding is when a descendant class declares a method with the same name as the parent's but implements it in its own way.

class Parent: def greet(self): print("Hello from the parent") class Child(Parent): def greet(self): # same name - its own implementation print("Hello from the child") Child().greet()
Hello from the child

When obj.greet() is called, Python searches for the method starting from the object's own class. The descendant's version "overrides" the parent's. And super().greet() lets you additionally run the parent's version as well.

Classical polymorphism

Method overriding

The base class holds the shared logic. The descendant class overrides the method:

class PaymentGateway: def process_payment(self, amount: float) -> None: print(f"[Core-Banking] Recording transaction for {amount} soum") class UzCardGateway(PaymentGateway): def process_payment(self, amount: float) -> None: super().process_payment(amount) # core logic first print(f"[UzCard API] Generating QR code. Charging {amount} soum.") # then its own

super() calls the parent's version. UzCardGateway adds its own behavior without losing the shared core logic.

Classical polymorphism

The same pattern - other gateways

Humo and Milly Bank override the method the same way. run_checkout works with any gateway:

class HumoGateway(PaymentGateway): def process_payment(self, amount: float) -> None: super().process_payment(amount) print(f"[Humo API] Acquiring request. Charging {amount} soum.") class MillyBankGateway(PaymentGateway): def process_payment(self, amount: float) -> None: super().process_payment(amount) print(f"[Milly Bank API] Online payment. Amount: {amount} soum.") def run_checkout(gateway: PaymentGateway, total: float): gateway.process_payment(total) # the function does not know the gateway type run_checkout(UzCardGateway(), 150000.0) run_checkout(HumoGateway(), 320000.0)

Method overriding

Result of the call

[Core-Banking] Recording transaction for 150000.0 soum [UzCard API] Generating QR code. Charging 150000.0 soum. [Core-Banking] Recording transaction for 320000.0 soum [Humo API] Acquiring request. Charging 320000.0 soum.

Each gateway overrides process_payment in its own way. super() first runs the shared core logic (writing to the log), then the code of the specific gateway. The run_checkout function calls a single method and does not know which class was passed to it.

Reinforcing overriding

Calculating the transfer fee

class BasicCardTariff: def calculate_fee(self, amount: float) -> float: return amount * 0.015 # 1.5% - standard fee class PremiumCardTariff(BasicCardTariff): def calculate_fee(self, amount: float) -> float: return amount * 0.005 # 0.5% - for premium clients class CorporateCardTariff(BasicCardTariff): def calculate_fee(self, amount: float) -> float: return 50000.0 # flat fee for legal entities tariffs_pool = [BasicCardTariff(), PremiumCardTariff(), CorporateCardTariff()] for tariff in tariffs_pool: fee = tariff.calculate_fee(10_000_000.0) print(f"Tariff: {tariff.__class__.__name__} | Fee: {fee} soum")
Tariff: BasicCardTariff | Fee: 150000.0 soum Tariff: PremiumCardTariff | Fee: 50000.0 soum Tariff: CorporateCardTariff | Fee: 50000.0 soum

Three tariffs override calculate_fee with their own formula. The loop calls the same method - Python itself substitutes the right implementation based on the object's type.

In other languages

Comparing overriding: Python vs Java

CriterionPythonJava
Default behaviorAll methods are virtual. A matching name in a descendant overrides the parent method.Requires matching signatures. The @Override annotation is recommended.
Type checkingNone at the runtime level.Enforced by the compiler.
Lookup principleDynamic lookup along the MRO at execution time.Via the virtual method table (VMT) at compile time.

Risk of overriding

The fragile base class

class DocumentProcessor: def format_inn(self, raw_inn: str) -> str: return raw_inn.strip() # guarantees returning a str def register_legal_entity(self, inn: str) -> str: clean_inn = self.format_inn(inn) return f"Registering company with TIN: {clean_inn.upper()}" class BadCustomProcessor(DocumentProcessor): def format_inn(self, raw_inn: str) -> None: # super() not called; no return -> the method returns None print(f"[Log] TIN verification request: {raw_inn}") processor = BadCustomProcessor() try: processor.register_legal_entity(" 123456789012 ") except AttributeError as error: print(f"RUNTIME ERROR: {error}")
[Log] TIN verification request: 123456789012 RUNTIME ERROR: 'NoneType' object has no attribute 'upper'

The cause: format_inn returned None, and the call None.upper() failed - the NoneType type has no such method.

Risk of overriding

A typo in the method name

class NotificationService: def send_critical_alert(self, msg: str): print(f"[Fallback SMS] Critical error: {msg}") class TelegramService(NotificationService): def send_criticall_alert(self, msg: str): # typo: double 'l' print(f"[Telegram Bot] Error sent to chat: {msg}") notifier = TelegramService() notifier.send_critical_alert("The authorization server is down!")
[Fallback SMS] Critical error: The authorization server is down!

In compiled languages such a typo would cause a build error. Python silently creates a new method and calls the parent's one.

Type hierarchy

What is object

object is a built-in class that Python itself created as the foundation of the entire type system. It exists before your code: it need not be defined or imported.

print(object) # the root class itself print(type(object)) # its type print(int.__bases__) # even int is a descendant of object
<class 'object'> <class 'type'> (<class 'object'>,)

Any class in Python is, ultimately, a descendant of object. It is precisely from object that all objects get their basic capabilities: __init__, __str__, __eq__, __hash__ and others.

Type hierarchy

Python supplies object automatically

When you write a class without specifying a parent, Python silently adds object as the base class. The two listings below are completely identical:

class Account: # this is how we write it pass class Account(object): # this is how Python sees it pass print(Account.__bases__) # the parent is there, even though we did not write it print(issubclass(Account, object))
(<class 'object'>,) True

The parentheses with object can be omitted - Python will supply it itself. That is why we "do not see" the parent in the code, but it is always there.

Type hierarchy

What a class gets from object

class SimpleEntity: pass entity = SimpleEntity() print(f"Base ancestors of the class: {SimpleEntity.__bases__}") print(f"__str__: {entity.__str__()}") print(f"__eq__: {entity.__eq__(entity)}") print(f"__dir__: {len(entity.__dir__())} elements")
Base ancestors of the class: (<class 'object'>,) __str__: <__main__.SimpleEntity object at 0x104b2b9a0> __eq__: True __dir__: 26 elements

If the parent class is not specified explicitly, Python inherits from object - the top of the entire hierarchy.

In other languages

Python object vs Java java.lang.Object

ParameterPythonJava
Auto-linkingCloses off any user-defined hierarchy.The absolute root of the object model.
Base set__new__, __init__, __repr__, __eq__.toString(), equals(), hashCode().
Multiple inheritanceThe final point of the MRO.Not applicable - Java supports single inheritance only.

Hierarchy conflict

Diamond inheritance (Diamond Problem)

┌────────────────┐ │ BaseValidator │ declares validate() └───────┬────────┘ ┌─────────┴─────────┐ ▼ ▼ ┌───────────────┐ ┌───────────────┐ │ CardCheck │ │ LimitCheck │ both have overridden it └───────┬───────┘ └───────┬───────┘ └─────────┬─────────┘ ▼ ┌───────────────┐ │ FullCheck │ whose validate() implementation? └───────────────┘

Deep dive

MRO - method resolution order

Python uses C3 linearization: when a method is called, classes are scanned in MRO order, and the first one found is executed.

class BaseValidator: def validate(self): print("Implementation: BaseValidator") class CardCheck(BaseValidator): def validate(self): print("Implementation: CardCheck") class LimitCheck(BaseValidator): def validate(self): print("Implementation: LimitCheck") class FullCheck(CardCheck, LimitCheck): pass checker = FullCheck() checker.validate() for i, cls in enumerate(FullCheck.__mro__, start=1): print(f"{i}. {cls.__name__}")
Implementation: CardCheck 1. FullCheck 2. CardCheck 3. LimitCheck 4. BaseValidator 5. object

Cooperative inheritance

super() and the MRO

super() calls a method not on the "parent from the declaration," but on the next class in the current object's MRO chain.

class Base: def __init__(self): print("Initializing Base") class Left(Base): def __init__(self): print("Start Left"); super().__init__(); print("End Left") class Right(Base): def __init__(self): print("Start Right"); super().__init__(); print("End Right") class Child(Left, Right): def __init__(self): print("Start Child"); super().__init__(); print("End Child") instance = Child() # MRO: Child -> Left -> Right -> Base
Start Child -> Start Left -> Start Right -> Initializing Base -> End Right -> End Left -> End Child

super() inside Left passed control to Right, even though they are not directly related - this is cooperative multiple inheritance.

Hierarchy conflict

Linearization errors

class X: pass class Y(X): pass try: class InvalidSystem(X, Y): # X cannot come before its descendant Y pass except TypeError as error: print(f"Hierarchy construction error:\n{error}")
Hierarchy construction error: Cannot create a consistent method resolution order (MRO) for bases X, Y

Polymorphism

Resolving calls at runtime

gateways = [UzCardGateway(), HumoGateway(), MillyBankGateway()] def run_billing(gateway_instance, sum_to_charge): # Python does not check the type in advance. # At the moment of the call it scans the MRO of the passed instance. gateway_instance.process_payment(sum_to_charge) for gw in gateways: run_billing(gw, 100000.0)

Polymorphism

Polymorphic processing of collections

A single interface lets you process sets of heterogeneous objects in ordinary loops - without type checks via if isinstance().

processing_pool = [UzCardGateway(), HumoGateway(), MillyBankGateway(), UzCardGateway()] for gateway in processing_pool: gateway.process_payment(50000.0) print("--- Transaction log saved ---")

Architecture

A programmatic interface as a contract

From an architectural standpoint, polymorphism isolates business logic from the technical details of specific vendors.

When connecting a new gateway (Click or Payme, for example), there is no need to change the core code. It is enough to create a class that descends from PaymentGateway and implement process_payment. The rest will work automatically.

Takeaways

Method overriding - summary

RuleWhy it matters
Keep the method name exactA typo will create a new method, while the old one keeps running silently
Keep the return typeThe parent code expects to receive a specific data type
Call super() when neededIf the base logic (audit log) must be executed
Keep the argument signatureOtherwise the polymorphic call will receive unexpected data
PaymentGateway ← shared contract ├── UzCardGateway ← its own implementation ├── HumoGateway ← its own implementation └── MillyBankGateway ← its own implementation

Dynamic polymorphism

Duck Typing

"If an object behaves like a duck - it is a duck." Common ancestors are not required: if there is a method with the right name, it can be called.

class ApplePayService: def charge_money(self, total: float): print(f"[Apple Pay] Charging card token: {total} soum") class GooglePayService: # not related to ApplePayService by hierarchy, but the method name matches def charge_money(self, total: float): print(f"[Google Pay] Request to the Google Wallet API: {total} soum") def process_mobile_wallet(payment_service, amount: float): payment_service.charge_money(amount) # only the presence of the method matters process_mobile_wallet(ApplePayService(), 120000.0) process_mobile_wallet(GooglePayService(), 120000.0)

Caution

Risks of duck typing in production

class TaxService: def charge_money(self, total: float): # the name matches the payment gateways, but the logic is different print(f"[Tax Office] Charging penalties: {total} soum") class FakePayGateway: def charge_monney(self, total: float): # typo in the name print(total) # Risk 1: AttributeError because of the typo try: process_mobile_wallet(FakePayGateway(), 100.0) except AttributeError as err: print(f"Runtime error: {err}") # Risk 2: a semantic error - the code runs, but the logic is broken process_mobile_wallet(TaxService(), 500000.0)

OOP in real projects

Duck Typing in analytics libraries

from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier # objects of different classes provide a single interface: .fit() and .predict() scoring_models = [LogisticRegression(), RandomForestClassifier()] for model in scoring_models: model.fit(X_train, y_train) predictions = model.predict(X_test)

Scikit-learn is built entirely on duck typing: any model with the .fit() and .predict() methods works in a shared pipeline.

Takeaways

Polymorphism - full summary

CharacteristicOverridingDuck typing
Requires a common ancestorYesNo
Contract enforcementThrough the hierarchyOnly by the method name
When errors become visibleAt the design stageOnly at runtime
Where it is usedGateways of one systemIndependent libraries
gateway.process_payment(amount) ↓ Python looks into the object's MRO ↓ → calls the right implementation automatically

Course map

Status of mastering OOP concepts

ConceptStatusDescription
Inheritance🟢 DONESyntax of class extension and super().
Polymorphism🟢 DONEOverriding, MRO and duck typing.
Encapsulation🟡 CURRENT TOPICHiding critical fields from external access.
Abstraction⚪ UPCOMINGSeparating business logic from implementation details.

Encapsulation

The problem - unrestricted modification of state

class BankUser: def __init__(self, login: str, raw_password: str): self.login = login self.password = raw_password # the password is stored in plain text user = BankUser("manager_olga", "123456") # any developer can change the password directly: user.password = "HACKED_OR_CLEARED" # or read it without any hashing: print(f"Password in the logs: {user.password}")

A plain-text password, readable and writable by anyone - a straight road to a leak.

Encapsulation

The goals of encapsulation

Encapsulation solves two tasks:

  • Combine data and the methods for working with it into a single structure
  • Hide internal details, exposing outward only a safe public interface with explicit validation

🔐 You cannot change the balance or the password directly. For that, the methods change_password() (with a check of the old password and hashing) and deposit() (with amount validation) are provided.

In other languages

The philosophy of access control

Java

A hard prohibition

The private modifier blocks access at the compilation level. Accessing user.password from the outside is a compiler error.

Python

A convention

There are no physical restrictions. Protection rests on attribute naming (_ and __). Developers follow it by agreement.

Encapsulation

Protected attributes: _

A single underscore is a signal: "this field is internal, not intended for use outside the class."

  • Technically, access is open
  • The IDE highlights such access as a style violation
  • It is not exported on from module import *
class InternalBankConfig: def __init__(self): self._swift_code = "UZUZBGBU" # a protected attribute by convention config = InternalBankConfig() print(config._swift_code) # works, but violates the architectural contract

Encapsulation

Private attributes: __ and Name Mangling

A double underscore turns on Name Mangling: __password_hash becomes _ClassName__password_hash. This protects the field from being accidentally overridden during inheritance.

import hashlib class SecureUser: def __init__(self, login: str, raw_password: str): self.login = login self.__password_hash = hashlib.sha256(raw_password.encode()).hexdigest() user = SecureUser("admin_damir", "qwerty2026") try: print(user.__password_hash) # no attribute with such a name exists except AttributeError as error: print(f"Security system: {error}") print(f"Workaround: {user._SecureUser__password_hash}")
Security system: 'SecureUser' object has no attribute '__password_hash' Workaround: 4bc82b9a76d...

Encapsulation

The @property decorator - getters

If you need to read data but control the process or mask the output - @property is used. It turns a method into a "virtual attribute" - accessed without parentheses.

class CreditCard: def __init__(self, client_name: str, raw_pan: str): self.client_name = client_name self._pan = raw_pan @property def masked_pan(self) -> str: print("[Security Audit] Request to read the card number.") return f"{self._pan[:4]} **** **** {self._pan[-4:]}" card = CreditCard("Arman", "8600112233445566") print(f"Card number: {card.masked_pan}") # no parentheses - like an attribute
[Security Audit] Request to read the card number. Card number: 8600 **** **** 5566

Why @property

Reason 1 - logic on read

class UserAccount: def __init__(self, email: str): self._email = email self._read_count = 0 @property def email(self) -> str: self._read_count += 1 return self._email acc = UserAccount("user@millybank.uz") print(acc.email) print(acc.email) print(f"Email was requested {acc._read_count} times")
user@millybank.uz user@millybank.uz Email was requested 2 times

Why @property

Reason 2 - computed values

class LoanPortfolio: def __init__(self, principal: float, interest_rate: float): self.principal = principal self.interest_rate = interest_rate @property def total_debt(self) -> float: return self.principal * (1 + self.interest_rate) portfolio = LoanPortfolio(10_000_000, 0.20) print(f"Total to repay: {portfolio.total_debt} soum") portfolio.principal -= 2_000_000 print(f"After partial repayment: {portfolio.total_debt} soum")
Total to repay: 12000000.0 soum After partial repayment: 9600000.0 soum

Why @property

Reason 3 - interface compatibility

If an attribute was public and then logic on read became necessary - @property lets you add it without changing the call syntax. All external code keeps working.

# Before: acc.balance → simply reads the value # Now: acc.balance → reads the value AND writes to the audit log # The call syntax across the whole project did not change

That is why in Python you start with an ordinary attribute and turn it into a @property only when logic appears.

In other languages

Approaches to getters and setters

Java

Boilerplate from day one

Every field is wrapped from day one into getBalance() / setBalance(), even with no logic inside. A large amount of boilerplate code.

Python

As needed

We start with an ordinary attribute. When necessary, we turn it into a @property - and all external code keeps working without changes.

Encapsulation

@property.setter - validation on write

A setter intercepts assignment via = and checks the data before storing it.

class CreditLimitManager: def __init__(self, current_limit: float): self._limit = current_limit @property def limit(self) -> float: return self._limit @limit.setter def limit(self, new_value: float): print(f"Attempt to change the credit limit to: {new_value} soum") if new_value < 0: raise ValueError("The credit limit cannot be negative!") if new_value > 500_000_000: raise ValueError("The branch's maximum allowed limit has been exceeded!") self._limit = new_value manager = CreditLimitManager(50_000_000) try: manager.limit = 700_000_000 except ValueError as error: print(f"Operation rejected: {error}")
Attempt to change the credit limit to: 700000000 soum Operation rejected: The branch's maximum allowed limit has been exceeded!

Under the hood

How the @property + setter pairing works

@property → Python creates a 'limit' descriptor with read logic @limit.setter → write logic is added to the same descriptor manager.limit → calls limit.fget(manager) manager.limit = 700000 → calls limit.fset(manager, 700000)

The getter and setter names must match - that is why the decorator is written as @limit.setter, and not simply @setter.

class PinValidator: def __init__(self): self._pin = None @property def pin(self) -> str: return "****" # the real value is not returned @pin.setter def pin(self, value: str): if not value.isdigit() or len(value) != 4: raise ValueError("The PIN code must consist of exactly 4 digits!") self._pin = value print("PIN code updated successfully.")

Encapsulation

A property without a setter - read-only

class Contract: def __init__(self, contract_id: str, client: str): self._contract_id = contract_id self.client = client @property def contract_id(self) -> str: """Contract identifier - immutable after creation""" return self._contract_id contract = Contract("UZ-2024-001", "Timur") print(contract.contract_id) try: contract.contract_id = "UZ-2024-999" except AttributeError as e: print(f"Protection triggered: {e}")
UZ-2024-001 Protection triggered: can't set attribute

Encapsulation

The reference BankAccount class

Constructor, a protected balance, and a deposit with a check:

class BankAccount: def __init__(self, owner: str, initial_balance: float = 0.0): self.owner = owner self._balance = initial_balance # protected balance @property def balance(self) -> float: return self._balance def deposit(self, amount: float) -> None: if amount <= 0: print("Error: the deposit amount must be greater than zero.") return self._balance += amount

The balance is closed to direct writes: it is read through @property and changed only through methods.

Encapsulation

BankAccount: withdrawing funds

The same class - a withdrawal method with a double check:

def withdraw(self, amount: float) -> None: if amount <= 0: print("Error: the withdrawal amount must be positive.") return if amount > self._balance: print("Transaction blocked: insufficient funds.") return self._balance -= amount acc = BankAccount("Dmitriy", 1_000_000.0) acc.withdraw(1_500_000.0)
Transaction blocked: insufficient funds.

The method itself checks the amount and the remaining balance - an incorrect operation simply will not go through.

Encapsulation

Dynamically computed properties

class LoanPortfolio: def __init__(self, principal: float, interest_rate: float): self.principal = principal self.interest_rate = interest_rate @property def total_debt(self) -> float: """Computed on every access from the current data""" return self.principal * (1 + self.interest_rate) portfolio = LoanPortfolio(10_000_000, 0.20) print(f"Total to repay: {portfolio.total_debt} soum") portfolio.principal -= 2_000_000 print(f"After partial repayment: {portfolio.total_debt} soum")
Total to repay: 12000000.0 soum After partial repayment: 9600000.0 soum

Encapsulation

Summary table of encapsulation tools

ToolWhat it doesWhen to use
_nameA signal of internal ownershipFields not intended for external use
__nameName Mangling - renames the field in memoryCritical data: passwords, tokens, PIN codes
@propertyControlled readingLogic on read, masking, computed values
@name.setterValidation before writingChecking data before storing it
@property without a setterRead-onlyImmutable identifiers: contract ID, account number

Takeaways

Three OOP concepts - summary matrix

ConceptStatusWhat problem it solvesKey tools
Inheritance🟢Code duplication across similar classesclass Child(Parent), super()
Polymorphism🟢Code's dependence on specific implementationsOverriding, Duck Typing, MRO
Encapsulation🟢Direct access to data breaks the logic_, __, @property, setter
AbstractionHigh-level code is mixed with the detailsABC, @abstractmethod
Inheritance → build a hierarchy: UzCardGateway(PaymentGateway) Polymorphism → single interface: gateway.process_payment() Encapsulation → protect the data: self.__pin_hash, @property

Class level

Instance attribute vs class attribute

An instance attribute is created via self.field separately for each object. A class attribute is declared in the class body - one copy for all instances.

class NationalBankCreditProduct: base_interest_rate = 0.20 # class attribute - shared by all def __init__(self, client_name: str, loan_amount: float): self.client_name = client_name # instance attribute self.loan_amount = loan_amount contract1 = NationalBankCreditProduct("Timur", 50_000_000) contract2 = NationalBankCreditProduct("Elena", 90_000_000) print(f"Rate for Timur: {contract1.base_interest_rate * 100}%") print(f"Rate for Elena: {contract2.base_interest_rate * 100}%")
Rate for Timur: 20.0% Rate for Elena: 20.0%

Class level

Changing a class attribute affects everyone

print("--- Before changing the rate ---") print(f"Timur: {contract1.base_interest_rate * 100}%") print(f"Elena: {contract2.base_interest_rate * 100}%") NationalBankCreditProduct.base_interest_rate = 0.22 print("--- After changing the rate ---") print(f"Timur: {contract1.base_interest_rate * 100}%") print(f"Elena: {contract2.base_interest_rate * 100}%")
--- Before changing the rate --- Timur: 20.0% Elena: 20.0% --- After changing the rate --- Timur: 22.0% Elena: 22.0%

When changed through an instance (contract1.base_interest_rate = 0.25), Python will create a new attribute on that instance - the class attribute will remain unchanged.

Practice

Practice materials

Two Jupyter notebooks for Part 1 of the lecture.

↓ Tasks and examples ↓ Practice snippets

Open in Jupyter Notebook, JupyterLab or VS Code.

RU UZ EN
Python · Lecture 7 · Part 1
1 / 64