Programming for Data Processing

Lectures: Nguyễn Tuấn Phong, Đào Việt Anh

Labs: Trịnh Ngọc Huỳnh, Phạm Tiến Du

Semester: 2025-2026-1

Institute for AI, VNU-UET

Lecture 3:
Functions
Modules
File Handling

1. Functions

Programming Principles: Decomposition & Abstraction

  • Decomposition: break problems into smaller parts
  • Abstraction: hide details, show essential features
  • Together: simplify, clarify, and maintain code

# decomposition: small reusable part (function)
def add(a, b):      
    return a + b

# abstraction: hide details from users
def calculator():   
    print(add(2, 3))
  

Functions

  • Write reusable pieces/chunks of code, called functions
  • Functions are not run until they are called or invoked
  • Function characteristics:
    • has a name
    • has parameters (0 or more)
    • has a docstring (optional but recommended)
    • has a body
    • returns something

How to Write and Call a Function


def is_even(i):
    """Check if an integer is even.

    Args:
        i (int): integer to check
    Returns:
        bool: True if even, False if odd
    """
    print("inside is_even")
    return i % 2 == 0

# Call the function
is_even(3)
  
  • def: keyword to define a function in Python
  • is_even: function name
  • i: function parameter
  • docstring: describes function
  • body: code inside function
  • How to call? Use function name and argument values, is_even(3)

Variable Scope

  • Scope: where a variable can be accessed
    • Local: defined inside a function
    • Global: defined outside functions

x = 10           # global

def show():
    y = 5        # local
    print(x, y)

show()           # works: 10 5
print(x)         # works: 10
print(y)         # error (not in scope)
  

Scope: Local vs Global

A more sophisticated example of scope:


a = 5                    # global

def g(x):                # global function g, parameter x (local to g)
    def h():             # nested function h, local to g
        x = 'abc'        # local to h
    x = x + 1            # local to g
    print('g: x =', x)
    h()
    return x

x = 3                    # global
z = g(x)                 # global
  

→ Run in Notebook

No return Statement

  • If no return, function gives None
  • Executes code but doesn’t send back a value

>>> def is_even(i):
...     print(i % 2 == 0)  # prints result
...
>>> result = is_even(3)
False
>>> print(result)
None
        

return vs. print

return print
Where used Only inside functions Works outside functions too
Count Only one return per call Can have many print statements
Execution Code after return not executed Code continues after print
Value Passed back to caller Output shown on console

More on Defining Functions

  • Functions can accept a variable number of arguments
  • Three forms of arguments (can be combined):
    • defaults
    • keyword arguments
    • arbitrary argument lists

Default Argument Values

Provide default values so callers can omit some arguments


def greet(name, msg="Hello"):
    print(msg, name)
  
  • Only the required argument:
    • greet("Alice") → Hello Alice
  • Override the optional argument:
    • greet("Bob", "Hi") → Hi Bob

Defaults: Evaluation Time

  • Defaults are evaluated at definition time

>>> i = 5
>>> def f(arg=i):
...     print(arg)
... 
>>> i = 6
>>> f()
5
    

⚠️ Mutable Defaults

  • Default values are evaluated once → shared across calls

>>> def f(a, L=[]):
...     L.append(a)
...     return L
... 
>>> print(f(1))
[1]
>>> print(f(2))
[1, 2]
>>> print(f(3))
[1, 2, 3]
    

Safe Pattern for Mutable Defaults

  • Use None sentinel and create object inside

def f(a, L=None):
    if L is None:
        L = []
    L.append(a)
    return L
    

Keyword Arguments

  • Call with kwarg=value; order doesn’t matter

>>> def greet(name, msg="Hello", punctuation="!"):
...     print(msg, name + punctuation)
  
  • One required argument: name
  • Two optional arguments: msg, punctuation

>>> greet("Alice")
Hello Alice!
>>> greet("Bob", msg="Hi")
Hi Bob!
>>> greet("Carol", punctuation=".")
Hello Carol.
>>> greet("Dave", punctuation=".", msg="Goodbye")
Goodbye Dave.

Example: Valid Calls


def greet(name, msg="Hello", punctuation="!"):
    print(msg, name + punctuation)

greet("Alice")                       # 1 positional
greet(name="Bob")                    # 1 keyword
greet(name="Carol", msg="Hi")        # 2 keywords
greet(msg="Hey", name="Dan")         # order doesn’t matter
greet("Eve", "Welcome")              # 2 positional
greet("Frank", punctuation=".")      # mix positional + keyword
    

Example: Invalid Calls


def greet(name, msg="Hello", punctuation="!"):
    print(msg, name + punctuation)

greet()                        # missing required arg 'name'
greet(name="Alice", "Hi")      # positional after keyword
greet("Bob", name="Bob")       # duplicate value for 'name'
greet(person="Eve")            # unknown keyword 'person'
    
  • Positional args must come before keyword args
  • No argument may get a value more than once

Special Parameters

  • Use / to mark positional-only params
  • Use * to mark start of keyword-only params
  • Both are optional

def f(pos1, pos2, /, pos_or_kwd, *, kwd1, kwd2):
      -----------    ----------     ----------
        |             |                  |
        |        Positional or keyword   |
        |                                - Keyword only
         -- Positional only
    

Argument Types


def f(pos1, pos2, /, pos_or_kwd, *, kwd1, kwd2):
      -----------    ----------     ----------
        |             |                  |
        |        Positional or keyword   |
        |                                - Keyword only
         -- Positional only
    
  • Positional-only (/): order matters, names not allowed
  • Positional-or-Keyword: default if no / or *
  • Keyword-only (*): must use names

Example: Function Parameters


def standard_arg(arg):
    print(arg)

def pos_only_arg(arg, /):
    print(arg)

def kwd_only_arg(*, arg):
    print(arg)

def combined_example(pos_only, /, standard, *, kwd_only):
    print(pos_only, standard, kwd_only)
    

standard_arg(2)
standard_arg(arg=2)

pos_only_arg(1)
pos_only_arg(arg=1)    # TypeError

kwd_only_arg(3)        # TypeError
kwd_only_arg(arg=3)

combined_example(1, 2, 3)                             # TypeError
combined_example(1, 2, kwd_only=3)                    # OK
combined_example(1, standard=2, kwd_only=3)           # OK
combined_example(pos_only=1, standard=2, kwd_only=3)  # TypeError
    

*args and **kwargs

  • *args: extra positional args → as a tuple
  • **kwargs: extra keyword args → as a dict
  • Order: *args before **kwargs

Example: *args and **kwargs


>>> def demo(x, *args, **kwargs):
...     print("x:", x)
...     print("args:", args)
...     print("kwargs:", kwargs)
... 
>>> demo(1, 2, 3, a=10, b=20)
x: 1
args: (2, 3)
kwargs: {'a': 10, 'b': 20}
>>> demo(1)
x: 1
args: ()
kwargs: {}
>>> demo(1, 2, 3)
x: 1
args: (2, 3)
kwargs: {}
>>> demo(1, a=10)
x: 1
args: ()
kwargs: {'a': 10}
  

Note: Both *args and **kwargs can be used to pass a variable number of arguments (0 or more) to a function.

More on *args

Params after *args are keyword-only


>>> def concat(*args, sep="/"):
...     return sep.join(args)
...
>>> concat("earth", "mars", "venus")
'earth/mars/venus'
>>> concat("earth", "mars", "venus", sep=".")
'earth.mars.venus'
    

>>> def f(*args, x):
...     print(args, x)
... 
    

Question: Given the function definition above, are the following function calls valid?

  1. f(1, 2, 3, x=4)
  2. f(x=4)
  3. f(1, 2, 3)

Avoiding Name Collisions

Positional-only names can appear in **kwds keys

Question: Explain the difference:


>>> def foo(name, **kwds):
...     return 'name' in kwds
...
>>> foo(1, name=2)
TypeError: foo() got multiple values for argument 'name'
    

>>> def foo(name, /, **kwds):
...     return 'name' in kwds
...
>>> foo(1, name=2)
True
    

Unpacking Argument Lists

  • * unpacks a list/tuple into positional args

>>> def add(a, b, c):
...     print(a + b + c)

>>> nums = [1, 2, 3]
>>> add(*nums)
6
  
  • ** unpacks a dict into keyword args

>>> def greet(name, msg="Hi"):
...     print(msg, name)

>>> opts = {"name": "Alice", "msg": "Hello"}
>>> greet(**opts)
Hello Alice
  

Lambda Expressions

  • lambda: defines a small anonymous function
  • Example: lambda x, y: x + y
  • Same as def, but only one expression

>>> f = lambda x, y: x + y
>>> f(1, 2)
3
>>> make_inc = lambda n: (lambda x: x + n)
>>> f = make_inc(10)
>>> f(5)
15
  

Lambda in Practice

  • Often used as a quick key function of sorted()

>>> nums = [5, 2, 9, 1]
>>> sorted(nums, key=lambda x: -x)
[9, 5, 2, 1]
>>> words = ["apple", "banana", "kiwi"]
>>> sorted(words, key=lambda w: len(w))
['kiwi', 'apple', 'banana']
  

Documentation Strings

  • Documentation is important to understand code
  • First line: concise summary, capitalized, ending with period
  • Second line: blank; then details

>>> def my_function():
...     """Do nothing, but document it.
...
...     No, really, it doesn't do anything.
...     """
...     pass
...
>>> print(my_function.__doc__)
Do nothing, but document it.

No, really, it doesn't do anything.
    

2. Modules

Modules: Intro

  • Scripts let you save and reuse code across sessions
  • Module: a .py file with definitions and statements
  • Use import to bring a module into your program

Creating and Using a Module

Create file fibo.py = Create a "fibo" module


# fibo.py: The Fibonacci module
def seq(n):
    a, b, out = 0, 1, []
    while a < n:
        out.append(a)
        a, b = b, a + b
    return out
  

Use the module in another script or interactive session


>>> import fibo  # import the module
>>> fibo.seq(20)
[0, 1, 1, 2, 3, 5, 8, 13]
  

>>> from fibo import seq as fib  # import funtion directly, rename
>>> fib(50)
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

More on Modules

  • Modules can contain executable statements and function definitions
  • Statements are executed only once when imported

# demo.py
print("Module is being imported")

def hello():
    print("Hello from demo")
  

>>> import demo
Module is being imported
>>> import demo   # nothing happens this time
>>> demo.hello()
Hello from demo
  

import Variants


# fibo.py
def fib(n):
    pass

def fib2(n):
    pass
  

Import specific functions:


>>> from fibo import fib, fib2
>>> fib(500)
  

Import everything (⚠️ not recommended):


>>> from fibo import *
>>> fib(500)
  

import Variants

Import module under an alias:


>>> import fibo as fib
>>> fib.fib(500)
  

Import function under an alias:


>>> from fibo import fib as fibonacci
>>> fibonacci(500)
  

Executing Modules as Scripts

  • Use if __name__ == "__main__": to add script behavior to a module
    • Allows file to be both script and importable module
  • Run a module directly:
    
                    $ python module.py args
                  

Executing Modules as Scripts: Example


# fibo.py
def fib(n):
    a, b = 0, 1
    while a < n:
        print(a, end=" ")
        a, b = b, a + b
    print()

# This block runs only if the module is executed directly (via command line)
if __name__ == "__main__":
    import sys
    fib(int(sys.argv[1])) # sys.argv[1] is the first command-line argument
  

Run the script with a command-line argument:


$ python fibo.py 50
0 1 1 2 3 5 8 13 21 34

Importing the module does not run the script portion:


>>> import fibo   # importing does not run fib()
>>> fibo.fib(10)
0 1 1 2 3 5 8
  

Module Search Path

  • When importing a module, Python searches for its path in this order:
    1. Built-in modules
    2. Current directory / script dir
    3. PYTHONPATH environment var
    4. Default installation dirs (site-packages)
  • Available in sys.path once initialized

“Compiled” Python Files

  • Modules cached in __pycache__
  • Filename: module.version.pyc
  • Faster load time, platform independent

Standard Modules

  • Python comes with a large standard library
  • Some modules are platform-specific
    • E.g., winreg only on Windows
  • sys is built-in: system functions and params

>>> import sys
>>> sys.ps1
'>>> '
>>> sys.ps2
'... '
>>> sys.ps1 = 'C> '
C> print('Yuck!')
Yuck!
    

The dir() Function

  • dir(module) → names defined in module
  • No args: shows current namespace

>>> a = [1, 2, 3, 4, 5]
>>> import fibo
>>> dir(fibo)
['__name__', 'fib', 'fib2']
>>> dir()
['__builtins__', '__name__', 'a', 'fib', 'fibo', 'sys']
    
  • Built-ins listed in builtins module

Packages

  • Package is a folder containing related modules
  • Each package folder contains __init__.py
  • Can contain subpackages
  • Helps organize large module collections

shapes/              Top-level package
    __init__.py
    utils.py
    geometry/        Subpackage
        __init__.py
        circle.py
        square.py
  

Importing from Packages


# Full path
import shapes.geometry.circle
shapes.geometry.circle.area(5)

# From subpackage
from shapes.geometry import square
square.perimeter(4)

# Direct function
from shapes.geometry.circle import area
area(5)
  

Importing * From a Package

  • __all__ in __init__.py controls what gets imported

# shapes/geometry/__init__.py
__all__ = ["circle", "square"]
  

# main.py
from shapes.geometry import *
print(circle.area(5))
print(square.perimeter(4))
  

‼️ Best practice: avoid import * in production code

Intra-package References


shapes/              
    __init__.py
    utils.py
    geometry/        
        __init__.py
        circle.py
        square.py
  

Absolute import (full path):


from shapes.geometry import circle
      

Relative import (use leading dots):


from . import circle           # current package
from .. import utils           # parent package
from ..geometry import square  # module from parent package
      

3. File Handling

Reading and Writing Files

  • open(filename, mode, encoding) → file object
  • Common modes:
    • 'r': read (default)
    • 'w': write (overwrite)
    • 'a': append
    • 'r+': read + write
  • Append 'b' → binary mode, e.g., 'rb', 'wb'
  • Recommended: Use encoding="utf-8" for text files

Example: Text Files


>>> with open("workfile", "w", encoding="utf-8") as f:
...     f.write("This is a test.\n")
... 
16
>>> with open("workfile", encoding="utf-8") as f:
...     read_data = f.read()
... 
>>> read_data
'This is a test.\n'
          

Text vs Binary Mode

  • Text mode: converts line endings
    • '\r\n' (Windows) → '\n'
    • '\n' (Linux, macOS) → '\n'
  • Binary mode: no conversion, data read as bytes
  • Always use binary mode for non-text files (e.g. JPEG, EXE)

✅ Best Practice: with

  • Use with to auto-close files
  • Safer and shorter than try / finally

>>> with open("workfile", encoding="utf-8") as f:
...     read_data = f.read()
... 
>>> # Check that the file has been automatically closed.
>>> f.closed
True
    

Closing Files

  • Must call f.close() if not using with
  • Otherwise, writes may not be flushed to disk

>>> f.close()
>>> f.read()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: I/O operation on closed file.
    

File Methods: f.read(size)

  • Read entire file or up to size chars/bytes
  • Returns str (text mode) or bytes (binary mode)

>>> with open("workfile", "w", encoding="utf-8") as f: 
...     _ = f.write("Hello\nWorld\n")
... 
>>> with open("workfile", "r", encoding="utf-8") as f:
...     f.read()
... 
'Hello\nWorld\n'
>>> with open("workfile", "r", encoding="utf-8") as f:
...     f.read(5)
... 
'Hello'
  

File Methods: f.readline()

  • Read a single line (keeps trailing \n if present)
  • Last line in file: Returns an empty string

>>> with open("workfile", "r", encoding="utf-8") as f:
...     f.readline()
... 
'Hello\n'
>>> with open("workfile", "r", encoding="utf-8") as f:
...     f.readline(); f.readline(); f.readline()
... 
'Hello\n'
'World\n'
''
  

File Methods: Iterate over Lines

Use a for loop to read lines one by one:


>>> with open("workfile", "r", encoding="utf-8") as f:
...     for line in f:
...         print(line.strip())
Hello
World
  
  • Iterate lines efficiently; minimal memory
  • Each line includes trailing \n
  • Bonus: s.strip() removes whitespace from string s

File Methods: f.write()

  • Writes text/bytes to file
  • Returns number of characters/bytes written
  • Note: Only str is allowed in text mode

>>> with open("log.txt", "w", encoding="utf-8") as f:
...     n = f.write("This is a test.\n")
... 
>>> n
16
  

File Methods: f.tell()

  • Get current file position

>>> with open("workfile", encoding="utf-8") as f:
...     f.read(4)
...     f.tell()
...
'This'
4
  

File Methods: f.seek(offset, whence)

  • Move file pointer to a new position
  • whence: 0=beginning (default), 1=current, 2=end
  • In text mode: only absolute seeks are allowed, i.e., whence=0
    • Special case: seek(0, 2) (got to end of file) is also allowed

>>> with open("workfile", "w", encoding="utf-8") as f:
...     _ = f.write("Hello\nWorld\n")
...
>>> with open("workfile", "r", encoding="utf-8") as f:
...     f.seek(0)      # Go to beginning
...     f.read(5)
...     f.seek(0, 2)   # Go to end
...     f.read()
...     
0
'This '
16
''
  

Saving Structured Data with JSON

  • JSON: common format for data exchange (e.g., APIs)
  • Serialization: convert Python objects → JSON string
    • json.dumps(obj): serialize to string
    • json.dump(obj, f): serialize to file
  • Deserialization: JSON string → Python objects
    • json.loads(s): deserialize from string
    • json.load(f): deserialize from file

Example: Handling JSON

To/From Strings


>>> import json
>>> data = {"name": "Alice", "age": 30, "is_member": True}
>>> # Serialize a dict to JSON string
>>> s = json.dumps(data)  
>>> s
'{"name": "Alice", "age": 30, "is_member": true}'
>>> # Deserialize JSON string back to dict
>>> json.loads(s)
{'name': 'Alice', 'age': 30, 'is_member': True}
  

To/From Files


>>> with open("data.json", "w", encoding="utf-8") as f:
...     json.dump(data, f)
>>> with open("data.json", "r", encoding="utf-8") as f:
...     loaded = json.load(f)
>>> loaded
{'name': 'Alice', 'age': 30, 'is_member': True}
  

json vs pickle

  • json: just text files, human-readable, cross-language
  • pickle: binary files, saves/loads arbitrary Python objects, but insecure with untrusted input

CSV: Tabular Data

  • CSV = Comma-Separated Values, common for tabular data
  • Python has built-in csv module
  • Supports reading and writing CSV files easily

Working with CSV — Writing

  • Use csv.writer for lists
  • Use csv.DictWriter for dicts

>>> import csv
>>> rows = [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}]
>>> with open("data.csv", "w", encoding="utf-8") as f:
...     writer = csv.DictWriter(f, fieldnames=rows[0].keys())
...     writer.writeheader()
...     writer.writerows(rows)
  

The created data.csv file contains:


name,age
Alice,30
Bob,25
  

Working with CSV — Reading

  • Use csv.reader to read rows as lists of values
  • Use csv.DictReader to read rows into dicts

>>> import csv
>>> with open("data.csv", encoding="utf-8") as f:
...     reader = csv.DictReader(f)
...     for row in reader:
...         print(row)
...
{'name': 'Alice', 'age': '30'}
{'name': 'Bob', 'age': '25'}
  

4. Bonus:
Fancier Output Formatting

Intro

  • Control layout beyond simple print()
  • Main tools: f-strings, str.format(), manual string methods
  • Debug helpers: str() vs repr()

Formatted String Literals — Basics

  • Prefix with f, embed {expressions}

>>> year, event = 2025, "IAI Connect"
>>> f"Summary of the {year} {event}"
'Summary of the 2025 IAI Connect'
  

Formatted String Literals — Precision & Width

  • Use : for format specifiers (precision)

>>> import math
>>> f"pi ≈ {math.pi:.3f}"
'pi ≈ 3.142'
  

Formatted String Literals — Alignment

  • Set field width to align columns

>>> tbl = {"Sjoerd": 4127, "Jack": 4098}
>>> for name, phone in tbl.items():
...     print(f"{name:10} => {phone:8d}")
Sjoerd     =>     4127
Jack       =>     4098
  

Formatted String Literals — Conversions

  • !r uses repr() (unambiguous)
  • !s uses str() (user-friendly)

        >>> animals = "eels"
        >>> f"My hovercraft is full of {animals!r}."
        "My hovercraft is full of 'eels'."
        >>> f"My hovercraft is full of {animals!s}."
        'My hovercraft is full of eels.'
          

Formatted String Literals — Self-Documenting (3.8+)

  • = shows name=value

>>> bugs, count = "roaches", 13
>>> f"{bugs=}, {count=}"
"bugs='roaches', count=13"
  

str.format() — Basics

  • Placeholders {} replaced by values

>>> "We are the {} who say '{}'!".format("knights", "Ni")
'We are the knights who say "Ni"!'
  

str.format() — Positional Arguments

  • Use numbers inside {} for positions

>>> "{0} and {1}; {1} and {0}".format("spam", "eggs")
'spam and eggs; eggs and spam'
  

str.format() — Keyword Arguments

  • Use names in {} for keywords

>>> "This {food} is {adjective}.".format(food="spam", adjective="awful")
'This spam is awful.'
  

str.format() — Dict Unpacking

  • Unpack dictionary with ** to match keys

>>> table = {"Jack": 4098, "Sjoerd": 4127, "Dcab": 8637678}
>>> "Jack: {Jack:d}; Sjoerd: {Sjoerd:d}; Dcab: {Dcab:d}".format(**table)
'Jack: 4098; Sjoerd: 4127; Dcab: 8637678'
  

str.format() — Column Formatting

  • Use width and alignment specifiers

>>> for x in range(1, 6):
...     print("{0:2d} {1:3d} {2:4d}".format(x, x*x, x*x*x))
 1   1    1
 2   4    8
 3   9   27
 4  16   64
 5  25  125
  

Manual String Formatting

  • Use str.rjust(), ljust(), center(), zfill()
  • Good for quick padding without placeholders

>>> # Right-justify columns (same table as previous slide's)
>>> for x in range(1, 6):
...     left  = repr(x).rjust(2)
...     mid   = repr(x*x).rjust(3)
...     right = repr(x*x*x).rjust(4)
...     print(left, mid, right)
...
 1   1    1
 2   4    8
 3   9   27
 4  16   64
 5  25  125
>>> # Pad with zeros
>>> "12".zfill(5), "-3.14".zfill(7)
('00012', '-003.14')
  

Old-Style Formatting (%)

  • "% spec" syntax: Legacy
  • Now prefer f-strings/format()

>>> import math
>>> "pi ≈ %5.3f" % math.pi
'pi ≈ 3.142'