Week 5: Prep to Build an Inclinometer (Namespaces, Modules, Objects)#

Laboratory 4a
Last updated September 21st, 2023

00. Content #

Mathematics

  • N/A

Programming Skills

  • Namespaces

  • Modules

  • Objects

  • Classes

  • Inheritance

Embedded Systems

  • N/A

0. Required Hardware #

  • N/A

Copy the file `student_info.py` into the folder you are working in, then run this cell to put your name and email into this document.

Last week you ran a script to download student_info.py and updated it with your own information. You'll be using that same Python file with your information for all the labs going forward. Copy the student_info.py file into the directory this lab is in.
# this makes it so that changes to the imported module are automatically updated in this document.
# usually not necessary, but if we are making changes, this is helpful.
%load_ext autoreload
%autoreload 2

from student_info import show_info
show_info()

Additional lab materials#

In addition to the student_info.py file, you’ll need a couple more specific Python files to complete this lab. Run the following cell to download those files to your current directory.

%%sh
wget -q -N https://raw.githubusercontent.com/TheDataScienceLabs/DSLab_Calculus/main/book/labs/4_measure_tall_things/a_prepare_for_building_inclinometer/lab4a_check.py
wget -q -N https://raw.githubusercontent.com/TheDataScienceLabs/DSLab_Calculus/main/book/labs/4_measure_tall_things/a_prepare_for_building_inclinometer/example_module.py

1. Namespaces #

We are going to start dealing with more complicated systems in the coming weeks. Next week, you will make a piece of measurement equipment, including a display, buttons, and sensor. If we kept track of everything using their own variable names, we would have to make long names, like screen_data_pin_number_1, for example. There are a few problems with that approach:

  • long variable names are hard to remember

  • long variable names are hard to type (lots of typos can happen!)

  • long variable names are hard to read:

    • it’s hard to see if they are typed incorrectly

    • they break up the flow of the sentences

Of course, if we just used short variable names for everything, we would run out of names very fast. It would not be much better to replace screen_data_pin_number_1 with sdpn1, for example.

Namespaces are one of several ways that we deal with this in Python. Namespaces are so common that you have already been using them, you just didn’t know it. Let’s look at an example using namespaces.

from types import SimpleNamespace

alden = SimpleNamespace(name="Alden", age=27, grade=22)
print(alden)
print(alden.age)
print(alden.grade)

We can see that there are three variables living inside the variable alden, and they each have a relatively short name. We have a special name for these variables: they are called “attributes”. We would say age is an attribute of alden. We access the attributes using the . which probably looks familiar to you; we have been using attributes every time we load up a package like numpy.

import numpy

numpy.cos(numpy.pi)

Both the function cos and the number pi are attributes in the numpy namespace, so we refer to them using a ..

More advantages of namespaces#

Another way namespaces are better than just having long function names is that we can pass them around in their own variables. We could write, for example:

np = numpy
np.cos(np.pi)

That’s what Python is doing “under the hood” when we use the as keyword during imports. When we write import numpy as np it’s doing two steps in one, import numpy and np = numpy.

Here is a practical way we can use this. Say we had another instructor:

kindyl = SimpleNamespace(name="Kindyl", age=23, grade=20)
kindyl

Now we can write a function which takes in just one argument and have it work on either instructor.

def describe(instructor):
    print("My instructor's name is {}. They are {} years old and in grade {}.".format(instructor.name, instructor.age, instructor.grade))
    

for instructor in [kindyl, alden]:
    describe(instructor)

This helps us to make more flexible, modular code.

Exercise 1 (10 pts)#

The following code is a mess! Use namespaces to make it more readable.

Write Answers for Exercise 1 Below

eiffel_tower_name = "Eiffel Tower"
eiffel_tower_height = 324
eiffel_tower_weight = 7_300_000
eiffel_tower_bmi = 7_300_000/324**2

turbine_name = "GE 1.5 Megawatt Wind Turbine"
turbine_height = 100
turbine_weight = 150_000
turbine_bmi = 150_000/100**2

burj_name = "Burj Khalifa"
burj_height = 830
burj_weight = 450_000_000
burj_bmi = 450_000_000/830**2

skytree_name = "Tokyo Skytree"
skytree_height = 634
skytree_weight = 32_600_000
skytree_bmi = 32_600_000/634**2

def describe_bmi(tower_name, tower_height, tower_weight, tower_bmi):
    message = ("The {} is {} meters tall and weighs {} kilograms. "
               .format(tower_name, tower_height, tower_weight)
              )
    if tower_bmi < 18.5:
        message += "Its low BMI of {:.1f} indicates that it is underweight."
    elif tower_bmi < 25:
        message += "Its BMI is {:.1f} which is typical for a person, but strange for a building."
    elif tower_bmi < 30:
        message += "Its BMI is {:.1f}, so according to the World Health organization, it's overweight."
    else:
        message += "Oh no! Its BMI is {:.1f}, so the World Health Organization says it's obese!"
    message = message.format(tower_bmi)
    print(message)

describe_bmi(turbine_name, turbine_height, turbine_weight, turbine_bmi)
describe_bmi(eiffel_tower_name, eiffel_tower_height, eiffel_tower_weight, eiffel_tower_bmi)
describe_bmi(skytree_name, skytree_height, skytree_weight, skytree_bmi)
describe_bmi(burj_name, burj_height, burj_weight, burj_bmi)

A bit of poetry#

To give you an idea of just how important namespaces are to understanding Python, read the following poem which is included in every installation of Python. Pay particular attention to the last line.

import this

2. Modules #

Namespaces in Python are much more than a trick for writing clean code (though they do help with that). As we noted above, whenever you do an import you are assigning a namespace to a variable. In fact, whenever you use any variable in Python, it is being used in a namespace. Usually the code that you write yourself lives in the so-called “global” namespace – the idea is that everything else lives inside that space. We can access our current namespace using the built-in function dir, which is meant to be short for “directory”.

dir()

In the list above, you probably recognize many of the variable names we have already used in this notebook. There are also several things which you probably don’t recognize, most of them beginning with an _. It is a convention in Python that if you don’t want most people to look at a variable, you start it with an _. All of the things in the list above are variables we can access. For example, look at the variable In.

In

We can see that this is a list showing every input we have entered so far. This variable was created for us by Jupyter. I have not found a use for it. A few of the other automatically-made variables are quite handy, though. This one in particular is used in most Python programs which people publish online (we will see why in a minute):

__name__

What’s the point of this? Just to show that every variable in Python lives in some namespace. When we import a module, all the code inside it is run right away. Then, all its variables are made available in the namespace we loaded. Open up the file example_module.py and see what’s written inside. Then, run the cell below.

import example_module

You can tell that the code in the module was run because it printed out the message above. When we use the function dir on the module, we see what is in its namespace.

dir(example_module)

Some things will look familiar, like numbers:

example_module.numbers

and the function share_favorites:

example_module.share_favorites()

Others were put in there for us automatically. We can see that it has its own __name__:

example_module.__name__

And the variable __doc__ holds some potentially-useful information:

example_module.__doc__

Since that’s a string, and it lives in the variable __doc__, it’s called a “docstring”. If you put a string as the first line of any module, or of any function, it gets stored as a docstring. Usually the docstring will contain helpful information about how to use a module or function.

Notice that every variable in example_module is in that namespace, including the modules which were imported inside it. This is great, because it means you can call a module whatever you want when you import it (like when using an abbreviation), and it won’t affect other parts of a big program. We can confirm that we are really getting the same thing either way:

example_module.my_favorite_module is np

Here we are using the special keyword is, which checks whether two variables point to the exact same thing.

One mystery still remains: what’s that block at the bottom of the module, inside the if statement? We saw that when importing the module, the variable __name__ had the value "example_module", not the value "__main__". Surely it would never run. So why include it at all?

Exercise 2 (5 pts)#

To see why, open up a terminal inside Jupyter and navigate to this directory. Then, type the command python3 example_module.py. Paste the output below to demonstrate that you have done this. (Hint: you can type cd my_folder/ to navigate into the folder named “my_folder”, and ls to list everything in your current folder)

Write Answers for Exercise 2 Below

As you have seen, when we ran the code on its own (as the only program, not as a module in a larger program) its __name__ was "__main__". That meant it ran the block at the bottom of the module. You will see this all the time when reading other people’s code. People want to be able to import useful functions from other projects without running them, and people want to be able to run their project as its own program. By taking advantage of the variable __name__, we get the best of both worlds.

Exercise 3 (20 pts)#

Now that you have seen some of how importing modules works, you should write your own module. It should conform to the following specifications:

  • name it my_module.py

  • Have a docstring which describes what your module does

  • Include a variable INCH with the value 2.54.

  • Include a function called chebyshev_five which takes one argument x and returns \(16x^{5}-20x^{3}+5x\).

  • Make it so that when you run the module on its own it prints your name, but if imported does not print anything.

Once you have written your module, run the following cells to demonstrate this.

Write Answers for Exercise 3 Below

# these two lines are instructions to python that it should reload your module if you make changes.
%load_ext autoreload
%autoreload 2
import lab4a_check
import my_module
lab4a_check.exercise_3(my_module)
%%bash
# this is another way you can run a program on its own.
python3 my_module.py

Exercise 4 (20 pts)#

Now you know how to write a module. Why would you want to write a module? Come up with at least five reasons you might want to put code into a module and import it, rather than copying and pasting the code into a document to run it. Each reason should be in the form of 2-4 sentences.

Write Answers for Exercise 4 Below

Exercise 5 (20 pts)#

Make a module called datalab.py and fill it with useful functions we have written, such as:

  • the show_info function you wrote in lab 3

  • the functions you wrote in lab 3 which compute properties of a heartbeat

In this document, demonstrate loading the module and using the functions inside of it. You will need to come up with examples which demonstrate what each function does clearly. Then run the following cell which will print the contents of your file in this document to be graded. You will be graded on the following scale:

Category

5 points

3 points

1 point

Documentation

There is a docstring at the top, describing what is in the file. Every function has its own docstring as well.

One or more docstring is missing.

No docstrings were provided.

Style

Code is presented in a consistent style with appropriate uses of spacing and parentheses.

Code is mostly well-formatted, but there are a few parts which are hard to read.

It is hard to tell what the code does.

Examples

The examples demonstrate what the purpose of each function is, as well as how the function should be used.

One or more examples are missing, or do not demonstrate what the function is good for.

Several examples are poorly-chosen or missing.

Write Answers for Exercise 5 Below

import inspect
import datalab
print(inspect.getsource(datalab))

3. Objects #

To review, we have introduced:

  • the concept of a namespace.

  • how Python groups code into namespaces when loading modules.

Most people who code in Python don’t think about namespaces, at least not by that name (although they should!). Most people don’t use the module types.Simplenamespace to write their code. Most Python programmers instead think about “objects”. Why? Because everything in Python is some kind of object.

What is an “object”? An object is a namespace. Python is considered an “object-oriented” programming language because everything in Python is an object. Even things which you are familiar with, like integers, lists, and strings, are objects. We can inspect their attributes using the dir function:

dir(1)
dir([1, 2, 3])
dir("hello")

Many of the attributes of these objects are functions. When an object’s attribute is a function, we call that function a method of the object. They usually have something to do with the object they are attached to. For example, the method upper of the string hello is a function which returns that string in upper case.

'hello'.upper()

Using object methods#

As you can see above, there are lots of attributes for even a simple object. Most of them start with __, which is a convention which means that most programmers won’t have to use that attribute – it’s handled by Python internally. Since these are methods which start with a double underscore, these are called “dunder methods”. As an example, look at the method __len__.

'hello'.__len__()

This does exactly the same thing as

len('hello')

In fact, that’s the first thing the function len tries when you call it: the function checks if the object you are calling it on has a method named __len__. The actual source code for len is written in C so that it runs faster, but if we wrote it in Python it would look something like this.

def len(x):
    if '__len__' in dir(x):
        return x.__len__()
    else:
        # find the length another way

So you see, you almost never need to use the double-underscore methods. When we filter them out, things start to feel a lot more tractable:

for attribute in dir([1]):
    if not attribute.startswith('__'):
        print(attribute)

You can see there are not that many methods which you actually will be using. If you want to know how to use one of the methods, you can ask Python for help:

help([1].count)

Exercise 6 (10 pts)#

Pick one of the methods of "hello" and use the built in help function to figure out how to use it. Demonstrate using the method as well.

Write Answers for Exercise 6 Below

As you use Python more, you will become used to the methods of common types of objects. All strings, for example, have the same methods. If you want to know an object’s type, you can use the built in function type,

type('hello'), type(1), type(1.0), type([])

Writing our own classes#

Most of the time it’s easiest to use objects that are already built in to Python. However, it is frequently helpful to be able to make up new types of objects. The type of an object is also called its class. Python gives us a short, clear way to come up with new classes. Let’s revisit our buildings from before. Instead of making them simple namespaces, let’s define a new class. Here is the way we would write that.

class Building:
    def __init__(self, name, height, weight):
        self.name = name
        self.height = height
        self.weight = weight
        self.bmi = weight/height**2
        
eiffel = Building("Eiffel Tower", 324, 7_300_000)
print(eiffel)
print(eiffel.name)

We used the keyword class to declare a new kind of object. Everything inside the class block lives inside the Building namespace. We defined a new function __init__, but it’s not available outside the class:

Building.__init__

The name __init__ is a special name which is used to make objects of the Building type.

type(eiffel)

You can see that the type of eiffel is __main__.Building, because it was created from the class Building which lives in the __main__ namespace. Since the type of eiffel is Building, we would say that eiffel is an instance of Building.

We can already see one benefit of making our own class: we can specify only the information needed to come up with it. We didn’t need to compute the bmi attribute ourselves; the function __init__ handled it. This helps to ensure that bmi is computed the same way every time, and it saves us from having to remember the formula whenever we want to declare a new Building.

Let’s get a bit more fancy. Instead of defining a function in the global namespace to describe a building, let’s make a method for the Building class which does the same description as before.

class Building:
    def __init__(self, name, height, weight):
        self.name = name
        self.height = height
        self.weight = weight
        self.bmi = weight/height**2

    def describe(self):
        message = ("The {} is {} meters tall and weighs {} kilograms. "
                   .format(self.name, self.height, self.weight)
                  )
        if self.bmi < 18.5:
            message += "Its low BMI of {:.1f} indicates that it is underweight."
        elif self.bmi < 25:
            message += "Its BMI is {:.1f} which is typical for a person, but strange for a building."
        elif self.bmi < 30:
            message += "Its BMI is {:.1f}, so according to the World Health organization, it's overweight."
        else:
            message += "Oh no! Its BMI is {:.1f}, so the World Health Organization says it's obese!"
        message = message.format(self.bmi)
        print(message)
        
turbine = Building("GE 1.5 Megawatt Wind Turbine", 100, 150_000)
turbine.describe()

We should comment on the special word self; actually it’s not special at all, it’s just the position of it that’s special. It’s traditional to use the parameter name self there. The first parameter of any method in Python refers to the object that called it. In our example, we called turbine.describe(). We could have just as easily done it like this:

Building.describe(turbine)

So you see, the class methods will always have a parameter self which Python automatically fills in with the instance of the class.

There is one more thing I would like to change about our class: it doesn’t look good when printed.

print(turbine)

By telling Python how to make it into a string, we will get a much nicer print message.

class Building:
    def __init__(self, name, height, weight):
        self.name = name
        self.height = height
        self.weight = weight
        self.bmi = weight/height**2

    def describe(self):
        message = ("The {} is {} meters tall and weighs {} kilograms. "
                   .format(self.name, self.height, self.weight)
                  )
        if self.bmi < 18.5:
            message += "Its low BMI of {:.1f} indicates that it is underweight."
        elif self.bmi < 25:
            message += "Its BMI is {:.1f} which is typical for a person, but strange for a building."
        elif self.bmi < 30:
            message += "Its BMI is {:.1f}, so according to the World Health organization, it's overweight."
        else:
            message += "Oh no! Its BMI is {:.1f}, so the World Health Organization says it's obese!"
        message = message.format(self.bmi)
        print(message)
        
    def __str__(self):
        return 'Building("{}", {}, {})'.format(self.name, self.height, self.weight)
        
turbine = Building("GE 1.5 Megawatt Wind Turbine", 100, 150_000)
print(turbine)

This is usually the only way programmers interact with the dunder methods; by defining them yourself, you can make your classes interact better with the rest of Python.

Exercise 7 (15 pts)#

Make a new class called Monomial to represent the monomial \(a x^b\) which takes two parameters, coefficient and exponent. It should have a single method derivative which returns a new Monomial, representing the derivative of \(a x^b\). When printed it should display nicely, like this:

>>> print(Monomial(2, 3))
2*x**3
>>> print(Monomial(2, 3).derivative())
6*x**2
>>> print(Monomial(2, 3).derivative().derivative())
12*x**1

You can use the function lab4a_check.exercise_7 to check your work.

Write Answers for Exercise 7 Below

lab4a_check.exercise_7(Monomial)

4. Inheritance #

It is fairly common to have a class which does almost everything you need; you just wish it did one more thing, or did something differently. For those times there is a clever system called inheritance; you can make a new class which “inherits” most of its behavior from the old one. Suppose I really wish I could make my strings print out in a more sarcastic way. Here’s how I might do that:

class meme_str(str):
    def sarcastically(self):
        return ''.join(c.upper() if i%2 else c.lower() for i, c in enumerate(self))
    
    def clapping(self):
        return '👏'.join(word.capitalize() for word in self.split())

definition = meme_str("the derivative is the slope of the tangent line")
print(definition)
print(definition.clapping())
print(definition.sarcastically())

We won’t use inheritance very much. Twenty to thirty years ago, when object-oriented programming was still something new, many people were impressed with how easy it was to do inheritance and so they built big, elaborate systems of inheritance. Mostly, the code that came out of that style was a tangled mess, where you could never be entirely sure where all the attributes came from. As a result, inheritance has become much less popular lately. For small things inheritance can still be useful, and many people still use inheritance so it’s good to know about it.