Scientific Computing Using Python - PHYS:4905

Lecture Notes #25 - Prof. Kaaret


Procedural programming

All of the code that we have written to date uses a paradigm called procedural programming.  One defines some data structures and then writes procedures that act on those structures.  The basic idea is illustrated below.  In the main body of the program, we create some global variables that hold the data structures we wish to use.  These global variables can be accessed either via lines of code in the program main body or by code in functions that we define.



Critical aspects are:
- any function may access any global variable, and
- all of the functions need to agree on how the data structures in the global variables are defined.

The latter means that if you change how a data structure in a global variable is defined, then you need to change all of the functions that access that variable.  The former means that it is possible to have unintended side effects.  If you inadvertently define two functions that access a global variable with the same name, but with different intent, then calling one function may causes errors when calling the other.  (This sort of programming error is often difficult to debug.)

Note that the functions may have local variables.  These are variables that can be accessed only within the function.  Also, the variables only persist as long as the function is active.  If you call Function A that defines some local variables, then those local variables are lost when then functions returns.

Object oriented programming

It is possible to write code using a different paradigm called object oriented programming.  In object oriented programming one encapsulates the data with the code.   The basic idea is illustrated below.  In the main body of the program, we create some objects.  An object contains both object variables that hold the data structures we wish to use and methods that act on the object variables. 

Object variables are fundamentally different from local variables within a function in that they persist for as long as the object itself persists.  One instantiates or creates an object at which time the object variables are created and initialized.  Then one calls the object's methods to change the state of the object or, equivalently, to perform computations on the object variables.  Think of methods as functions that are specific to an object.  Methods can act only on the variables of the object in which they are defined and cannot be used to act on the variables other objects.  The object variables persist between successive method calls.




Critical aspects are:
- we no longer use global variables, and
- an object's variables can be accessed only by the methods in that object.

The fact that the variables and methods are grouped together inside objects and that an object's variables can be accessed only by the methods in an object is called encapsulation.  One nice result of encapsulation is that it is not possible to have two different objects access a global variable with the same name, but with different intent.  This eliminates the potential for errors that arise from using the same variable name in different objects.  Encapsulation keeps control of an object's data structures inside the methods that define the object - you don't have to worry about someone coming along and messing with your variables.  This makes code cleaner and easier to debug, particularly for large and complex programs. 

Object Oriented EROS or OOEROS

This discussion is a bit abstract, so let's look at some Python code.  We'll translate the elementary row operations (EROS) that we wrote to do Gaussian elimination into object oriented Python.

The first thing that we do is define a class.  We use the Python keyword class and the syntax looks like a function definition with no arguments.  Keeping with the idea of objects rather than procedures, we call our class augmentedMatrix.

The first method that we define in our class is the initialization method which must be called __init__.  This method is called every time we create a new object or instance from our class.  The first argument of our initialization method is self.  This will be the first argument of all the methods and gives the methods a way to access the data structures associated with the object.  The second argument is the initial value that we wish to assign to our augmented matrix.  The method takes those values, converts them to an array (just in case they don't start that way), and stores the values in the variable m that is part of self.

class augmentedMatrix :
  """
  Define a class to perform elementary row operations on augmented matrices.
  The augmented matrix is stored as a object variable.
  The three standard EROS are defined as methods along with methods to
  print and return the augmented matrix.
  """   
 
  def __init__(self, initialMatrix) :
    # store the initial matrix into the object variable m
    self.m = np.array(initialMatrix)

To create an object using this class, we use a line like the one below. 

m = augmentedMatrix([[1.0, 1.0, 27],[2, -1, 0]]) # define augmented matrix

It looks like a function call, but since we defined augmentedMatrix as a class instead of as a function, it creates a new object that we then assign to the variable m.  Note that Python handles objects just as it does any other data type (such as float or list), for example you can make lists of objects.  If you recall the early lecture where I said that all variables in Python are objects, this starts to make sense.

Now let's look at the row addition method. 

  def row_add(self, i, j, c) :
    # multiply row i by c, add to row j, and put sum back into row j
    self.m[j,:] = c*self.m[i,:]+self.m[j,:]

The first argument, as always for classes, is self.  The other arguments are the same as we had in the procedural case.  The row i that gets added after being multiplied by the scalar c, and the other row j in the addition where the sum ends up. 

We use self to refer to data stored inside the object.  In this case, self.m is the augmented matrix.  The line of code that does the computation is very similar to the corresponding line of code in the row_add function from the procedural code, but with self in front of all of the variables.  For comparison, that code is:

def row_add(m, i, j, c) :
  """
  Multiply row i of matrix m by c, add to row j, and replace row j.
  """
  mp = m.copy() # make a copy of the input matrix
  mp[j,:] = c*mp[i,:]+mp[j,:] # multiply row i by c, add to row j, and put sum back into row j
  return mp


To call the row addition method, we use a line like

m.row_add(1, 0, 0.5)

while in the procedural code, we the corresponding line would be

m = row_add(m, 1, 0, 0.5)

Note the differences in the calls and in the function versus method.  In the procedural code, the function takes the matrix as an argument, explicitly makes a copy of it so as to not change the original matrix, and then returns the matrix.  It's up to the user to keep track of the matrix.  In the object oriented version, the matrix stays internal to the object.  The user does not pass the matrix as an argument and the method does not return the matrix.  In fact, the user doesn't have access to the matrix unless we write a method to allow that.

Object Oriented Gaussian Elimination

Now let's redo homework problem 6-1 using object oriented Python code.  Download augmentedMatrix.py to your machine and load it into Spyder.  Trying running it.

The first part of the code defines the augmentedMatrix class.
The second part of the code uses the augmentedMatrix class to perform Gaussian elimination on the example from class.  The line

m = augmentedMatrix([[1.0, 1.0, 27],[2, -1, 0]])

creates an object of  the augmentedMatrix class with the matrix initialized to the starting matrix.

The lines

m.row_swap(0, 1)
m.scalar_mult(0, 0.5)
m.row_add(0, 1, -1)
m.scalar_mult(1, 2.0/3.0)
m.row_add(1, 0, 0.5)

perform the elementary row operations.  Note (yet again) that the methods act on data hidden inside the object.  You don't have to pass the matrix back and forth.

To access the data, we use

m.print()

which displays the augmented matrix.

Inheritance

It's possible to extent class definitions.  The new class inherits all of the methods defined in the base class and you can add and/or replace methods as you see fit.  For example, say you wanted to add a method to the augmentedMatrix class that gives you access to the matrix so that you can use it in other calculations.  You could define a new class using

class derivedAugmentedMatrix(augmentedMatrix) :

Now the class definition takes an argument, which is another class that has already been defined.  If you do nothing else, the new class will be a copy of the old one.  But you can add method definitions.  If the new methods have the same name as methods in the original class, then they will replace the original methods.  If the names are different, then your code adds new methods.  All of the original methods that are not replaced are available in the new class.  This is call inheritance.  You can actually make a new class that inherits from several different classes by listing more than one class in the argument in the class statement.  This is called multiple inheritance

Let's add the following lines to our Python code after the definition of the augmentedMatrix class.

class derivedAugmentedMatrix(augmentedMatrix) :

  def matrix(self) :

  # return the matrix

  # we need this to gain access to the matrix stored as an object variable

    return self.m


Then change the line


m = augmentedMatrix([[1.0, 1.0, 27],[2, -1, 0]]) # define augmented matrix


to be


m = derviedAugmentedMatrix([[1.0, 1.0, 27],[2, -1, 0]]) # define augmented matrix


And at the end of the program add the line

print(2*m.matrix())


Now run the program.  The last action of the program should be to print the matrix multiplied by 2.  You have now learned to do object oriented programming in Python including using inheritance to extend class definitions.  Isn't that nice?