← Computer Programming II

Introduction

Encapsulation is the principle of hiding the internal details of an object and exposing only what is necessary. Consider a smartphone: the user interacts with the screen, but the complex circuitry inside remains hidden. In object-oriented programming, encapsulation means bundling data and methods together inside a class while controlling access to them.

The Problem: Uncontrolled Access

Consider the following class definition:

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

An instance of this class can be created and its attributes modified freely:

s = Student("Alisher", 19)
s.age = -5

This code executes without any errors, despite the fact that an age of -5 is logically invalid. This illustrates the core problem: when attributes are openly accessible, there is no mechanism to prevent invalid data from being assigned.

This is precisely why encapsulation is needed. The principle is straightforward: external code should not modify an object’s data directly. Instead, access should be mediated through methods that can enforce rules — for example, ensuring that an age value cannot be negative.

Access Control in Python

Different programming languages handle access control differently. Languages such as Java and C++ provide strict keywords — public, private, and protected — that enforce access restrictions at the language level.

Python takes a more relaxed approach. Python’s approach is based on convention over enforcement, relying on developers to follow established standards rather than technical restrictions. Code that violates these conventions will still execute, but doing so is considered poor practice.

Examples of Python conventions include:

  • Using snake_case for variable and function names
  • Using PascalCase for class names

Instead of strict access modifiers, Python uses naming conventions to indicate the intended access level of attributes.

Public Attributes

By default, all attributes in Python are public. When an attribute is defined as self.name = name, it can be freely read, written, and modified from anywhere:

s.name = 'Sevara'

For many attributes, public access is entirely appropriate — not every piece of data requires protection. However, certain data — such as a bank balance or a password — should be shielded from unrestricted access.

Protected Attributes

Prefixing an attribute name with a single underscore (_) signals that the attribute is intended for internal use:

class Student:
    def __init__(self, name, age):
        self.name = name
        self._age = age  # protected by convention

Attempting to access this attribute from outside the class still works:

print(s._age)

Python does not enforce any restriction here. The single underscore is purely a convention, not a language-enforced rule. When a developer encounters _age in someone else’s code, it indicates that the class author did not intend for this attribute to be accessed externally. The author may change or remove it at any time without notice, since external use was never expected.

In short: Python will not prevent access to a protected attribute, but other developers will expect this convention to be respected.

Private Attributes

Private attributes are denoted by a double underscore (__) prefix. While similar to protected attributes in intent, they carry a stronger message: this attribute should not be accessed outside the class.

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

Attempting to access the attribute directly raises an error:

print(s.__gpa)
AttributeError: 'Student' object has no attribute '__gpa'

However, the attribute is not truly invisible. Python applies a mechanism called name mangling: the attribute __gpa is internally renamed to _Student__gpa. It can therefore still be accessed:

s._Student__gpa

The purpose of name mangling is not to provide secrecy. Its primary function is to prevent accidental attribute name collisions in inheritance hierarchies — a topic covered in Week 4. For now, the key takeaway is that double underscores make an attribute harder, but not impossible, to reach from outside the class.

Summary of Access Levels

Syntax Access Level Behavior
self.name Public Freely accessible from anywhere
self._name Protected Accessible, but conventionally treated as internal
self.__name Private Name-mangled; accessible via _ClassName__name, but strongly discouraged

Python’s access control is fundamentally based on trust and convention.

Getters and Setters

To achieve real control over attribute access — where data is validated before being stored — getter and setter methods are used.

The Traditional Approach

In languages like Java, getters and setters are written as explicit methods:

class Student:
    def __init__(self, name, gpa):
        self._name = name
        self._gpa = gpa

    def get_gpa(self):
        return self._gpa

    def set_gpa(self, value):
        if value < 0 or value > 4.0:
            raise ValueError("GPA must be between 0.0 and 4.0")
        self._gpa = value

These methods are then called explicitly:

s = Student("Alisher", 3.5)
print(s.get_gpa())     # 3.5
s.set_gpa(3.9)         # works
s.set_gpa(5.0)         # ValueError!

This approach provides validation (validation refers to verifying that data is correct before using it), but the syntax is cumbersome. In Python, the preferred approach is to access attributes using natural syntax — s.gpa for reading and s.gpa = 3.9 for writing — while still having validation occur behind the scenes.

This is the purpose of the @property decorator.

The @property Decorator

Brief Introduction to Decorators

A decorator is a construct that modifies the behavior of a function or method without changing its core logic. An analogy: consider a function as a smartphone and a decorator as a phone case. The phone still works the same way, but the case adds additional features — such as protection or a different appearance.

For example, given a simple function:

def say_hello():
	print('hi alisher')

A decorator can be created to announce when the function starts and ends:

def announcer(func):
	def wrapper():
		print('function is starting')
		func()
		print('function is ending')
	return wrapper

The decorator is applied using the @ syntax:

@announcer
def say_hello():
	print('hi alisher')

A detailed study of decorators is covered in Week 6. For the purposes of this week, @property should be understood as a special decorator that transforms a method into something that behaves like an attribute.

Defining a Getter with @property

To control how an attribute is read, the data is stored in a protected attribute (e.g., self._gpa), and a property is defined to return it:

class Student:
    def __init__(self, name, gpa):
        self._name = name
        self._gpa = gpa

    @property
    def gpa(self):
        return self._gpa

The @property decorator turns the gpa method into a getter. When s.gpa is accessed, Python does not look for an attribute called gpa — it calls this method instead. Notably, no parentheses are required, even though a method is being invoked.

At this point, gpa is read-only. Attempting to assign a value raises an error:

s.gpa = 4.0  # AttributeError

Defining a Setter

To allow controlled modification, a setter is defined using the @<property_name>.setter decorator:

class Student:
    def __init__(self, name, gpa):
        self._name = name
        self._gpa = gpa

    @property
    def gpa(self):
        return self._gpa

    @gpa.setter
    def gpa(self, value):
        if value < 0 or value > 4.0:
            raise ValueError("GPA must be between 0.0 and 4.0")
        self._gpa = value

Now, when the assignment s.gpa = 3.9 is executed, Python calls the setter method with value = 3.9. The setter validates the value and, if acceptable, stores it in self._gpa. If the value is invalid, an error is raised:

s.gpa = 3.9        # Valid — stored successfully
s.gpa = 5.0        # ValueError: GPA must be between 0.0 and 4.0

From the outside, it appears as though a normal attribute is being read and written. Behind the scenes, however, Python is invoking the getter and setter methods.

The __init__ Trick

An important consideration arises during initialization. If the __init__ method stores the value directly into the protected attribute, validation is bypassed:

s = Student("Jasur", 5.0)  # No error! 5.0 is stored without validation.

The solution is to assign through the property (using self.gpa = gpa instead of self._gpa = gpa) within __init__. When Python encounters self.gpa = ..., it checks whether a property with that name exists. If so, the setter is called:

s = Student("Jasur", 5.0)  # ValueError — validation is triggered during initialization

In this way, the setter handles both attribute creation and validation, ensuring data integrity from the moment an object is created.

Example: Product Class

The following example demonstrates a class with multiple properties. The Product class accepts a name, price, and an optional discount:

class Product:
    def __init__(self, name, price, discount=0):
        self._name = name
        self.price = price        # goes through setter
        self.discount = discount  # goes through setter

The price property ensures that the price is non-negative:

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if value < 0:
            raise ValueError("Price cannot be negative")
        self._price = value

The discount property ensures the discount is between 0 and 100 (inclusive):

    @property
    def discount(self):
        return self._discount

    @discount.setter
    def discount(self, value):
        if value < 0 or value > 100:
            raise ValueError("Discount must be between 0 and 100")
        self._discount = value

A computed read-only property, final_price, calculates the price after applying the discount. Note that properties can exist without a corresponding stored attribute — they may be purely computed:

    @property
    def final_price(self):
        discount_amount = self._price * self._discount / 100
		return self._price - discount_amount

Since no setter is defined for final_price, it is read-only.

Testing the class:

p = Product("Laptop", 1000, 10)
print(p.price)        # 1000
print(p.discount)     # 10
print(p.final_price)  # 900.0

p.price = -500        # ValueError: Price cannot be negative
p.discount = 150      # ValueError: Discount must be between 0 and 100

Read-Only Properties

Read-only properties are particularly useful for computed values that should not be set manually. Consider a Circle class where the area is derived from the radius:

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def area(self):
        return 3.14159 * self._radius ** 2
c = Circle(5)
print(c.area)    # 78.53975
c.area = 100     # AttributeError: property 'area' has no setter

Setting the area manually would be logically incorrect — the area is a function of the radius and should always be calculated from it.

The Deleter

In addition to getters and setters, Python properties also support deleters. The del keyword in Python can be used to remove variables, list items, dictionary keys, and object attributes.

A deleter can be defined using the @<property_name>.deleter decorator to intercept attribute deletion:

@gpa.deleter
def gpa(self):
    print("GPA has been deleted")
    del self._gpa

When del s.gpa is executed, the deleter method runs — in this case, printing a message and then removing the underlying _gpa attribute. Attempting to delete it a second time raises an AttributeError, since self._gpa no longer exists.

Comprehensive Example: BankAccount Class

The following example integrates all of the concepts covered in this week:

class BankAccount:
    _bank_name = "Al-Khwarizmi Bank"  # protected class variable

    def __init__(self, owner, balance=0):
        self._owner = owner
        self.balance = balance  # goes through setter
        self.__pin = 1234       # private — name-mangled

    @property
    def balance(self):
        return self._balance

    @balance.setter
    def balance(self, value):
        if value < 0:
            raise ValueError("Balance cannot be negative")
        self._balance = value

    @property
    def owner(self):
        return self._owner

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit must be positive")
        self.balance += amount  # triggers the setter? No — += reads and writes
                                # but since new value = old + positive, it's fine

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("Withdrawal must be positive")
        if amount > self._balance:
            raise ValueError("Insufficient funds")
        self._balance -= amount  # bypassing setter intentionally (we already validated)

Testing the class:

acc = BankAccount("Nodira", 1000)

# Test basic state
print(acc.owner)    # Nodira
print(acc.balance)  # 1000

# Test deposit
acc.deposit(500)
print(acc.balance)  # 1500

# Test withdraw
acc.withdraw(200)
print(acc.balance)  # 1300

# Test validation
try:
    acc.balance = -100
except ValueError:
    print("Caught: negative balance")

# Test privacy
try:
    print(acc.__pin)
except AttributeError:
    print("Caught: can't access private attribute")

# But name mangling still works (don't do this in real code!)
print(acc._BankAccount__pin)  # 1234

This class demonstrates protected attributes (_owner, _balance), a private attribute (__pin with name mangling), property-based validation on balance, a read-only property for owner, and methods that interact with properties appropriately.

When to Use Properties

Not every attribute needs to be a property. The following guidelines help determine when properties are appropriate:

Use simple public attributes when:

  • No validation is required — the value is simply stored and returned.
  • No computation is involved.
  • Example: a student’s name (self.name = name) typically requires no validation or computation.

Use properties when:

  • Data validation is needed (e.g., GPA must be between 0.0 and 4.0).
  • Computation is required (e.g., a final price that applies taxes and discounts).
  • Read-only access is desired.
  • Internal implementation may change without affecting external code.

A key advantage of Python’s property system is that it allows developers to start with simple public attributes and introduce properties later without changing the external interface. For example, code that initially uses self.gpa = gpa can later be refactored to use a property with a setter, while all external code (s.gpa = 3.5) remains unchanged.

Docstrings

When hovering over built-in functions such as print() or len() in an IDE, documentation is displayed. This documentation is generated from docstrings — string literals placed as the first statement inside a class, method, or function.

Unlike comments (# ...), which are informal notes ignored by the interpreter, docstrings are stored by Python as part of the object’s metadata. They are accessible via the help() function and are displayed by IDEs.

A docstring is written using triple quotes ("""...""") and placed immediately after the definition:

class Product:
    """A product with a price and optional discount.

    Attributes:
        name: The product name.
        price: The price in dollars (must be non-negative).
        discount: Discount percentage (0-100).
    """

    def __init__(self, name, price, discount=0):
        """Initialize a Product.

        Args:
            name: The product name.
            price: The price in dollars.
            discount: The discount percentage (default 0).

        Raises:
            ValueError: If price is negative or discount is out of range.
        """
        self._name = name
        self.price = price
        self.discount = discount

Calling help(Product) displays this documentation in the console. The format describes what the class does, what arguments it expects, and what exceptions it may raise.

In summary:

  • # comments are notes for developers reading the source code.
  • """docstrings""" are documentation for anyone using the code.

Type Hints

Type hints provide a way to annotate the expected types of variables, function parameters, and return values. They serve two primary purposes: improving code readability and enabling IDE features such as autocompletion.

The Problem

Consider a function that accepts a parameter without any type information:

def get_first(items):
    items.  # No autocompletion — the IDE cannot determine the type.

The IDE cannot provide method suggestions because it has no way of knowing that items is intended to be a list.

Parameter Type Hints

A type hint is added after a parameter name, separated by a colon:

def get_first_string(items: list):
    items.  # The IDE now suggests list methods (append, sort, pop, etc.)

Return Type Hints

The return type is specified using an arrow (->) before the colon:

def get_first(items: list) -> str:
    return items[1]

a = ['1,2,3', 'asd']
b = get_first(a)
b.

With the return type annotated, the IDE can provide string method suggestions for b.

Type Hints in Classes

Type hints can be applied throughout a class definition:

class Product:
    """A product with a price and optional discount."""

    def __init__(self, name: str, price: float, discount: float = 0) -> None:
        # name: str       — means "name should be a string"
        # price: float    — means "price should be a number"
        # discount: float = 0  — means "discount should be a number, default is 0"
        # -> None          — means "this method returns nothing"
        self._name = name
        self.price = price
        self.discount = discount

    @property
    def price(self) -> float:
        return self._price

    @price.setter
    def price(self, value: float) -> None:
        if value < 0:
            raise ValueError("Price cannot be negative")
        self._price = value

    @property
    def final_price(self) -> float:
        return self._price * (1 - self._discount / 100)

Type Hints for Variables

Type hints may also be applied to simple variable declarations:

age: int = 20           # age is an integer
name: str = "Ali"       # name is a string
price: float = 9.99     # price is a float (decimal number)
is_active: bool = True  # is_active is a boolean (True or False)

Important Note

Type hints are not enforced at runtime. The following code executes without error:

age: int = "Ali"
name: str = 20
price: float = [1,2,3]
is_active: bool = 1.3

Python does not raise any exceptions, because type hints are exactly that — hints, not rules. They exist for the benefit of developers and tools (such as IDEs and static type checkers), not for the interpreter itself.