PP(MC)

  1. Table of contents
  2. Built-in data types (previous)
  3. Control flow (next)

Functions and methods

  1. Top
  2. Arguments
  3. Built-in functions
  4. Built-in container type methods
  5. Defining functions
  6. lambda functions
  7. Exercises
  8. References

Interludes

  1. Program files (an introduction)

Functions and methods

Think of functions as containers, or sequences, of expressions. They serve a specific use, a specific function as the name implies, and are meant to perform a clearly defined task. When a function is called, which is to say a part of your program is "using" the function, the expressions it contains are executed. Defining groups of expressions as functions allows you to reuse and share a solution to a given task without resorting to typing out the same sequence of expressions as many times as you might need it, thereby significantly simplifying your programs and reducing the potential for mistakes and bugs. If you write a function and later decide you want to modify the expressions it contains, you only need to change the one function and every other part of your program that calls the function will be running the new sequence of instructions.

A function could return a value resulting from the expressions it has executed; it could change the state of the program, a file, or a database but return nothing; or it could do both. Some functions require values as input (these are called the function's arguments), others require no input at all.

If data-types are the building blocks of your program's data model, functions are the building blocks of your program's application architecture. Methods, simply, are functions that are associated to objects. We will be studying objects, their methods, and Object Oriented Programming generally in a later chapter. For now we will limit our study to how methods are used.

Arguments

Function descriptions

Some functions (and methods, but henceforth everything that is said about functions implicitly also applies to methods) can take arguments as input. Let's take the example of a function named apply_interval which requires a MIDI note and an interval in semitones, and returns the MIDI note which results from applying the interval to the base note. If we name the arguments base and interval, the function description would look something like this :

apply_interval(base, interval)

Note that this is just a way of describing the function. We will learn how to define, or create, functions a little further.

If we wanted to find the MIDI note that is a perfect fifth (7 semitones) above middle C (MIDI note 60), we would call the function like so :

>>> apply_interval(60, 7)
67

We've given the value of 60 for the base argument and the value of 7 for the interval argument. The function then returned the value 67, which is the MIDI note a perfect fifth above middle C.

In the chapter on built-in data types, we had created a dictionary of intervals.

>>> interval_semitones = {"m3": 3, "P5": 7, "M6": 9}

We could use the dictionary to find the number of semitones for a conventional interval abbreviation and pass that value to the function like so :

>>> apply_interval(60, interval_semitones["m3"])
63

The interpreter first gets the value in the interval_semitones dictionary at key "m3" and passes that value, 3, to the function.

Required and optional arguments

Let's say we have a function named make_chord which takes a root MIDI note, a chord name, and a value for inversion which defaults to 0, meaning root position. The function description would look like this :

make_chord(root, name, inversion=0)

The root and name arguments are required arguments and they need to be given to the function in the same order as they are defined and described. Calling the function with the arguments in the wrong order will assign the input values to the wrong variables inside the function and this will either cause the function to raise an exception and stop executing, halting your program, or worse : a value will be returned from the function that is different from the expected result and the program will continue running with the wrong value being passed around. If you fail to provide a value for a required argument, the function will raise an exception.

The inversion argument is an example of an optional argument. If no value is provided for it, it will (in the case of this example) default to the value 0. You can recognize an optional argument by the = sign that separates the argument's name from its default value. The Python documentation also uses [ ] square brackets around an argument to say that it is optional. In this case there is no indication of a default value and you can assume that the most reasonable guess of the effect of that argument on the function is probably the right one.

If you have a good reason for passing arguments in a different order from the one specified by the function description you can name the arguments in your function call like so :

>>> make_chord(name="minor", root=61)
(61, 64, 68)

Arbitrary number of arguments

It is possible for a function to receive an arbitrary number of arguments. If that is the case, an argument name will be used to represent the list of arguments and it will be preceded by a * (an asterisk). For example, a function that takes an arbitrary number of MIDI notes and returns the set of all interval relations between the pitches would look like interval_relations(*pitches). Here's how it could be called :

>>> interval_relations(48, 51, 55)
{"m3", "M3", "P5"}

Built-in functions

As with data types, Python has a number of functions built into every standard installation. This chapter won't cover all of Python's built-in functions but it will give a good overview of what's available.

Interaction

While it isn't always necessary, interacting with a user is often part of the specifications for a program. The simplest way to give a user feedback and to get user input is by printing to the terminal and reading from the keyboard. The built-in functions Python provides for these are, respectively : print(*objects, sep=' ', end='\n', file=sys.stdout, flush=False) and input([prompt]).

The print function has a rather intimidating description, but if we take the arguments one at a time we'll see that it is all quite straight forward. The first argument, *objects, is preceded by an asterisk so we know that it can receive an arbitrary number of objects to print. The remaining arguments are all optional, which we know because an equals sign separates the argument's name from a default value. The first, sep=' ', is the separator string to be used between printing each object given for the first argument. end is the string to print after the last object. file is the printing destination, which could be a file or, by default, sys.stdout which is the standard output, which in most cases is the terminal in which your program is running. The flush argument has to do with how the output is buffered and we can safely ignore it for now.

The input function is much simpler. It reads text written by the user at the terminal and only has one argument, [prompt]. The prompt is the string that appears to the left of the user's input, prompting the user to input some text. The argument is optional, though it doesn't have a default value. If no string prompt value is provided, no prompt is printed.

If we had a program that could write a fugue algorithmically given a bit of user input, we could give it a user interface that looks like this :

>>> print(
      "Welcome to AlgoFugue !",
      "Please answer the following questions.",
      sep="\n"
    )
    Welcome to AlgoFugue !
    Please answer the following questions.
>>> n_bars = input("how many bars should we generate ? : ")
>>> n_voices = input("how many voices ? (no more than 4 please) : ")
>>> theme_pitches = input("please provide the pitches of the theme : ")
>>> theme_durations = input("please provide the durations of the theme's notes : ")
>>> print("Thanks ! We'll be back shortly with your fugue.")

In this example, the value entered by the user for how many bars should we generate ? will be assigned to the variable n_bars, and likewise for all the other calls to input. The function returns the value entered by the user as a string.

Program files (an introduction)

You shouldn't expect your users to run the hypothetical program of the previous example in the Python console, they would need to be actually writing your code. Users are more likely to run your program from a terminal. For that to be possible, you'll have to write your code to a text file. Open your text-editor and type in your program like so :

print(
    "Welcome to AlgoFugue !",
    "Please answer the following questions.",
    sep="\n"
)
n_bars = input("how many bars should we generate ? : ")
n_voices = input("how many voices ? (no more than 4 please) : ")
theme_pitches = input("please provide the pitches of the theme : ")
theme_durations = input("please provide the durations of the theme's notes : ")
print("Thanks ! We'll be back shortly with your fugue.")

If you save this file as algofugue.py, you can now open your terminal, activate your virtual environment, and run the program by typing python algofugue.py.

String formatting

Format Specification Mini-Language

Characters

>>> ord('A')
65

>>> chr(65)
'A'

Type conversions (explicit casting)

In the algofugue example above, and this is generally the case, the values returned by input() are of the type string. That might not be what you want as a data type. For example, the number of bars should clearly be an int. It is possible to convert types explicitly using built-in functions for each type. For example, converting the string '16' to the int 16 could be done like so :

>>> int('16')
16

The line from our user interface for algofugue could then be re-written like this :

>>> n_bars = int(input("how many bars should we generate ? : "))

Here are some more examples of explicit casting :

>>> float(16)
16.0

>>> str(16.0)
'16.0'

The type of an object can be found using the type(object) function like so :

>>> type(n_bars)
int

frozenset

More arithmetic

>>> divmod(67, 12)
(5, 7)

>>> pitch_class, octave = divmod(67, 12)

>>> min(67, 48, 72)
48

>>> max(67, 48, 72)
72

>>> pow(2, 7)
128

>>> 2**7
128

>>> round(2.71828)
3

Iterables

Iteration is the process of going through a sequence of items in order until none are left. Of the built-in data types, all the containers types are iterable. The following are descriptions of a selection of built-in functions that can be used to either create iterators, which are objects that iterate over a sequence, or that can be applied to iterable types.

len(s) returns the number of items contained by the sequence or collection s.

>>> notes = [48, 51, 50, 56, 48]
>>> len(notes)
5

sum(iterable[, start]) returns the sum of the items of the iterable. start, which is optional and defaults to 0, is a value to be added to the total.

>>> sum([1, 3, 2], 4)
10

sorted(iterable, *, key=None, reverse=False) returns a sorted list of the items in the iterable, which can be of any iterable type. Note that the return type is always a list regardless of the type of the given iterable. The key and reverse arguments are explained in detail in the official documentation (see the references section below for a link) but we won't be covering their use in this chapter.

>>> sorted([7, 15, 3, 11, 19])
[3, 7, 11, 15, 19]

reversed(seq) returns an iterator which iterates over the values in the sequence seq in reverse order.

>>> reversed([3, 7, 11, 15, 19])
<list_reverseiterator at 0x1074c9780>

To convert the iterator into a sequence, you'll need to cast the iterator to a sequence type (a list, or a tuple).

>>> list(reversed([3, 7, 11, 15, 19]))
[19, 15, 11, 7, 3]

range(start, stop, [step]) returns an iterator which begins at start, ends at stop -1 (up to but not including stop), and progresses in steps of step. Though it isn't clear from the function's description, start is an optional value which defaults at 0 and step defaults to 1. Note : this function returns an iterator, which is an object that iterates over a sequence, it does not return a sequence. You can cast a range to a list or tuple and obtain a sequence, if that's what you want.

>>> range(10)
range(10)

>>> list(range(10))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

>>> tuple(range(3, 13, 3))
(3, 6, 9, 12)

You're probably wondering what the point is of creating iterators if we have to cast them to a sequence type for them to be of any use. The answer to that is that they are very useful as iterators, but that we need the next(iterator[, default]) function to illustrate. This function, when called, fetches the next item in the sequence over which the iterator is iterating. Here's an example (and remember that reversed returns an iterator):

>>> notes = [50, 56, 48]
>>> retrograde = reversed(notes)
>>> next(retrograde)
48
>>> next(retrograde)
56
>>> next(retrograde)
50
>>> next(retrograde)
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
 in ()
----> 1 next(retrograde)

StopIteration:

This makes it possible, for example, to grab the first value from a sequence, go off and do something with it, then come back to fetch the next value while being certain that we are progressing systematically through the sequence from start to finish. Speaking of finishing, when an iterator reaches the end of the sequence, calling next will raise an exception. More specifically, the StopIteration exception that we see here above. The next function's default argument allows us to specify an alternative ending to the exception being raised.

>>> notes = [50, 56]
>>> retrograde = reversed(notes)
>>> next(retrograde, 'the end')
56
>>> next(retrograde, 'the end')
50
>>> next(retrograde, 'the end')
'the end'

Now that we understand the power of iterators, we might want to create iterators over the sequence types that we know. This can be done using the iter(object[, sentinel]) function.

>>> notes = [50, 56, 48]
>>> notes_iterator = iter(notes)
>>> next(notes_iterator, 'the end')
50
>>> next(notes_iterator, 'the end')
56
>>> next(notes_iterator, 'the end')
48
>>> next(notes_iterator, 'the end')
'the end'

The optional sentinel argument allows us to create iterators for objects that are not iterable, but we'll leave that feature unexamined for now.

Let's say we have two lists, the first represents pitches and the second durations. Let's agree that it takes (at a minimum) a pitch and a duration to represent a note. The zip(*iterables) function allows us to aggregate the two lists and to create an iterator over a sequence of tuples of the combined values. Here's what that would look like in practice :

>>> pitches = [50, 56, 48]
>>> durations = [4, 4, 2]
>>> notes = zip(pitches, durations)
>>> next(notes)
(50, 4)
>>> next(notes)
(56, 4)
>>> next(notes)
(48, 2)

enumerate(iterable, start=0) returns an iterator over a sequence of tuples, with the first item of the tuple being a count value, starting at start and increasing by one thereafter, and the second item of the tuple being the next item in the iterable sequence.

help

While this next function is not a built-in function, it is a function that is available to you in the Python interactive shell. help(object) will print out the object's docstring, a descriptive text built into the documentation of every well written function. If you type help() without passing in any value, an interactive help prompt is launched which you can keep open as you program.

>>> help(min)

Help on built-in function min in module builtins:

min(...)
    min(iterable, *[, default=obj, key=func]) -> value
    min(arg1, arg2, *args, *[, key=func]) -> value

    With a single iterable argument, return its smallest item. The
    default keyword-only argument specifies an object to return if
    the provided iterable is empty.
    With two or more arguments, return the smallest argument.

>>> help()

Welcome to Python 3.7's help utility!

If this is your first time using Python, you should definitely check out
the tutorial on the Internet at https://docs.python.org/3.7/tutorial/.

Enter the name of any module, keyword, or topic to get help on writing
Python programs and using Python modules.  To quit this help utility and
return to the interpreter, just type "quit".

To get a list of available modules, keywords, symbols, or topics, type
"modules", "keywords", "symbols", or "topics".  Each module also comes
with a one-line summary of what it does; to list the modules whose name
or summary contain a given string such as "spam", type "modules spam".

help> 

Built-in container type methods

list methods

Docs

dict methods

Docs

Defining functions

With a full understanding of what functions are for and how they can be used, we will now look at how we can define functions of our own. Here is an example of a simple function definition :

def descent(pitches, durations):
    """Returns a descending sequence of note tuples keeping durations unchanged.

    Sorts pitches descending (from highest to lowest pitch) and combines them
    with the given durations, without changing their order, to create a
    descending sequence of notes.

    pitches   : iterable sequence of int MIDI notes
    durations : iterable sequence of int note durations represented by the
        duration value's denominator int. Eg. 4 represents a 1/4 note.

    returns : list of descending (pitch, duration) tuples
    """
    descending_pitches = reversed(sorted(pitches))
    descent_sequence = zip(descending_pitches, durations)
    descent_sequence = list(descent_sequence)

    return descent_sequence

With this function now defined, we can now call it as we would any function :

>>> descent([48, 56, 55, 51], [4, 4, 2, 1])
[(56, 4), (55, 4), (51, 2), (48, 1)]

With that done, let's dig into each of the parts that make up the function's definition.

Comments and documentation

Scope

None

None is used to represent the absence of a value. It is a built-in constant of the type NoneType. It is the only value of this type.

>>> type(None)
    NoneType

Positional and Keyword arguments

pass, return, and yield

lambda functions

Exercises

  1. Define the function apply_interval(base, interval) such that it returns the MIDI note that is interval semitones away from the MIDI note base.

  2. Define a function retrogrades(pitches, durations) which returns a dictionary containing iterators over sequences of tuples for the four possible combinations of retrogrades of given sequences of pitches and durations. The dictionary keys could, for example, be : 'NPND', 'RPND', 'NPRD', 'RPRD', with N meaning normal, R meaning retrograde, P meaning pitches, and D meaning durations.

    Hint : retrograde is the musical term for reversed.

    You can test your program with the following example :

    >>> pitches = [48, 56, 55, 51]
    >>> durations = [4, 4, 2, 1]
    >>> retro_dict = retrogrades(pitches, durations)
    >>> list(retro_dict['NPND'])
        [(48, 4), (56, 4), (55, 2), (51, 1)]
    >>> next(retro_dict['RPND'])
        (51, 4)
    >>> next(retro_dict['NPRD'])
        (48, 1)
    >>> next(retro_dict['RPRD'])
        (51, 1)

References