Encapsulation Hands-On: Advanced

kameshcodes

This is a two-part blog series on Encapsulation in Python:

  1. Encapsulation Hands-on: Basics
  2. Encapsulation Hands-on: Advanced

It's recommended to go through the Basics first if you haven't already.



In python, we can manage how data inside the classes is accessed or modified by:\text{In python, we can manage how data inside the classes is accessed or modified by:}

  • Defining getter and setter methods manually
  • Using the @property decorator to define methods that can be accessed like normal attributes
  • Adding validation logic to ensure the data remains valid and consistent
  • Defining whether an attribute should be read-only or allow changes

1. Manual Getter and Setters


it is common practice in many object-oriented languages like C++, Java, etc where we define getter and setter functions to access or update the private and protected variables, ensuring encapsulation and data validation.

class Balance:
  def __init__(self, name, balance=0):
    self.name = name
    self.__balance = balance

  def get_balance(self):
    return self.__balance

  def set_balance(self, new_balance):
    if new_balance < 0:
      raise ValueError("New Balance can't be negative.")
    elif not isinstance(new_balance, (int, float)):
      raise TypeError("New Balance must be number")
    self.__balance = new_balance
kamesh_acc = Balance("Kamesh Dubey")
kamesh_acc.get_balance()
0
kamesh_acc.set_balance(-5)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
/tmp/ipython-input-4-2890290163.py in <cell line: 0>()
----> 1 kamesh_acc.set_balance(-5)

/tmp/ipython-input-1-1916304541.py in set_balance(self, new_balance)
      9   def set_balance(self, new_balance):
     10     if new_balance < 0:
---> 11       raise ValueError("New Balance can't be negative.")
     12     elif not isinstance(new_balance, (int, float)):
     13       raise TypeError("New Balance must be number")

ValueError: New Balance can't be negative.
kamesh_acc.set_balance('10')
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
/tmp/ipython-input-5-1251207161.py in <cell line: 0>()
----> 1 kamesh_acc.set_balance('10')

/tmp/ipython-input-1-1916304541.py in set_balance(self, new_balance)
      8 
      9   def set_balance(self, new_balance):
---> 10     if new_balance < 0:
     11       raise ValueError("New Balance can't be negative.")
     12     elif not isinstance(new_balance, (int, float)):

TypeError: '<' not supported between instances of 'str' and 'int'
kamesh_acc.set_balance(100000000)
kamesh_acc.get_balance()
100000000
kamesh_acc.set_balance(100000000.5)
kamesh_acc.get_balance()
100000000.5

Pros:\text{Pros:}

  • Explicit control over how the data is accessed and modified.
  • Easy to add validation logic inside set_balance.

Cons:\text{Cons:}

  • Using methods for what feels like attribute access (kamesh_acc.get_balance() instead of kamesh_acc.balance)

2. Decorators

@Property, @<name>.setter and @<name>.deleter


These are part of python's descriptor protocol, and they allow you to encapsulate access, mutation, and deletion of attributes in a class.

2.1 @Property

This decorator allows you to define a method that can be accessed like an attribute. In other words, it lets you treat a method as if it were a regular variable.

class User:
  def __init__(self, email):
    self._email = email

  @property
  def email(self):
    return self._email

  @email.setter
  def email(self, new_email):
    if '@' in new_email:
      self._email = new_email
    else:
      raise ValueError("Email not Valid.")

  @email.deleter
  def email(self):
    del self._email
user1 = User("kamesh")
user1 = User("kamesh@gmail.com")

Getter\textbf{Getter}

user1.email
'kamesh@gmail.com'

Setter\textbf{Setter}

user1.email = "kameshdubey@gmail.com"
user1.email
'kameshdubey@gmail.com'

Deleter\textbf{Deleter}

del user1.email
print(user1.email)
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
/tmp/ipython-input-17-1296906016.py in <cell line: 0>()
----> 1 print(user1.email)

/tmp/ipython-input-10-3027672222.py in email(self)
      5   @property
      6   def email(self):
----> 7     return self._email
      8 
      9   @email.setter

AttributeError: 'User' object has no attribute '_email'

2.2 Time to Disect\text{2.2 Time to Disect}

What is going under the hood when using\text{What is going under the hood when using} @property decorator?\text{decorator?}

  • The magic behind @property is a core python feature called Descriptor Protocol

    A "descriptor" is simply any object that defines `one or more` of these special methods:
    
      __get__(self, instance, owner): Defines what happens when you read/access the attribute.
      __set__(self, instance, value): Defines what happens when you write/assign to the attribute.
      __delete__(self, instance): Defines what happens when you del the attribute.
    

The @property decorator is nothing more than a simple and readable way to create a descriptor object and bind it to your class.

2.3 Let's trace the flow step by step:



class User:
  def __init__(self, email):
    self._email = email

  @property
  def email(self):
    return self._email

  @email.setter
  def email(self, new_email):
    if '@' in new_email:
      self._email = new_email
    else:
      raise ValueError("Email not Valid.")

  @email.deleter
  def email(self):
    del self._email

user1 = User("kamesh@gmail.com")
print(user.email)
del user.email

The getter execution


When you run
            user1 = User("kamesh@gmail.com"),

=> the __init__ method executes self.email = "kamesh@gmail.com"
=> Python detects that email is a `descriptor`.
=> Instead of just creating an attribute, it calls the descriptor's __set__ method, which is your @email.setter code.
   The validation runs, and if passes the value is stored in self._email.

When you run
            print(user.email)

=> Python again sees the email is a descriptor and calls its __get__ method—which is @property code.
=> It fetches the value from the internal self._email and returns it.

The setter execution


when you run
            user1.email = "kamesh@gmail.com"

=> Python doesn’t treat it like a simple attribute assignment.
=> Instead, it notices that email is a property and automatically calls the @email.setter method.
=> This method receives the new value ("kamesh@gmail.com") and checks whether it contains an '@'.
=> If the validation passes, it updates the internal _email attribute; otherwise, it raises a ValueError

The deleter execution


when you do
            del user1.email

=> Python calls the @email.deleter method, which deletes internal _email variable, removing the stored email from the object.

The @property decorator in Python lets you use methods like attributes. It gives you control over how data is accessed, modified, or deleted inside a class.

3. Attribution Access


3.1 Read-only Attribute?\text{3.1 Read-only Attribute?}

A read-only attribute is one that:

  • You can read its value (via a getter)
  • But you cannot change it (no setter)

Note: It allow you to write once, when creating object, but does not let you change afterwards.

class Voter:
  def __init__(self, name, id, address):
    self._name = name
    self._id = id
    self._address = address

  @property
  def name(self):
    return self._name

  @property
  def id(self):
    return self._id

  @property
  def address(self):
    return self._address
voter1 = Voter("Kamesh", 'XYZ123', 'Mumbai')

print(voter1.name)
print(voter1.id)
Kamesh
XYZ123
voter1.name = 'Arun'
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
/tmp/ipython-input-20-1786042385.py in <cell line: 0>()
----> 1 voter1.name = 'Arun'

AttributeError: property 'name' of 'Voter' object has no setter

In the Voter class, the name is set up as a read-only attribute. This means you can look at it using voter1.name, but you can't change it later, because there's no @name.setter defined. Python raises an AttributeError saying you can't assign to a read-only property.


Similary, id is also read only\textbf{Similary, id is also read only}

voter1.address = 'Haridwar'
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
/tmp/ipython-input-21-4185655236.py in <cell line: 0>()
----> 1 voter1.address = 'Haridwar'

AttributeError: property 'address' of 'Voter' object has no setter

3.2 Write-enabled Attribute\text{3.2 Write-enabled Attribute}

A write-enabled attribute is one that:

  • Has a getter (via @property)
  • And a setter (via @.setter)

You can both read and update its value.

class Voter:
  def __init__(self, name, id, address):
    self._name = name
    self._id = id
    self._address = address

  @property
  def name(self):
    return self._name

  @property
  def id(self):
    return self._id

  @property
  def address(self):
    return self._address

  @address.setter
  def address(self, new_address):
    if isinstance(new_address, str):
      self._address =  new_address
    else:
      raise ValueError("Enter a Valid Address")
voter1 = Voter("Kamesh", 'XYZ123', 'Haridwar')
voter1.id = 'abc123'
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
/tmp/ipython-input-24-2757810351.py in <cell line: 0>()
----> 1 voter1.id = 'abc123'

AttributeError: property 'id' of 'Voter' object has no setter

The id is read-only because you probably don't want users changing their own unique ID, it's meant to stay fixed. Same with name, once it's set, it usually doesn't change. But something like address is more flexible; people move, and you might want to let them update it. That's where a setter comes in, it gives you the control to allow changes only where they make sense.

voter1.address = 'Mumbai'
voter1.address
'Mumbai'

4. Why Use Properties When We Have _ and __?

Using _ (single underscore) or __ (double underscore) in Python is just a convention to signal that a variable is intended to be private. But it doesn't actually stop anyone from accessing or modifying it (you can access __ with name mangling).


Whereas @property is more powerful because it gives you real control. It lets you:

  • Control access to an attribute of the class
  • Make attributes read-only (just use a getter only)
  • Add validation checks before setting a value (via setter)
  • Keep clean syntax — use user.name instead of user.get_name()

That is,

_ and __ are like "Please don't touch this"
@property is like "You really can't touch this unless I say so"

Made with REPL Notes Build your own website in minutes with Jupyter notebooks.