In the previous lessons, you learned how Python classes and objects work, how constructors initialize objects, and how instance, class, and static methods provide different types of behavior. Now we move to one of the important principles of Object-Oriented Programming: Encapsulation.
Encapsulation is one of the four commonly discussed pillars of OOP, along with Inheritance, Polymorphism, and Abstraction. It is important because it helps us organize data and the operations that work on that data inside a class. :contentReference[oaicite:0]{index=0}
In simple terms, encapsulation means keeping related data and behavior together and controlling how that data is accessed or modified.
Consider a bank account. A bank account has data such as the account holder and balance. It also has operations such as depositing money, withdrawing money, and checking the balance.
class BankAccount:
def __init__(self, balance):
self.balance = balance
def deposit(self, amount):
self.balance += amount
def withdraw(self, amount):
self.balance -= amount
Here, the balance is the data, while deposit() and withdraw() are behaviors that operate on that data.
Conceptually, we can represent the class like this:
BankAccount
|
├── Data
│ └── balance
|
└── Behavior
├── deposit()
└── withdraw()
This relationship between data and behavior is the foundation of encapsulation. :contentReference[oaicite:1]{index=1}
Imagine a large application where different parts of the program can directly modify important data whenever they want.
For example:
account.balance = -50000
If the program represents a real bank account, allowing any part of the application to freely assign an invalid balance could create serious problems.
Instead, we can put rules inside methods that control how the balance changes.
class BankAccount:
def __init__(self, balance):
self.balance = balance
def deposit(self, amount):
if amount > 0:
self.balance += amount
def withdraw(self, amount):
if 0 < amount <= self.balance:
self.balance -= amount
Now the class contains the basic rules for deposits and withdrawals.
A valid deposit can be performed:
account1 = BankAccount(50000)
account1.deposit(10000)
print(account1.balance)
Output:
60000
But a negative deposit will not satisfy the validation condition.
account1.deposit(-5000)
The method prevents the invalid operation.
This is an important practical purpose of encapsulation: instead of allowing important data to be changed without rules, the class can provide controlled operations for working with that data. :contentReference[oaicite:2]{index=2}
This point is particularly important for Python beginners.
In some programming languages, access modifiers can strictly control whether a member is public, protected, or private. Python takes a more flexible approach.
Python uses naming conventions and language mechanisms to communicate and control access to members. It does not automatically make every attribute completely inaccessible from outside the class. :contentReference[oaicite:3]{index=3}
Python commonly uses three naming styles when discussing access:
name_name__nameThese three forms will become especially important when designing classes and deciding how much internal implementation should be exposed.
A normal attribute is considered public by convention.
class Student:
def __init__(self, name, marks):
self.name = name
self.marks = marks
Here:
self.name
self.marks
are public instance variables.
They can be accessed directly:
student1 = Student("Rahul", 85)
print(student1.name)
print(student1.marks)
Output:
Rahul
85
They can also be changed directly:
student1.marks = 90
Now:
print(student1.marks)
produces:
90
Public attributes are appropriate when direct access is acceptable and there is no need to hide the implementation detail.
Python commonly uses a single leading underscore to communicate that a member is intended for internal or subclass use.
class Employee:
def __init__(self, name, salary):
self.name = name
self._salary = salary
Here:
self._salary
is commonly described as a protected-style member.
The underscore communicates an intention:
_salary
essentially tells other programmers:
“This is an internal implementation detail; use it with care.”
For example:
employee1 = Employee("Rahul", 50000)
print(employee1._salary)
Python does not normally prevent this access simply because the name begins with one underscore.
This is a key difference between Python’s convention-based approach and strict access-control systems found in some other languages.
A single underscore is mainly a communication convention.
Suppose a class contains:
self.name
self._salary
self._calculate_tax()
A programmer reading the code can understand that name is part of the normal public interface, while _salary and _calculate_tax() are intended to be internal implementation details.
This makes the class easier to understand and maintain.
However, a single underscore does not create a strict private barrier.
Python also supports names beginning with two underscores.
class BankAccount:
def __init__(self, balance):
self.__balance = balance
Here:
__balance
is treated differently by Python because of a mechanism called name mangling.
Name mangling changes the internal name of the attribute so that it is less likely to be accidentally accessed or overridden from outside the class.
For example:
class BankAccount:
def __init__(self, balance):
self.__balance = balance
The programmer normally works with:
self.__balance
inside the class.
The details of name mangling and how double-underscore attributes behave will be covered in depth in the next part of this lesson.
One of the most useful ways to implement encapsulation is to provide methods that control how an object’s data is modified.
Consider this example:
class BankAccount:
def __init__(self, balance):
self.balance = balance
def deposit(self, amount):
if amount > 0:
self.balance += amount
def withdraw(self, amount):
if 0 < amount <= self.balance:
self.balance -= amount
def check_balance(self):
return self.balance
The user interacts with the account through meaningful operations:
account1.deposit(5000)
account1.withdraw(1000)
print(account1.check_balance())
The user does not need to know every internal step involved in processing these operations.
The class provides a simpler interface while the implementation remains inside the class. This idea is closely related to abstraction, but encapsulation focuses particularly on organizing and controlling the data and behavior. :contentReference[oaicite:4]{index=4}
Let’s apply the same idea to student marks.
class Student:
def __init__(self, name, marks):
self.name = name
self.marks = marks
def update_marks(self, new_marks):
if 0 <= new_marks <= 100:
self.marks = new_marks
else:
print("Invalid marks")
def display(self):
print("Name:", self.name)
print("Marks:", self.marks)
Create a student:
student1 = Student("Rahul", 85)
Display the information:
student1.display()
Output:
Name: Rahul
Marks: 85
Now update the marks correctly:
student1.update_marks(92)
The class accepts the value because it is within the valid range.
But:
student1.update_marks(150)
produces:
Invalid marks
The class therefore contains the rule governing valid marks.
This example illustrates the central idea behind encapsulation:
Student
|
├── Data
│ ├── name
│ └── marks
|
└── Behavior
├── update_marks()
└── display()
The data and the operations that work on that data are organized within the same class.
This becomes particularly useful as applications become larger. Instead of scattering student-related data and validation logic across many unrelated functions, the class provides a clear structure.
Encapsulation is not limited to examples such as banking or student management. It can also be useful in Data Analytics and Data Science applications.
Imagine a class representing a dataset:
class Dataset:
def __init__(self, data):
self.data = data
def row_count(self):
return len(self.data)
def display_summary(self):
print("Rows:", len(self.data))
The dataset is stored inside the object, while methods provide operations for working with it.
In larger Python libraries, you will frequently encounter objects that contain data and expose methods for processing that data. Understanding this object-oriented structure makes it easier to work with external Python libraries and frameworks. :contentReference[oaicite:5]{index=5}
A major benefit of encapsulation is that the class can define rules around important data.
For example, consider product stock:
class Product:
def __init__(self, name, stock):
self.name = name
self.stock = stock
def add_stock(self, quantity):
if quantity > 0:
self.stock += quantity
def sell(self, quantity):
if 0 < quantity <= self.stock:
self.stock -= quantity
Instead of simply changing stock without rules, the class provides controlled operations:
product1.add_stock(10)
product1.sell(3)
The class can therefore protect the consistency of the object’s state through its behavior.
Beginners often confuse encapsulation and abstraction because both involve managing complexity.
| Concept | Main Idea |
|---|---|
| Encapsulation | Organizes and controls data and behavior. |
| Abstraction | Hides unnecessary implementation details and exposes essential functionality. |
For example, encapsulation can organize a bank account’s balance and operations inside the BankAccount class and control how the balance is changed.
Abstraction is more concerned with giving the user a simple interface without requiring them to understand every internal implementation detail. :contentReference[oaicite:6]{index=6}
name._salary, communicates an internal or protected-style convention.__balance, activates Python’s name-mangling mechanism.The next part will focus specifically on private members and name mangling. You will see what happens when an attribute begins with __, how Python internally changes its name, and why this mechanism is different from strict private access found in some other programming languages.
In the previous part, you learned that Python supports different naming conventions for object members. Normal names are generally treated as public, a single leading underscore communicates an internal or protected-style member, and a double leading underscore activates Python’s name-mangling mechanism.
In this part, we will focus on double-underscore attributes and methods, understand how name mangling works, and see why Python’s approach to private members is different from strict access-control systems in some other programming languages.
When a name begins with two underscores, Python applies name mangling to that name.
For example:
class BankAccount:
def __init__(self, balance):
self.__balance = balance
Here, __balance is commonly described as a private-style attribute.
Inside the class, we can use it normally:
class BankAccount:
def __init__(self, balance):
self.__balance = balance
def show_balance(self):
print(self.__balance)
Create an object:
account1 = BankAccount(50000)
Call the method:
account1.show_balance()
Output:
50000
Inside the class, the attribute is accessed using:
self.__balance
The important question is what Python does internally with this name.
Name mangling is a Python mechanism that changes the internal name of an attribute or method beginning with two underscores.
Suppose we write:
class BankAccount:
def __init__(self, balance):
self.__balance = balance
Python internally transforms the name into a form associated with the class.
Conceptually:
__balance
↓
_BankAccount__balance
Therefore, the name stored internally is different from the name we normally write inside the class.
This mechanism helps prevent accidental name conflicts, especially when classes are extended through inheritance.
It is important to understand that name mangling is not the same thing as absolute security or encryption. Python does not make the data mathematically inaccessible. Instead, it changes the attribute’s name to reduce accidental access and naming conflicts.
Consider:
class Employee:
def __init__(self, salary):
self.__salary = salary
Create an object:
employee1 = Employee(50000)
If we try:
print(employee1.__salary)
Python will not find an attribute with that exact external name in the normal way.
However, because of name mangling, the internal name is conceptually:
_Employee__salary
This illustrates why the double underscore is different from a single underscore.
| Naming Style | Example | Meaning |
|---|---|---|
| Public | salary |
Normal public member |
| Protected-style | _salary |
Internal-use convention |
| Private-style | __salary |
Name mangling is applied |
A single underscore mainly communicates an intention to other programmers.
A double underscore causes Python to transform the internal name.
For example:
self._salary
remains essentially that name, while:
self.__salary
is internally associated with:
self._ClassName__salary
One important reason for name mangling is to avoid accidental conflicts between a parent class and a child class.
Consider a base class:
class Employee:
def __init__(self):
self.__id = 101
Now imagine another class inherits from it:
class Manager(Employee):
def __init__(self):
super().__init__()
self.__id = 202
Both classes use the name:
__id
But because Python applies name mangling based on the class name, they are internally associated with different names.
Conceptually:
Employee:
__id
↓
_Employee__id
Manager:
__id
↓
_Manager__id
This helps prevent accidental collisions between attributes defined in different classes.
Name mangling can also apply to methods beginning with two underscores.
class BankAccount:
def __validate_amount(self, amount):
return amount > 0
Here:
__validate_amount()
is a private-style method.
It can be called from another method inside the same class:
class BankAccount:
def __validate_amount(self, amount):
return amount > 0
def deposit(self, amount):
if self.__validate_amount(amount):
print("Valid deposit")
else:
print("Invalid deposit")
Create the object:
account1 = BankAccount()
Then:
account1.deposit(5000)
Output:
Valid deposit
The public-facing method is deposit(), while the validation logic is kept inside the private-style helper method.
A common pattern is to keep an internal value private-style and expose controlled methods for working with it.
class BankAccount:
def __init__(self, balance):
self.__balance = balance
def deposit(self, amount):
if amount > 0:
self.__balance += amount
def get_balance(self):
return self.__balance
Create the account:
account1 = BankAccount(50000)
Deposit money:
account1.deposit(10000)
Read the balance:
print(account1.get_balance())
Output:
60000
The important idea is that the class provides a controlled interface for working with its internal data.
You could write:
class BankAccount:
def __init__(self, balance):
self.balance = balance
and then allow:
account1.balance = -100000
But if the application has a rule that the balance should not become invalid, direct modification can bypass that rule.
Using an internal attribute with controlled methods gives the class more responsibility over its own state:
class BankAccount:
def __init__(self, balance):
self.__balance = balance
def deposit(self, amount):
if amount > 0:
self.__balance += amount
def withdraw(self, amount):
if 0 < amount <= self.__balance:
self.__balance -= amount
def get_balance(self):
return self.__balance
Now the class provides specific operations for changing the balance.
This is one of the most important points to remember.
Python’s double underscore does not create an absolutely inaccessible private variable.
Name mangling changes the attribute name internally. A programmer who understands the mechanism can still access the mangled name.
For example, an attribute written as:
self.__balance
inside BankAccount is associated with:
_BankAccount__balance
The purpose is therefore better understood as:
It should not be interpreted as a security mechanism for protecting secrets such as passwords, encryption keys, or sensitive credentials.
Name mangling becomes especially useful when inheritance is involved.
Consider:
class Parent:
def __init__(self):
self.__value = "Parent value"
def show_parent_value(self):
print(self.__value)
Now create a child class:
class Child(Parent):
def __init__(self):
super().__init__()
self.__value = "Child value"
The two __value attributes are not treated as the same internal name.
Conceptually:
Parent
↓
_Parent__value
Child
↓
_Child__value
This is one reason name mangling is useful in class hierarchies.
Let’s put all three naming styles into one class:
class Employee:
company = "ABC Technologies"
def __init__(self, name, salary, employee_id):
self.name = name
self._salary = salary
self.__employee_id = employee_id
def display(self):
print("Name:", self.name)
print("Salary:", self._salary)
def get_employee_id(self):
return self.__employee_id
Here:
self.name
is public.
self._salary
is protected-style.
self.__employee_id
is private-style and subject to name mangling.
The class can expose the employee ID through:
get_employee_id()
instead of requiring direct access to the internal attribute.
Suppose an employee ID should never be changed after the object is created.
If it is public:
employee1.employee_id = 999
the program can directly change it.
Instead, the class can keep the value internal:
self.__employee_id = employee_id
and expose only a getter:
def get_employee_id(self):
return self.__employee_id
Now users of the class can retrieve the ID without the class providing a public method for changing it.
This is a practical example of encapsulation through controlled access.
Private-style attributes become even more useful when combined with validation.
class Student:
def __init__(self, marks):
if 0 <= marks <= 100:
self.__marks = marks
else:
raise ValueError("Marks must be between 0 and 100")
def get_marks(self):
return self.__marks
Now:
student1 = Student(85)
print(student1.get_marks())
Output:
85
But:
student2 = Student(150)
will raise an error because the constructor validates the data before storing it.
This pattern becomes even more powerful when setters and the @property decorator are introduced. Those techniques will be covered in the next part.
Double-underscore attributes should not be added to every variable automatically.
They are useful when you specifically want name mangling and want to reduce accidental access or naming conflicts.
For example, a class may contain an internal implementation detail that should not normally be accessed directly:
class DataProcessor:
def __init__(self, data):
self.__raw_data = data
The class can expose meaningful operations instead:
def clean_data(self):
...
def get_summary(self):
...
This allows the class to define a clearer public interface while keeping certain implementation details internal.
__variable means the variable is completely inaccessible.__balance is internally associated with a name such as _BankAccount__balance.The next part will move from the idea of private members to getters, setters, and Python’s @property decorator. You will learn how to control reading and updating attributes while keeping the syntax simple and Pythonic.
In the previous parts, you learned how encapsulation helps organize data and behavior inside a class and how Python uses single and double underscores to communicate internal members. You also learned that a double underscore activates name mangling and can help prevent accidental access or naming conflicts.
Now we will look at a more practical technique for controlling access to object data: getters, setters, and the @property decorator.
These techniques are especially useful when an attribute needs validation, calculation, or some additional logic whenever its value is read or changed.
A getter is a method used to retrieve the value of an internal attribute.
Consider a student class:
class Student:
def __init__(self, marks):
self.__marks = marks
def get_marks(self):
return self.__marks
Here, __marks is a private-style attribute.
Instead of accessing it directly, we provide:
get_marks()
to retrieve its value.
Create an object:
student1 = Student(85)
Then:
print(student1.get_marks())
Output:
85
The getter provides controlled read access to the internal value.
A setter is a method used to update an internal attribute, usually after performing validation or other logic.
For example:
class Student:
def __init__(self, marks):
self.__marks = marks
def get_marks(self):
return self.__marks
def set_marks(self, marks):
if 0 <= marks <= 100:
self.__marks = marks
else:
print("Invalid marks")
Now create a student:
student1 = Student(85)
Read the value:
print(student1.get_marks())
Output:
85
Update it with a valid value:
student1.set_marks(92)
Now:
print(student1.get_marks())
produces:
92
But if we try:
student1.set_marks(150)
the validation rejects the value.
This gives the class control over how its internal data is modified.
At first, getters and setters may seem unnecessary.
Instead of:
student1.get_marks()
why not simply write:
student1.marks
And instead of:
student1.set_marks(92)
why not write:
student1.marks = 92
The answer is control.
If an attribute can be assigned directly, any value may potentially be stored:
student1.marks = 500
But a setter can enforce a rule:
if 0 <= marks <= 100:
self.__marks = marks
This means the class can protect the consistency of its internal state.
Getters and setters are therefore useful when reading or changing a value requires additional logic.
Python provides a cleaner and more Pythonic approach through the @property decorator.
Instead of calling:
student1.get_marks()
we can make the getter behave like an attribute:
student1.marks
Consider:
class Student:
def __init__(self, marks):
self.__marks = marks
@property
def marks(self):
return self.__marks
Now create the object:
student1 = Student(85)
We can access the value using:
print(student1.marks)
Output:
85
Notice that we did not write:
student1.marks()
We simply wrote:
student1.marks
The @property decorator allows a method to be accessed using attribute-style syntax.
The real power of @property becomes clear when we combine it with a setter.
class Student:
def __init__(self, marks):
self.__marks = marks
@property
def marks(self):
return self.__marks
@marks.setter
def marks(self, value):
if 0 <= value <= 100:
self.__marks = value
else:
raise ValueError("Marks must be between 0 and 100")
Now we can read the marks naturally:
student1 = Student(85)
print(student1.marks)
Output:
85
And we can update the marks using normal assignment:
student1.marks = 92
The setter automatically runs.
We do not need to write:
student1.set_marks(92)
Instead, Python executes the property setter behind the scenes.
Consider this code:
class Student:
def __init__(self, marks):
self.__marks = marks
@property
def marks(self):
return self.__marks
The method:
def marks(self):
becomes accessible like an attribute because of:
@property
Therefore:
student1.marks
causes Python to execute the property method.
Similarly, when we define:
@marks.setter
def marks(self, value):
...
an assignment such as:
student1.marks = 95
causes the setter to execute.
The overall flow is:
student1.marks
↓
@property getter
↓
return self.__marks
student1.marks = 95
↓
@marks.setter
↓
validation
↓
self.__marks = 95
One of the most common reasons to use properties is validation.
Consider an employee salary:
class Employee:
def __init__(self, salary):
self.salary = salary
@property
def salary(self):
return self.__salary
@salary.setter
def salary(self, value):
if value < 0:
raise ValueError("Salary cannot be negative")
self.__salary = value
Create an employee:
employee1 = Employee(50000)
Read the salary:
print(employee1.salary)
Output:
50000
Now update it:
employee1.salary = 60000
This works because the value is valid.
But:
employee1.salary = -5000
raises:
ValueError: Salary cannot be negative
The property therefore provides a clean interface while keeping validation inside the class.
A property does not necessarily need a setter.
This allows us to create a read-only property.
For example:
class Employee:
def __init__(self, name, monthly_salary):
self.name = name
self.monthly_salary = monthly_salary
@property
def annual_salary(self):
return self.monthly_salary * 12
Now:
employee1 = Employee("Rahul", 50000)
print(employee1.annual_salary)
Output:
600000
There is no setter for annual_salary.
It is calculated from the monthly salary whenever it is requested.
This means:
employee1.annual_salary = 700000
is not a normal supported operation because the property does not define how such an assignment should be handled.
Properties are also useful when a value should be calculated rather than stored independently.
Consider a rectangle:
class Rectangle:
def __init__(self, length, width):
self.length = length
self.width = width
@property
def area(self):
return self.length * self.width
Create an object:
rectangle1 = Rectangle(10, 5)
Access the area:
print(rectangle1.area)
Output:
50
There is no need to write:
rectangle1.area()
because area behaves like a calculated attribute.
This makes the interface intuitive.
Another useful example is temperature conversion.
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@property
def fahrenheit(self):
return (self.celsius * 9 / 5) + 32
Create the object:
temperature1 = Temperature(25)
Now:
print(temperature1.fahrenheit)
Output:
77.0
The Fahrenheit value is calculated when accessed.
This demonstrates an important property pattern:
Stored Data
↓
Calculation
↓
@property
↓
Looks Like an Attribute
| Traditional Approach | Property Approach |
|---|---|
get_marks() |
marks |
set_marks(90) |
marks = 90 |
| Explicit method calls | Attribute-style syntax |
| Useful and valid | Often more Pythonic |
Traditional getters and setters are still valid:
student1.get_marks()
student1.set_marks(90)
But properties allow a cleaner interface:
student1.marks
student1.marks = 90
Internally, Python can still execute methods to control these operations.
Consider:
class Product:
def __init__(self, price):
self.price = price
@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
When this runs:
product1 = Product(50000)
the constructor executes:
self.price = 50000
Because price has a setter, Python sends the value through:
@price.setter
The setter validates the value and then stores it internally:
self.__price = value
This pattern is useful because the same validation logic is used during initialization and later updates.
Consider:
product1.__price
The double underscore means that Python has applied name mangling.
Instead of exposing the internal representation directly, the class can provide:
product1.price
through the property.
The user of the class does not need to know that the value is internally stored as:
self.__price
This creates a clean separation between the class’s interface and its internal implementation.
The overall design can be visualized as:
Outside Code
↓
object.price
↓
@property / setter
↓
Validation / Logic
↓
__price
↓
Internal Object State
This is a practical implementation of encapsulation.
The outside code uses a simple interface, while the class controls what happens internally.
@property decorator allows a method to behave like an attribute.@property can be combined with @attribute.setter.You now understand the main tools used for encapsulation in Python: public members, protected-style members, private-style members, name mangling, getters, setters, and properties. The final part of this lesson will bring everything together with a practical project, a comparison table, common mistakes, exercises, interview questions, and a complete revision.
In this lesson, you learned how Python uses encapsulation to organize data and behavior inside classes and control how object data is accessed or modified. You also learned about public members, protected-style members, private-style members, name mangling, getters, setters, and the @property decorator.
Now let’s bring these concepts together in a practical Employee Management System. The goal is not only to write the code but to understand why each part of the class is designed in a particular way.
Suppose we want to create an employee object containing a name, employee ID, and salary.
We want the following rules:
We can implement these requirements using encapsulation and properties.
class Employee:
company = "ABC Technologies"
def __init__(self, employee_id, name, salary):
self.__employee_id = employee_id
self.name = name
self.salary = salary
@property
def employee_id(self):
return self.__employee_id
@property
def salary(self):
return self.__salary
@salary.setter
def salary(self, value):
if value < 0:
raise ValueError(
"Salary cannot be negative"
)
self.__salary = value
@property
def annual_salary(self):
return self.__salary * 12
def display(self):
print("Employee ID:", self.__employee_id)
print("Name:", self.name)
print("Monthly Salary:", self.__salary)
print("Annual Salary:", self.annual_salary)
Now create an employee:
employee1 = Employee(
101,
"Rahul",
50000
)
Display the employee:
employee1.display()
Output:
Employee ID: 101
Name: Rahul
Monthly Salary: 50000
Annual Salary: 600000
This small class demonstrates several encapsulation techniques together.
The employee ID is stored internally as:
self.__employee_id
This activates name mangling and communicates that the ID is an internal implementation detail.
The name is stored as:
self.name
because there is no special validation requirement in this example.
The salary is stored internally as:
self.__salary
but exposed through a property:
@property
def salary(self):
return self.__salary
When someone writes:
print(employee1.salary)
Python executes the property getter.
When someone writes:
employee1.salary = 60000
Python executes:
@salary.setter
and the value is validated before being stored.
Let’s update the salary with a valid value:
employee1.salary = 60000
print(employee1.salary)
Output:
60000
Now try an invalid value:
employee1.salary = -5000
The setter raises:
ValueError: Salary cannot be negative
The important point is that the validation happens automatically whenever the salary is assigned through the property.
This means the following two operations both pass through the same validation mechanism:
employee1 = Employee(101, "Rahul", 50000)
employee1.salary = 60000
The constructor uses:
self.salary = salary
and therefore the setter also validates the initial salary.
Notice that the class defines:
@property
def employee_id(self):
return self.__employee_id
but there is no:
@employee_id.setter
This means the property is intended to be read-only through the normal property interface.
We can read:
print(employee1.employee_id)
But the class does not provide a normal setter for:
employee1.employee_id = 999
This design is useful when a value should be established during object creation but should not normally be changed afterward.
The annual salary is not stored separately.
Instead, it is calculated using:
@property
def annual_salary(self):
return self.__salary * 12
Now we can write:
print(employee1.annual_salary)
Suppose the monthly salary is ₹60,000.
The property automatically calculates:
60000 × 12 = 720000
Therefore:
print(employee1.annual_salary)
returns:
720000
This avoids storing duplicate information. If we stored both monthly salary and annual salary separately, the two values could potentially become inconsistent. A calculated property avoids that problem.
Let’s review the three common naming styles in Python.
| Type | Example | Main Meaning |
|---|---|---|
| Public | name |
Normal public member |
| Protected-style | _salary |
Internal-use convention |
| Private-style | __salary |
Name mangling is applied |
Remember that Python’s terminology can sometimes be misleading for beginners.
A single underscore does not create a strict access restriction.
A double underscore triggers name mangling, but it does not provide security or encryption.
These mechanisms are primarily about code organization, conventions, preventing accidental access, and reducing naming conflicts.
Compare two designs.
Direct access:
class Employee:
def __init__(self, salary):
self.salary = salary
Now outside code can directly assign:
employee1.salary = -10000
There is no validation in the class.
Now compare that with:
class Employee:
def __init__(self, salary):
self.salary = salary
@property
def salary(self):
return self.__salary
@salary.setter
def salary(self, value):
if value < 0:
raise ValueError(
"Salary cannot be negative"
)
self.__salary = value
Now the class controls how salary values are assigned.
The external interface remains simple:
employee1.salary = 60000
but the internal implementation contains the validation rules.
Create a BankAccount class with:
Use encapsulation so that:
A possible starting structure is:
class BankAccount:
def __init__(self, account_number,
account_holder, balance):
self.__account_number = account_number
self.account_holder = account_holder
self.balance = balance
@property
def account_number(self):
pass
@property
def balance(self):
pass
@balance.setter
def balance(self, value):
pass
def deposit(self, amount):
pass
def withdraw(self, amount):
pass
Try completing the methods yourself.
Create a Product class containing:
Requirements:
inventory_value.The inventory value should be calculated as:
price × stock
For example:
price = 1000
stock = 20
inventory_value = 20000
Because inventory value is derived from other data, it is a good candidate for a property.
Create a Student class with:
Use encapsulation to ensure that marks always remain between 0 and 100.
Create:
result.grade.For example:
student1 = Student("Rahul", 101, 92)
print(student1.marks)
print(student1.grade)
print(student1.result)
Possible output:
92
A
Pass
The exact grade rules can be defined by you as part of the exercise.
1. Thinking double underscore means complete security
It does not. Python name mangling is not encryption and should not be used to protect secrets.
2. Using private members everywhere
Not every attribute needs to be private-style. If direct public access is perfectly appropriate, a normal attribute can be simpler.
3. Creating unnecessary getters and setters
Python does not require you to create a getter and setter for every attribute. Properties are useful when additional logic such as validation or calculation is required.
4. Forgetting validation in setters
The purpose of a setter is often to control how a value changes. If validation is required, it should be implemented there.
5. Duplicating calculated data
If a value can reliably be calculated from other attributes, consider using a property instead of storing a second copy.
6. Confusing encapsulation with abstraction
Encapsulation focuses on organizing and controlling data and behavior. Abstraction focuses on exposing essential functionality while hiding unnecessary implementation details.
1. What is encapsulation in Python?
Encapsulation is an OOP principle that organizes data and related behavior within a class and can control how that data is accessed or modified.
2. What is a public member?
A normal attribute or method that is intended for general access.
3. What does a single underscore mean?
A single leading underscore communicates that a member is intended for internal or protected-style use. It is primarily a convention.
4. What does a double underscore do?
It triggers Python’s name-mangling mechanism.
5. What is name mangling?
It is the process by which Python changes the internal name of a double-underscore member to reduce accidental conflicts and access.
6. Is a double-underscore variable completely private?
No. Name mangling does not provide absolute privacy or security.
7. What is a getter?
A method used to retrieve an internal value.
8. What is a setter?
A method used to update an internal value, often with validation.
9. What is @property?
It is a Python decorator that allows a method to be accessed using attribute-style syntax.
10. Why use a read-only property?
To expose a calculated or internal value for reading without providing a normal setter for changing it.
Now build a complete small project using the concepts from this lesson.
Create a Student class with:
A possible implementation is:
class Student:
def __init__(self, roll_number, name, marks):
self.__roll_number = roll_number
self.name = name
self.marks = marks
@property
def roll_number(self):
return self.__roll_number
@property
def marks(self):
return self.__marks
@marks.setter
def marks(self, value):
if 0 <= value <= 100:
self.__marks = value
else:
raise ValueError(
"Marks must be between 0 and 100"
)
@property
def grade(self):
if self.__marks >= 90:
return "A"
elif self.__marks >= 75:
return "B"
elif self.__marks >= 60:
return "C"
elif self.__marks >= 40:
return "D"
return "F"
@property
def result(self):
if self.__marks >= 40:
return "Pass"
return "Fail"
def display(self):
print("Roll Number:", self.__roll_number)
print("Name:", self.name)
print("Marks:", self.marks)
print("Grade:", self.grade)
print("Result:", self.result)
Create a student:
student1 = Student(
101,
"Rahul",
92
)
Display the result:
student1.display()
Output:
Roll Number: 101
Name: Rahul
Marks: 92
Grade: A
Result: Pass
Now update the marks:
student1.marks = 78
The setter validates the value.
Now:
print(student1.grade)
print(student1.result)
Output:
B
Pass
If we try:
student1.marks = 150
the setter raises a ValueError instead of allowing invalid data into the object.
Let’s summarize the complete lesson in one structure:
Encapsulation
|
├── Public Members
│ └── name
|
├── Protected-Style Members
│ └── _name
|
├── Private-Style Members
│ └── __name
│
├── Name Mangling
│ └── _ClassName__name
│
├── Getter
│ └── Read internal data
│
├── Setter
│ └── Validate / modify data
│
└── @property
├── Attribute-style getter
└── Controlled setter
The most important lesson is not that every variable should be private. Instead, you should choose the simplest design that correctly represents the object’s data and behavior.
If an attribute needs no special logic, a public attribute may be perfectly appropriate.
If an attribute represents an internal implementation detail, a single underscore can communicate that intention.
If name mangling is useful to avoid accidental conflicts, particularly in inheritance scenarios, a double underscore can be appropriate.
If reading or modifying a value requires validation, calculation, or other logic, a property can provide a clean interface while keeping that logic inside the class.
You now have a solid foundation in Python encapsulation and controlled object data. The next major OOP concept is Inheritance, where one class can reuse and extend the behavior of another class.