Skip to content

Latest commit

 

History

History
1043 lines (704 loc) · 24.7 KB

File metadata and controls

1043 lines (704 loc) · 24.7 KB

Functions

A function is a mapping of zero or more input parameters to zero or more output parameters.

The advantages of using functions are:

  • code organization
  • reducing duplication of code
  • decomposing complex problems into simpler pieces
  • improving clarity of the code
  • reuse of code
  • information hiding

Functions in Python are first-class citizens. It means that functions have equal
status with other objects in Python. Functions can be assigned to variables, stored
in collections, or passed as arguments. This brings additional flexibility to the language.

Function definition
Kinds of functions
Third-party functions
Docstrings
Instance, class, plain, inner functions
Functions are objects
Function scope
Implicit arg value
Unpacking
Passing by reference
Global variables
The pass keyword
Returning values
Arbitrary number of args
Nested functions
Passing functions as parameters
Function redefinition
Function overloading
No function hoisting
Collection of functions
Annotations

Function definition

Functions are defined with the def keyword.

# fahrenheit.py

def cel_to_fahr(c):
    return c * 9/5 + 32

print(cel_to_fahr(100))
print(cel_to_fahr(0))
print(cel_to_fahr(30))

Kinds of functions

  • built-in readily available
  • standard in modules
  • custom
from math import sqrt

def cube(x):
    return x * x * x    
    
print(abs(-1))
print(cube(9))
print(sqrt(81))

Third-party functions

Third-party functions defined in external modules.
For instance, the numpy module.

$ pip install numpy
import numpy as np

# generate 10 random integers from 1 to 100
r_vals = np.random.randint(1, 100, 10)
print(r_vals)

# generate 1 random integer from 1 to 10
r_vals2 = np.random.randint(1, 10)
print(r_vals2)

# generate an array of random integers
r_vals3 = np.random.randint(5, size=(2, 4))
print(r_vals3)

Docstrings

A docstring is a string literal placed as the first statement inside a function,
class, or module. It describes what the function does, its parameters, its return
value, and any exceptions it may raise. The conventions follow PEP 257.

# docstrings.py

def celsius_to_fahrenheit(celsius):
    """
    Convert temperature from Celsius to Fahrenheit.

    Args:
        celsius (float): Temperature in degrees Celsius.

    Returns:
        float: Temperature in degrees Fahrenheit.
    """
    return celsius * 9/5 + 32

# Accessing the docstring
print(celsius_to_fahrenheit.__doc__)

# The built-in help() function displays the docstring nicely.
help(celsius_to_fahrenheit)

Good docstrings make code more readable and enable tools like pydoc or IDE
hints to provide instant documentation.

Instance, class, plain, inner functions

Functions defined inside classes are member functions. They are
often called methods.

class Info:

    def say(self):
        print('This is Info class')

class Some:

    @staticmethod
    def f():
        print ("f() static method")

def f():
    print ("f() plain function")

def g():
    def f():
        print ("f() inner function")
    f()


i = Info()
i.say()

Some.f()
f()
g()

self is a reference to the current instance of a class. It's how an
object refers to itself, allowing each instance to access its own data
and methods.

class Circle:
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return 3.14 * self.radius ** 2

    def circumference(self):
        return 2 * 3.14 * self.radius


c = Circle(5)
print("Area:", c.area())
print("Circumference:", c.circumference())

c2 = Circle(10)
print("Area of c2:", c2.area())
print("Circumference of c2:", c2.circumference())

self must be the first parameter in every instance method — it's a
Python convention (you could name it anything, but self is universal)
You never pass it manually — Python injects it automatically when you call c.area()
It's not a keyword — it's just a strongly followed naming convention.

Functions are objects

Object attributes are accessed with the dot operator.
The object is the mother of all objects in Python; every Python
object implicitly derives from object.

# functions in Python are objects

def f():
    """This function prints a message """
    return 'f() function'

print(isinstance(f, object))
print(id(f))
print(f())

print(f.__doc__)
print(f.__name__)

In Python, everything is an object.

#!/usr/bin/env python

import sys

def f():
    pass

print(type(1))
print(type(""))
print(type([]))
print(type({}))
print(type(()))
print(type(object))
print(type(f))
print(type(sys))

Function scope

Variables defined inside a function have a function scope. They are also
called local variables.

# a local variable is valid in the 
# function scope

name = "Jack"

def f():
   name = "Robert"
   print("Within function", name)

print("Outside function", name)
f()

Implicit arg value

def power(x, y=2):

    r = 1

    for i in range(y):
        r = r * x

    return r

print(power(3))
print(power(3, 3))
print(power(5, 5))
# print(1, end=' ')
# print(2, end=' ')
# print(3, end=' ')
# print(4, end=' ')
# print(5, end=' ')
# print(6, end=' ')

print(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, sep=', ')
print('sky', 'blue', 'sink', 'pen', 'rock', sep='|')

Unpacking

Unpacking is cutting an object (such as a list) into its elements.
It is also called destructuring. The _ operator is used to ignore the
value. The * operator eagerly takes all elements until the next
argument.

Unpacking function return values

def fn():
    return [1, 2, 3, 4, 5, 6]

a, b, c, d, e, f = fn()
print(a, b, c, d, e, f)

a, *mid, b = fn()
print(a, mid, b)

a, b, c, _, _, _ = fn()
print(a, b, c)

a, b, *_ = fn()
print(a, b)

a, b, c, *d = fn()
print(a, b, c, d)

*a, b, c, d = fn()
print(a, b, c, d)

Unpacking function arguments

After the star operator, only keyword arguments can be used.

def fn(a, b, c, d, e, f):
    print(a, b, c, d, e, f)

def fn2(a, b, c, *d):
    print(a, b, c, d)

def fn3(a, b, c, *d, e, f):
    print(a, b, c, d, e, f)

fn(1, 2, 3, 4, 5, 6)
fn2(1, 2, 3, 4, 5, 6)
fn3(1, 2, 3, 4, e=5, f=6)

A list can be destructured into elements and passed to the function.

def fn(a, b, c, d, e, f):
    print(a, b, c, d, e, f)

def fn2(a, b, c, *d):
    print(a, b, c, d)

def fn3(a, b, c, *d, e, f):
    print(a, b, c, d, e, f)

vals = [1, 2, 3, 4, 5, 6]

fn(*vals)
fn2(*vals)
fn3(*vals, e=7, f=8)

The **kwargs defines a keyworded, variable-length argument list

def display(**user):

    for k, v in user.items():
        print(f'{k}: {v}')


display(name='Lary Jones', age=43, sex='M')
display(name='Jone Doe', occupation='gardener', age=35)

In Python, a function defined with only **kwargs like def display(**user)
accepts any number of keyword arguments and stores them as a dictionary.
Inside the function, user.items() is used to iterate over the dictionary,
returning each key-value pair as k and v, which are then printed using an f-string.

The real power here is flexibility — notice that the two calls to display() don't
need to pass the same arguments. The first call passes name, age, and sex, while
the second passes name, occupation, and age. The function handles both without
any changes, because **kwargs doesn't care how many or which keyword arguments are
passed — it simply captures whatever is given into a dictionary and works with it dynamically.


def show(a, *args, **kwargs):
    print(a)
    print(args)
    print(kwargs)

show(1, 2, 3, 4, 5, 6, name='John Doe', occupation='gardener', age=34)

In Python, functions can be made highly flexible using *args and **kwargs.
When defining a function like def show(a, *args, **kwargs), the regular parameter a
captures the first positional argument, *args collects any additional positional
arguments into a tuple, and **kwargs collects any keyword (named) arguments
into a dictionary. So calling show(1, 2, 3, name='John') would
give a=1, args=(2, 3), and kwargs={'name': 'John'}.

When it comes to positioning, the order strictly matters — regular parameters
must come first, followed by *args, and finally **kwargs must always be
last. Writing them in any other order, such as placing **kwargs before
*args, will raise a SyntaxError. This ordering rule ensures Python can
unambiguously separate plain values, extra positional values, and named values
from one another when the function is called.


This code demonstrates using ** to unpack a dictionary directly into
a function call. The display function expects three named parameters —
name, occupation, and age — and when called with **u1, Python unpacks
the dictionary so that each key maps to its matching parameter by name.

def display(name, occupation, age):

    print(f'{name}, {occupation}, {age}')

u1 = {'name': 'Lary Jones', 'occupation': 'driver', 'age': 45}
u2 = {'name': 'Jone Doe', 'occupation': 'gardener', 'age': 35}

display(**u1)
display(**u2)

This is essentially the reverse of **kwargs — instead of collecting keyword
arguments into a dictionary inside the function, here you're spreading
a dictionary out into keyword arguments at the call site. It's a clean and
practical pattern when your data is already stored in a dictionary and matches
the function's parameter names, saving you from having to write display(name=u1['name'], occupation=u1['occupation'], age=u1['age']) manually.

Positional and keyword only

Python 3.8 introduced the / parameter to specify that all parameters before it
must be passed positionally. Similarly, a single * marks the beginning of
keyword-only parameters. This allows API designers to enforce a strict
calling convention.

# position_only_example.py

def describe_person(first, last, /, title='', *, age, city):
    """
    Print a person's description.
    first, last  -> positional-only (before /)
    title        -> optional, positional or keyword (between / and *)
    age, city    -> keyword-only, required (after *)
    """
    full = f"{title + ' ' if title else ''}{first} {last}"
    print(f"{full}, age {age}, from {city}")

# Valid calls
describe_person("Jane", "Doe", "Dr.", age=30, city="New York")  # title passed positionally
describe_person("John", "Smith", age=45, city="London")          # title omitted, uses default ''

# Invalid calls
# describe_person(first="Jane", last="Doe", age=30, city="NY")   # Error: first and last are positional-only
# describe_person("Jane", "Doe", 30, "NY")                       # Error: age and city must be passed as keywords

The function signature def describe_person(first, last, /, title='', *, age, city)
demonstrates all three parameter zones in one definition. first and last sit
before the /, making them positional-only — they cannot be passed by name. title
sits between / and *, meaning it's flexible and can be passed either positionally
or as a keyword, and since it has a default value of '' it's also optional.
age and city come after the bare *, making them keyword-only — they must always
be passed by name like age=30, and since they have no defaults, they are required.

Using / and * together gives API designers precise control over how a function
must be called, prevents accidental reliance on parameter names that might change
in future versions, and makes the intended calling convention immediately visible
from the signature itself.

Passing by reference

In Python, mutable objects are passed by reference as function arguments.
A list is passed by reference to the function. Therefore, the original list is
modified inside a function.

n = [1, 2, 3, 4, 5]

print('Original list:', n)

def f(x):

    x.pop()
    x.pop()
    x.insert(0, 0)
    print('Inside f():', x)

f(n)

print('After function call:', n)

Global variables

Global variables are defined in module.
Global variables are automatically valid in functions.

name = "Jack"

def f():
   print("Within function", name)

print("Outside function", name)
f()

The global keyword allows us to modify the variable outside
of the current scope.

name = "Jack"

def f():
    
   global name 
   name = "Robert"
   print ("Within function", name)


print("Outside function", name)
f()
print("Outside function", name)

The pass keyword

The pass keyword is used to define functions that are not yet implemented.

def f():
    pass

def g():
    pass
  
def h():
    return 'h fun'

print(f())
print(g())
print(h())

print(f.__name__)
print(g.__name__)
print(h.__name__)

Returning values

The return keyword is used to return values from functions.
A function returns None if no keyword is defined.

# returning.py

def showMessage(msg):

    print(msg)


def cube(x):

    return x * x * x


x = cube(3)
print(x)

showMessage("Computation finished.")
print(showMessage("Ready."))

We can return multiple values with tuples.

n = [1, 2, 3, 4, 5]

def stats(x):

    mx = max(x)
    mn = min(x)
    ln = len(x)
    sm = sum(x)

    return mx, mn, ln, sm

mx, mn, ln, sm = stats(n) # deconstruction
print(stats(n))

print(mx, mn, ln, sm)

Arbitrary number of args

With * operator, function can accept arbitrary number of arguments.

def do_sum(*args):
   '''Function returns the sum 
   of all values'''
   
   r = 0
   
   for i in args:
      r += i
      
   return r


print(do_sum.__doc__)
print(do_sum(1, 2, 3))
print(do_sum(1, 2, 3, 4, 5))

Nested functions

Nested/inner functions are functions defined inside other functions.

# nested functions are functions defined inside other
# functions

def myfun():
    print("inside myfun()")

    def greet():
        return "greeting message"

    def welcome():
        return "welcoming message"

    print(greet())
    print(welcome())
    print("inside myfun()")

myfun()

Passing functions as parameters

Functions can be passed to other functions as parameters.

def inc(x):
    return x + 1

def dec(x):
    return x - 1

def operate(fun, x):

    res = fun(x)
    return res

x = 2

print(operate(inc, x))
print(x)

print(operate(dec, x))
print(x)

Function redefinition

Python allows to redefine existing function definitions.

# redefinition.py

from time import gmtime, strftime


def showMessage(msg):

    print(msg)


showMessage('Ready.')


def showMessage(msg):

    print(strftime('%H:%M:%S', gmtime()))
    print(msg)


showMessage('Processing.')

Function Overloading

Python does not support traditional method overloading as found in Java or C#. In those
languages, you can define multiple methods with the same name as long as their parameter
types or counts differ, and the compiler picks the right one at call time. Python, being
dynamically typed and interpreted, takes a different approach: the last definition wins.

class A:
    def foo(self, x):
        print("one argument")

    def foo(self, x, y):      # silently replaces the first foo
        print("two arguments")

a = A()
a.foo(1)      # TypeError: foo() missing 1 required positional argument: 'y'
a.foo(1, 2)   # "two arguments"

The class body is just a namespace dict. Assigning foo twice simply overwrites the key.
There is no signature table to dispatch from.

Idiomatic Alternatives

1. Default Arguments — the go-to approach

The simplest and most Pythonic solution. Mark optional parameters with a sentinel
default (usually None) and branch inside the method.

class Formatter:
    def render(self, text, width=None, align="left"):
        if width is None:
            return text
        return f"{text:{align}{width}}"

f = Formatter()
print(f.render("hello"))           # "hello"
print(f.render("hello", 10))       # "hello     "
print(f.render("hello", 10, ">"))  # "     hello"

When to use: whenever callers share a common "simple" case and a richer "extended" case.

2. *args / **kwargs — flexible arity

Accept any number of positional or keyword arguments and dispatch on their count or content.

class Vector:
    def __init__(self, *args):
        if len(args) == 2:
            self.x, self.y, self.z = *args, 0
        elif len(args) == 3:
            self.x, self.y, self.z = args
        else:
            raise TypeError(f"Expected 2 or 3 arguments, got {len(args)}")

    def __repr__(self):
        return f"Vector({self.x}, {self.y}, {self.z})"

print(Vector(1, 2))      # Vector(1, 2, 0)
print(Vector(1, 2, 3))   # Vector(1, 2, 3)

When to use: when argument count varies naturally, or when you are wrapping another
callable. Avoid overusing it — it erases type information and makes signatures harder to understand.

3. typing.overload — type-safe signatures (no runtime dispatch)

The @overload decorator lets you declare multiple typed signatures for type checkers
(mypy, Pyright) while keeping a single runtime implementation. It provides no dispatch
logic — it is purely a static-analysis tool.

from typing import overload

class Parser:
    @overload
    def parse(self, data: str) -> list[str]: ...
    @overload
    def parse(self, data: bytes) -> list[bytes]: ...

    def parse(self, data):          # actual implementation — no decorator
        if isinstance(data, bytes):
            return data.split(b",")
        return data.split(",")

p = Parser()
print(p.parse("a,b,c"))            # ['a', 'b', 'c']
print(p.parse(b"a,b,c"))          # [b'a', b'b', b'c']

The overloaded stubs (ending in ...) are invisible at runtime — only the final, undecorated
definition executes. Type checkers use the stubs to validate call sites and infer return types correctly.

When to use: any time the return type depends on argument types. This is the standard
approach in well-typed library code.

4. functools.singledispatch — runtime type dispatch for functions

singledispatch registers separate implementations per argument type and selects
the right one at call time based on the type of the first argument.

from functools import singledispatch

@singledispatch
def serialize(value):
    raise NotImplementedError(f"No serializer for {type(value)}")

@serialize.register
def _(value: int) -> str:
    return str(value)

@serialize.register
def _(value: list) -> str:
    return "[" + ", ".join(serialize(v) for v in value) + "]"

@serialize.register
def _(value: str) -> str:
    return f'"{value}"'

print(serialize(42))              # "42"
print(serialize("hi"))            # '"hi"'
print(serialize([1, "two", 3]))   # '[1, "two", 3]'

New types can be registered from outside the module, making this pattern ideal for extensible
plugin-style APIs. When to use: library or framework code where callers need to register
handlers for their own types.

5. singledispatchmethod — same idea, for methods (Python 3.8+)

singledispatch does not work on methods directly because the dispatcher sees self as the first argument. singledispatchmethod handles this correctly.

from functools import singledispatchmethod

class Renderer:
    @singledispatchmethod
    def draw(self, shape):
        raise NotImplementedError(f"Cannot draw {type(shape)}")

    @draw.register
    def _(self, shape: int):          # stand-in for a Circle radius
        print(f"Drawing circle with radius {shape}")

    @draw.register
    def _(self, shape: str):          # stand-in for a named shape
        print(f"Drawing shape: {shape}")

r = Renderer()
r.draw(5)        # Drawing circle with radius 5
r.draw("star")   # Drawing shape: star

When to use: the same scenarios as singledispatch, but inside a class. Still relatively
uncommon — prefer @overload + isinstance branching for simpler cases.

Comparison at a Glance

Technique Runtime dispatch Type checker support Extensible externally Best for
Default arguments No ✓ (with @overload) No Simple optional params
*args / **kwargs Manual Partial No Variable arity
typing.overload No ✓✓ No Typed signatures
singledispatch Yes Yes Library / plugin APIs
singledispatchmethod Yes Yes Same, inside a class

What Python Developers Actually Do

In practice, most Python code reaches for default arguments first, because:

  • The signature stays readable.
  • IDEs and type checkers handle it well.
  • There is no indirection to trace through.

@overload is the right next step when the return type changes with the input type and
you care about static correctness. singledispatch earns its place in extensible library
code. *args/**kwargs are valuable but should come with clear documentation, since
they erase the signature.

The deeper Python philosophy is duck typing: rather than dispatching on a type,
write code that works with any object that supports the needed protocol. A function that
calls .read() on its argument does not need to dispatch on File vs BytesIO vs StringIO — it just
calls .read() and lets the object sort it out.

No function hoisting

Functions must be defined before being called. Python does not support
function hoisting like JavaScript.

def f1():
    print("f1()")


f1()
# f2()


def f2():
    print("f2()")

Collection of functions

Python is flexible, it allows to store functions in collections.

def f():
    pass

def g():
    pass
  
def h(f):
    print (id(f))
  
a = (f, g, h)

for i in a:
    print(i)
    
h(f)
h(g)

Annotations

Python allows you to annotate the parameters and return value of a function with
arbitrary expressions. When used to specify expected data types, they are called
type hints (standardised in PEP 484). The Python runtime does not enforce
type hints, but they serve as live documentation and are checked by static
analysers like mypy.

Enforce type checking in VS Code with "python.analysis.typeCheckingMode": "basic".

# annotations.py

def greet(name: str, times: int = 1) -> str:
    """Return a greeting repeated *times* times."""
    return (f"Hello, {name}!\n" * times).rstrip()

print(greet("Alice", 2))
print(greet("Bob"))          # uses default times=1

# Annotations are stored in the __annotations__ attribute
print(greet.__annotations__)  

Annotations can be any Python expression, not just types, though type hints are
by far the most common use case. They improve code clarity and enable better
autocompletion and linting in modern editors.