Class vs. Instance

Imagine a car factory.

  • Class: This is the blueprint. It describes what a car is (it has wheels, a color, and an engine) but it isn’t a physical car yet.
  • Object / Instance: This is the actual car that rolls off the assembly line. Your red Toyota and your neighbor’s blue Ford are different instances of the “Car” blueprint.
class Car:
    pass 

car_1 = Car() # Instance 1
car_2 = Car() # Instance 2

print(car_1) # Shows a unique memory address

People often say “Car Object”. What they mean is “a specific instance created from the Car class”. In this case, the words “object” and “instance” are interchangeable in meaning.

__init__ and self

When you build a car, you need to set its color and brand immediately. We use the __init__ method for this.

class Car:
    def __init__(self, brand, model, price):
        # Instance Variables
        self.brand = brand
        self.model = model
        self.price = price

    # Instance Method
    def description(self):
        return f"This is a {self.brand} {self.model} costing ${self.price}"

my_car = Car("Tesla", "Model 3", 45000)
print(my_car.description())

What is a method?

A method is simply a function that belongs to a class. While a regular function stands on its own, a method is tied to a specific instance (like your actual car_1) and usually performs an action using that car’s unique data.

  • Function: drive() (Generic)
  • Method: my_car.drive() (Specifically telling your car to move.)

What are those __something__?

They are called Magic Methods or Dunder Methods (“Dunder” stands for Double Underscore). These are built-in “superpowers” that let you tell Python how to handle your object (instance) in specific situations.

Think of them as Automatic Triggers. You don’t usually call my_car.__str__() manually, Python triggers it automatically when you try to print(my_car).

  • __init__: Triggers when you create the car. Sets up the attributes.
  • __str__: Triggers when you print(). It should be a pretty, readable string for users.
  • __repr__: Triggers during debugging with repr(). It should be an unambiguous string that shows exactly how to recreate the object.
  • __add__: Triggers when you use the + operator.
class Car:
    def __init__(self, brand, model, price):
        self.brand = brand
        self.model = model
        self.price = price

    def __repr__(self):
        # Goal: Look like the code used to create the car
        return f"Car('{self.brand}', '{self.model}', {self.price})"

    def __str__(self):
        return f"{self.brand} {self.model} (${self.price})"

    def __add__(self, other):
        # We can define what happens when we "add" two cars
        return self.price + other.price

car1 = Car("Toyota", "Camry", 20000)
car2 = Car("Honda", "Civic", 18000)

print(repr(car1)) # Output: Car('Toyota', 'Camry', 20000)
print(car1)       # Output: Toyota Camry ($20000)
print(car1 + car2) # Output: 38000

What is self?

self represents the specific instance (specific car) that the code is currently working on.

Imagine 100 cars are rolling off the assembly line. When a mechanic goes to “paint” a car red, they need to know which car to paint. self is like the mechanic pointing at a specific car and saying, “I’m working on this one.”

Under the Hood

When you call a method like my_car.description(), Python actually transforms it into a class-level call behind the scenes. It automatically passes your object as the first argument.

my_car = Car("Tesla", "Model 3", 45000)

# How you write it:
my_car.description()

# What Python does "Under the Hood":
# It calls the class, and passes the specific instance (my_car) into 'self'
Car.description(my_car)

Without it, the description() method wouldn’t know which car’s price or brand to look up

Class vs. Instance Variables

  • Instance Variables: Unique to each car (e.g., color, brand).
  • Class Variables: Shared by all cars from that blueprint (e.g., number_of_wheels = 4).
class Car:
    num_of_wheels = 4 # Class variable: Every car has 4 wheels
    total_cars_made = 0

    def __init__(self, brand):
        self.brand = brand # Instance variable
        Car.total_cars_made += 1 # We use 'Car' because this is shared data

c1 = Car("Audi")
c2 = Car("BMW")
print(Car.total_cars_made) # 2

Method Types: Regular, Class, and Static

  • Regular Methods (self): Use these when you need to change or see data about a specific car.
  • Class Methods (@classmethod): Use these to change data for the whole factory (the blueprint). They take cls instead of self.
  • Static Methods (@staticmethod): Use these for simple functions that relate to cars but don’t need to know anything about a specific car (we don’t use self there).
class Car:
    tax_rate = 1.05 # Class Variable

    def __init__(self, brand, price):
            self.brand = brand # Instance variable
            self.price = price
    
    # Regular Method: Changes ONE car
        def apply_discount(self):
            self.price = self.price * 0.9
    
    # Class Method: Changes a FACTORY-WIDE (class rule like the tax rate)
    # This affects every single car made by this blueprint.
    @classmethod
    def change_tax(cls, new_rate):
        cls.tax_rate = new_rate

    # Static Method: Just a helpful tool
    # We don't need or use 'self' here.
    @staticmethod
    def is_safe_speed(speed):
        return speed < 120 
        
        
# Create two specific cars (Instances)
car_1 = Car("Toyota", 20000)
car_2 = Car("BMW", 40000)

# --- CALLING REGULAR METHODS ---
# This only affects car_1. car_2's price stays the same.
car_1.apply_discount()
print(car_1.price) # 18000.0
print(car_2.price) # 40000 (No change)

# --- CALLING CLASS METHODS ---
# We call this on the CLASS itself (the factory).
Car.change_tax(1.10)

# Now EVERY car sees the new tax rate
print(car_1.tax_rate) # 1.10
print(car_2.tax_rate) # 1.10

# --- CALLING STATIC METHODS ---
# We usually call this on the CLASS. It's just a utility tool.
# It doesn't need to know about car_1 or car_2 to work.
is_safe = Car.is_safe_speed(100)
print(is_safe) # True

What is cls?

Just like self is a placeholder for the Instance (the specific car), cls is a placeholder for the Class (the blueprint itself).

  • When you use self, you are saying: “Change this specific car.”
  • When you use cls, you are saying: “Change the blueprint for every car.”

The most common real-world use for cls is creating an “Alternative Constructor”. Imagine you have car data coming in as a string like "Toyota-Camry-20000". Instead of splitting that string manually every time, you can use a class method to handle it.

class Car:
    def __init__(self, brand, model, price):
        self.brand = brand
        self.model = model
        self.price = price

    @classmethod
    def from_string(cls, car_string):
        brand, model, price = car_string.split("-")
        # cls() is the same as calling Car()
        return cls(brand, model, int(price))

# Now you can create a car in a special way:
new_car = Car.from_string("Tesla-Model3-45000")
print(new_car.brand) # Tesla

Getters and Setters (@property)

Sometimes you want an attribute (like an email or a full name) to update automatically when other data changes. Using @property lets you call a method as if it were a simple variable.

class Car:
    def __init__(self, brand, model):
        self.brand = brand
        self.model = model

    @property
    def fullname(self):
        return f"{self.brand} {self.model}"

    @fullname.setter
    def fullname(self, name):
        brand, model = name.split(" ")
        self.brand = brand
        self.model = model

my_car = Car("Ford", "Focus")
my_car.fullname = "BMW M3" # This triggers the setter!
print(my_car.brand) # BMW

Inheritance

Why rewrite code? If we want to make an Electric Car, we can just inherit from the Car class. It gets everything from the parent automatically.

super(): This tells Python to run the __init__ method of the parent class so we don’t have to re-type self.brand = brand, etc.

class Car:
    def __init__(self, brand, model, price):
        self.brand = brand
        self.model = model
        self.price = price


class ElectricCar(Car):
    def __init__(self, brand, model, price, battery_size):
        # Let the Parent (Car) handle the basic stuff
        super().__init__(brand, model, price)
        self.battery_size = battery_size

my_ev = ElectricCar("Tesla", "Model S", 80000, 100)
print(my_ev.brand) # Inherited from Car!

Inheriting Methods

When a child class (ElectricCar) inherits from a parent (Car), it gets every method for free.

class Car:
    tax_rate = 1.05

    def __init__(self, brand, price):
        self.brand = brand
        self.price = price

    def description(self):
        return f"A standard {self.brand}."

    @staticmethod
    def is_safe_speed(speed):
        return speed < 120

class ElectricCar(Car):
    def __init__(self, brand, price, battery_size):
        super().__init__(brand, price)
        self.battery_size = battery_size

# Testing Inheritance
my_ev = ElectricCar("Tesla", 80000, 100)

# Regular Method: Inherited!
print(my_ev.description()) # Output: A standard Tesla.

# Static Method: Inherited!
print(ElectricCar.is_safe_speed(100)) # Output: True

Overriding

Sometimes, the child needs to do things differently. This is called Overriding. If the child class defines a method with the exact same name as the parent, Python will use the child’s version.

class ElectricCar(Car):
    # OVERRIDING a Regular Method
    def description(self):
        return f"A high-tech electric {self.brand}."

my_ev = ElectricCar("Tesla", 80000, 100)
print(my_ev.description()) # Output: A high-tech electric Tesla.

super() with Methods

Just like in the __init__, you can use super() to call the parent’s version of a method inside the child’s method. This is great when you want to do the parent’s action plus something extra.

class ElectricCar(Car):
    def apply_maintenance(self):
        # We might do generic car maintenance...
        # super().apply_maintenance() 
        print("Checking battery health and motor software...")

Inheritance and Class Methods (cls)

Class methods are inherited too, but they have a special “superpower.” Because they use cls, they are aware of which class is calling them.

If you call a class method from ElectricCar, the cls argument will point to ElectricCar, not the original Car.

class Car:
    tax_rate = 1.05

    @classmethod
    def show_tax(cls):
        print(f"Tax for {cls.__name__} is {cls.tax_rate}")

class ElectricCar(Car):
    tax_rate = 1.02 # Electric cars have lower tax!

# The parent method uses the child's data!
ElectricCar.show_tax() # Output: Tax for ElectricCar is 1.02

How can I check the family tree of a class?

# This shows you the search order: ElectricCar -> Car -> Object
print(ElectricCar.__mro__)

Inheritance of Properties

If the parent has a property, the child gets it automatically. You don’t have to do anything.

class Car:
    def __init__(self, brand, model):
        self.brand = brand
        self.model = model

    @property
    def fullname(self):
        return f"{self.brand} {self.model}"

class ElectricCar(Car):
    pass

my_ev = ElectricCar("Tesla", "Model 3")
# The child uses the parent's property perfectly!
print(my_ev.fullname) # Output: Tesla Model 3

Overriding the Getter

Sometimes the child needs to display data differently. For example, maybe an ElectricCar should always show ”⚡” next to its name.

class ElectricCar(Car):
    @property
    def fullname(self):
        # We use super() to get the standard name, then add our own flair
        return f"⚡ {super().fullname}"

my_ev = ElectricCar("Tesla", "Model 3")
print(my_ev.fullname) # Output: ⚡ Tesla Model 3

Overriding the Setter (and using super())

This is where it gets interesting. Imagine the Car class has a rule: “Price cannot be negative.” The ElectricCar wants to keep that rule but adds its own: “Price cannot be less than $20,000” (because batteries are expensive).

In Python, if you want to override a setter in a child class, you usually have to re-declare the property in the child class to keep things clean.

class Car:
    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

class ElectricCar(Car):
    @property
    def price(self):
        return super().price

    @price.setter
    def price(self, value):
        if value < 20000:
            raise ValueError("EVs cannot be cheaper than $20,000!")
        # We use the parent's setter to check for negative numbers!
        # This is how you call a setter through super()
        Car.price.fset(self, value) 

my_ev = ElectricCar(30000)
my_ev.price = 25000 # Works!
# my_ev.price = 15000 # Raises ValueError: EVs cannot be cheaper than $20,000!

What happens if you only override the setter in the child class?

If you only override the setter but don’t re-define the getter in the child, you might break the property.

In Python, when you define a setter in a child class with the same name as a property in the parent, it can ‘shadow’ or hide the parent’s property entirely. It is a best practice to re-define both the @property (getter) and the @name.setter in the child if you are changing how they work.