We have used many functions in our programs: input
, int
, round
, … . Those are built into Python and always there for us to use.
The basics again: functions are called by giving them zero or more arguments (in parens) and can return a value as their result. e.g.
len("string") - 1
Here, len
is a function and "string"
is an argument, and len("string")
is a function call. This function call returns 6 and the expression that it's in evaluates to 5.
Functions can also not return anything, like print
.
In this case, the function probably does something (like putting some characters on the screen), so you call it for that, instead of getting its result
.
We can define our own functions in Python. Functions are defined by a def
block. Here's a simple function that doesn't return anything:
def print_one(): """ Print the number 1. """ print("1")
This code doesn't do anything (visible) when we pass these lines of code. It defines a function (print_one
) that can be used later.
Somewhere later, we can call that function:
print_one() print_one()
And the body of the function definition will run when we do.
1 1
def print_one(): """ Print the number 1. """ print("1")
The print_one
function has no arguments: the ()
is empty.
def print_one(): """ Print the number 1. """ print("1")
The triple-quoted string after the def
is the documentation string or docstring. It's a special kind of comment intended to describe (to people) what the function does: what it does, how it's used, what the arguments are.
Including it is good style, and for this course, it's mandatory on all function definitions.
Here's a function that takes one argument (a string) and returns a result (also a string):
def double_word(word): """ Return a doubled version of the word. """ doubled = word + " " + word return doubled
When it's called:
dbl = double_word("hello") print("The result: " + dbl)
The result: hello hello
def double_word(word): """ Return a doubled version of the word. """ doubled = word + " " + word return doubled
The arguments act like variables that are defined by calling the function: they will take on the values given by the code that calls it.
When a function hits a return
statement, it will exit (even if there's more code after that line) and give that result back to the calling code.
Effectively, a function that returns something must be used as (part of) an expression. A function that doesn't return anything acts as a statement.
okayokay = double_word("okay") print_one()
The last line of code here will never run: there's no way to get to it without hitting a return
first.
def absolute_value(num): """ Calculate the absolute value of the number. """ if num >= 0: return num else: return -num print("this never happens")
It's otherwise a correct absolute value
function.
We have seen functions being called: both functions we write (with def
like double_word
) and built-in functions (like len
and print
):
print(len(double_word("abc")))
But what happens when you call a function? Let's look at this definition and call:
def add_one(n): return n + 1 print(add_one(2 * 3) + 2)
This code prints 9
, but how does it get there?
def add_one(n): return n + 1 print(add_one(2 * 3) + 2)
2*3
and get 6
.add_one
: the calling code pauses and the function starts.n
is set to 6
.6+1
is calculated and the function returns 7
.7+2
.print
prints 9
.So when we call a function (defined by us or built-in), our code waits until the function is done. Then it resumes (with the return value).
Or, every function call just magically has its return value substituted into the place where the function call happened. [Both of those understandings of functions are in my head for some functions, depending on the code.]
Reason #1 to use functions: easy re-use of code.
Functions can be called several times to do related things. Writing a function to do that means you can write once, debug once, and use as many times as necessary.
e.g. in our line-intersection code, we had many lines that were almost the same:
x1 = float(input("Enter x1: ")) y1 = float(input("Enter y1: ")) x2 = float(input("Enter x2: ")) y2 = float(input("Enter y2: ")) x3 = float(input("Enter x3: ")) y3 = float(input("Enter y3: ")) x4 = float(input("Enter x4: ")) y4 = float(input("Enter y4: "))
If you're copying-and-pasting code, you almost certainly should have written a function instead.
The first thing I'd do: define a function to ask the user for one value and returns it, like this.
def get_coord(label): """ Ask the user for one coordinate of a point. """ v = float(input("Enter " + label + ": ")) return v
That could be used eight times like this:
x1 = get_coord("x1") y1 = get_coord("y1") ⋮
But its use would still be very repetitive: we have to ask for \(x\) and \(y\) values for each point. We can save that repetition:
def get_point(n): """ Ask the user for an x,y pair and return it. """ x = get_coord("x" + str(n)) y = get_coord("y" + str(n)) return x, y
Note: this function is returning two values (separated by a comma). That's possible if it's what we need to do.
We can get ask the user for those values and get them into variables like this:
x1, y1 = get_point(1) x2, y2 = get_point(2) x3, y3 = get_point(3) x4, y4 = get_point(4)
Doing this with functions is just better code: the function names give an immediate hint what's going on, the docstrings clarity more, anything that needs to be fixed is in exactly one place (e.g. we could easily change the prompt displayed to the user in get_coord
).
Consider this code:
def add_double(x, y): dbl = 2 * y return x + dbl print(add_double(2, 3)) print(dbl)
There's an error on the last line:
.NameError: name 'dbl' is not defined
Variables used inside a function are local variables within that function: the exist only while that function is executing and are destroyed when it returns.
That means variables inside functions are nicely separated from any outside: it's harder to affect the world outside the function, so you don't have to worry about everything else in the program while writing a function.
Consider code like this function definition:
def count_to(num): """ Return a string counting from 1 to num. """ counting = "" for i in range(num): counting = counting + str(i + 1) + " " return counting print(count_to(4))
That will print
.1 2 3 4
We can use the function without worrying about reusing the same variable names it used.
num = int(input("How much counting? ")) for i in range(num): print(count_to(i+1))
This num
is completely independent from the function's num
Functions having their own local variables is a very important property of a programming language: if not, it becomes very hard to write programs that are more than a few screens of text.
Imagine a program with 100 functions, and having to scan all of them to see if anybody used the variable name
before you did.num
Reason #2 to use functions: logical separation of code.
Since variables are separate between functions, we should be able to write, test, and debug a function on its own, without worrying about the rest of our program. That lets us think about small pieces of code without worrying about the rest of the (possibly thousands of lines of) code.
So what you should do:
When you see a relatively-independent part of your logic, move it into a function.
Whatever information you need to get that done will be the arguments to the function. Whatever result(s) it produces will be the return value(s).
Write it, test it, make sure it does what you need it to do. Then forget about it: just use it where it's needed, knowing it will do its job.
You will make errors when writing programs.
Fixing them is a big part of the process of programming (and learning to program).
First: don't start coding until you know what you want your program to do (and how you're going to get that done).
There's no hope of writing a correct program if you don't have a plan. You should have an algorithm in mind (or even sketched out/written down). Then think about how to get that done in Python.
Finding errors in a program can be very hard. The actual cause of a problem might not be anywhere close to where you notice it.
Some common categories of error, in approximate order of easiest to hardest to fix…
Syntax errors: these occur when what you've typed just isn't legal Python.
A syntax error could be the result of inconsistent indenting in a block:
if x == 0: print("We're done.") print("Thanks.")
Or badly matched quotes or parentheses:
print(int("34") print("Hello world)
Or =
instead of ==
in a condition:
if x = 0: print("zero")
Or many other things. Sometimes your editor's syntax highlighting will help you see syntax errors, but not always.
Runtime errors: while running your code, some situation the language can't deal with occurs. Also called exceptions.
These can be things like a NameError
where you try to use a variable name that hasn't yet been created.
Or an impossible type conversion (a ValueError
):
n = int("one")
Or division by zero (ZeroDivisionError
):
q = 10 / 0
Or trying to extract part of a string that doesn't exist (IndexError
):
s = "abc" print(s[4])
It's possible to catch
these exceptions and handle them gracefully (later). For now, if something like this is unavoidable in our code (like bad user input), we'll have to let the exception occur.
The worst category of error we're going to have to deal with: semantic errors.
This is occurs when your program runs and does something but it's not what you want/expect.
These are hard to find because there's no error message to guide you, and many possibilities for the true cause. It could be because of an incorrect algorithm or incorrect translation of algorithm to code.
First strategy: write your program in small chunks and test as you go.
Add print
statements as you're working to inspect whatever variables you are working with. Try enough different things to convince yourself they are being set to the values you need/expect.
If not, the problem should be in your most recent addition.
From that last slide: …the values you need/expect.
You can't evaluate that if you don't know exactly what you want your program to be doing at that point. Have a plan before you start typing.
Writing modestly-sized functions can help: if there's a problem with the function, the cause is likely in the function's code, not somewhere else in the program.
It's a decent strategy to write building-blocks of your solution in functions (and test as you go), then assemble them by calling as needed.
e.g. in assignment 1, you could have (but certainly weren't required to) write a function to get the user's input that disassembled the date string, did the integer conversions, like this:
def get_date(): ⋮ return y, m, d
Convince yourself it works and then call it in exactly one place:
year, month, day = get_date()
If you realize there's a problem somewhere in your program, it can be much harder to find.
First: what's the symptom? Variable with the wrong value? An if
not taken when it should be? Loop exiting too soon?
Probably start by adding print
statements to figure out what's going on. Try to narrow down where the behaviour diverges from what you need done.
Work your way back through the program until you find the cause.
Writing code that someone (possibly including you a week in the future) can actually read and understand is important.
Most of the expense
of most programs is in maintenance (adding feature, fixing bugs). That generally involves someone else (or you in the future) going through code to figure out what's going on.
There are no fixed rules, but a few guidelines.
Choose descriptive variable names. Names should describe what the variable is holding: counter
, total
, year
, user_name
.
Avoid names that are too short to be meaningful (c
, t
, y
, n
) unless it's very clear from the context, or names that don't give a good hint about the value's role (stuff
, things
).
The same advice for functions names: choose get_date()
or input_y_m_d()
over inp()
.
Also break your logic up into reasonably-sized functions. Opinions vary, but if I write more than ≈20 lines of code in a straight line, I start to wonder if it could be broken into functions in some sensible way.
Spaces around operators aren't meaningful, so use spacing to make lines more readable.
total=a+b/factor
total = a+b / factor
total = a + b / factor
total = a + b/factor
And insert blank lines to break code up into logical chunks.
If all of that fails to make the code easy to understand, use comments to describe what's happening.
Comments: everything after a #
on a line is ignored by Python, so you can write human-readable text there. Think of comments as a way to give a hint about why code is doing what it's doing, not about what it's doing: that should be clear to anyone who knows the language.
e.g. in this code, I thought initializing total
to zero was obvious enough (so I didn't comment), but the reason for the initial value for val
wasn't clear.
total = 0.0 val = 1.0 # dummy value so we enter the loop while val != 0.0:
My usual strategy: try to make beautiful, readable code using any other combination of good coding practices. If I can't for some reason, I leave a comment explaining what's happening.
Except function docstrings: those are a specialized type of comment. I always write them and so should you (and they're required for this course).
We have seen functions that are built into the Python language (input
, int
, round
). We have also seen how to define our own (with def
).
But there are many common tasks that don't have built-in functions. We don't want to have to solve every problem by hand forever. There are lots of problems others have already solved: we'd like to use their solutions.
In Python, this other code
that we can use in our program is broken up into modules.
The language comes with a bunch of commonly-useful modules: the standard library.
The built-in functions (int
, print
) are available in every program because they're very commonly needed. There's enough in the standard library that it doesn't make sense to have all them there all the time: there would just be too many functions hanging around.
If we want to use any of these modules, they need to be imported into our program.
e.g. the tkinter
module contains functions to create graphical user interfaces: it's what IDLE uses to draw its windows on the screen. It's complex: not where we should start.
The math
module contains a bunch of functions for math-related calculations. We can import it (typically at the start of the .py
file) so it can be used anywhere in the program:
import math
Then the sin
function to calculate the sine of an angle, or the gcd
function from the math
module to calculate a greatest common divisor:
s = math.sin(3.14) g = math.gcd(18, 30) print(s, g)
0.0015926529164868282 6
The contents of a module (when imported that way) can be accessed with a period, like
.modulename.function(args)
Or the time
module has some tools to work with dates and times. For example, we can ask it for the current time.
import time now = time.localtime() print(now)
The result is a value (unlike others we have seen before) that contains some representation of the date.
time.struct_time(tm_year=2024, tm_mon=6, tm_mday=7, tm_hour=8, tm_min=41, tm_sec=39, tm_wday=4, tm_yday=159, tm_isdst=1)
It also has functions to work with those date/time objects, so we can turn it into output in a format we'd like:
now = time.localtime() date_string = time.strftime("%Y-%m-%d", now) print(date_string)
The "%Y-%m-%d"
format string gives us a year-month-day result:
2024-06-07
There are also some tools (monotonic
) that can be used to capture the time some code takes, like this:
start_time = time.monotonic() result = calculate_something() end_time = time.monotonic() print("Time taken: " + str(end_time - start_time) + "s")
That will output something like:
Time taken: 0.4138080640695989s
Another example: the random
module with tools to generate random values, like randint
.
import random print(random.randint(1, 100)) print(random.randint(1, 100)) print(random.randint(1, 100)) print(random.randint(1, 100))
One time I ran it, I got:
52 43 32 48
There are many things in the Python standard library: they'll be pointed out if you need them in this course.
If there's something from a module that you're going to use a lot and don't want to type the modulename.
In the previous example we did this:
import random print(random.randint(1, 100))
It could also have been:
from random import randint print(randint(1, 100))
There are also many many packages freely distributed for Python, generally collected at PyPI. These have to be installed before they can be used (generally with pip, which we won't be covering/using).
e.g. Pillow for manipulating images (PNG, JPEG, etc), or Pandas for manipulating/analyzing data.
Python has (as do many/most modern programming languages) the concept of objects.
An object is a way to encapsulate some part of your program: all of the information (≈variables) and actions (≈functions).
Think of a real-world object like a car.
It has a certain state: how much gas/charge it has, how many people are in it, tires are at 30% wear, top speed, is it off or on, etc.
And there are certain actions we can take with it: add gas/charge, change the tires, drive it for 1 km, honk the horn, etc.
There are many cars: each one has its own properties but is interacted with in basically the same way.
Objects in a programming language do a similar thing. An object is a container that holds other stuff. Each kind of object is a class and is a new type in Python.
Objects have a state
that represents their current status, and there are actions that you can take with them. These are represented with variable that live inside the object.
When talking about objects, the kind of object is a class and one example is an instance. (e.g. string
is the class, and "abc"
is an instance.)
For example, in the fractions
module contains a Fraction
class that can represent a fraction or rational number.
From the docs, I see that a fraction instance can be created by giving the numerator and denominator to the constructor
:
import fractions half = fractions.Fraction(1, 2) print(half)
This class is automatically converted to a nice string when printed:
1/2
Each class can have properties or instance variables: variables that live inside the instance and represent their state. This can be visible from the outside or can be hidden from whoever is using the class.
Instances of fraction
have two visible properties:
ratio = fractions.Fraction(6, 14) print(ratio.numerator) print(ratio.denominator)
The fraction was automatically reduced to lowest terms:
3 7
Classes can also contain methods: functions that live inside instances and operate on them.
A fraction
has a method limit_denominator
that returns a fraction with a similar numeric value, but small demonimator.
f = fractions.Fraction(198698242, 1823719847) print(f) similar = f.limit_denominator(100) print(similar)
198698242/1823719847 6/55
Note: the method gets both its arguments (100
on the last slide) and implicitly the instance itself (f
) and can use both to produce its results.
Are those really numerically-similar fractions? We can check: fractions can be converted to floating point.
print(float(f), float(similar))
0.10895217394648445 0.10909090909090909
Summary: each class is a new Python type. Instances of a class can contain variables (properties) and functions (methods). Both are accessed from the instance with a
..
The methods a class provides are intended to help you work effectively with those values.
This specific class gives us a way to do exact arithmetic on fractional values, if that's what we need. Classes can also implement basic operations (like +
) in ways that make sense for them.
>>> from fractions import Fraction >>> Fraction(1, 10) + Fraction(2, 10) Fraction(3, 10) >>> Fraction(1, 10) + Fraction(2, 10) == Fraction(3, 10) True >>> 0.1 + 0.2 0.30000000000000004 >>> 0.1 + 0.2 == 0.3 False
We can experiment with another class given to us by a module in the standard library: the datetime
module which provides classes to work with dates and times, specifically the date
class.
A date
object can be constructed by giving a year, month, day:
import datetime d = datetime.date(2024, 6, 7)
Date objects print nicely and are a distinctly new type:
print(d) print(type(d))
2024-06-07 <class 'datetime.date'>
From the docs, I see that date
objects have a month
property, and a weekday
method that can calculate the day of week (with Monday = 0, Tuesday = 1, etc).
print(d.month) print(d.weekday())
6 4
Dates can also be subtracted to get another type: a timedelta
.
newyear = datetime.date(2024, 1, 1) diff = d - newyear print(diff) print(type(diff)) print(diff.total_seconds())
158 days, 0:00:00 <class 'datetime.timedelta'> 13651200.0
No, you weren't allowed to use this for assignment 1. But it's a good lesson: there are many things you could write yourself, and many libraries that will save you the work.
For many programming tasks, the challenge is more in finding the right library, understanding how it works, reading the documentation, etc.
Let's try some more with Pillow, the Python Imaging Library for manipulating (bitmap) images. An image is represented by Pillow as an Image
object.
We need the module installed first, since it's not part of the Python standard library. The least-worst way to do that (without the command line) seems to be:
>>> import pip >>> pip.main(["install", "pillow"])
This is a one time installation step: run it once at the interactive prompt. Don't put it in your program.
Then we can start with an image saved in the same directory as our Python code.
from PIL import Image img = Image.open("Vancouver_Skyline_and_Mountains.jpg")
Now we have a Pillow Image
object that we can manipulate.
It's not clear what we're supposed to do with that object.
>>> print(img) <PIL.JpegImagePlugin.JpegImageFile image mode=RGB size=1200x674 at 0x7F88CD4C3310>
We can use any of the methods described in the Image
object docs. Most of them don't immediately make a huge amount of sense, but I know what rotating
and saving
are:
rotated = img.rotate(45) rotated.save("rotated.png")
There are also some properties that tell us useful things about the image.
>>> img.width 1200 >>> img.height 674 >>> img.mode 'RGB'
So Pillow Image
is a class that can represent bitmap images.
The Image.open
function can construct an instance of Image
by reading a file. So can Image.new
that we'll see soon.
The instance (an object) has some properties (like .width
and .height
) that tell us about the image.
We can't change those properties directly for this class: the class protects its contents from modification. This will fail:
img.width = 1201
If we want to modify the image, we need to use methods it provides.
The Image
class also contains methods that work on the image.
We say .rotate
that returns another Image
instance (but doesn't change the existing Image
), and .save
which doesn't return anything, but writes a file to disk.
An aside: it's often useful to give multiple values as a single argument. e.g. the size
of an image is actually two values (width and height), or a colour
might be three (red, green, blue).
This is done with tuples in Python: multiple values in parentheses. e.g. this tuple might represent the size of an image: (1920, 1080)
, or this might be an RGB value representing orange: (255, 127, 0)
.
Or in complete code, creating a new image (the Image.new
function) requires two arguments: the colour mode, and the size (as (width, height)
). Setting a pixel (an image's .putpixel
method) requires two: the (x, y)
location, and the colour as (r, g, b)
.
blank = Image.new('RGB', (200, 100)) blank.putpixel((20, 10), (255, 0, 0)) blank.save("one_red_pixel.png")
We actually already used a tuple: the function that got user input for an \(x, y\) point returns a tuple of two things (i.e. a pair).
return x, y
Also, function or method arguments can be given in order as we usually have:
blank = Image.new("RGB", (200, 100)) blank.putpixel((20, 10), (255, 0, 0))
Or, by name so it's more explicit what's going on. I checked the Image
docs to learn the argument names, and this is equivalent:
blank = Image.new(mode="RGB", size=(200, 100)) blank.putpixel(xy=(20, 10), value=(255, 0, 0))
blank = Image.new(mode="RGB", size=(200, 100)) blank.putpixel(xy=(20, 10), value=(255, 0, 0))
With named arguments, order doesn't matter so this is also equivalent:
blank = Image.new(size=(200, 100), mode="RGB") blank.putpixel(value=(255, 0, 0), xy=(20, 10))
Giving arguments by name can be more clear if there are many arguments, or if some are excluded (because they are optional).
Back to this code:
blank = Image.new('RGB', (200, 100)) blank.putpixel((20, 10), (255, 0, 0)) blank.save("one_red_pixel.png")
This starts with a blank image (default colour is black but there's an optional color
argument), and draws a single red pixel near the upper-left.
In Pillow, (0, 0)
is the upper-left pixel and (width-1, height-1)
is the lower-right.
The methods on Image
instances let us start to actually work with images. Let's draw more than one pixel on an image:
img = Image.open("Vancouver_Skyline_and_Mountains.jpg") for x in range(100): img.putpixel((x, 10), (255, 0, 0)) img.save("edited.png")
This draws red pixels from x=0
to 99
, ten pixels from the top.
Drawing individual pixels is fine, but tedious and I note in the putpixel
docs:
this method is relatively slow. For more extensive changes, use … the ImageDraw
module instead.
Okay, let's look at the the ImageDraw
module then…
It looks like the ImageDraw
object is created from an image and then modifies it:
from PIL import ImageDraw img = Image.new("RGB", (200, 100)) draw = ImageDraw.Draw(img)
Then we can use draw
to draw on img
and eventally, save it.
img.save("output.png")
Let's draw some stuff. I see draw.rectangle
and draw.line
that can be given a tuple of four things: (x1, y1, x2, y2)
.
You can specify colours to Pillow either as a RGB triple, or use any of the named colours for the web: the named colours seem easier.
The (0, 0) pixel is the upper-left, so (10, 20) is near there, and I'll choose (150, 75) as the other corner of our rectangle.
img = Image.new("RGB", (200, 100)) draw = ImageDraw.Draw(img) draw.rectangle((10, 20, 150, 75), outline="white", fill="blue", width=8) draw.line((10, 20, 150, 75), fill="green", width=4) draw.line((10, 75, 150, 20), fill="red", width=4) img.save("drawn.png")
The result:
Or we can draw some text, if we have an appropriate font file to work with. We can get Noto Sans from the Pillow source and save it in the same directory as our code. Then (according to the docs) we have to create an ImageFont
object before we can draw some text.
from PIL import ImageFont font = ImageFont.truetype("NotoSans-Regular.ttf", size=20)
But now we have a thing we can use to get some text on our image. After some experimenting, the position
seems to be the upper-left of the text.
draw.text((10, 75), "Hello world", font=font, fill="white") img.save("drawn2.png")
A live-coding challenge: I'd like our guessing game to produce a visualization of the range of possible secret numbers as the game progressed.