Python Functions and OOP

Defining functions, default and keyword arguments, *args/**kwargs, classes, inheritance and dunder methods.

Defining functions

Functions are defined with def, and Python infers the return type from whatever the return statement produces — there's no return-type declaration:

Python
def add(a, b):
    return a + b

print(add(2, 3))   # 5

A function with no explicit return implicitly returns None.

Default and keyword arguments

Parameters can have default values, and any call can pass arguments by name regardless of their position:

Python
def greet(name, greeting="Hello"):
    return f"{greeting}, {name}!"

print(greet("Ada"))                     # Hello, Ada!
print(greet("Ada", "Hi"))               # Hi, Ada!
print(greet(name="Ada", greeting="Hey")) # Hey, Ada! — keyword arguments, order doesn't matter

Keyword arguments make call sites self-documenting, especially for functions with several optional parameters.

*args and **kwargs

*args collects any number of extra positional arguments into a tuple; **kwargs collects extra keyword arguments into a dict. Both are common in Python APIs that need to accept flexible input:

Python
def sum_all(*args):
    return sum(args)

print(sum_all(1, 2, 3, 4))   # 10

def build_profile(**kwargs):
    return kwargs

print(build_profile(name="Ada", age=30))
# {'name': 'Ada', 'age': 30}

def log(message, *args, **kwargs):
    print(message, args, kwargs)

log("event", 1, 2, source="api")
# event (1, 2) {'source': 'api'}

Classes and __init__

A class is defined with class, and its constructor is the __init__ method — Python automatically passes the new instance as the first argument, conventionally named self:

Python
class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary

    def give_raise(self, amount):
        self.salary += amount

    def describe(self):
        return f"{self.name} earns ${self.salary:,.2f}"

emp = Employee("Ada", 85000)
emp.give_raise(5000)
print(emp.describe())   # Ada earns $90,000.00

Unlike Java or C#, there's no private keyword — Python relies on convention (a leading underscore, self._balance, signals "internal, don't touch it directly") rather than compiler-enforced access control.

Instance methods, class methods, and static methods

Python
class Circle:
    pi = 3.14159   # class attribute, shared by all instances

    def __init__(self, radius):
        self.radius = radius   # instance attribute, unique per object

    def area(self):                      # instance method — needs an instance
        return Circle.pi * self.radius ** 2

    @classmethod
    def unit_circle(cls):                # class method — receives the class, not an instance
        return cls(radius=1)

    @staticmethod
    def is_valid_radius(value):          # static method — no access to self or cls at all
        return value > 0

c = Circle.unit_circle()
print(c.area())                          # 3.14159
print(Circle.is_valid_radius(-2))        # False

Use @classmethod for alternative constructors (like unit_circle above), and @staticmethod for a utility function that logically belongs to the class but doesn't need any instance or class state.

Inheritance

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

    def speak(self):
        return f"{self.name} makes a sound"

class Dog(Animal):
    def speak(self):
        return f"{self.name} barks"

class Cat(Animal):
    def __init__(self, name, indoor=True):
        super().__init__(name)   # call the parent's __init__
        self.indoor = indoor

    def speak(self):
        return f"{self.name} meows"

animals = [Dog("Rex"), Cat("Whiskers")]
for animal in animals:
    print(animal.speak())
# Rex barks
# Whiskers meows

super() gives access to the parent class's methods — essential when a subclass needs to extend, rather than fully replace, the parent's behavior.

Dunder (magic) methods

Methods surrounded by double underscores let your objects integrate with Python's built-in behavior — print(), ==, len(), and more:

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

    def __str__(self):
        # controls what print(point) and str(point) show
        return f"Point({self.x}, {self.y})"

    def __eq__(self, other):
        # controls what == does between two Point instances
        if not isinstance(other, Point):
            return NotImplemented
        return self.x == other.x and self.y == other.y

p1 = Point(1, 2)
p2 = Point(1, 2)

print(p1)             # Point(1, 2) — thanks to __str__
print(p1 == p2)        # True — thanks to __eq__ (without it, this would be False: default equality is identity)

Without __eq__, == falls back to comparing object identity (the same as is) — two separately-created Point instances with identical coordinates would compare unequal.

Common mistakes

  • The mutable default argument trap — a default argument is evaluated once, when the function is defined, not on every call:
Python
def add_item(item, items=[]):   # BUG: the same list is reused across calls
    items.append(item)
    return items

print(add_item("a"))   # ['a']
print(add_item("b"))   # ['a', 'b'] — surprise! Not a fresh list.

The fix is to default to None and create the mutable object inside the function:

Python
def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items
  • Forgetting self as the first parameter of an instance method (Python won't infer it for you).
  • Confusing @staticmethod (no access to self/cls) with @classmethod (receives cls, used for alternative constructors).

Interview questions

Q: What's the difference between @classmethod and @staticmethod? A @classmethod receives the class itself as its first argument (cls) and is commonly used for alternative constructors that need to create an instance of that class. A @staticmethod receives neither self nor cls — it's just a plain function namespaced inside the class for organizational purposes.

Q: Why is the mutable default argument gotcha dangerous, and how do you avoid it? Default argument values are evaluated exactly once, at function definition time — not once per call — so a mutable default (like [] or {}) is silently shared and accumulates state across every call that doesn't override it. The standard fix is to default the parameter to None and initialize the mutable value inside the function body.