2 Introduction to python#

2.1 Python Basics#

In this course we assume that you have programmed before, but not necessarily in python.

All programming languages have the same basic constructs:

  1. Basic Datatypes

  2. Collections

  3. Flow control

  4. Higher level concepts such as functions or classes

There can however, in amongst stuff that looks familiar, be important differences. You can for example try to write python in a way that looks like c code merely updating the syntax. However, if you do that you’ll miss out on much of what makes python great, readable. Your code will also probably not work as well. There are also subtle differences which go beyond syntax which it can be helpful to grasp.

I will therefore go over a lot of basic info very quickly but leave you this as a reference guide in case I say something you want to lookup. There will be a few places where I’ll also slow down in order to make an important distinction, and get you to try a few things. As always these notes are accompanied by exercises so you can check you’ve understood properly.


2.1.1 Basic Datatypes#

Programming languages divide data into different types. In Python the most basic types are:

Datatype

Explanation

Casting order

Type conversion

bool

Values that can be True or False

low

bool()

int

Whole numbers : 5, -1, 356

middle

int()

float

Decimal values : 3.1415

high

float()

complex

Complex values : 2 + 3j

highest

complex()

str

Strings : “Hello World”

n/a

str()

Python is dynamically typed rather than statically typed like c or java. This means we don’t need to declare the datatype of the variables we are using. Python makes educated guesses based on the values we assign to a variable. It’s also not super fussy.

#We can write stuff like this without any errors
a=True
a=1
a=3.25

That flexibility can create problems, so it’s up to you to be careful. Just because you can do something doesn’t mean you should! Being disciplined (getting into good habits) when you write code is therefore important. For example don’t write short variable names like a, make them descriptive, and don’t reuse the same variable with different types. oops ;-)

You can check the datatype of a variable using type(variable).

Python allows for “casting” - that is where possible one datatype can be interpreted as a different compatible datatype. See the example below where True is treated as if it is a 1 (which is an integer). So some types can be used as if they were other ones in the table. We can also force python to convert some values into lower datatypes (ie treat an integer 1 as a boolean True) by passing them to functions. This only works if it is obvious how the number should be converted. Otherwise python throws an error. All non-zero numbers go to True. 0 and None become False but we can’t convert a string to a number if it doesn’t look like a number (int(‘1’) works but int(‘one’) doesn’t).

#Example adding bool to int. True becomes 1 whereas False becomes 0
a = True
b = 1
print(a+b)

#Example adding int to complex
c=2
print(type(c))
d=3.24 + 5j
print(type(d))
print(c+d)

#We can also force python to interpret compatible numbers in certain way.
print(a and bool(b))
2
<class 'int'>
<class 'complex'>
(5.24+5j)
True

2.2 Operators#

Values can be combined with the usual operators.

Class of operators

Operators

Assignment operator

=

Bitwise operators

&, |, ~

Logical operators

and, or, not

comparison operators

==, >, <, >=, <=, is

arithmetic operators

+,-,*,/,//,%,+=,**,(,)

Many of these work as you’d expect:

a=2  # Assign value to a
b=3 # Assign value to b
print(a+b) # a + b
print(a**b) # a to the power of b
b+=1 # Add 1 to b
print(b)
print(b > a)
5
8
4
True

/, // and %#

All of these relate to division of numbers

a=10            #int
b=3             #int

print(a/b)      #Division as if floats
print(a//b)     #Division as integers
print(a%b)      #Remainder from integer division
3.3333333333333335
3
1

=, ==, is#

  • = assigns a value to a variable (e.g a=1)

  • == checks if two variables have the same value,

  • is checks if two items are exactly the same (ie are the same physical object, stored in the same bit of memory in the computer)

id(variable) displays a code showing where in memory a variable is located. Thus we can check if something is the same physical bit of memory or different.

#= , ==, is
a=257
b=257

print(id(a))    #memory id
print(id(b))

#a and b have the same number but the 257 in a is stored in a different place from the 257 stored in b
print(a==b)
print(a is b)
2571001642192
2571001641072
True
False

WARNING#

Python has a quirk which will drive you absolutely nuts if you are not aware of it. To save memory, it stores None, True, False and all small numbers from -5 to 256 in the same place. cf the following with above:

a=256
b=256

print(id(a))
print(id(b))

print(a is b)

c=None 
d=None
print(c is d)
2570921648336
2570921648336
True
True

2.2.2 Bitwise and Logical comparisons#

When you want to compare boolean values or expressions you are probably looking for the logical operators and, or, not.

Bitwise operators are used in specific contexts, for example to do array operations in Numpy. They convert the number to bits (binary 1s and 0s) and perform the operations on each bit. Bitwise operations can quickly get confusing! If you’d like to understand more about how they work in python, I suggest reading this article Bitwise Operators. We are going to gloss over them here because they are not used as much as logical operators.

Make sure you don’t get them muddled up as you’ll get some very strange answers.

There is a precedence for the order in which operations are calculated. Don’t waste your time!! At some point you’ll spend hours debugging to look for some incorrect logic. Just put brackets in and make your code easy to read.

"""Logical operators"""
print(3 > 5 and 2 > 1)      #   This gives the same answer as the one below but which is easier to understand?
print((3 > 5) and (2 > 1))  #   Using a logical operator to see if both expressions are True.
print((3 > 5) or (2 > 1))   #   Same with an or
print(not (3 > 5))          # T he not operator
False
False
True
True

2.2.3 Finite precision#

When python stores a number and hence does a calculation it only stores so many decimal places. As a result the last figure is often rounded meaning that values that are nominally the same evaluate as not equal. You can use the in builtin python round() function to chop off figures you are not interested in and make the comparison work the way you expect.

a = 0.01
b = 0.1**2
print(a==b)

print(round(a, 5) == round(b, 5))   #   Round both values to 5 figures.
False
True

2.3 Collections#

Dealing with individual values is important, but an important power of data is the way we group it together. Logically grouping values is convenient, makes code easier to understand and enables operations to be performed on the whole collection.

exam_marks = [27, 93, 42, 56]

Python has a lot of ways to collect data. We will just look at the most common.

2.3.1 Strings#

Strings are a collection of characters. To write a string we simply wrap the characters in a pair of either single or double quotation marks.

"This is a string" but 'This is also a string'

Sometimes you might want to mix and match.

"If you want an apostrophe it's best to use doubles"

Strings also contain what are called escape characters that help with formatting the data. Here are a few common ones:

Type

Escape characters

tab

\t

newline

\n

single backslash

\\

Although "2" and 2 may look the same they are not. The first is a string and the second is a number. You can convert a number to its string equivalent by wrapping it within str().

Another option which you may want to use is creating a string that includes the values stored in some variables. For this example:

    first = "a string"
    second = 3

Note that the first is a string whilst the second is a number. However, python does the casting into a string automatically.

    "Use {} and a number {} to create a string".format(first,second)
    "Specify what goes where: {first} and number {second}".format(first=first, second=second)
    f"More modern use of {a} and a number {b} using 'f-string' method"
first = "a string"
second = 3

print("some \t examples \n\n of escape strings ")
print("Use {} and a number {} to create a string".format(first,second))
print("Specify what goes where: {first} and number {second}".format(first=first, second=second))
print(f"More modern use of {first} and a number {second} using 'f-string' method")
some 	 examples 

 of escape strings 
Use a string and a number 3 to create a string
Specify what goes where: a string and number 3
More modern use of a string and a number 3 using 'f-string' method

2.3.1.1 Slicing a string#

We can obtain part of a string using “slicing”. This uses square brackets and a few indices to select the bit of the string we want. The first character corresponds to 0 and the final index is not included in the string. Up to 3 indices can be given corresponding to

    [start_index:stop_index:step]

Indices can also be omitted indicating for example all the characters from the beginning up to stop_index

    [:stop_index:]

One can also count from the end of the string using negative indices:

    [:-2] 

meaning all but the last 2 characters.

Here are some examples:

original_string = "Some long complicated string"
lon = original_string[5:8] # Note index 8, i.e 'g' is not included
print(lon)
string = original_string[-6:]
print(string)
every_other = original_string[::2]
print(every_other)
lon
string
Sm ogcmlctdsrn

There are also many useful methods you can use to manipulate strings: splitting a string at particular characters, replacing sections of a string, changing the case of letters etc.

Look up string methods documentation

string_to_manipulate = "I want this removed this this".replace(" this","")
print(string_to_manipulate)
I want removed

You can then put two strings together using “concatenation”. In python this is done using a + operator. Be careful of your datatype. Numbers are added but strings are concatenated so

a=1
b=2
c="1"
d="2"

print(a+b)
print(c+d)
3
12

2.3.1.2 Manipulating filenames and paths#

A common task for strings is their use in filenames and paths (the location / directory of a file). On Windows your file explorer and command prompt write filenames using backslashes \. However, this creates an issue when using with python as the backslash is an escape character (see above). You can rewrite your paths using / or you must add an additional backslash. As a result:

    C:\Documents\Videos\myfilename.mp4

in your fileexplorer becomes:

    "C:/Documents/Videos/myfilename.mp4"

or

    "C:\\Documents\\Videos\\myfilename.mp4"

You will often want to manipulate filenames and paths. You can do this manually using slicing and concatentation:

movie = 'path/to/videofilename.mp4'
datafilename = movie[:-4] + '.txt'
print(datafilename)
path/to/videofilename.txt

However, it is usually better to use functions from the built in python library called os.path or another library named glob which has some more advanced features. The main reason is that if you use your code on Mac or Linux machines these operating systems use / in filenames. Consequently code can break. These other libraries resolve this issue, making things operating system independent.

import os

fullpath = "path/to/videofilename.mp4"

print(os.path.exists(fullpath)) # Note this file doesn't exist so this returns False
basename = os.path.basename(fullpath)
directory_path, filename = os.path.split(fullpath)
filename, extension = os.path.splitext(filename)

print(basename)
print(directory_path)
print(filename)
print(extension)

print(os.path.join(directory_path, filename + extension))
False
videofilename.mp4
path/to
videofilename
.mp4
path/to\videofilename.mp4

2.3.2 Tuples#

There are a number of ways to store a collection of numbers or strings. We will start with Tuples as they behave quite like strings. To create a Tuple, just as with strings, we can slice and concatenate tuples to create new tuples:

a = (1,2,3,4,5)
b=(6,7)
a_short=a[1:3]
print(a_short+b)

c=("A","few","strings")
print(c)

#Have a think about how this works to just give a w?
print(c[1][2])
(2, 3, 6, 7)
('A', 'few', 'strings')
w

Python enables us to pack and unpack variables contained in a collection very easily. It also enables us to play tricks.

colour = (255, 100, 70)
red, green, blue = colour
print(red, green, blue)

blue, green, red = red, green, blue
print(red,green, blue)  # swap the red and blue colour channels over.

blue, *othercolours = colour # extract some of the colours
print(blue)
print(othercolours)

blue, _, _ = colour # Indicate that you want the blue variable and aren't bothered about the other variables.
255 100 70
70 100 255
255
[100, 70]

2.3.3 Lists, Dictionaries and Mutability#

Python also has a number of other collections which are extremely important / common. Chief amongst these are the List and Dictionary. However, there is a fundamental distinction in python between two types of data: mutable and immutable. This distinction is very powerful but can result in some confusion.

All the datatypes and collections we’ve looked at so far are immutable. This means that once you create something and assign it to a variable it cannot be changed. Trying to do so will result in an error. If you want to modify a particular value you have to create a new object.

# This doesn't work
a = (1,2,3,4,5)
a[1] = 5
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[17], line 3
      1 # This doesn't work
      2 a = (1,2,3,4,5)
----> 3 a[1] = 5

TypeError: 'tuple' object does not support item assignment
# This does but notice its a completely new tuple containing the numbers we want
a_tuple = (1,2,3,4,5)
print(id(a_tuple))
a_tuple = (a_tuple[0],) + (5,) + (a_tuple[2:]) # Note tuples that are only 1 value long have a trailing ","
print(id(a_tuple))
print(a_tuple)

Note the new a_tuple is not in the same bit of memory as the old a_tuple. You are making a copy of an entirely new tuple when all you want is to alter a single value within it. This makes this very costly in terms of memory. In contrast Lists and Dictionaries can have their values modified without being moved to a new location.

2.3.4 Lists#

Lists are created using square brackets. On the surface they seem to behave the same as tuples, but they are an example of a mutable collection. Compare the output with the equivalent tuples above:

a_list = [1,2,3,4,5]
print(id(a_list))
a_list[1]=5
print(id(a_list))
print(a_list)

This is much better from a memory point of view, but this flexibility comes with some complications you need to be aware of.

first_list = [1,2,3,4,5]
second_list = first_list    # This might look like a straightforward copy but it is really just a different name pointing to the same bit of memory
print(first_list)
print(second_list)
second_list[1] = 5  # We changed the value in the second list
print(first_list)   # But it also changed the value in the first list
second_list.append(10) # Notice this is never assigned to a new variable. You couldn't do this on a tuple.
print(first_list)
second_list.pop(2) #remove the third element
print(first_list)
second_list.sort(reverse=True) # Again notice this is not assigned, the sorting is done without moving to new memory.
print(first_list)

Lists are often used when you have a set of items (numbers or strings) and want to perform operations on each item in the list. They operate a bit like arrays in many other languages. Later we will look at Numpy arrays. These look syntactically a bit like lists. Numpy arrays are designed for performance but lists are much more flexible. Until you are dealing with large datasets or concerned about performance, lists are probably better.

2.3.4.1 Nesting Lists etc#

One can store lists inside lists. In fact all the data collections can be combined. A list of lists can look similar to a matrix but it doesn’t enable easy manipulation. Numpy which we’ll look at later is setup for this kind of thing so if you want a matrix use numpy. However, on the plus side a list of lists is very flexible. For example, not all the lists have to be the same length and they can contain a heterogeneous mixture of datatypes.

nested_list = [[1,2,3],[4,5,6],[7,8,9]]
print(nested_list)
row3 = nested_list[2]
print(row3)
# N.B this would work in numpy but doesn't work for a list col2 = nested_list[:,1]

another_nested_list = [['a', 1, (2,3)],[2,3],[{'key':'value'},3,'word']]
first_list_third_val_first_part_tuple = another_nested_list[0][2][0]
print(another_nested_list)
print(first_list_third_val_first_part_tuple)

2.3.4.2 “in” operator#

A very useful operator in conjunction with lists and tuples is the in operator which tests whether a list contains a value

names = ['Mark','Jane','Bob','Lucy']
if 'Mark' in names:
    print('found Mark')

2.3.5 Dictionaries#

Whereas lists are about sequential items where you access each element by its index position, dictionaries are a data structure that allow you to categorise or name data. The data is stored as a series of key : value pairs. The important thing to remember is that the order of values in a dictionary is not preserved.

n.b if you’ve studied lower-level languages then a dictionary is a type of hash table

module_dictionary = {   'lecturer' : 'Mike Smith',
                        'module_code':'PHYS4038',
                        'number_students': 40,
                        } #Setup the initial dictionary using squiggly brackets

print(module_dictionary)

module_dictionary['marks'] = [70,60,50,40] # Add a new field to the dictionary
print(module_dictionary['lecturer'])  #Access particular value via its key

module_dictionary.pop('number_students')  # Remove an item from the dictionary

print(module_dictionary.items()) # Extract data as a list of tuples
print(module_dictionary.keys()) # get all the keys
print(module_dictionary.values()) # get all the values.

2.4 Flow control#

The real power of programming comes through computers making decisions based on the logic we give them. Python like other languages uses a few constructs to control the flow of a program. Unlike many languages which often indicate a code block using a syntax which looks like:

    if(some condition){ code block }

python uses indenting by 4 spaces, to indicate which level code is at. If you don’t indent correctly you change the logic of your program. Consequently, how you lay out your code matters a lot!

condition = False
if condition:   #   Note we don't need to write condition == True
    print('inside the if statement')    # Indented
print('not inside the if statement')    # not indented

One can provide multiple options. This replaces things like “switch” statements in other languages. The statements are evaluated in order

a=5
#Use if, elif, else
if a < 5:
    print('a < 5')
elif a <= 5:
    print('a <= 5')
elif a < 20:
    print('This is never reached')
else:
    print('final catch all for values not yet covered')


#Which is different to this where we just use ifs:
if a < 5:
    print('a < 5')
if a <= 5:
    print('a <= 5')
if a < 20:
    print('This is now reached')
else:
    print('final catch all for values not yet covered')

It is also possible to write a quick one liner:

print('Good maths' if 5 > 4 else 'Bad maths' )

2.4.2 try except finally#

Sometimes code will result in an error. It is possible to implement code that will run in the event of an error. There are two types of errors:

  • the first type of error is due to poor programming. Don’t use this construct to cover this up it will create more issues for you later!

  • the second type of error is when under certain conditions an error is expected but you want to deal with this error.

other_number = 5
input_number = 0

try:
    # If input_number is 0, this will raise a ZeroDivisionError exception.
    answer=other_number / input_number
except Exception:
    print("You can't divide by zero!")

# You can also specify some cleanup code to be executed no matter what happens,

try:
    answer=other_number / input_number
    # We can use the exact error we want to catch
except ZeroDivisionError:
    print("You can't divide by zero!")
except Exception:
    # Not executed in this case but if input_number were None, this would be executed.
    print("Catch all other exceptions")
finally:
    print("This will always be printed")

2.5 Loops#

2.5.1 while loops#

The while loop in python works like nearly every other language

i=0
while True:
    i+=1
    if i == 5:
        continue    # skips rest of loop and hence doesn't print out 5
    if i > 10:
        break       # Terminates the loop
    print(i)

2.5.2 For loops#

When people come to python from other languages they often try to write python for loops like this:

indices = range(10) 
print(list(indices))

alphabet = ['a','b','c','d','e','f','g','h','i','j']

for i in range(10):
    print(alphabet[i])

This works, but it misses the power and flexibility of python. Python collections are iterables which enables you to write much simpler, human readable code. “Pythonic”. Look how much more readable this is:

alphabet = ['a','b','c','d','e','f','g','h','i','j']

for letter in alphabet:
    print(letter)
# There are also more advanced ways of looping through lists if for example you want to know the index of each item
alphabet = ['a','b','c','d','e','f','g','h','i','j']

for idx, letter in enumerate(alphabet):
    print(f'{letter} is letter number {idx+1} in the alphabet')
# If you are looping through two lists at the same time, you can use zip
greek_letter_names = ['Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta', 'Eta','Theta','Iota','Kappa']
greek_alphabet = ['\u0391', '\u0392','\u0393','\u0394','\u0395','\u0396','\u0397','\u0398','\u0399','\u039a']

print('\n')

for letter_name, symbol in zip(greek_letter_names, greek_alphabet):
    print(f'The greek symbol for {letter_name} is {symbol}')

2.5.3 Comprehensions#

Often we want to convert one list into another list following some simple rules. This can be done in one line using a list comprehension. This can also be combined with logic to filter the lists.

old_list = [1,2,3,4,5,6,7,8,9,10]
new_list = [item**2 for item in old_list]   # Create a list of squared values
print(new_list)
new_list = [item**2 for item in old_list  if (item > 5)]
print(new_list)

Similar behaviour can be used with dictionaries, though the synatax is a little harder. Note the brackets change. Or to link lists together in a dictionary.

old_dictionary = {'a':2,'b':5, 'c':3,'d':10}
new_dictionary = {key : value for key,value in old_dictionary.items() if value > 3}
print(new_dictionary)

student = ['Mark', 'Jane', 'Bob', 'Lucy']
marks = [80, 50, 65, 70]

new_dictionary = {name :  mark for name, mark in zip(student, marks)}
print(new_dictionary)

2.6 Reading & Writing to / from Text Files#

Writing and reading information from files is fundamental. We may need to read some stored settings / parameters or load some data. Alternatively we might want to save results from our program to a file. The simplest example of doing this is using text files. This is a 3 stage process

  1. Opening the file

  2. Writing to or reading from the file

  3. Closing the file

When we open the file we need to specify how we would like to open it: for example to read text (r), write text (w) - replacing the existing contents of the file or to append text (a) - add text to the end of a file.

f=open('resources/textfiles/exampletext.txt', 'r')  # open file in read mode

firstline = f.readline()
print(firstline)
secondline = f.readline()
print(secondline)
f.close()

A better way to write this which makes sure we don’t leave any files unclosed by accident is to use a context manager. The context manager uses the with statement. When you exit the indented portion of the code this will close the file.

with open('exampletext.txt', 'a') as f:
    f.write('\nAdd a third line to the file \n A fourth line \n')  # note \n gives us a new line
    print( 27, 81, 36, sep='\t',file=f)     # We can also use print with the key word argument file to redirect the output to the file.

2.7 Functions#

A function is a bit of code that we wrap inside a code block. It takes inputs, runs some code and provides outputs. Why do we need to separate out bits of code like this?

  1. DRY - Don’t repeat yourself! Functions allow us to reuse important or frequent bits of code.

  2. By breaking code into chunks and using descriptive names and doc strings that describe the inputs and outputs of a function, I don’t need, to hold all the logic of a large program in my mind at once

  3. Makes the code more readable and hence speeds up future use and development.

2.7.1 Regular functions#

Each function should represent a particular task. Name them using a descriptive term, often including a verb which indicates that the function does something. Always write a docstring that at a minimum explains what the function does. For more developed code indicate what the type and purpose of the inputs and return values from the function are.

See some popular formats for docstrings: Google, Numpy

Functions are not run when they are declared, but must be called separately.

def calc_power(a,n):
    """Calculate the nth power of a number a"""
    a_power_n = a**n
    return a_power_n

print(calc_power(2,3))

In the above function, all the arguments are known as positional arguments. That is python knows which number should be assigned to which variable based on the ordering. It is also possible to have keyword arguments. These always come after the positional arguments in a function definition. These assign a default value to a keyword and as a result supplying a value is optional

def calc_power(a, n=2):
    "Same power function with a default value for n"
    return a**n

print(calc_power(3))
print(calc_power(3, n=3))

Sometimes you may need to write an unknown number of positional or keyword arguments. By convention *args is used to define an unknown number of positional arguments as a list. Similarly **kwargs defines some keyword arguments as a dictionary. Notice in the example below there are 6 values, 3 positional and 3 keyword arguments but the function declaration only has 4 terms.

def multiply_bunch_numbers(a, *args, b=2, **kwargs):
    """Demonstrate multiplying unknown number of positional and keyword arguments together"""
    print(args)
    print(kwargs)
    total = a
    for arg in args:
        total *= arg
    total *= b

    for value in kwargs.values():
        total *= value
    print('Total =', total)
    return total
    

multiply_bunch_numbers(1,2,3,b=4, c=5, d=6)

Warning - Never use mutable datatypes as keyword arguments in a function#

A lesson learnt the hardway is that whilst keyword arguments are great, you should only provide immutable datatypes or collections to the function. Otherwise very odd things can happen:

def bad_namelist_function(new_name, names=[]):
    names.append(new_name)  # add name to the list of names
    print(names)    # print current list

bad_namelist_function('Matt')
# Since we supplied an empty list we might expect the list to just contain John but it remembers Matt
bad_namelist_function('John')

We won’t spend time exploring this, if you want to you can look up an explanation . Just remember do not use lists or dictionaries as default keyword arguments.


2.7.2 Lambda functions#

Sometimes we need a very quick one liner function. These are also commonly used as call back functions in graphical user interfaces where for example we want a calculation done when the user presses the button. Lambda functions can be written like this:

f = lambda x : x**2
g = lambda x, y : y if x > y else x

print(f(2))
print(g(2,3))

2.7.3 Scopes#

The top level of your program (ie the script that you run) contains what is called the global scope. All variables defined here are visible to all parts of your program. If we create a function, this results in a new local scope. Everything inside your function is in the local scope. Variables created here are not visible outside of the function. If they have the same name as a global variable then python assumes you mean the local variable.

a=1
def func():
    """Even though we do not pass a to the function it is visible inside the function since the variable is in the global scope"""
    print(a)

func()
a=1
def func():
    #locals() returns a dictionary of all local variables
    print(locals())
    a=2
    print(a)
    print(locals())
    print(a)    #   This prints 2 because local scope a takes precedence over global one

print(a)
func()  # When function is finished the local scope

Always make your life easy. Avoid using names inside and outside of functions that conflict. Pass the variable to a function if possible.

Resources#