Object-oriented programming → OOP → a programming style where dev tries to treat everything in code →as real world obj.
Class¶
a blueprint → creating obj.
every obj. created from class → has attributes → defines data & methods → defines behaviour of the obj.
Key principles¶
OOP has 4 key principles → to organize and manage code effectively
They are :
EncapsulationInheritancePolymorphismAbstraction
Encapsulation¶
capsuling the attributes and methods of objects inside the class
hide the internal state of objs. behind a set of (public methods and attributes)
Public Methods & Attributes → class func. that we can use to manipulate obj data from outside
Let’s say you want to track a wallet balance. You want to allow people to deposit or withdraw money from the wallet, but no one should be able to tamper with the balance directly.
In that case, you can make deposit() and withdraw() public methods, and you hide the balance under the _balance attribute:
class Wallet:
def __init__(self, balance):
self._balance = balance # For internal use by convention
def deposit(self, amount):
if amount > 0:
self._balance += amount # Add to the balance safely
def withdraw(self, amount):
if 0 < amount <= self._balance:
self._balance -= amount # Remove from the balance safelyPrefixing attributes & methods with single underscore → meant for internal use only → no one should access them outside the class ⟺ if happened → break Encapsulation rule , create bugs
single underscore prefix → just convension ( technically we can still acess the function outside class )
double underscore prefix → prevent accessing outside the class ⇒ making the methods & attributes private
class Wallet:
def __init__(self, balance):
self.__balance = balance # Private attribute
def deposit(self, amount):
if amount > 0:
self.__balance += amount # Add to the balance safely
def withdraw(self, amount):
if 0 < amount <= self.__balance:
self.__balance -= amount # Remove from the balance safely
account = Wallet(500)
print(account.__balance) # AttributeError: 'Wallet' object has no attribute '__balance'To get the value of the private attribute __balance we can use get_balance method
class Wallet:
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
acct_one = Wallet(100)
acct_one.deposit(50)
print(acct_one.get_balance()) # 150
acct_two = Wallet(450)
acct_two.withdraw(28)
print(acct_two.get_balance()) # 422
acct_two.deposit(150)
print(acct_two.get_balance()) # 572define a private __validate method to check if every deposit or withdrawal amount is a positive number:
class Wallet:
def __init__(self):
self.__balance = 0
def __validate(self, amount):
if amount < 0:
raise ValueError('Amount must be positive')
def deposit(self, amount):
self.__validate(amount)
self.__balance += amount
def withdraw(self, amount):
self.__validate(amount)
if amount > self.__balance:
raise ValueError('Insufficient funds')
self.__balance -= amount
def get_balance(self):
return self.__balance
acct_one = Wallet()
acct_one.deposit(3)
print(acct_one.get_balance()) # 3
acct_one.deposit(50)
print(acct_one.get_balance()) # 53
acct_one.deposit(-4) # ValueError: Amount must be positive
acct_one.withdraw(-8) # ValueError: Amount must be positive
acct_one.withdraw(58) # ValueError: Insufficient funds__validate method is private, and runs behind the scenes in the deposit() and withdraw() public methods to make sure the amount is always valid.
Getters and Setters¶
control how the attributes of a class are accessed and modified
Getters : retrieve a value
Setters : set a value
-- These actions are done through → Properties
what connect Getters & Setters
allow access to data
can be accessed with dot notation
run extra logic behind the scenes when we get, set, or delete values with them
we use it specificly because of → readability and convention
decorator func.¶
a function that modifies the functionalities of other functions, or classes, without changing their original code.
To create a property, you define a method and place the @property decorator above it → turns the method into property
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self): # A getter to get the radius
return self._radius
@property
def area(self): # A getter to calculate area
return 3.14 * (self._radius ** 2)
my_circle = Circle(3)
print(my_circle.radius) # 3
print(my_circle.area) # 28.26To make a setter to create the radius, for example, you have to define another method with the same name and use @<property_name>.setter above it:
Using self.radius inside __init__ ensures the setter runs during object creation, so invalid radius values are caught immediately.
class Circle:
def __init__(self, radius):
self.radius = radius # Calling the setter
@property
def radius(self): # A getter to get the radius
return self._radius
@radius.setter
def radius(self, value): # A setter to set the radius
if value <= 0:
raise ValueError('Radius must be positive')
self._radius = value
my_circle = Circle(3)
print('Initial radius:', my_circle.radius) # Initial radius: 3
my_circle.radius = 8
print('After modifying the radius:', my_circle.radius) # After modifying the radius: 8Once you define getters and setters, Python automatically calls them under the hood whenever you use normal attribute syntax:
my_circle.radius # This will call the getter
my_circle.radius = 4 # This will call the setterWhen you define self.radius = value inside the setter method, you are effectively creating a recursive loop:
You call my_circle.radius = 10.
Python sees that radius is a property and jumps into the setter method.
Inside the setter, you have the line self.radius = value.
Python interprets self.radius = value as: “Call the radius setter again.”
The setter runs, hits self.radius = value again... and the cycle repeats infinitely until Python halts the program with a RecursionError.
The Solution: The “Backing Variable”
To break this loop, you must use a different name for the actual storage of the data. By convention, we use an underscore prefix (e.g., _radius).
self.radius: This is the public interface. Users interact with this, and it triggers the getter/setter logic (like validation or calculation).
self._radius: This is the internal backing variable. It is just a standard attribute used to store the actual data.By setting self._radius = value inside the setter, you are performing a direct assignment to the underlying variable. Because this is not a property, it does not trigger the setter logic, the recursion is avoided, and the value is successfully saved.
A deleter runs custom logic when you use the del statement on a property. To create one, you use the @<property_name>.deleter decorator:
class Circle:
def __init__(self, radius):
self.radius = radius
# Getter
@property
def radius(self):
return self._radius
# Setter
@radius.setter
def radius(self, value):
if value <= 0:
raise ValueError("Radius must be positive")
self._radius = value
# Deleter
@radius.deleter
def radius(self):
print("Deleting radius...")
del self._radiusdeleter in use
# Create circle object with a radius
my_circle = Circle(33)
print("Initial radius:", my_circle.radius) # 33
# Delete the radius
# This calls the deleter
del my_circle.radius # Deleting radius...
print("Radius deleted!") # Radius deleted!
# Try to access radius after deletion
try:
print(my_circle.radius)
except AttributeError as e:
print("Error:", e) # Error: 'Circle' object has no attribute '_radius'Understanding Python Encapsulation: Properties & Setters1. Why use a Setter instead of a direct Public Attribute?API Stability: If you start by using a public attribute (e.g., user.age = 25), you cannot add validation later without breaking external code. If you rename it to user._age to add logic, all existing code outside the class will break because it is still trying to access .age.The Setter Advantage: By using the property and age.setter decorators, you keep the name of the attribute (.age) identical.The Result: From the outside, your class looks exactly the same as it did when you used a simple public attribute. However, you now have the ability to run “behind-the-scenes” validation logic whenever the value is changed.2. Why we use self._age inside the SetterAvoiding Infinite Recursion: In the setter method, if you wrote self.age = value, you would accidentally call the setter method again. This creates an infinite loop that crashes your program with a RecursionError.The Solution: Using self.age (or any different internal name) tells Python to update the raw data storage directly, bypassing the setter method.The Convention: The underscore () is a clear signal to other developers: "This variable is for internal use only. Always interact with it through the public .age property so that validation logic is applied."3. Summary of the Best PracticeGoalStrategyExpose dataUse property (the getter).Validate/Protect dataUse name.setter (the setter).Store data internallyUse self._name (internal name).Maintain compatibilityKeep the public property name the same as the original attribute.Note for your notes: This design pattern is often called “Uniform Access.” It allows you to change the implementation of your class (how it stores data) without changing the interface (how people use your class).
by setting up getter wr can also use dot notation instead of ._ to access the attributes? yes and we can also do that inside the class inside a func or method?
The repr method is a special method that is supposed to return a string representation of the object that can be used to instantiate it.
For example, the repr method of Employee(‘Charlie’, ‘developer’) should return the string Employee(‘Charlie’, ‘developer’), which is the same string used to create the object.
Give your Employee class a repr method with a self parameter, and make it return a string that can be used to instantiate the object.
class attribute is a variable inside a class
When you define a class, the code inside the class body (where you defined _base_salaries) is executed when the class is created. However, once you are inside a method (like init), that method has its own local scope.
Methods cannot see variables defined directly in the class body as if they were local variables.
They can see them if you qualify them with the class name (e.g., Employee._base_salaries) or through an instance (self._base_salaries).To understand the difference, think of these as three different “levels” of access to your data. Here is the breakdown of how they behave inside your Employee class:
1. level (The Local Variable)¶
What it is: This is just a standard local variable that exists only while the
__init__method is running.When to use it: You use this during the initial setup (like in your
ifstatements) to validate the input before you “lock it in” to the object.Scope: It disappears the moment
__init__finishes. It is not “attached” to the object.
2. self._level (The Protected Attribute)¶
What it is: This is the actual storage location (the “private” variable). It lives inside the object (
self).When to use it: Use this when you want to bypass your getter/setter logic. It is the raw data.
Scope: It lasts as long as the
Employeeobject exists.Convention: The underscore tells other programmers: “Don’t touch this directly; use the property instead.”
3. self.level (The Property)¶
What it is: This is the interface (the “public” door). It is not a variable itself; it is a call to your
@propertymethod (the getter) or your@level.settermethod.When to use it: Use this for all your regular logic.
Why it’s powerful: Because it runs through the method, it automatically triggers any validation or side effects (like updating the salary) that you defined in your getter/setter.
Quick Comparison Table¶
| Term | What is it? | Accesses Logic? | Visibility |
|---|---|---|---|
level | Local argument | No | Only inside __init__ |
self._level | The raw variable | No | Inside the whole class |
self.level | The property/method | Yes | Public (anywhere) |
A Concrete Example¶
If you had a setter for level that printed “Level Updated!” whenever it changed:
If you did
self._level = 'senior'inside a method, nothing would be printed.If you did
self.level = 'senior'inside a method, it would trigger your setter and print “Level Updated!”.
Bottom line: Inside your class, always use self.level if you want your validation/logic to run. Use self._level only if you want to perform a “silent” update or read without triggering any extra behavior.
Does that distinction make sense, or would you like to see how they behave in a specific scenario?