Python Basics

Python Basics

December 29, 2021·Jingyao Zhang
Jingyao Zhang

image

Summary of Function Concepts


Calling Functions


Python has many useful built-in functions that can be called directly. To call a function, you need to know its name and parameters. You can check the official Python documentation or use the help function, such as help(abs).

If you pass the wrong number of arguments when calling a function, a TypeError will be raised, and Python will clearly tell you how many arguments the function requires. If the number of arguments is correct but the argument type is not accepted by the function, a TypeError will also be raised.

Some functions can accept any number of arguments and return the largest one, such as the max function:

max(1, 3)  # 3
max(1, -3, 2)  # 2

Data Type Conversion

Python’s built-in functions also include data type conversion functions, such as int, str, etc.

A function name is actually a reference to a function object, so you can assign the function name to a variable, which is equivalent to giving the function an “alias”:

m = max  # Variable m points to the max function
m(1, 3, 5)  # You can call the max function through variable m

Defining Functions


In Python, functions are defined using the def statement, followed by the function name, parentheses, parameters, a colon, and then the indented function body. The return statement is used to return a value. Example: absolute value function my_abs:

def my_abs(x):
        if x >= 0:
                return x
        else:
                return -x

When the function body executes a return statement, it ends and returns the result. If there is no return statement, the function returns None by default, which is equivalent to return None or just return.

Empty Functions

If you want to define an empty function that does nothing, you can use the pass statement in the function body. For example:

if height < 180:
        pass

Parameter Checking

As mentioned above, if you call a function with the wrong number of arguments, Python will raise a TypeError. However, if the argument type is incorrect, Python will not automatically check it like built-in functions do. This may cause problems during execution. You can use Python’s built-in isinstance function to check parameter types:

def my_abs(x):
        if not isinstance(x, (int, float)):
                raise TypeError("bad operand type")  # If the wrong type is passed, the function raises an error
        if x >= 0:
                return x
        else:
                return -x

Returning Multiple Values

You can use return A, B to return two values, or more. In fact, Python always returns a single value, but when returning multiple values, they are automatically packed into a tuple.

Function Parameters


When defining a function, the parameter names and positions are determined, and the function interface is defined. Although Python function definitions are simple, they are very flexible.

Positional Parameters

Example: a function to calculate the power of a number:

def power(x):
        return x ** x

For the power function, the parameter x is a positional parameter. When calling power, you must pass exactly one argument. But the above function is not practical if you want to calculate cubes, fourth powers, etc. You would have to write many functions. You can modify the power function as follows:

def power(x, n):
        s = 1
        while n > 0:
                n -= 1
                s *= x
        return s

This modified power function can calculate any power, and now it has two positional parameters, x and n. When calling, you need to assign values to these two parameters in order.

Default Parameters

In daily development, calculating squares is more common than other powers. You can use default parameters to set the default value of n to 2:

def power(x, n = 2):
        s = 1
        while n > 0:
                n -= 1
                s *= x
        return s

So calling power(5) is equivalent to power(5, 2). For cases where n ≠ 2, you need to explicitly pass the value of n. Default parameters simplify function calls, but there are some pitfalls:

  • Default parameters must come after required parameters;
  • If there are multiple default parameters, you can provide some of them in order, or provide them out of order by specifying parameter names;

(e.g., add_student('Jensen', 'Male', city = 'Hefei') means city uses the passed value, other default parameters use their defaults)

  • Default parameters must point to immutable objects;

(For example:

def add_end(L = []):
        L.append('END')
        return L

Calling add_end() twice returns ['END', 'END'] because the default parameter is a variable pointing to [], and its value changes with each call.)

Variable Parameters

Python also allows variable parameters, i.e., the number of arguments passed in is variable. Just add a * before the parameter:

def calc(seed, *nums):
        sum = 0
        for n in nums:
                sum += n * n
        return seed, sum
        
calc(1, 2, 3, 4)  # 1 corresponds to seed, the rest are variable parameters nums

Inside the calc function, nums receives a tuple. You can pass any number of arguments, including just the required parameter seed. If you already have a list or tuple, you can add a * before it to unpack its elements as variable parameters:

alist = [2, 5, 6, 7]
calc(1, *alist)  # Unpack the list as variable parameters

Keyword Parameters

Keyword parameters allow you to pass zero or more named arguments, which are automatically packed into a dict inside the function. Like variable parameters, you can assemble a dict and use ** to unpack it as keyword parameters:

def person(name, age, **kw):
        print("name: ", name, ", age: ", age, ", otr: ", kw)

info = {'city': 'HFE', 'major': 'Computer Science'}
        
person('Jensen', 22)  # Only required parameters
person('Jensen', 22, city = 'Hefei', uni = 'HFUT')  # Two named parameters
person('Jensen', 22, **info)  # Unpack pre-assembled info dict

Named Keyword Parameters

To restrict the names of keyword parameters, e.g., only accept city and job, add a separator * in the parameter list. Parameters after * are named keyword parameters:

def person(name, age, *, city, uni):  # Parameters after * are named keyword parameters
        print("name: ", name, ", age: ", age, ", city: ", city, ", uni: ", uni)

If the function already has a variable parameter, named keyword parameters do not need the * separator:

def person(name, age, *args, city, uni):  # Already has variable parameters
        print("name: ", name, ", age: ", age, ", city: ", city, ", uni: ", uni)
        print("\nargs: ", args)

Named keyword parameters must be passed with parameter names, otherwise Python will treat them as positional parameters and raise an error.

Named keyword parameters can also have default values and may be omitted when calling.

Parameter Combination

In Python, all the above parameter types can be combined, but the order must be: required parameters, default parameters, variable parameters, named keyword parameters, and keyword parameters. However, using too many combinations is not recommended as it reduces readability:

def final(a, b, c = 0, *args, d, **kw):
        print("a: ", a, ", b: ", b , ", d: ", d, ", args: ", args, ", d: ", d, ", kw: ", kw)
        
final(1, 2, 3, 4, d = 5, name = 'final function')

Recursive Functions


A recursive function is a function that calls itself. Example: factorial function:

def fact(n):
        if n == 1:  # Only when n==1 needs special handling
                return 1
        return n * fact(n - 1)

fact(1), fact(10)

Be careful to avoid stack overflow when using recursion. In computers, function calls are implemented through a stack data structure. Each function call adds a stack frame, and each return removes one. The stack size is limited, so too many recursive calls will cause a stack overflow. For example, running fact(10000) will raise RecursionError: maximum recursion depth exceeded in comparison.

Tail recursion optimization can solve stack overflow. Tail recursion means the function returns itself, and the return statement does not contain an expression. This way, no matter how many times the function calls itself, only one stack frame is used. The above fact function is not tail recursive because its return statement uses a multiplication expression:

def fact(n):
        return fact_iter(n, 1)

def fact_iter(num, product):
        if num == 1:
                return product
        return fact_iter(num - 1, num * product)

fact(10)  # Running fact(10000) still raises an error

Unfortunately, most programming languages, including Python, do not optimize tail recursion, so even the improved fact function will cause stack overflow.

Advanced Python Features


Slicing


To get the first three elements of a list or tuple, use Python’s slicing feature:

names = ['Jensen', 'Eric', 'Jerry', 'Anderson', 'Taylor']
names[0:3]  # Output: ['Jensen', 'Eric', 'Jerry']

names[0:3] means take elements from index 0 up to, but not including, index 3. If the start index is 0, it can be omitted, e.g., names[:3]. (Tip: names[:] copies the entire list.)

Similarly, Python supports negative slicing, e.g., names[-1] for the last element, names[-3:] for the last three elements.

Slicing is very useful. For example, create a list from 0 to 99:

L = list(range(100))

You can easily get sublists using slicing:

L[:10]  # First ten elements: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
L[-10:]  # Last ten elements: [90, 91, 92, 93, 94, 95, 96, 97, 98, 99]
L[25:35]  # Elements 25-34: [25, 26, 27, 28, 29, 30, 31, 32, 33, 34]
L[:10:2]  # Every other element in the first ten: [0, 2, 4, 6, 8]
L[::10]  # Every tenth element: [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]

Strings can also be treated as lists, so slicing works on them as well.

(The above also applies to tuples. Tuples and lists are similar, but tuples are immutable after initialization.)

Iteration


Given a list or tuple, you can iterate over them using a loop. In Python, use for ... in to iterate, which also works on other iterable objects (like dict):

d = {'a': 1, 'b': 2, 'c': 3}
for key in d:
        print(key)  # Outputs: a, b, c (default iterates over keys)

To iterate over values, use for value in d.values(). To iterate over both keys and values, use for k, v in d.items(). Strings can also be iterated over.

You can use collections.abc.Iterable to check if an object is iterable:

from collections.abc import Iterable
isinstance('abc', Iterable)  # True
isinstance(123, Iterable)  # False
isinstance([1, 2, 3], Iterable)  # True

The built-in enumerate function turns a list into index-element pairs, allowing you to iterate over both index and value:

L = [100, 'Jensen', 99, 'Eric', 98]
for idx, value in enumerate(L):
        print(idx, value)

You can also iterate over multiple variables at once:

for x, y in ((1, 2), (2, 3), (3, 4)):
        print(x, y)

List Comprehensions


List comprehensions are a simple yet powerful way to create lists in Python. Example: generate list([1, 2, 3, 4, 5, 6, 7, 8, 9]) with list(range(1, 10)).

To generate [1*1, 2*2, ..., 100*100], use [x * x for x in range(1, 101)].

You can add an if condition to filter, e.g., [x * x for x in range(1, 101) if x % 2 == 0].

You can use nested loops for permutations:

[m + n for m in 'ABC' for n in 'XYZ']

You can also list all files and directories in the current directory:

import os
[dir for dir in os.listdir('.')]

List comprehensions can use multiple variables:

d = {'a': 1, 'b':2 , 'c': 3}
[k + '=' + v for k, v in d.items()]  # Output: ['a=1', 'b=2', 'c=3']

if…else

In a list comprehension, an if ... else before the for must have an else clause, while an if after the for is a filter and cannot have else.

For example:

[x if x % 2 == 0 else 0 for x in range(1, 11)]

Generators


If you create a huge list but only need a few elements, most of the space is wasted. Python provides generators, which compute elements on the fly.

To create a generator, replace [] with () in a list comprehension: g = (x * x for x in range(1, 10)). Use next to get elements one by one.

Generators are iterable, so you can use a for loop:

g = (x * x for x in range(1, 10))
for i in g:
        print(i)

If the algorithm is complex, use a function with yield to create a generator, e.g., Fibonacci sequence:

def fib(max):
        n, a, b = 0, 0, 1
        while n < max:
                yield b
                a, b = b, a + b
                n += 1
        return 'done'

When calling a generator, each next resumes from the last yield. Multiple generator calls create independent objects.

To get the return value from a generator, catch the StopIteration exception:

g = fib(10)
while True:
        try:
                x = next(g)
                print('g: ', x)
        except StopIteration as e:
                print('Generator return value: ', e.value)
                break

Iterators


Objects that can be used in a for loop include:

  • Collection types: list, tuple, dict, set, str, etc.
  • Generators

These are called iterable objects (Iterable). Use isinstance([], Iterable) to check.

Objects that can be called by next are iterators (Iterator). Use isinstance([], Iterator) to check.

Generators are both iterable and iterators. list, dict, str, etc., are iterable but not iterators; use iter to convert them.

Iterator computes values lazily, only when needed. Python’s for loop is essentially implemented by repeatedly calling next.

Functional Programming


Higher-Order Functions


Variables Can Point to Functions

You can assign a function to a variable, e.g., f = abs. Calling f() is the same as calling abs().

Function Names Are Variables

A function name is just a variable pointing to a function. For example, abs = 100 would make abs point to 100, so you can’t call the absolute value function anymore. Don’t do this in real code.

Passing Functions as Arguments

Since variables can point to functions, and function parameters can accept variables, a function can accept another function as a parameter. This is called a higher-order function:

def add(x, y ,f):
        return f(x) + f(y)

When calling add(-6, 5, abs), x, y, and f receive -6, 5, and abs respectively.

map & reduce

The map function takes a function and an iterable, applies the function to each element, and returns a new iterator. Example: apply a square function to a list:

def f(x):
        return x * x

m = map(f, [1, 2, 3, 4 ,5, 6, 7, 8])
list(m)

map can be used for any function, e.g., convert all elements to strings: list(map(str, [1, 2, 3, 4, 5, 6, 7, 'A', 'B'])).

The reduce function also takes a function and an iterable. The function must take two arguments, and reduce accumulates the result: reduce(f, [x, y, z, k]) = f(f(f(x, y), z), k).

Example: sum a list:

from functools import reduce
def add(x, y):
        return x + y

reduce(add, [2, 5, 6, 7, 5])

Example: convert a string to an integer:

from functools import reduce

DIGITS = {'0': 0, '1': 1, '2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9}

def str2int(s):
        def fn(x, y):
                return x * 10 + y
        def char2num(s):
                return DIGITS[s]
        return reduce(fn, map(char2num, s))

str2int('2021111052')

This can be further simplified with lambda:

from functools import reduce

DIGITS = {'0': 0, '1': 1, '2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9}

def str2int(s):
        return reduce(lambda x, y: x * 10 + y, map(lambda x: DIGITS[x], s))

str2int('13579')

filter

The built-in filter function filters elements in an iterable. It takes a function and an iterable, applies the function to each element, and keeps those where the function returns True. Example: remove odd numbers, keep even numbers:

def odd(n):
        return n % 2 == 0
list(filter(odd, [2, 5, 6, 7, 5, 0, 1, 3, 9]))

Example: remove empty strings:

def not_empty(s):
        return s and s.strip()

list(filter(not_empty, ['A', ' ', 'C', None]))

sorted

The built-in sorted function sorts an iterable and returns a list.

sorted can take a key function for custom sorting, e.g., sort by absolute value: sorted([2, 5, 6 ,7, 9, 0, -3, -11], key = abs).

Example: sort strings:

sorted(['Jensen', 'Bob', 'eric', 'yiming'])

To sort alphabetically, use key = str.lower:

sorted(['Jensen', 'Bob', 'eric', 'yiming'], key = str.lower)

To sort in reverse, use reverse = True.

d = {'A': 'America', 'C': 'China', 'R': 'Russia'}
sorted(d.values())

Returning Functions


Functions as Return Values

Higher-order functions can also return functions. For example, a sum function:

def calc_sum(*nums):
        ax = 0
        for n in nums:
                ax += n
        return ax

If you want to delay the calculation, return a function instead:

def lazy_sum(*nums):
        def sum():
                ax = 0
                for n in nums:
                        ax += n
                return ax
        return sum

f = lazy_sum(1, 3, 4, 5, 7 ,9)
f()

Each call to lazy_sum returns a new, independent function. The inner function can reference parameters and variables from the outer function. This is called a “closure”.

Closures

When a function returns another function that references variables from the outer function, it’s called a closure. Note that the returned function is not executed immediately. Be careful not to reference loop variables or variables that may change:

def count():
        fs = []
        for i in range(1, 4):
                def f():
                        return i * i
                fs.append(f)
        return fs

f1, f2, f3 = count()
f1(), f2(), f3()

All three return 9 because the variable i is 3 when the functions are called. To fix this, bind the current value:

def count():
        def f(j):
                return lambda: j * j
        fs = []
        for i in range(1, 4):
                fs.append(f(i))
        return fs

f1, f2, f3 = count()
f1(), f2(), f3()

nonlocal

If the inner function only reads the outer variable, it’s fine:

def inc():
        x = 0
        def fn():
                return x + 1
        return fn

f = inc()
print(f())
print(f())

But if you assign to the outer variable, Python treats it as a local variable unless you declare nonlocal:

def inc():
        x = 0
        def fn():
                nonlocal x
                x += 1
                return x
        return fn

f = inc()
print(f())
print(f())

Anonymous Functions (Lambda)


Anonymous functions, or lambda expressions, are convenient for passing functions without defining them explicitly. For example, lambda x: x * x is equivalent to:

def f(x):
        return x * x

lambda can only have one expression and does not need return.

Anonymous functions are function objects and can be assigned to variables or returned.

Decorators


Decorators enhance functions, e.g., automatically print logs before and after calling a function, without modifying the function itself. Example:

def log(func):
        def wrapper(*args, **kw):
                print('call %s():' % func.__name__)
                return func(*args, **kw)
        return wrapper

@log
def date():
        print('2021-12-11')
        
date()

If the decorator itself needs parameters, use a closure:

def log(text):
        def decorator(func):
                def wrapper(*args, **kw):
                        print('%s %s(): ' % (text, func.__name__))
                        return func(*args, **kw)
                return wrapper
        return decorator

@log('execute')
def date():
        print('2021-12-11')
        
date()

To preserve the original function’s attributes, use functools.wraps:

import functools

def log(func):
        @functools.wraps(func)
        def wrapper(*args, **kw):
                print('call %s(): ' % func.__name__)
                return func(*args, **kw)
        return wrapper

@log
def date():
        print('2021-12-11')
        
date.__name__

Partial Functions


A partial function fixes some parameters of a function and returns a new function, making calls simpler. Use functools.partial.

For example, to frequently convert hexadecimal strings:

import functools

int16 = functools.partial(int, base=16)
int16('25675')

Partial functions can also be used with variable parameters:

import functools
 
max2 = functools.partial(max, 10, -11)
max2(19)

Object-Oriented Programming


Classes and Instances


The most important concepts in OOP are classes and instances. For example, define a Student class:

class Student(object):
        pass

After defining a class, you can create instances:

class Student(object):
        pass

stu = Student()
stu, Student

You can freely bind attributes to an instance, e.g., stu.name = 'Jensen'. Usually, use the __init__ constructor to bind required attributes:

class Student(object):
        
        def __init__(self, name, score):
                self.name = name
                self.score = score
                
stu = Student('Jensen', 100)
stu.name, stu.score

Data Encapsulation

Encapsulation is important in OOP. You can define methods inside the class to access data:

class Student(object):
        
        def __init__(self, name, score):
                self.name = name
                self.score = score
                
        def print_score(self):
                print('%s: %s' % (self.name, self.score))
                
stu = Student('Jensen', 20)
stu.print_score()

Access Restriction


Although data is encapsulated, external code can still modify instance variables. To make attributes private, prefix them with double underscores __:

class Student(object):
        
        def __init__(self, name, score):
                self.__name = name
                self.__score = score
                
        def print_score(self):
                print('%s: %s' % (self.__name, self.__score))
                
stu = Student('Jensen', 20)
stu.print_score()

To allow controlled modification, add setter methods.

Note: variables like __XXX__ are special, not private. Variables like _name are meant to be private by convention.

Private variables like __name are actually changed to _Student__name internally.

Inheritance and Polymorphism


A class can inherit from another class. The new class is called a subclass, and the inherited class is the base or parent class.

class Animal(object):
        def run(self):
                print('Animal is running...')
                
class Dog(Animal):
        pass

class Cat(Animal):
        pass

Subclasses can override parent methods:

class Animal(object):
        def run(self):
                print('Animal is running...')
                
class Dog(Animal):
        def run(self):
                print('Dog is running...')
                
class Cat(Animal):
        def run(self):
                print('Cat is running...')
                

dog, cat = Dog(), Cat()
dog.run()
cat.run()

Polymorphism

A subclass instance is both its own type and its parent’s type:

a, b, c = list(), Animal(), Dog()

print(isinstance(a, list))
print(isinstance(b, Animal))
print(isinstance(c, Dog))
print(isinstance(c, Animal))
print(isinstance(b, Dog))

You can pass any subclass instance to a function expecting the parent class:

class Animal(object):
        def run(self):
                print('Animal is running...')
                
class Dog(Animal):
        def run(self):
                print('Dog is running...')
                
class Cat(Animal):
        def run(self):
                print('Cat is running...')
                
def run_twice(animal):
        animal.run()
        animal.run()
        
run_twice(Animal())
run_twice(Dog())
run_twice(Cat())

Static vs Dynamic Languages

In static languages, you must pass the correct type. In Python, as long as the object has the required method, it works.

Getting Object Information


Use type to check object types:

print(type(123))  
print(type('Jensen'))
print(type(None))
a = abs
print(type(abs))
print(type(a))

You can compare types:

type(123) == type(456), type('jensen') == str, type(123) == str

To check if an object is a function, use the types module.

Using isinstance()

For inheritance, use isinstance:

isinstance([1, 2, 3], (list, tuple))
isinstance('jensen', (str, int))

Using dir()

To get all attributes and methods of an object:

dir('Jensen')

Special methods like __len__ can be used to customize behavior:

class TestLen(object):
        def __len__(self):
                return 120

test = TestLen()
len(test)

You can use getattr, setattr, hasattr to inspect and modify object state.

Instance Attributes vs Class Attributes


Instances can have attributes bound via instance variables or self. Class attributes are defined in the class and shared by all instances.

class Student(object):
        name = 'Students'

If an instance has an attribute with the same name as a class attribute, the instance attribute overrides the class attribute.

Advanced Object-Oriented Programming


You can dynamically bind attributes and methods to instances and classes.

Using slots

To restrict instance attributes, define __slots__:

class Student(object):
        __slots__ = ('name', 'age')
        
s = Student()
s.name = 'Eric'
s.age = '22'

__slots__ only affects the current class, not subclasses unless they also define __slots__.

Using @property


To add validation to attributes, use the @property decorator:

class Student(object):
        @property
        def score(self):
                return self._score
        
        @score.setter
        def score(self, value):
                if not isinstance(value, int):
                        raise ValueError('Score must be an integer!')
                if value < 0 or value > 100:
                        raise ValueError('Score must between 0 ~ 100!')
                self._score = value

s = Student()
s.score = 90
s.score

You can define read-only properties by only defining a getter.

Multiple Inheritance


Python supports multiple inheritance (MixIn):

class Ostrich(Bird, Runnable):
        pass

Custom Classes


You can define special methods like __str__, __iter__, __getitem__, __getattr__, and __call__ to customize class behavior.

Using Enum


Use the built-in Enum class to define enumerations:

from enum import Enum

Month = Enum('Month', ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'))

Or define your own:

from enum import Enum, unique

@unique
class Weekday(Enum):
        Sun = 0
        Mon = 1
        Tue = 2
        Wed = 3
        Thu = 4
        Fri = 5
        Sat = 6

Using Metaclasses


type()

You can dynamically create classes using type:

def func(self, name = 'world'):
        print('Hello, %s.' % name)
        
Hello = type('Hello', (object,), dict(hello = func))
h = Hello()
h.hello()

metaclass

Metaclasses allow you to control class creation. For example, add an add method to a custom list:

class ListMetaclass(type):
        def __new__(cls, name, bases, attrs):
                attrs['add'] = lambda self, value: self.append(value)
                return type.__new__(cls, name, bases, attrs)
        
class MyList(list, metaclass = ListMetaclass):
        pass

L = MyList()
L.add(1)
L

ORM frameworks use metaclasses to map classes to database tables.

Error Handling, Debugging, and Testing


Error Handling


Python uses try...except...finally... for error handling.

try

Example: division by zero:

try:
        print('try...')
        r = 10 / 0
        print('result: ', r)
except ZeroDivisionError as e:
        print('except: ', e)
finally:
        print('finally...')
print('END')

You can have multiple except blocks for different errors.

Errors are classes, and except catches the specified type and its subclasses.

You can catch errors at a higher level to simplify code:

def foo(s):
        return 10 / int(s)

def bar(s):
        return foo(s) * 2

def main():
        try:
                bar('0')
        except Exception as e:
                print('Error: ', e)
        finally:
                print('finally...')

main()

Call Stack

If an error is not caught, Python prints a traceback and exits.

Logging Errors

Use the logging module to record errors:

import logging

def foo(s):
        return 10 / int(s)

def bar(s):
        return foo(s) * 2

def main():
        try:
                bar('0')
        except Exception as e:
                logging.exception(e)
                
main()
print('END')  

Raising Errors

You can raise errors with raise:

class FooError(ValueError):
        pass

def foo(s):
        n = int(s)
        if n == 0:
                raise FooError('invalid value: %s' % s)
        return 10 / n

foo('0')

You can also re-raise errors or convert them to another type.

Last updated on