Python Object-Oriented Programming


Python Object-Oriented Programming Interview with follow-up questions

1. Can you explain the concept of object-oriented programming in Python?

Object-oriented programming (OOP) is a paradigm that organizes code around objects — instances of classes that bundle state (attributes) with behavior (methods). A class is a blueprint; an object is a concrete instance with its own data.

class Account:
    def __init__(self, owner: str, balance: float = 0.0) -> None:
        self.owner = owner          # instance attribute
        self.balance = balance

    def deposit(self, amount: float) -> None:
        self.balance += amount

Interviewers expect you to name the four pillars and show you understand Python's take on each:

  • Encapsulation — bundling data with methods. Python has no true private; it's convention (_protected, __name_mangled).
  • Inheritance — reuse via subclassing; super() and C3-linearized MRO drive method resolution.
  • Polymorphism — Python leans on duck typing: any object with the right methods works, no shared base class required.
  • Abstraction — hide details behind a clean interface, often via abc.ABC or @property.

A common follow-up: when should you NOT use OOP? For simple data containers reach for @dataclass or NamedTuple; for stateless logic, plain functions and modules are often cleaner than forcing a class.

↑ Back to top

Follow-up 1

What are the main principles of object-oriented programming?

The main principles of object-oriented programming are:

  1. Encapsulation: This principle involves bundling data and methods together within a class, hiding the internal details of how the class works from the outside world.

  2. Inheritance: This principle allows a class to inherit attributes and methods from another class, promoting code reuse and creating a hierarchy of classes.

  3. Polymorphism: This principle allows objects of different classes to be treated as objects of a common superclass, enabling code to be written that can work with objects of multiple types.

  4. Abstraction: This principle involves creating abstract classes or interfaces that define a common set of methods that subclasses must implement. This allows for code to be written that can work with objects at a higher level of abstraction.

Follow-up 2

Can you give an example of how to define a class in Python?

Sure! Here's an example of how to define a simple class in Python:

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def greet(self):
        print(f"Hello, my name is {self.name} and I am {self.age} years old.")

# Creating an instance of the Person class
person = Person("John", 25)

# Calling the greet method
person.greet()

Output:

Hello, my name is John and I am 25 years old.

Follow-up 3

How is inheritance implemented in Python?

Inheritance in Python is implemented using the syntax class ChildClass(ParentClass):. The child class (also known as the derived class) inherits attributes and methods from the parent class (also known as the base class). The child class can then add its own attributes and methods, and override or extend the ones inherited from the parent class.

Here's an example:

class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        print("The animal makes a sound.")

# Dog class inherits from Animal class
class Dog(Animal):
    def speak(self):
        print("The dog barks.")

# Creating instances of the classes
animal = Animal("Animal")
dog = Dog("Dog")

# Calling the speak method
animal.speak()  # Output: The animal makes a sound.
dog.speak()  # Output: The dog barks.

Follow-up 4

What is the difference between a class and an object in Python?

In Python, a class is a blueprint or a template for creating objects, while an object is an instance of a class. A class defines the attributes and methods that an object will have, but it is not an actual object itself. When you create an object from a class, you are creating a specific instance of that class with its own unique set of attribute values.

For example:

class Person:
    def __init__(self, name):
        self.name = name

# Creating objects from the Person class
person1 = Person("John")
person2 = Person("Jane")

Follow-up 5

Can you explain the concept of encapsulation with an example in Python?

Encapsulation is the principle of bundling data and methods together within a class and hiding the internal details of how the class works from the outside world. It allows for data abstraction and provides control over the access to the data.

Here's an example in Python:

class BankAccount:
    def __init__(self, account_number, balance):
        self.account_number = account_number
        self.__balance = balance  # Private attribute

    def deposit(self, amount):
        self.__balance += amount

    def withdraw(self, amount):
        if amount <= self.__balance:
            self.__balance -= amount
        else:
            print("Insufficient balance.")

    def get_balance(self):
        return self.__balance

# Creating an instance of the BankAccount class
account = BankAccount("123456789", 1000)

# Accessing the public methods
account.deposit(500)
account.withdraw(200)

# Accessing the private attribute (not recommended)
print(account._BankAccount__balance)

2. What is the purpose of the __init__ method in Python classes?

__init__ is the initializer — it runs automatically right after an object is created, to set up its instance attributes. The first parameter, self, is the new instance; you assign its state inside.

class Account:
    def __init__(self, owner: str, balance: float = 0.0) -> None:
        self.owner = owner
        self.balance = balance

Key gotchas interviewers probe:

  • It's not the constructor. __new__ actually creates the object; __init__ only initializes the already-created instance. __init__ must return None.
  • Mutable default arguments are a trap. def __init__(self, items=[]) shares one list across all instances. Use None and assign inside:
  def __init__(self, items: list[int] | None = None) -> None:
      self.items = items if items is not None else []
  • @dataclass generates __init__ for you, reducing boilerplate for data-holding classes.
  • Calling super().__init__(...) in a subclass is needed to run the parent's setup, especially under multiple inheritance.
↑ Back to top

Follow-up 1

Can you give an example of how to use the __init__ method?

Sure! Here's an example:

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

person = Person('John', 25)
print(person.name)  # Output: John
print(person.age)  # Output: 25

In this example, the Person class has an __init__ method that takes two parameters, name and age. When an object of the Person class is created, the __init__ method is automatically called with the provided arguments, and the name and age attributes of the object are initialized.

Follow-up 2

What happens if a class does not have an __init__ method?

If a class does not have an __init__ method, Python will automatically create a default __init__ method for the class. This default method does not do anything and does not take any parameters. However, if you want to initialize attributes or perform any setup when creating objects from the class, it is recommended to define your own __init__ method.

Follow-up 3

Can the __init__ method return a value?

No, the __init__ method cannot return a value. Its purpose is to initialize the object, not to return a value. If you need to return a value from a class method, you can define another method for that purpose.

Follow-up 4

Can we call the __init__ method explicitly on an object?

Yes, you can call the __init__ method explicitly on an object, but it is not recommended. The __init__ method is automatically called when an object is created, so there is usually no need to call it explicitly. However, if you have a specific use case where you want to re-initialize the object's attributes, you can call the __init__ method explicitly. Here's an example:

class Person:
    def __init__(self, name):
        self.name = name

    def change_name(self, new_name):
        self.__init__(new_name)

person = Person('John')
print(person.name)  # Output: John

person.change_name('Alice')
print(person.name)  # Output: Alice

In this example, the Person class has a change_name method that calls the __init__ method to re-initialize the name attribute with a new value.

3. Can you explain the concept of inheritance in Python?

Inheritance lets a class (the child/derived) reuse and extend the attributes and methods of another (the parent/base). It models an "is-a" relationship and reduces duplication.

class Animal:
    def __init__(self, name: str) -> None:
        self.name = name

    def speak(self) -> str:
        return "..."

class Dog(Animal):
    def speak(self) -> str:          # override
        return f"{self.name} says woof"

The child can override methods and still reach the parent's version via super():

class Puppy(Dog):
    def speak(self) -> str:
        return super().speak() + " (tiny)"

What interviewers really dig into:

  • super() and the MRO. Python resolves methods using C3 linearization — inspect it with Dog.__mro__. With multiple inheritance, super() follows the MRO, not just the literal parent.
  • Prefer composition over inheritance when the relationship is "has-a," not "is-a." Deep hierarchies are fragile.
  • isinstance()/issubclass() check relationships at runtime; abstract base classes (abc.ABC) enforce that subclasses implement required methods.
↑ Back to top

Follow-up 1

What is the difference between single and multiple inheritance?

In single inheritance, a class inherits from only one parent class. This means that the child class can access and use the attributes and methods of the parent class.

In multiple inheritance, a class can inherit from multiple parent classes. This means that the child class can access and use the attributes and methods of all the parent classes. However, multiple inheritance can lead to method name conflicts if two or more parent classes have methods with the same name. In such cases, the method resolution order (MRO) determines which method is called.

Follow-up 2

Can you give an example of multiple inheritance in Python?

Sure! Here's an example of multiple inheritance in Python:

class ParentClass1:
    def method1(self):
        print('This is method 1 from ParentClass1')

class ParentClass2:
    def method2(self):
        print('This is method 2 from ParentClass2')

class ChildClass(ParentClass1, ParentClass2):
    pass

child = ChildClass()
child.method1()  # Output: This is method 1 from ParentClass1
child.method2()  # Output: This is method 2 from ParentClass2

In this example, the ChildClass inherits from both ParentClass1 and ParentClass2. As a result, the ChildClass can access and use the methods method1 from ParentClass1 and method2 from ParentClass2.

Follow-up 3

What is the use of super() function in Python?

The super() function is used to call a method from the parent class. It is commonly used in the child class to invoke the parent class's implementation of a method while adding additional functionality.

Here's an example that demonstrates the use of super():

class ParentClass:
    def method(self):
        print('This is the parent class method')

class ChildClass(ParentClass):
    def method(self):
        super().method()
        print('This is the child class method')

child = ChildClass()
child.method()

Output:

This is the parent class method
This is the child class method

In this example, the ChildClass overrides the method of the ParentClass. However, it still calls the method of the ParentClass using super().method() before adding its own functionality.

Follow-up 4

How does Python resolve the method resolution order in case of multiple inheritance?

Python uses a method resolution order (MRO) algorithm called C3 linearization to determine the order in which methods are resolved in case of multiple inheritance. The MRO ensures that each method in the inheritance hierarchy is called exactly once and in the correct order.

The MRO algorithm follows three main rules:

  1. Depth-first search: The algorithm starts with the leftmost parent class and traverses the inheritance hierarchy depth-first.
  2. Left-to-right ordering: When multiple parent classes are at the same level in the inheritance hierarchy, the algorithm follows a left-to-right ordering.
  3. First come, first served: If a method is found in multiple parent classes, the algorithm selects the method from the leftmost parent class first.

By following these rules, Python resolves the method order in a consistent and predictable manner.

4. What is polymorphism in Python?

Polymorphism means the same operation behaves differently depending on the object it acts on — "many forms." In Python it's driven mainly by duck typing: if an object has the right method, it works, regardless of its class.

class Dog:
    def speak(self) -> str: return "woof"

class Cat:
    def speak(self) -> str: return "meow"

def announce(animal) -> None:        # no shared base class needed
    print(animal.speak())

Forms interviewers expect you to distinguish:

  • Duck typing — the Pythonic default; "if it walks like a duck..." No common superclass required.
  • Method overriding — a subclass redefines a parent method (runtime polymorphism).
  • Operator/dunder polymorphism+ works on ints, strings, and lists because each type implements __add__. Implement dunders to make your own types polymorphic.
  • functools.singledispatch — gives you type-based function overloading, which Python lacks natively (it has no method overloading by signature — the last definition wins).

A typical follow-up: how is this different from overloading? Python has no compile-time overloading; you use default args, *args, or singledispatch instead.

↑ Back to top

Follow-up 1

Can you give an example of polymorphism in Python?

Sure! Here's an example of polymorphism in Python:

class Animal:
    def sound(self):
        pass

class Dog(Animal):
    def sound(self):
        return 'Woof!'

class Cat(Animal):
    def sound(self):
        return 'Meow!'

def make_sound(animal):
    print(animal.sound())

dog = Dog()
cat = Cat()

make_sound(dog)  # Output: 'Woof!'
make_sound(cat)  # Output: 'Meow!'

Follow-up 2

What is the difference between static and dynamic polymorphism?

Static polymorphism, also known as compile-time polymorphism, is achieved through function overloading and operator overloading. It is resolved at compile-time based on the number and types of arguments passed to a function or operator.

Dynamic polymorphism, also known as runtime polymorphism, is achieved through method overriding. It is resolved at runtime based on the actual type of the object being referred to.

Follow-up 3

How does Python handle operator overloading?

In Python, operator overloading is achieved by defining special methods or magic methods that are invoked when certain operators are used on objects. These methods have special names that start and end with double underscores, such as __add__ for the + operator.

For example, to overload the + operator for a custom class, you can define the __add__ method in the class. When the + operator is used on objects of that class, the __add__ method is called.

Here's an example:

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)

v1 = Vector(1, 2)

v2 = Vector(3, 4)

v3 = v1 + v2  # Calls the __add__ method

print(v3.x, v3.y)  # Output: 4 6

Follow-up 4

Can you explain the concept of method overriding in Python?

Method overriding is a feature of object-oriented programming that allows a subclass to provide a different implementation of a method that is already defined in its superclass. In Python, method overriding is achieved by defining a method with the same name in the subclass.

When a method is called on an object of the subclass, Python first looks for the method in the subclass. If the method is found, the implementation in the subclass is executed. If the method is not found in the subclass, Python looks for it in the superclass.

Here's an example:

class Animal:
    def sound(self):
        return 'Animal sound'

class Dog(Animal):
    def sound(self):
        return 'Woof!'

animal = Animal()
dog = Dog()

print(animal.sound())  # Output: 'Animal sound'
print(dog.sound())  # Output: 'Woof!'

5. What is encapsulation in Python?

Encapsulation bundles data with the methods that operate on it inside a class, and controls how that data is accessed from outside. The goal is a clean public interface that hides internal details.

The catch — and the point interviewers want you to make — is that Python has no true access modifiers. Encapsulation is by convention:

  • _single_underscore — "internal, please don't touch." Purely a convention; nothing enforces it.
  • __double_underscore — triggers name mangling (__x becomes _ClassName__x), which avoids accidental clashes in subclasses but is not real privacy.
class Account:
    def __init__(self, balance: float) -> None:
        self._balance = balance       # "protected" by convention

    @property
    def balance(self) -> float:        # controlled read access
        return self._balance

    @balance.setter
    def balance(self, value: float) -> None:
        if value < 0:
            raise ValueError("balance cannot be negative")
        self._balance = value

Follow-ups: use @property to expose validated, computed, or read-only attributes without changing the public API. acct._Account__x still reaches a mangled name — proof there's nothing truly private; encapsulation in Python is a discipline, not a guarantee.

↑ Back to top

Follow-up 1

How can we access private attributes in Python?

Although private attributes are intended to be accessed only within the class, Python provides a way to access them using name mangling. Name mangling is done by adding _ClassName as a prefix to the attribute name. Here's an example:

class MyClass:
    def __init__(self):
        self.__private_attr = 'Private attribute'

obj = MyClass()
print(obj._MyClass__private_attr)  # Output: Private attribute

Follow-up 2

Can you give an example of encapsulation in Python?

Sure! Here's an example:

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

    def drive(self, distance):
        self.__mileage += distance

    def get_mileage(self):
        return self.__mileage

my_car = Car('Toyota', 'Camry')
my_car.drive(100)
print(my_car.get_mileage())  # Output: 100

Follow-up 3

What is the use of private attributes in Python?

Private attributes in Python are used to encapsulate data and prevent direct access from outside the class. They are denoted by prefixing the attribute name with double underscores (e.g., __attribute). By making attributes private, we can control how they are accessed and modified, and ensure data integrity and security.

Follow-up 4

What is the difference between public, private and protected attributes in Python?

In Python, attributes can be classified into three types based on their visibility and accessibility:

  1. Public attributes: These attributes can be accessed and modified from anywhere, both inside and outside the class.

  2. Private attributes: These attributes are intended to be accessed only within the class. They are denoted by prefixing the attribute name with double underscores (e.g., __attribute). Accessing them directly from outside the class raises an AttributeError.

  3. Protected attributes: These attributes are intended to be accessed only within the class and its subclasses. They are denoted by prefixing the attribute name with a single underscore (e.g., _attribute). Although they can be accessed from outside the class, it is considered a convention that they should not be accessed directly.

6. What are dataclasses, and when would you use them over a normal class or NamedTuple?

A modern-Python staple. @dataclass (3.7+) auto-generates boilerplate — __init__, __repr__, __eq__ — from annotated fields, so you write intent, not ceremony:

from dataclasses import dataclass, field

@dataclass(slots=True)
class Point:
    x: int
    y: int = 0
    tags: list[str] = field(default_factory=list)   # avoids the mutable-default trap

Why/when:

  • Use it for data-holding classes where you'd otherwise hand-write __init__/__repr__/__eq__.
  • frozen=True makes instances immutable and hashable; slots=True (3.10+) cuts memory and speeds attribute access; order=True adds comparison methods.
  • field(default_factory=...) is the correct way to give list/dict defaults (sidesteps the shared-mutable-default bug).

Contrast for the follow-up: NamedTuple/namedtuple is immutable, tuple-like, and indexable but lighter; TypedDict types plain dicts; pydantic.BaseModel adds runtime validation/parsing (used by FastAPI). Choose dataclass for plain in-memory records, pydantic when you need to validate external/untrusted data.

↑ Back to top

Live mock interview

Mock interview: Python Object-Oriented Programming

Intermediate ~5 min Your own free AI key

Your voice and your AI key never touch our servers; the key stays in this browser and is sent only to Google. Only your round scores are saved to track progress.