PE100-05: Functions

Functions in Python are very, very similar to functions in mathematics. Our functions take one or more input values and transform them into precisely one output. Let’s start with an example.

def inductiveReactance(l, f):
    reactance = 2 * 3.14159 * f * l
    return reactance

print (inductiveReactance(1e-3, 2e6))
12566.36

The above code defines a function named “inductiveReactance” that accepts two input parameters. The first one, l, is the amount of inductance a coil has in henrys. The second one, f, is the frequency of interest (in hertz). We can call that function with parameters of one millihenry and two megahertz. The function computes the value 12566.36 (the unit is ohms) and returns that to the code that called it. In this case, it was a print statement that called it.

Take a look at the first line. The very first thing is the keyword def (shortened from the word “define”). After the “def” is where you specify the name of the new function you want to create. In this case, it’s “inductiveReactance”. Next is a list of names of parameters, enclosed in parentheses. In our example, the parameters are “l” and “f”. Finally at the end of the line is a colon.

Once that first line is done, the next step is to write the body of the function. Just like an “if”, “while”, or “for” statement, the code block has to be indented consistently. Our example function computes the reactance of the device in question. On the last line of the function, the return statement is how the function sends its computed value back to the code that called the function in the first place. Every function should have at least one return statement. I won’t get drawn into this debate: some say a function should have precisely one return statement and utilize whatever logical means necessary to make sure that all code paths eventually lead to it. Others say it’s not a problem at all for a function to have multiple return statements (and hence multiple ways for a function to end) if it makes the logic more understandable. Personally, I try to minimize the number of return statements in my functions but I’m by no means a zealot on this one. If I need seven different places to exit the function and return a value then so be it.

Encapsulation

Functions are useful in programming for the same reason they’re useful in math - ours encapsulate a chunk of code so you don’t have to think about what is in it every time. Imagine how tedious it would be to write a program that needed to compute cosine in a lot of different places in the code. You could, I suppose, type in a Taylor series expansion for cosine in each of the places where we need to compute a cosign. That would be irritating, error prone, and confusing to anyone else who has to read it. Instead, we can write a function exactly once to compute cosine and then call that function from many places in our code. Once we have the function tested and debugged, we don’t have to think about it again. That frees up mental energy for more productive uses.

Functions can be classified into one of two types. Void Functions exist for encapsulation and don’t actually return a value. print() is an example of a void function. Value-Returning Functions, as the name strongly implies, return a value to the calling code. inductiveReactance() is an example of one.

Here’s another example. This time, we’ll define a function that calls another function.

def squared(x):
    return x ** 2

def circle_area(radius):
    area = 3.14159 * squared(radius)
    return area


print("Area of a circle with a radius of 2 is", circle_area(2))
Area of a circle with a radius of 2 is 12.56636

We defined a function to compute the area of a circle. It needed to square a number and so we decided to write a function to do that. Functions can call other functions ad infinitum. In fact, functions can even call themselves! When that happens the function is said to be recursive. Recursive functions are very useful for solving some hard problems but they’re a little beyond an introductory module like this one.

Function (and Variable) Naming

What kinds of names can we use for functions? The same ones we can use for variables! More specifically, * No keywords (e.g., False is invalid) * No spaces (e.g., my function is invalid) * The first character must be: * a-z, A-Z, or _ (the underscore character) * No numbers (e.g., 1st_function is invalid) * After the first character, the following are allowed: * a-z, A-Z, _, and 0-9 * No other symbols (e.g., get_room&board is invalid)

As a widely agreed upon best practice, names should be meaningful and be composed of lowercase characters with underscores as separators.

Function Arguments

Input Parameters to functions are called arguments. They are the primary and best way to put information into a function, and definitely the way that causes the fewest problems. Arguments to a function in Python are mostly analagous to what we’re used to in math, but of course Python has some extensions.

A function can have any number of arguments, including zero. “A function of zero arguments” might sound like a mathematician’s idea of “humor”, but it can actually make sense in programming. Sometimes you just need to encapsulate part of your code so you don’t have to worry with it again. For instance:

def say_hi():
    print("==============================")
    print("==============================")
    print("Greetings, User. I'll start ")
    print("loading the instrument config")
    print("files and opening connections")
    print("to them. It'll take a minute.")
    print("==============================")
    print("==============================")

say_hi()
==============================
==============================
Greetings, User. I'll start 
loading the instrument config
files and opening connections
to them. It'll take a minute.
==============================
==============================

Now the code to print that banner is hidden away inside a function we’ll never have to look at again. Less mental clutter means fewer bugs.

And for the sake of completeness, functions can also take one or more arguments:

def convert_to_miles(kilometers):
    return kilometers * .6213712

def interesting_polynomial(a, b, c, d):
    result = 2*(c**3) + 3.91*(c**2) + 1.1*c + d
    return result

print("The race was", convert_to_miles(10), "miles long and my ankles were hurting the ENTIRE way.")

print("The polynomial evaluates to:", interesting_polynomial(7,4,8,1))
The race was 6.213712 miles long and my ankles were hurting the ENTIRE way.
The polynomial evaluates to: 1284.04

When arguments are passed into a function, they become parameter variables and can be referred to inside the function just like any other variable. This handy because the variables inside a function are called local variables and they have special properties: nothing outside of the function can modify their value, they’re destroyed and re-created every time the function is called, and these local variables supercede any outside variables with the same name.

Take a look for yourself:

def show_twice_the_wavelength(wavelength):
    wavelength = wavelength * 2
    print("Twice the wavelength is", wavelength)

# a good wavelength to listen for synchrotron radiation
# emitted from Jupiter (the planet, not the software), in meters
wavelength = 20
show_twice_the_wavelength(wavelength)
show_twice_the_wavelength(wavelength)
show_twice_the_wavelength(wavelength)

    
Twice the wavelength is 40
Twice the wavelength is 40
Twice the wavelength is 40

Does that seem odd to you? What happened is this: four lines from the bottom we created a variable named “wavelength” and set it to 20. We then called the function to print it out doubled. We passed the global variable “wavelength” to our function which took it as its only argument. That argument became a parameter variable that was coincidentally named “wavelength”. That “wavelength” parameter variable has nothing to do with the “wavelength” variable in the main part of the program. Our function doubles that parameter variable and prints it out. At that point, the function completes and the flow of control goes back to the main body.

The next time our function is called an entirely new, fresh set of variables and parameter variables is created. This is important - it means that if we call the function with the same value every time then we always get the same result. Functions are unable to save their “state”. Like a football player on a stretcher, they have no memory of what happened before.

(OK, yes, there are ways for them to save their state. Sometimes it’s unavoidable and you just have to do it, but doing so makes more places for bugs to creep into your programs and makes it harder to understand later. Try to avoid it. We’ll talk about it later.)

Variable Scope

The degree to which your programs can “see” a variable is called scope. There are two levels of scope in most Python programming:

  • Global Scope
    • Defined in main Python file
    • Outside of ANY function
    • Try to avoid these!
      • Considered poor design
      • Dangerous to use: any part of the program anywhere can change these
      • Bug Magnet!
  • Local Scope
    • Variables defined within a function
    • Only visible and useable from inside their own function!
    • Use these if at all possible.

The danger in global variables comes from two things. The first is the fact that the value can be changed anywhere in your program, either in the main program or inside of a function, and it’s devilishly hard to keep track of where that might be.

The second danger is more subtle. When a function saves a value into a global variable, the function is now said to have side effects. Side effects break the idea of isolation that functions are meant to give us. Imagine a mathematical function, such as tangent, if it had side effects. Calling tan(.0125) would not only result in the tangent of .0125, but it would have some other effect on some unrelated part of math. Imagine if calling tan caused your coordinate system to change every time? That would be insane.

It gets worse, though. What if our tangent function also read from a global variable and changed its behavior based on that. Then each time we called tan(.0125) we might get a different value.

In other words, we basically broke math.

Similarly, when we write programs, if our functions have side effects then we’ve complicated them tremendously. And more complication means more places for bugs to sneak into our code and they’ll also be harder to find.

As an aside, there is a style of programming that eliminates global variables and, to an extent, even local variables. It’s called functional programming, and Python has some support for that style. There is usually more than one way to do anything in Python, and experienced Pythonistas will usually try to choose the most Pythonic way. Part of being in Pythonic style means to use (at least partially) a functional style.

Constants

There is an exception to the “no globals” rule: Constants. Just like in math, a constant is given a value once and never changed again. “Never changing” means “no side effects” so everything is OK. It is good practice to define your constants using ALL CAPITAL LETTERS.

PLANCK = 6.626e-34

def photon_energy(freq):
    gumption = PLANCK*freq
    return gumption

print(photon_energy(3e14))
1.9878e-19

Abstraction

A valuable property of functions is how they isolate the code and variables inside of them from being manipulated elsewhere in your software. A consequence of that is their ability to “hide” detail from us. We’ve already talked about writing a function, debugging it, and never having to look at the code inside of it again. What is every bit as useful, if not more so, is using functions to provide abstraction.

Abstraction is something we’ve used every day even if we haven’t thought it. Remember learning math? You started off counting things, and yes, that counts as math. If you had four bottle caps in one hand and three in the other, you could toss them all on the table, count them, and know that you have seven in total.

There are two problems with having to count everything. One is that the amount of stuff can get big in a hurry. Try using two hands and table to count sand grains. The other problem is that if there are any insights to be had, it’s hard to find them when you’re stuck down in the details. Fortunately, we learned arithmetic.

Arithmetic is great. We don’t have to deal with handfuls of stuff anymore. We can just use numbers and operators and get an answer without a bunch of messing around. We can start to see patterns we never would have just tossing bottle caps on the table. If we need to add 12 to something, we can instead add 10 and then add 2 more. This is so handy. Of course, it would be nice if we could just do something to analyze entire families of arithmetic problems.

Algebra lets us analyze entire families of arithmetic problems. We don’t have to fool with numbers if we don’t have to - we can just substitute variables in their place. We’ve hidden some of the complexity, like the petty little details of numbers, and abstracted that complexity away.

Similarly, a lot of problem solving is perfectly amenable to using abstraction. Let’s write a bit of code to run an experiment…

def run_experiment():
    safe=is_it_safe_to_run()
    if safe == True:
        light_em_up()
        put_facility_back_in_a_safe_state()
        print("Better than Ghostbusters, huh?")
        

That function is a (admittedly fanciful) representation of running an experiment. It makes sense, anyone can understand it, and if there’s a bug in there then it’s going to be really obvious. The only problem: if we try to run it, it’ll crash because those other functions haven’t been defined yet. Shall we fix that?

def is_it_safe_to_run():
    if badges_swiped_in() == 0 and shutter_closed() and not red_light_illuminated():
        return True
    else:
        return False

def shutter_closed():
    # put some code to interface with the solenoid sense switch here
    return True;

def red_light_illuminated():
    # more code, this time to see if the light is on...
    return False

def badges_swiped_in():
    # do some database lookups or something to see if we
    # think anyone is still in the room.
    return 0;
def light_em_up():
    turn_on_red_light()
    high_voltage(True)
    shutter_open()
    # a few seconds delay here, perhaps?

def put_facility_back_in_safe_state():
    shutter_closed()
    high_voltage(False)
    turn_off_red_light()

Notice how the program is broken up into several functions? The best part is that you don’t have to keep everything in your head. All you have to remember is the part you’re working on. Smaller pieces, fewer bugs.

Modules

One reason Python has become so popular is the sheer amount of code that has been written in it and made available for public use. We’ve seen a few functions already that were built in to Python - int(), float(), and str(), for example - but there are many tens of thousands of modules that are freely available for use in your own software. Just picking five common ones at random:

  • math
  • random
  • os
  • PyMySql
  • psycopg

The first two contain functions for general-purpose math and for producing random numbers. The “os” module interfaces Python with the operating system the code is running on. PyMySql and psycopg provide connectivity to relational databases.

Remember at the beginning of this lesson when we wrote a function to calculate inductive reactance? I put the value of pi in there as 3.14159, but that really isn’t anywhere near enough digits for some problems. Let’s fix that:

import math
print(math.pi)
3.141592653589793

There are two things to note here. First, the keyword import is used to tell Python to go find a module with the right name and load it. The name we want it to find is the word right after the import. And secondly, just looking at the output we can see that there are a lot more digits than when we did something by hand in our Inductive Impedance example (top of this page). In general, using a module that was (a) written by someone else and (b) is widely used and has been checked by a lot of people is going to avoid a lot of bugs. For instance, I would never code my own Fast Fourier Transform. Instead, I would use the one in the “numpy” module. I know how easy it is to make a mistake and I trust their work a lot more than my own. They have tens or hundreds of thousands of users and scores of developers. I have… a copy of Numerical Recipes that’s old enough to run for President.

Since we used the “math” module already, here’s a very incomplete list of what is in there: * sin(), cos(), tan(), acos(), asin(), atan()… - “acos” is “arc cosine”, etc. * log(), log10(), sqrt() - square root * radians(), degrees() - converts between them

And lots more stuff. How do you know what’s in it? Go to the online documentation: https://docs.python.org/3/library/math.html

Random Numbers

Another module that is heavily used is “random”. It generates random numbers, yes, but it can also do things like take a list of things and shuffle them randomly.

import random
integer_number = random.randint(10,100)
real_number = random.random()

print("The random integer between 10 and 100 was:", integer_number)
print("The random float between 0 and 1 is:", real_number)
The random integer between 10 and 100 was: 20
The random float between 0 and 1 is: 0.6474502367565016

There are more functions available in the “random” module, including ones to select a real number from a non-uniform distribution. Take a look at https://docs.python.org/3/library/random.html

Here’s a slightly more complicated example:

# for 25 hypothetical proposals, use random() to decide
# if it gets funded.

for proposal in range(0,25):
    if random.random() < 0.12:
        print("Proposal number", proposal,"was funded!")
    else:
        print("Don't feel bad... proposal number", researcher, "didn't get funded either.")
Don't feel bad... proposal number 24 didn't get funded either.
Don't feel bad... proposal number 24 didn't get funded either.
Don't feel bad... proposal number 24 didn't get funded either.
Don't feel bad... proposal number 24 didn't get funded either.
Don't feel bad... proposal number 24 didn't get funded either.
Don't feel bad... proposal number 24 didn't get funded either.
Don't feel bad... proposal number 24 didn't get funded either.
Don't feel bad... proposal number 24 didn't get funded either.
Don't feel bad... proposal number 24 didn't get funded either.
Don't feel bad... proposal number 24 didn't get funded either.
Don't feel bad... proposal number 24 didn't get funded either.
Don't feel bad... proposal number 24 didn't get funded either.
Don't feel bad... proposal number 24 didn't get funded either.
Don't feel bad... proposal number 24 didn't get funded either.
Don't feel bad... proposal number 24 didn't get funded either.
Don't feel bad... proposal number 24 didn't get funded either.
Don't feel bad... proposal number 24 didn't get funded either.
Don't feel bad... proposal number 24 didn't get funded either.
Don't feel bad... proposal number 24 didn't get funded either.
Don't feel bad... proposal number 24 didn't get funded either.
Don't feel bad... proposal number 24 didn't get funded either.
Don't feel bad... proposal number 24 didn't get funded either.
Don't feel bad... proposal number 24 didn't get funded either.
Proposal number 23 was funded!
Don't feel bad... proposal number 24 didn't get funded either.

Let’s try out what we’ve learned so far. Use the next code cell to write a bit of Python that simulates rolling a pair of dice and adds the two values. Print the value out.

Let’s add to that… add a loop so that we keep doing that over and over until we get the same sum twice in a row. Some questions to ask yourself are “What kind of loop do I need?” and “How can I compare what happened between two different loop iterations?”