What is Python?
Recommendation
C/C++/Rust/Odin/Zig/FORTRAN/...
Pros
Cons
How Python works
def my_function(x):
x = x + 2
y = 5
return x
Dynamic typing
a = 5 # a is the name of an object of type integer
a = 5.0 # a is now the name of an object of type float
a = 5
print(type(a))
a = 5.0
print(type(a))
a = "5"
print(type(a))
print(type(print))
def func(a):
return(a * 5)
print(type(func))
print(func(5))
print(func("Hello "))
print(func("Na") + " Batman!")
Basic data types
Integer, Float, String, Boolean, Tuple, List, Ditionary, Set
my_dict = {"key": 1.0, "x": "this is a string"}
print(my_dict["key"])
my_dict["x"] = 2.0
my_list = [1, 2, "string"]
print(my_list[0])
my_list[0] = "10"
my_tuple = (2, 3, 4)
print(my_list[0])
my_tuple[0] = 10 # TypeError
Variable assignment
Multiple return values
# Return type is tuple when more than one argument is returned
def func(a):
return a, a + 2
x = func(5)
print(type(x), x)
Multiple variable assignment
x, y = func(5)
print(f"{x=} {y=}")
Function arguments
Positional and keyword arguments
# Keyword arguments have a default
def func(a, b=2):
return a + b
print(func(5), func(5, b=10))
Lets talk about immutability and pointers...
Lets talk about immutability and pointers...
Immutable data types: numbers, booleans, tuples, and strings
a = "hello"
b = a # pointer is copied
print(id(a))
print(id(b)) # Same memory!
b = "world" # immutable -> new object is created
print(id(a))
print(id(b)) # Now different!
methods return a new object - does not modify in-place!
a = "hello"
a.upper()
print(a)
b = a.upper()
print(a, b)
print(a[2]) # can access
a[2] = "X" # TypeError - but not modify
Lets talk about immutability and pointers...
Mutable types (the rest) CAN modify their memory
a = [3, 2, 1]
b = a # pointer is copied
print(id(a), id(b)) # Same memory
b.sort()
print(a)
All pointers to the memory can modify it
Lets talk about immutability and pointers...
Be careful about side-effects!
def func(x=[]):
x.append("world!")
print(x)
# what does this print?
a = ["hello "]
func(a)
func(a)
# what does this print?
func()
func()
# Same as above but more clear what is going on
DEFAULT_LIST = []
def func(x=DEFAULT_LIST):
x.append("world!")
print(x)
Argument catching and expanding
def func(*args, **kwargs):
print(args)
print(kwargs)
a = (1, 2, 3)
func(*a)
b = {"hello": None}
func(**b)
You should probably not do this!
But sometimes it is useful (e.g. decorators)
Decorators!
def say_hello(func):
def hello_func(*args, **kwargs):
print("hello!")
return func(*args, **kwargs)
return hello_func
def add(a, b):
return a + b
hello_add = say_hello(add)
x = hello_add(1, 2)
Decorators!
def say_hello(func):
def hello_func(*args, **kwargs):
print("hello!")
return func(*args, **kwargs)
return hello_func
@say_hello
def add(a, b):
return a + b
x = add(1, 2)
This is an advanced technique but useful for some structural patterns - do not over-use!
One thing I like to use it for is registering functions
Decorators!
FUNCS = {}
def register(func):
# Side-effect! GASP!
FUNCS[func.__name__] = func
return func
@register
def sub(a, b):
return a - b
@register
def add(a, b):
return a + b
print(FUNCS)
Error handling
def add(a, b):
if not isinstance(a, float):
raise TypeError("a is not a float!")
return a + b
x = add(1, 1)
isinstance is very useful for doing type checks (returns true for all sub-classes too)
There is an extensive list of standard exceptions in the python docs
The python error tutorial is also a good read
But what if we want to handle the error?
Error handling
def add(a, b):
if not isinstance(a, float):
raise TypeError("a is not a float!")
return a + b
try:
x = add(1, 1)
except TypeError:
x = None
But what if we have more behaviour?
Error handling
def odd_add(a, b):
if not isinstance(a, float):
raise TypeError("a is not a float!")
if b % 2 == 0:
raise ValueError("b has to be odd!")
return a + b
try:
x = odd_add(1.0, 2)
except TypeError:
x = None
Error handling
def odd_add(a, b):
if not isinstance(a, float):
raise TypeError("a is not a float!")
if b % 2 == 0:
raise ValueError("b has to be odd!")
return a + b
try:
x = odd_add(1.0, 2)
except TypeError:
x = None
except ValueError as e:
x = str(e)
else:
x += 1
finally:
print("this will always get run!")
print(x)
Try to get every block of this to execute!
Creating your own "types" - classes
class Square:
def __init__(self, length):
self.length = length
def area(self):
return self.length ** 2
s = Square(10)
print(s.area())
You can define all behaviour of this class, e.g. see operators
Creating your own "types" - classes
class Square:
...
def __add__(self, value):
if isinstance(value, float):
new_square = Square(self.length + value)
else:
raise TypeError(f"Can only add floats, not {type(value)}")
return new_square
s = Square(10)
q = s + 10.5
print(f"{s.area()=} {q.area()=}")
You should probably never overload operators - unless you have a very good reason
Classes are good for some things and bad for others - we will deep dive later
Data classes are nice for specifying containers!
from dataclasses import dataclass
from typing import Tuple
import numpy.typing as npt
import numpy as np
@dataclass
class Meteors:
"""Class for meteor data from an observer."""
observer_name: str
observer_geo: Tuple[float, float]
start_velocity: npt.NDArray[np.float64]
instrument: str = "radar"
m = Meteors("me", (1.0, 2.0), np.zeros((100, )))
Also named tuples and enums!
from collections import namedtuple
from enum import Enum
Point = namedtuple('Point', ['x', 'y'])
p = Point(1.0, 2.0) # Not mutable!
print(p)
class Color(Enum):
RED = 1
GREEN = 2
BLUE = 3
print(Color["BLUE"]) # Nice identifier declaration if needed!
Control flow
For loop
for i in range(10):
print(f"Hello world number {i}")
While loop
a = 0
while a < 10:
print(f"Looping {a=}")
a += 1
Control flow
Most sequences can be iterated
for c in "HELLO":
print(f"{c}={ord(c)}")
d = {"a": 3.14, "b": 42, "c": 1}
# Iterating a dict iterates the keys
for c in d:
print(c)
# dict.items return a iterable of tuples with both keys and values
for item in d.items():
print(f"{item[0]}={item[1]}")
# multiple variable assignment in loops too!
for key, value in d.items():
print(f"{key}={value}")
Iterator tricks!
c = 0
for a, b in zip(range(10), range(20, 30)):
c += a * b
for ind, ch in enumerate(str(c)):
print(ind, ch)
We can combine however we like
Iterator tricks!
c = 0
for ind, (a, b) in enumerate(zip(range(10), range(20, 30))):
c += a * b + ind
Just don't overdo it!
Then we have generators
Iterator tricks!
def my_iter(num):
c = num
for ind in range(num - 1, 0, -1):
c *= ind
yield c
for x in my_iter(10):
print(x)
print(type(my_iter(100)))
Iterator tricks!
def my_iter(num):
c = num
for ind in range(num - 1, 0, -1):
print(f"now at {ind=}")
c *= ind
yield c
g = my_iter(20)
x = next(g)
print(f"{x=}")
x = next(g)
Iterator tricks!
def my_iter(num):
c = num
for ind in range(num - 1, 0, -1):
print(f"now at {ind=}")
c *= ind
yield c
g = my_iter(2)
x = next(g)
print(f"{x=}")
x = next(g) # StopIteration!
Control flow
If statement
import numpy as np
x = np.random.randn(1)
if x < -1 or x > 1:
print("Shocked!")
elif x < -2 and x > 2:
print("WHAT?")
else:
print("Completely normal phenomenon")
List and dict comprehension!
squares = [x ** 2 for x in range(10)]
print(squares)
some_strs = [f"{x}" for x in squares if x > 10]
print(some_strs)
dicts_also = {c: ord(c) for c in "abcd"}
print(dicts_also)
Math in C
numpy arrays are our bread and butter!
import numpy as np
a = np.random.randn(2, 5) # 2 rows, 5 columns
print(type(a))
# Array slicing
print(a[:, 3]) # print column 4
b = a[0, :] * 2 # Multiply a row and assign result
print(f"{a[0, :]=} {b=}")
Math in C
numpy matrix operations
import numpy as np
a = np.array([1, 1, 1], dtype=np.float64)
M = np.eye(3)
M[1, 1] = 2
print(f"{M=}")
x = M @ a # Matrix multiplication!
print(f"{x=}")
# Broadcasting! ( or maybe brotcasting ;] )
M = M[:, :] + x[:, None]
print(f"{M=}")
Ok bear with me here...
matmul_python.py
import numpy as np
import time
np.random.seed(12345)
x = np.random.randn(3, 10_000).T.tolist()
M = np.eye(3).T.tolist()
repeats = 1_000
t0 = time.time()
# do work a lot!
for ri in range(repeats):
out = [] # calculate the matrix multiplication and sum
for ind in range(10_000):
x1, x2, x3 = x[ind]
y = [
x1 * M[0][dim] + x2 * M[1][dim] + x3 * M[2][dim]
for dim in range(3)
]
out.append(sum(y))
dt = time.time() - t0
print(f"Execution time [native] {dt} seconds")
I am speed!
matmul_numpy.py
import numpy as np
import time
np.random.seed(12345)
x = np.random.randn(3, 10_000)
M = np.eye(3)
repeats = 1_000
t0 = time.time()
# do work a lot!
for ri in range(repeats):
out = np.sum(M @ x, axis=0)
dt = time.time() - t0
print(f"Execution time [numpy ] {dt} seconds")
~100 times faster!
Packages and the import statement
import blargh
How does python find "blargh"?
import sys
for path in sys.path:
print(repr(path))
For more info see sys path init
pip installs into "site_packages"
Packages and the import statement
So that's modules - what is a "package"?
__init__.py is executed upon import my_package
We can of course nest this!
Packages and the import statement
# my_package/__init__.py
from . import foo
# script.py
import my_package
my_package.foo.bar()
Packages and the import statement
pyproject.toml
[project]
name = "spam-eggs"
version = "1.0.0"
dependencies = [
"httpx",
"gidgethub[httpx]>4.0.0",
]
requires-python = ">=3.8"
authors = [
{name = "Jane Doe", email = "obviously_not_my@email.com"},
]
maintainers = [
{name = "Someone else", email = "bob@example.com"}
]
description = "Lovely Spam! Wonderful Spam!"
readme = "README.rst"
license = "MIT"
[project.urls]
Homepage = "https://example.com"
Repository = "https://github.com/me/spam.git"
[project.scripts]
spam-cli = "spam:main_cli"
Packages and the import statement
pyproject.toml
[project]
name = "spam-eggs"
version = "1.0.0"
dependencies = [
"httpx",
"gidgethub[httpx]>4.0.0",
]
requires-python = ">=3.8"
authors = [
{name = "Jane Doe", email = "obviously_not_my@email.com"},
]
maintainers = [
{name = "Someone else", email = "bob@example.com"}
]
description = "Lovely Spam! Wonderful Spam!"
readme = "README.rst"
license = "MIT"
[project.urls]
Homepage = "https://example.com"
Repository = "https://github.com/me/spam.git"
[project.scripts]
spam-cli = "spam:main_cli"
pip install .
Read more in the handouts
there are excellent tutorials online
Lets check out the handout above!