This is a two-part blog series on Encapsulation in Python:
It's recommended to go through the Basics first if you haven't already.
@property decorator to define methods that can be accessed like normal attributesit 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_balancekamesh_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
kamesh_acc.get_balance() instead of kamesh_acc.balance)@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.
@PropertyThis 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._emailuser1 = User("kamesh")user1 = User("kamesh@gmail.com")
user1.email'kamesh@gmail.com'
user1.email = "kameshdubey@gmail.com"user1.email'kameshdubey@gmail.com'
del user1.emailprint(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'
@property
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.
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.
A read-only attribute is one that:
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.
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
A write-enabled attribute is one that:
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'
_ 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:
user.name instead of user.get_name() That is,
_and__are like "Please don't touch this"@propertyis like "You really can't touch this unless I say so"