Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Object-oriented programming

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 :

  1. Encapsulation

  2. Inheritance

  3. Polymorphism

  4. Abstraction

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 safely

Prefixing 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()) # 572

define 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

  1. what connect Getters & Setters

  2. allow access to data

  3. can be accessed with dot notation

  4. run extra logic behind the scenes when we get, set, or delete values with them

  5. 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.26

To 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: 8

Once 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 setter

When you define self.radius = value inside the setter method, you are effectively creating a recursive loop:

  1. You call my_circle.radius = 10.

  2. Python sees that radius is a property and jumps into the setter method.

  3. Inside the setter, you have the line self.radius = value.

  4. Python interprets self.radius = value as: “Call the radius setter again.”

  5. 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._radius

deleter 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 if statements) 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 Employee object 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 @property method (the getter) or your @level.setter method.

  • 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

TermWhat is it?Accesses Logic?Visibility
levelLocal argumentNoOnly inside __init__
self._levelThe raw variableNoInside the whole class
self.levelThe property/methodYesPublic (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?