Updated: 15 May 2024, 10:00+02:00

A gentle primer on Python, Part 2

Table of contents

Learning objectives

This notebook introduces some more advanced, but still common concepts of Python:

  • List comprehension and dictionary comprehension
  • Lambda expressions
  • Type hints
  • Decorators
  • Classes (in the object-oriented programming sense)
  • Modules and packages
  • Import statements

The primary purpose is for you to be able to read code using these concepts whenever you stumble upon them, not necessarily for you to actively use them in your code. Therefore, contrary to Part 1, no exercises are included here.

Without further ado, let’s dive into the first concept, namely list/ dictionary comprehension:

1. More about control flow: list comprehension and dictionary comprehension

List comprehension and dictionary comprehension provide a concise, pythonic syntax to create lists and dictionaries, respectively.

Speaking of pythonic, here is the official Style Guide for Python Code.

List/ dictionary comprehension comes in handy whenever you want to …

  1. iterate through an existing data structure and …
  2. want to apply some computation to its individual entries.

Often, the comprehension syntax saves you the need to explicitly create nested control flows like if and for. Typically, the result is not only shorter code, but also more comprehensible code. This sounds abstract, so let’s examine some examples next.

1.1. List comprehension

The following list comprehension filters all fruits from a list that contain the letter a:

fruits = ['apple', 'plum', 'orange', 'kiwi', 'pear', 'melon']

fruits_with_a = [fruit for fruit in fruits if fruit.count('a')]

print(fruits_with_a)  # ['apple', 'orange', 'pear']

The list comprehension syntax consists of:

  • The element(s) to be appended to a list (here: fruit),
  • followed by a for statement.
  • This might be followed by zero or more if and/or for clauses, which creates nesting.
  • The whole expression is surrounded by a square bracket [ ], indicating that the result will be a list.

The list comprehension example above is equivalent to the following code:

fruits = ['apple', 'plum', 'orange', 'kiwi', 'pear', 'melon']

fruits_with_a = []
for fruit in fruits:
    if fruit.count('a'):
        fruits_with_a.append(fruit)

print(fruits_with_a)  # ['apple', 'orange', 'pear']

1.2. Dictionary comprehension

Dictionary comprehension works the same way, only that this time you create a dictionary, not a list - therefore the expression is surrounded by a curly bracket { }.

Here’s an example, in which we create a dictionary with keys and values that initially have been stored in separate lists (for whatever reason, toy examples don’t make always sense):

keys = ['id', 'name', 'address', 'birthday']
values = [1, 'Joe', 'Springfield', 1990]

user = {key: value for key, value in zip(keys, values)}

print(user)  # {'id': 1, 'name': 'Joe', 'address': 'Springfield', 'birthday': 1990}

In this example we match key with value on the fly with the built-in zip function.

This dictionary comprehension is equivalent to:

keys = ['id', 'name', 'address', 'birthday']
values = [1, 'Joe', 'Springfield', 1990]

user = {}
for i in range(len(keys)):
    user[keys[i]] = values[i]

print(user) # {'id': 1, 'name': 'Joe', 'address': 'Springfield', 'birthday': 1990}

You don’t have to create your own list or dictionary comprehensions. If it’s easier for you, continue with nested for and if statements.

Having said that, now you now know how to read list/ dictionary comprehensions whenever you see them in foreign code (which will happen a lot).

2. More about functions: lambda, type hints, decorators

2.1. Lambda expressions: functions without a name

Sometimes, you want to define a short function without the boilerplate of a function definition. In Python (and also languages like Java) this feature is called lambda expression.

Here’s an example of two ways for defining a function that multiplies two values together and prints the calculation:

# Variant 1: standard function definition
def print_multiply(a, b):
    print(f'{a} x {b} = {a * b}')

print_multiply(4, 5)  # 4 x 5 = 20

# Variant 2: lambda expression, store function in variable for later use
print_multiply = lambda a, b : print(f'{a} x {b} = {a * b}')

print_multiply(4, 5)  # 4 x 5 = 20

Variant 1 is your normal function definition. Variant 2 is the interesting bit:

  • With the lambda keyword we initiate the lambda expression.
  • Next, we define the arguments, here a, b.
  • The function body follows after the colon :.
  • A lambda expression returns a function, which we can store in a variable. Thus, the variable has become a function itself (kind of difficult to wrap your head around at first, I know).

It is completely possible to write good quality, highly readable code without ever knowing anything about lambda expressions. Still, this is something that pops up quite a lot on Stackoverflow threads and in other communities.

And, if you ever have the urge to delve into functional programming with Python, you will learn to absolutely love lambda expressions.

2.2. Type hints

Python is a dynamically typed language: you don’t need to specify the data type of function arguments or return values. Still, the language supports type hints. These are annotations that let you describe which data types you want to use, e.g., as function arguments.

Type hints are not enforced by the Python interpreter at runtime. But they have their benefits:

  • Type hints help with documentation and contextual help in your code editor.
  • If you decide to use a static type checker like Mypy, type hints will be the essential enabler.

We return to the body_mass_index() function from Part 1 to demonstrate the type hint syntax:

def body_mass_index(weight: float, height, round_digits: int=1) -> float:
    print(type(weight) is float)  # False or True, depending on the provided value
    bmi = round((weight / height ** 2), round_digits)
    return bmi

print(body_mass_index(85, 1.85))  # 24.8
  • To annotate an argument with a type hint, use the : (colon) sign as delimiter between argument name and argument data type
  • To annotate the return value with a type hint, use an -> (arrow) at the end of the function header, right before the closing : colon sign

There are a few things to note:

  • First, you are not obliged to go all-in with type hints. It is completely okay to annotate only those parts that are important to you.
    • In the example above, we didn’t care about the data type of height.
  • Second, a type hint does not perform type casting.
    • Therefore, we can’t tell beforehand if weight is of type float or not. This depends entirely on the actually provided value at function invocation.
  • Finally (and a corollary to the previous point), an annotated argument will accept any data type.
    • Use a static type checker like Mypy as part of your workflow to catch such errors.

Don’t confuse the Python type hints feature with the converter feature of Flask route registration: the latter enforces usage of particular data types, the former does not. Also note the different syntax:

  • Python type hints: variable: data_type, e.g., id: int
  • Flask converter: '<converter: variable>', e.g., '<int: id>'

2.3. Decorators

For all practical purposes, a decorator wraps some additional processing around the original function, thus augmenting it with more functionality.

You might have heard about the decorator pattern in object-oriented programming. The Python decorator concept is related, but not the same.

The usefulness of decorators are best shown by example, so here is ours: We want to offer a service to calculate a person’s body mass index and body fat percentage. Since such calculators are hard to come by online, we’ll charge a coin per use.

Here is the code of our main business logic:

from functools import wraps

FEMALE = 0
MALE = 1

coin_inserted = False

def coin_required(func):  # (1.)
    @wraps(func)  # (2.)
    def wrapper(*args, **kwargs):  # (3.)
        if coin_inserted:  # (4.)
            return func(*args, **kwargs)  # (5.)
        else:
            return 'insert a coin first'
    return wrapper  # (6.)

@coin_required  # (7.)
def body_mass_index(weight, height, round_digits=1):
    bmi = round((weight / height ** 2), round_digits)
    return bmi

@coin_required
def body_fat_percentage(weight, height, age, sex, round_digits=1):
    bmi = body_mass_index(weight, height, 6)
    if age < 18:
        bfp = 1.51 * bmi - 0.7 * age - 3.6 * sex + 1.4
    else:
        bfp = 1.39 * bmi + 0.16 * age - 10.34 * sex - 9
    return round(bfp, round_digits)


print(f'BMI: {body_mass_index(58, 1.73)}')  # BMI: insert a coin first
print(f'BFP: {body_fat_percentage(58, 1.73, 38, FEMALE)}')  # BFP: insert a coin first

coin_inserted = True
print(f'BMI: {body_mass_index(58, 1.73)}')  # BMI: 19.4
print(f'BFP: {body_fat_percentage(58, 1.73, 38, FEMALE)}')  # BFP: 24.0

coin_required() is the function with that we intend to decorate other functions, or the decorator function. A decorator function has a few very particular elements:

  1. It requires a function passed to it (this is the undecorated, original function). By convention, the argument is called func.
  2. Notice the additional decorator @wraps() which is part of the functools module: this ensures the decorated function will look like the non-decorated function.
    1. Adding @wraps() is considered good practice, not a hard requirement.
  3. The function body contains another function definition, conventionally named wrapper(). The wrapper() function accepts arbitrary arguments passed to it. This gives us two advantages:
    1. First, wrapper() can be called with the exact same arguments as func().
    2. Second, we can return the function wrapper() later on, which is exactly what we want to do.
  4. The function body of wrapper() contains the actual processing steps of the decorator function. In our case, we want func() executed only if con_inserted is True. Otherwise, we return an error message.
  5. Practically all decorator functions will at some point call func() and do something with its result.
  6. We return the function wrapper() itself. This is already a hint to:
  7. The syntax to decorate a function is @decorator_function, followed by the function definition to be decorated.
    1. This is equivalent to func = decorator_function(func). In other words, func() is assigned the new function wrapper() (which is the return value of decorator_function()).

If the above explanation is too much for you, just memorize the function decorator blueprint:

def decorator_function(func):
   @wraps(func)
   def wrapper(*args, **kwargs):
       # additional business logic comes here
       # at some point, it is likely you want to do this:
       result = func(*args, **kwargs)
       pass  # replace `pass` with actual business logic
       # return result  # <-- Likely you want to return 'result'
   return wrapper

3. Classes

People that started their programming journey with Java are often amazed that Python lets you code without the need to define a single class: Python does not force you to follow the object-oriented programming (OOP) paradigm.

Obviously, the OOP paradigm is a useful one, so it’s a good thing that Python lets you define classes and instantiate them.

As a reminder: A class organizes data (aka. attributes) and business logic that operates on these data (aka. methods) into a cohesive bundle. The class defines which data and which associated logic should be together in a bundle. Using the class as a blueprint, an arbitrary number of instances of this class type can be created.

Contrary to Java, the instantiation of a class is not called object, but instance. In Python, everything is an object, including classes. Therefore, it makes sense to give the instantiation of a class a different name, simply to be more precise.

Having said that, many Python programmers will use the term “object” when they actually mean an instance. First, this is technically correct: an instance is also an object (since everything is an object in Python). Second, it doesn’t make a difference to the code how you call it - as long as others understand what you mean when you explain it to them.

3.1. Structure of a class

After all this intro, let’s dive into an exemplary class definition:

class Superhero:

    _owner = 'Marvel'

    def __init__(self, name, powers='superhuman'):
        self.name = name
        self.powers = powers
        self._abilities = []

    def gain_ability(self, ability):
        self._abilities.append(ability)


spiderman = Superhero('Spider-Man')
spiderman.gain_ability('wall-climbing')

black_widow = Superhero('Black Widow', 'human')
black_widow.gain_ability('hand-to-hand combat')

print(spiderman._owner)  # Marvel
print(spiderman._abilities)  # ['wall-climbing']
print(black_widow.powers)  # human

The variable _owner is a class attribute: it exists on the class level, not the instance level. In practical terms this means that all instances share the same attribute value.

By convention, any attribute that starts with a single underscore (_) is declared non-public. That is, thou shall preferably not read and certainly not write nor delete such an attribute outside of class methods.

  • This is not a hard enforcement by Python, just a convention of Python programmers.
  • In Python, everything is public and thus can be accessed from the outside. But only because you can do something it doesn’t mean you should do it.

The special __init__() method is called automatically to set the initial instance attribute values - i.e. the values of attributes when an instance is created.

  • By convention, this is the only place to define instance attributes. (A notable exception are class decorators, which we won’t discuss here.)
  • __init()__ requires at least one argument (commonly called self), which is the instance itself.
  • Values for all arguments after self are provided when the instance is created, e.g., spiderman = Superhero('Spider-Man').

A method is called instance method, since it exists only after an instance has been created. Just as __init__() (which is also an instance method) you need to supply it at least the instance object itself, conventionally named self.

  • When calling this method, you don’t supply self, just values to the remaining arguments. Hence, it’s spiderman.gain_ability('wall-climbing'), not spiderman.gain_ability(spiderman, 'wall-climbing').

You’d be forgiven to think that __init()__ is the constructor of a class, since it fulfils a crucial function that you know from Java: assign values upon creation.

However, the actual constructor is another method called __new__().

Except for very specific cases, you will never need to change the default behavior of __new__() - but you will very often want to define your own custom __init__() function.

3.2. Shadowing a class attribute with an instance attribute

You can write something like batwoman._owner = 'DC'. What you actually do is to define a new instance attribute on-the-fly to which you assign a value:

batwoman = Superhero('Batwoman')
batwoman._owner = 'DC'
print(batwoman._owner)  # DC

del batwoman._owner
print(batwoman._owner)  # Marvel

Python lets you define a new attribute (or a new lambda function, for that matter) to an instance on-the-fly with the . notation. Hence, batwoman._owner = 'DC' is valid, just as batwoman.car = 'VW' would be.

The new instance attribute batwoman._owner shadows the class attribute of the same name: from now on, only the former is accessible, not the latter.

If you delete the instance attribute (del batwoman._owner), the class attribute will “step out of the shadow” - it has always existed, but wasn’t accessible due to the instance attribute of the same name.

Code like above is considered bad practice for three reasons:

  • First, the only proper place to define instance attributes is in the __init__() method of the class.
  • Second, to extend functionality, the OOP paradigm gives us means like inheritance - so don’t define new data or new behavior on-the-fly.
  • Third, shadowing reduces readability of your code.

3.3. Class methods and static methods

Besides instance methods (the default method type), you may define class methods and static methods:

class Superhero:

    _owner = 'Marvel'

    def __init__(self, name, powers='superhuman'):
        self.name = name
        self.powers = powers
        self._abilities = []

    def gain_ability(self, ability):
        self._abilities.append(ability)

    @classmethod
    def owner(cls, owner):
        cls._owner = owner

    @staticmethod
    def inquire(alter_ego):
        print(f'Who knows - maybe I really am {alter_ego}.')


spiderman = Superhero('Spider-Man')
black_widow = Superhero('Black Widow', 'human')

spiderman.owner('Disney')
print(black_widow._owner)  # Disney

Superhero.owner('Disney, Inc.')
print(black_widow._owner)  # Disney, Inc.

black_widow.inquire('Natasha Romanoff')  # Who knows - maybe I really am ...
Superhero.inquire('Harleen Quinzel')  # Who knows - maybe I really am ...

A class method exists at class level, not instance level. By convention, you will use a class method to manipulate class attributes. To create a class method, precede the method definition with the built-in @classmethod decorator.

  • Again by convention, the first argument is called cls instead of self, to remind you that it operates on the class itself, not on any of its instances.

A static method also belongs to the class, but contrary to a class method, it has no access to any attributes of the class. To define a static method, decorate it with @staticmethod.

  • Notice that a static method doesn’t need a reference to the class (cls) nor to the instance (self).

Both class methods and static methods can be invoked from an instance (e.g., spiderman) or directly from the class itself (i.e., Superhero).

Notice how little of the OOP concepts Python actually enforces, and how much is left to convention, i.e., informal agreements of Python programmers. This has to do with the urge of Python creators to support OOP without adding too much additional syntax. For example, if Python included data hiding, you would need to write public and private keywords everywhere in your code.

3.4. Getting, setting, (and deleting) “private” attributes

Remember how you shouldn’t read, write or otherwise manipulate an attribute that has an _ (underscore) as its first character?

In Java, we use separate getAbc() and setAbc() methods to read and write private attributes.

We could do something similar with Python:

class Superhero:

    _owner = 'Marvel'

    def __init__(self, name, powers='superhuman'):
        self.name = name
        self.powers = powers
        self._abilities = []

    def gain_ability(self, ability):
        self._abilities.append(ability)

    def get_abilities(self):
        return self._abilities if self._abilities else None

    def set_abilities(self, abilities):
        self._abilities = abilities

    def del_abilities(self):
        self._abilities = []


spiderman = Superhero('Spider-Man')

spiderman.set_abilities(['wall-climbing', 'spider-sense'])
print(spiderman.get_abilities())  # ['wall-climbing', 'spider-sense']

spiderman.del_abilities()
print(spiderman.get_abilities())  # None
  • To set the “private” _abilities attribute value, we call spiderman.set_abilities().
  • Likewise, to read the attribute we call spiderman.get_abilities() and to reset it we call spiderman.del_abilities().

This works, but is syntactically quite cumbersome. Wouldn’t it be nicer to be able to do something like del spiderman.abilities?

With properties, Python gives us just that: from the outside, it looks like you’d access the attribute directly, but in reality a getter or setter method is called (or a delete method, if you are so inclined).

There are several ways to use the property feature. Here is a particularly readable approach:

class Superhero:

    _owner = 'Marvel'

    def __init__(self, name, powers='superhuman'):
        self.name = name
        self.powers = powers
        self._abilities = []

    def gain_ability(self, ability):
        self._abilities.append(ability)

    @property
    def abilities(self):
        return self._abilities if self._abilities else None
	
    @abilities.setter
    def abilities(self, abilities):
        self._abilities = abilities

    @abilities.deleter
    def abilities(self):
        self._abilities = []


spiderman = Superhero('Spider-Man')

spiderman.abilities = ['wall-climbing', 'spider-sense']
print(spiderman.abilities)  # ['wall-climbing', 'spider-sense']

del spiderman.abilities
print(spiderman.abilities)  # None

Here, we decorated the abilities() function with the built-in @properties decorator. abilities() thus becomes the get method to the _abilities attribute.

With @abilities.setter and @abilities.deleter we annotate the corresponding set and delete methods, respectively.

Now we call these methods in a very intuitive manner, just as if we were able to get, set, and delete the attribute directly:

  • spiderman.abilities to get spiderman._abilities
  • spiderman.abilities = [...] to set spiderman._abilities
  • del spiderman.abilities to reset spiderman._abilities

3.5. Class inheritance

Unsurprisingly, Python supports class inheritance, i.e. the ability to base a class off of another class.

Let’s assume that we want to inherit the Superhero class from a class Person, which among others defines different age groups. Here is the exemplary Person class:

class Person:

    def __init__(self, name, age):
        self.name = name
        self.age = age
	
    @property
    def age_group(self):
        if self.age:
            if self.age < 1:
                return('baby')
            elif self.age < 3:
                return('toddler')
            elif self.age < 12:
                return('kid')
            elif self.age < 18:
                return('teenager')
            elif self.age < 50:
                return('adult')
            elif self.age < 100:
                return('best-ager')
            else:
                return('seriously old')
        else:
            return('ageless')


peter = Person('Peter Parker', 16)
print(peter.age_group)  # teenager

And here is the modified Superhero class:

class Superhero(Person):

    _owner = 'Marvel'

    def __init__(self, name, powers='superhuman'):
        super().__init__(name, age=0)
        self.powers = powers

    def who_are_you(self):
        print(f'Call me by the name of {self.name}')


spiderman = Superhero('Spider-Man')
spiderman.who_are_you()  # Call me by the name of Spider-Man
print(spiderman.age_group)  # ageless
  • Write the base class in () (brackets) after the class definition.
  • In the __init__() method body, call the __init__() method of the base class with this syntax: super().__init__(...).
  • After that, you can use attributes and methods inherited from the base class very naturally - e.g., self.name to access the inherited name attribute.

Try to avoid complicated features as much as possible - class inheritance is one of those. If you want to use inheritance primarily because you feel smart by doing it, consider an alternative way.

As a general guidance:

  • Use inheritance primarily if you need to add new attributes or methods, that is when you need some specialization.
  • Don’t use inheritance if you plan to significantly change behavior between base class and inheriting class.

4. Modules, packages, and imports

So far, we have looked at three of the four main structuring elements of Python. In this section, we will learn about modules and packages (i.e., a collection of modules).

Here are the four (most relevant) structuring elements of Python, with some advise when to use which one:

  • Use data structures to store and retrieve data that belong together.
  • Use functions to perform a particular task with/without input values, possibly returning some value (or function).
  • Use classes to group data and business logic operating on these data together.
  • Use modules (i.e., individual .py files) to organize code by purpose or category. (You can have multiple classes in one module, by the way.)

Of course, there are more structuring elements, e.g., when it comes to packaging your Python code for shipment. These advanced topics are well beyond the scope of this notebook.

4.1. Creating modules and packages

A module is an object that Python automatically creates out of any .py file. The module is named like the file - e.g., app if the file is called 📄app.py.

A package (also called regular packages) is an object that Python automatically creates out of any folder containing an 📄__init__.py file. 📄__init__.py could be the only .py file in that folder, but it makes more sense to place multiple modules into such a folder. The package is named like the folder, e.g., myapp if the folder is called 📁myapp/.

Often, Python programmers call .py files “modules” and folders containing an 📄__init__.py file “packages”. But they actually mean the objects that Python creates, not the files and folders.

As illustration, consider this example:

┬ repository/
├── tools.py             <-- module
├── worker.py            <-- module
└─┬ myapp/               <-- folder that Python shall interpret as package
  ├── __init__.py        <-- this file makes Python treat the folder as package
  ├── __main__.py        <-- use this file to offer a command-line interface
  ├── app.py
  ├── schema.sql         <-- non-.py files can be placed here as well
  └─┬ tests/             <-- package within a package
    ├── __init__.py
    └── …
  • tools is a Python module created from 📄tools.py.
  • myapp is a Python package created from 📁myapp/, since it contains an 📄__init__.py.
    • Notice how a package folder is allowed to contain also non-.py files.
  • The package tests is nested within myapp.
  • Notice the special 📄__main__.py file - which we won’t discuss further.
    • I’ve included it here since (at some point or another) you will likely encounter error messages that reference 📄__main__.py or __main__.

The use of 📄__init__.py is not the only way to define a package. There are also namespace packages - im most cases, you won’t need this alternative, though.

This occasion is as good as any to remind you of the Python styleguide, commonly just called PEP 8.

On module and package naming, PEP 8 tells us to use short all-lowercase names, and to avoid underscores (_) particularly in package names.

Tools like Black (which can be integrated with your favorite code editor, e.g., VS Code) help you to enforce the numerous advises of PEP 8, but of course you can follow PEP 8 also manually.

4.2. Understanding imports

In the section about decorators, we have already used the import mechanism of Python:

from functools import wraps

By importing, we make code of one module available to another module, just as it were written plainly in the importing module.

For example, let’s assume the module tools includes a function named items_with_char():

# file 'tools.py' = module 'tools'

def items_with_char(items: list, char: str) -> list:
    return [item for item in items if item.count(char)]

# ...

To use use this function in another module, we import it:

# file 'worker.py'

from tools import items_with_char

items = ['apple', 'orange', 'plum']
print(items_with_char(items, 'a'))  # ['apple', 'orange']

That import statement is equivalent to writing:

# file 'worker.py'

def items_with_char(items: list, char: str) -> list:
    return [item for item in items if item.count(char)]

items = ['apple', 'orange', 'plum']
print(items_with_char(items, 'a'))  # ['apple', 'orange']

Order matters: this would raise an error, since the function is called before it has been defined:

# file 'worker.py'

items = ['apple', 'orange', 'plum']
print(items_with_char(items, 'a'))  # program exits with a 'NameError' message

from tools import items_with_char

Above, we used a statement that imports individually named objects, i.e., variables, functions, classes:

from module import individual_object

# access imported object like so:
#     individual_object

To import a complete module (i.e. copy its complete code), you may write this:

from module import *

# access imported objects like so:
#     individual_object
#     crucial_function()
#     ...
# imported module and importing module share the same namespace

Beware this somewhat quirky behavior: the import * statement does not import objects whose names start with an _ (underscore).

However, such a wholesale import is not recommended:

  • All that module’s objects become part of the importing module’s namespace. Let’s assume that both the imported and the importing module defined a function crucial_function(): this screams trouble!
  • Importing a complete module is akin to executing its code. This doesn’t matter if the module consists only of function definitions. But what if it were to execute (potentially damaging) business logic that you are not aware of?
  • By importing a complete module, you import also that module’s import statements. This makes bug-fixing potentially harder.

Therefore, it is a better idea to explicitly import only those elements which you are going to use later on:

from module import func, var

result = func(2*var)

Let’s assume you want to import a lot of functionality from an external module (or package for that matter). To avoid spelling out the import of every single object (from module import obj1, obj2, ..., objN) and circumvent some of the issues of from module import *, you can write the following:

import module

# access any object like so:
#     module.individual_object
#     module.crucial_function()
#     module. ...
# imported module gets its own namespace 'module'

…or this:

import module as m

# access any object like so:
#     m.individual_object
#     m.crucial_function()
#     m. ...
# imported module gets its own namespace 'm'

This import statement does two things:

  • First, it creates a new namespace, nicely separating crucial_function() from module.crucial_function().
  • Second, this import statement does not actually copy the code, but rather creates a reference to it. Most of the times this doesn’t make a difference, but in some cases it does.

To summarize, don’t do this:

from module1 import *
from module2 import *

…but rather do this:

from module1 import obj1, obj2
import module2

Importing from a package follows the same logic as importing from a module. The Python Tutorial offers some good explanation.

It is worth mentioning that upon importing a package, the code of that package’s __init__ module will be executed. This makes it a great place for some initialization code (as its name suggests) - i.e., anything that you want to have executed whenever the package is imported to some other place.

Congratulations, you’ve made it to the end of this challenging notebook!


Annex: Additional online resources to follow-up with

In addition to the resources recommended in Part 1, here are some more advanced online resources:

  • DMOJ: This is a great resource if you want to improve your coding skills by solving some programming problems. Upon registering you are asked to set your default programming language (naturally, you want to select Python 3). Here is a small selection of problems I recommend:
  • Python reference documentation: Here, you will find authoritative documentation on the Python language and its standard library. As an added benefit, the documentation collects also how-tos, FAQs and setup instructions. If you are looking for design explanations, head over to the PEP repository.

As a general remark, it is useful to keep in mind the four types of documentation when searching for particular information about any piece of software. The four types are:

  • Tutorial: a beginner-friendly lesson that guides the reader from start to end. Tutorials are useful when you want to learn a new skill (as opposed to learning something to solve a concrete problem).
  • How-to guide: an instruction of how to solve a particular problem. A how-to guide is typically more focused than a tutorial, and it often assumes some familiarity with the topic.
  • Reference: a (technical) description of the software. Reference documentation is code-centric and typically mirrors the code structure - it aims to systematically cover all parts of the software. While you will find information how to operate a granular part of the software (e.g., how to call a particular function), it will not tell you how the individual parts work together.
  • Explanation: some reasoning about (a particular part of) the software. An explanation often clarifies why the software was designed in a particular way, or discusses central concepts that guided the software implementation.

Copyright © 2024 Prof. Dr. Alexander Eck. All rights reserved.