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:
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:
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:
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:
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
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
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:
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:
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:
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
- Forgetting
selfas the first parameter of an instance method (Python won't infer it for you). - Confusing
@staticmethod(no access toself/cls) with@classmethod(receivescls, 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.