Skip to content

Instantly share code, notes, and snippets.

@iankronquist
Last active February 26, 2022 17:45
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save iankronquist/99db09745d4df46b5d8b933f5bd80c3c to your computer and use it in GitHub Desktop.
Save iankronquist/99db09745d4df46b5d8b933f5bd80c3c to your computer and use it in GitHub Desktop.
An Introduction to Python

Typing

When programmers talk about typing, most of the time they aren't talking about the odious task of pressing keys on a keyboard (watch any programmer and look to see how much of their time they spend actually typing out code. What you'll see instead is a lot of frowning and staring at the screen with an expression of great consternation as you can see them think "why the hell didn't my code do what I thought?"). Instead they're talking about the types of variables. Now you're probably familiar with the idea that there are numbers and strings and lists, and mixing and matching them will only lead to more headaches, but types are so much more interesting and potentially powerful than those handful of shallow built-ins. Different programming languages have incredibly different approaches to types. Sometimes this leads to more frustration because the type system is too strict or lax, and sometimes it can save your ass.

Broadly speaking, languages can be broken down into statically typed languages and dynamically typed languages.

If a language is statically typed its variables cannot change types at runtime. If a language is dynamically typed its variables can change types at runtime.

This static/dynamic distinction is something you may see pop up again, by the way. Static things happen at build time and dynamic things happen at runtime.

The advantage to statically typed languages is that you can't screw up the type, because the compiler will refuse to compile the code. Here's a basic example in C:

int greeting(char *name) {
	printf("Hello %s!\n", name);
}

int main() {
	greeting("Sequoia"); // Works
	greeting(42); // Nope, the compiler will give you an error.
}

The big advantage here is type safety, saving the programmer from themself.

Now, let's consider the analogous program in python:

def greeting(name):
	print('Hello ', name, '!')

greeting('Sequoia')
greeting(42)

Now the print function can print anything in python, so it works really well in this case. But sometimes you want the additional safety which comes with static types.

Python also has this idea of "duck typing", that is "if it walks like a duck, and it talks like a duck, then it might as well be a duck". Say you need a data structure like a mathematical set. That is, each element is unique, it's easy to find the intersection or union of two sets, and the order you put an item into a set doesn't matter. You still want to be able to iterate over the elements in the set, and check how many items are in the set. This is easy enough to implement:

class set:
	items = []
	offset = 0

	def __init__(self, iterable):
		for item in iterable:
			self.items.append(item)
	
	def __len__(self):
		return len(self.items)

	def __iter__(self):
		if self.offset == len(self):
			self.offset = 0
			raise StopIteration
		item = self.items[self.offset]
		self.offset += 1
		return item

Since this class implements some of the same functions as a list you can use it nearly anywhere you can use a list! This is the key advantage to using Duck typing.

Incidentally, Python includes a built in set type which works pretty much like this but is a lot more efficient. It uses something called a 'hash map' or 'hash table' to provide really fast lookups.

Here's another example of duck typing:

import math

class Circle:
	def __init__(self, radius):
		self.radius = radius

	def area(self):
		return math.pi * self.radius * self.radius

	def perimeter(self):
		return math.pi * 2 * self.radius

class Rectangle:
	def __init__(self, height, width):
		self.height = height
		self.width = width

	def area(self):
		return self.height * self.width

	def perimeter(self):
		return 2 * self.height + 2 * self.width

# Since both Circle and Square both implement these methods they'll both work.
def shape_info(shape):
	print(shape.area())
	print(shape.perimeter())

square = Rectangle(4, 4)
c = Circle(10)

shape_info(square)
shape_info(c)

Some languages have a way of formalizing and enforcing this Duck Typing approach. For instance, in C++ you can use duck typing and the compiler will catch many errors which you wouldn't find in Python until they occurred at runtime. Other languages like Go have an idea of interfaces, a way of formalizing the methods which you create in Python. Again, the difference is that you can check the types at compile time to catch errors.

Here is an example of the same shapes program in Go. Notice how I have to explicitly declare the Shape interface and have to explicitly declare the types of all of these variables. This allows the compiler to check that all of the types are correct before running the program.

import "fmt"
import "math"

// Any object which has an Area method and a Perimeter method is a shape
type Shape interface {
	Area() float64
	Perimeter() float64
}

// A Rectangle has two numbers, a height and a width
// Since we implement the Area and Perimeter functions it is a shape.
type Rectangle struct {
	height float64
	width float64
}

// A Circle just has a radius
// Again, since we implement the Area and Perimeter functions it is a shape.
type Rectangle struct {
type Circle struct {
	radius float64
}


// Implement the Area method for the Rectangle structure.
// r is just like self in Python.
func (Rectangle r) Area() float64 {
	return r.width * r.height
}

func (Rectangle r) Perimeter() float64 {
	return 2 * r.width + 2 * r.height
}

func (Circle c) Area() float64 {
	return math.Pi * c.radius c.radius
}

func (Circle c) Perimeter() float64 {
	return math.Pi * 2 * c.radius
}

// This function can take any shape
func shape_info(Shape s) {
	fmt.Println(s.Area())
	fmt.Println(s.Perimeter())
}

func main() {
	square := Rectangle{ height: 4, width: 4 }
	c := Circle{radius: 10}
	shape_info(square)
	shape_info(c)
}

Other languages like Haskell have even more complicated type systems which enforce certain mathematical properties on your types. This helps make programs easily composable and makes them really robust to errors, but also makes the language a royal pain sometimes which has little traction outside of academia. Once you get a handle on Python and a statically typed language under your belt I encourage you to take a look at Haskell. It will change the way you think about programming.

Exceptions

The hardest thing about programming is error handling. It's easy to write a program which works well when your data is going down the golden path which you laid out for it, but when there's something unexpected all hell can break loose. Errors can range from failing loudly, failing silently, silently overwriting important data, to crashing your computer. Given the choice I'd take the first one every time (too often at my job errors are of the last kind).

Traditionally, languages like C could only return one variable from a function. Often a special value of that variable was chosen. This is sometimes called 'in-band error handling' and can be problematic because it's way to easy to reuse that special value, or forget to check it.

def do_work():
    # Work
    if work_was_successful:
        return result
    else
        return -1

result = do_work()
if result == -1:
    # Handle error
    pass

do_work()
# Oops, forgot to check the status.

Python takes a different approach to error handling. It has special errors called exceptions. Exceptions have to be dealt with, also known as caught, otherwise the function will exit. If the calling function doesn't deal with the exception it will bubble up all the way to the top and kill the program. This follows the "Explicit is better than implicit" mentality and keeps you from screwing up silently. Because you aren't using the return value to both indicate the result of the function, and whether there was an error, this is known as "out of band" error handling.

Exceptions can be raised (sometimes also called thrown in other languages like Java), using the raise keyword. Anything can be raised as an exception, but it's best if you create an explicit type of exception, or reuse one of the built in types of exceptions.

raise 'A string message'

class MySpecialPurposeException(Exception):
    # Inherits from the base Exception.
    pass

raise MySpecialPurposeException('A message')

raise NameError # Seen any of exceptions like this?

Now that we know how to raise exceptions, how can we deal with them? We can use a special construct called a try/catch or try/except block (again, the names change slightly depending on the language, and you will hear both)

try:
    f = open('some_file_which_may_not_exist', 'r')
    print(f.read())
except IOError as io_exception:
    print('An exception was thrown', io_exception)

You can also swallow all of the exceptions a function may throw like this:

try:
    f = open('some_file_which_may_not_exist', 'r')
    print(f.read())
except:
    print('Swallow all of the exceptions!')

This is considered bad practice because the "Explicit is better than implicit".

Exceptions can also be used as a form of flow control, that is a way of directing program flow just like if statements and for loops. We'll encounter this soon enough.

Scope

A crucial concept in programming is scope. Certain variables can only be accessed from certain places in a program. This helps ensure that running a function won't have unintended side affects. Python's scoping is quite flexible compared to languages like C, C++, Java or Haskell.

In python, when a variable is declared outside of a function it is in global scope, and can be accessed or changed from any function. These are called global variables. When a variable is declared within a function it is in a different, local scope. Function arguments have local scope, but there are some tricky things about arguments which we'll get to later.

To change a global variable within a function you need to use the global keyword. This is a handy built-in precaution to keep you from monkeying with global variables too much.

global_variable = 10

def scope_example(function_argument):
    local_variable = 42
    print('you can access globals without any problems', global_variable)
    print global variable
    print('but in order to change them you have to use the global keyword')
    global global_variable
    global_variable += 1
    print('Inside a function:', global_variable, local_variable,
          function_argument)

    print 'before function call', global_variable # 10
scope_example(12)
    print 'global after function call', global_variable # 11 because the function
# changed it
    print 'local after function call', local_variable # Raises a NameError because
# it's not present in the global scope

Local variables are destroyed when the function ends. Global variables can only be destroyed with the del keyword.

global_variable = 10
print global_variable
del global_variable
print global_variable # Raises NameError

Local variables can have the same name as a global variable. This is called "shadowing" a variable, and is generally a bad idea because it's confusing. There are also a number of built-in functions and variables like open, str, hex, and so on. It's generally a bad idea to shadow those too because you might find you actually need them later, and then you need to rename the variable.

shadow = 1
def scope_example(shadow):
    print shadow

print shadow
print scope_example(2)

You can have nested functions too. This is actually occasionally useful.

def outer():
    outer_local = 'o'
    print(outer_local)
    def inner():
        inner_local = 'i'
        print('inner local', inner_local)
        outer_local = 'i'
   inner()
   print('after inner function call', outer_local, inner_local)

outer()

Variables from the outer function can be changed without any special keywords.

#Pass by Reference and by Value

There are two ways to pass a variable to a function. You can either pass a copy of the variable, or the original variable itself.

Here is an example of passing a copy of a variable (AKA by value):

def change_me(x):
	print(x) # 0
	x += 42
	print(x) # 42

number = 0
print(number) # 0
change_me(number)
print(number) # still 0

This works for other primitive types like floating point numbers and strings.

Here is an example of passing the original variable (AKA by reference):

def change_me(l):
	print(l) # [1,2,3]
	l.extend([4,5,6])
	print(x) # [1,2,3,4,5,6]

number = [1,2,3]
print(number) # [1,2,3]
change_me(number)
print(number) # [1,2,3,4,5,6]

This works for everything which isn't one of that small handful of primitive types.

Note that if we assign to the variable in the function it won't affect the variable outside of the function.

def change_me(l):
	print(l) # [1,2,3]
	# This time we assign to the variable, not add to it!
	l = [4,5,6]
	print(x) # [4,5,6]

number = [1,2,3]
print(number) # [1,2,3]
change_me(number)
print(number) # [1,2,3]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment