10. Object-Oriented Programming#

10.1. Lesson overview#

Python refers to all data as objects. These objects can have certain features and functionalities associated with them. In this lesson, we will explore this concept more by introducing a programming paradigm called object-oriented programming (OOP), which focuses on what we can do with objects rather than the values they represent. We will first introduce an example where our current programming knowledge cannot provide an efficient solution. This will lead us into understanding the basic concepts of object-oriented programming, as well as realizing that these concepts have always been present in Python, but have been somewhat hidden until now. After exploring these ideas, we end our lesson by introducing how to create our own custom data classes and objects.

10.2. Example scenario: Creating a sample database#

Imagine we want to create a Python program that stores sample data from a set of experiments. The goal of these experiments is to measure the amount of mechanical force needed to cause a sample to permanently deform (a.k.a. to yield). We will call this force the yield force, and is often determined by taking a sample with a known cross-sectional area and pulling it apart. By knowing both the cross-sectional area of the sample and the force required to cause yielding, the yield stress of the sample can be calculated with the equation,

\[ \sigma_o = \frac{F_o}{A} = \frac{F_o}{wt} \]

where \(\sigma_o\) is the yield stress, \(F_o\) is the force required for yielding (a.k.a. yield force), and \(A\) is the cross-sectional area. If we assume that the sample has a rectangular cross-section, we can say that \(A = wt\), where \(w\) is the width and \(t\) is the thickness.

Therefore, we would like our Python program to do the following:

  • Record the sample’s name, thickness, and width.

  • Record the name of the device that performed the test, the testing date, and the measured yield force.

  • Calculate and store the yield stress.

One way we could accomplish this is to create a series of variables that store all of these characteristics and create a function to calculate the yield stress. For example, imagine our first sample, which we will call Sample 1, has the following characteristics:

  • Sample name: Sample 1

  • Thickness: 5.2 mm

  • Width: 2 mm

  • Instrument: Tester 1

  • Measurement date: 2022.10.25

  • Measured yield force: 1000 N

This could be implemented using the code block shown below:

sample1_name = "Sample 1"
sample1_yield_force = 1000    # Units: N
sample1_thickness = 5.2       # Units: mm
sample1_width = 2             # Units: mm
sample1_device = "Tester 1"

def calc_yield_stress(force, thickness, width):
    """Function to calculate the yield stress."""
    yield_stress = force / (thickness * width)    # Units: MPa
    return yield_stress

sample1_yield_stress = calc_yield_stress(sample1_yield_force, sample1_thickness, sample1_width)

print(sample1_name)
print(sample1_yield_force)
print(sample1_thickness)
print(sample1_width)
print(sample1_device)
print(round(sample1_yield_stress, 3))
Sample 1
1000
5.2
2
Tester 1
96.154

While this works, there are two major disadvantages to this route. First, if we want to create another sample (i.e., Sample 2), this would require us to manually create a whole new set of variables to store Sample 2’s defining attributes. This would be cumbersome and potentially prone to error. Secondly, each variable is technically independent of one another. There is no inherent association of the various variables with one another outside of our own mental bookkeeping.

One way to address the latter issue is to use the dict class, which was introduced in an earlier lesson. Here we can create a set of keys and values to link these characteristics within a larger object. The following block of code demonstrates this by creating a dict object called sample1 to store all these characteristics:

sample1 = {
    "name" : "Sample 1",
    "yield_force" : 1000,   # Units: N
    "thickness" : 5.2,      # Units: mm
    "width" : 2,            # Units: mm
    "device" : "Tester 1"
}

def calc_yield_stress(force, thickness, width):
    """Function to calculate the yield stress."""
    yield_stress = force / (thickness * width)    #Units: MPa
    return yield_stress

sample1_yield_stress = calc_yield_stress(sample1["yield_force"], sample1["thickness"], sample1["width"])

print(sample1["name"])
print(sample1["yield_force"])
print(sample1["thickness"])
print(sample1["width"])
print(sample1["device"])
print(round(sample1_yield_stress, 3))
Sample 1
1000
5.2
2
Tester 1
96.154

This again works to a certain extent as now all Sample 1’s characteristics are associated with the sample1 dictionary object. However, Sample’s 1 yield stress is not linked with sample1, which is not good for organizational purposes. Furthermore, if we wanted to create an entry for another sample, we would need to manually create a new dict object. Another potential issue is that the function that calculates the yield stress is agnostic of the sample. While not a big issue in this example, one can imagine scenarios where we would like to only have a function operate on a specific object rather than any object in the kernel space.

As this example illustrates, our current programming approach is not well suited for scenarios where we want to link various variables and functions to a particular object. Thankfully, most modern programming languages, like Python, have solutions to this problem, but this requires us to rethink how we code. Rather than focus our attention on the value that our data represents, we should focus on what we can do to the objects (i.e., create, manipulate, delete) that store these values.

10.3. The Object-Oriented Programming Paradigm#

As we previously discussed, the creation, modification, and deletion of data lies at the heart of coding. This data, which is called an object in Python, can take the form of many things. An object can be a simple number, a logical true or false, a single text-based character, a combination of these, or even something more! To help in our identification of an object, we assign it a symbolic name, which is called a variable. Therefore, variables are objects and objects are variables.

Definition: object

Individual data (e.g., the number 4, the word “dog”, an array of decimal numbers) is known as an object. Each data is its own object.

Definition: variable

A symbolic name assigned to an object.

In Python, we group similar objects together in a unit called a “class”. For example, we know that all objects that consist of text-based characters are part of the str class. While all str objects share common features and behaviors, each object is unique. Therefore, we can think of objects as “instanced” (i.e., unique) versions of a given class.

Definition: class

The type of data that the object represents. There are many classes out there. You can even create your own classes in Python!

Definition: instanced

A unique version of something more general. In Python, an object is an instanced (i.e., unique) version of a class.

Object-oriented programming (OOP) is a programming paradigm that focuses on the importance of what we can do with objects rather than the values that objects represent. For example, imagine an object called temperature that has a value of 300, which represents the temperature at 300 K. Object-oriented programming is less concerned with the value 300 but more with what can be done with the temperature object itself. We could, for example, convert it to another temperature scale like Celsius or Fahrenheit, or multiply it by Boltzmann’s constant to get the thermal energy. Therefore, temperature can be much more than just a variable name for the number 300, it can have features and functionality.

This focus on what we can do with classes and objects rather than the values they represent is a departure from what we have currently used, which is sometimes called functional programming (FP). The focus on FP is with the calculation of values through the use of program flow (i.e., for loops, if statements, etc.) and function calls. Typically with FP, variables are often considered immutable, meaning that once initialized, they are not changed. Many of the blocks of code from previous lessons follow this paradigm. We often initialized some variables, used small blocks of code that may contain functions and if / for statements for program control, and generated a set of output variables. With OOP, we focus on the objects themselves and treat the values they represent as mutable. Here, we may go back and change variables and re-run functions. Hence, the focus is not with the values that the objects represent, but rather the objects themselves.

Data classes act as the foundational basis for OOP as they represent the default version for all instanced objects. We can assign variables (which are objects!) that are common across an entire class or specific to an instanced object. Variables that we assign to classes and objects are called attributes. We can also associate functions to specific classes and objects, which are called methods. These specialized functions are often used to access and modify the attributes of a class or object.

Definition: attribute

A variable that describes a class or instanced version of a class (i.e., an object). This is also an object, so it is an object that describes an object.

Definition: method

A function that is associated with a specific class or an instanced version of a class (i.e., an object). Methods are often used to access or modify attributes for a class or object.

Default states for attributes and methods are typically assigned at the class level, but are usually modified for each object. For example, let us imagine we want to create a data class that describes a person (called Person) which will store features like name, height, weight, hair color, etc. for any object associated with this class. These features are the attributes, so we can assign attribute names to them such as name, height, weight, hair_color, etc. If we then create two instanced objects of the Person class (e.g., marle and lucca for two people named Marle and Lucca, respectively), each object would have its own unique version of these attributes. So we can assign the instanced version of the hair_color attribute for marle to blonde while assigning the instanced version for hair_color for lucca as purple. An attribute that is unique to a specific object is called an instanced attribute. On the other hand, we can also create attributes that are shared across all objects of a class, which are called class attributes. Following along with our example, we can create an attribute for the Person class called species and set this to human. Since all objects associated with the Person class are assumed to be human, this attribute would have the same value for all Person-based objects. This idea of instanced- and class-based features can also be extended out to methods to create instanced methods and class methods.

Definition: instanced and class attribute / method

  • Class attribute: Attribute that is shared across all objects of a class.

  • Instanced attribute: Attribute that is unique to an instanced version of that class (i.e., an object).

  • Class method: Method that is shared across all objects of a class.

  • Instanced method: Method that is unique to an instanced version of that class (i.e., an object).

Even though the above example does not explicitly discuss the actual process of accessing and creating these attributes and methods, it does highlight the fact that objects in Python have attributes and methods associated with them. All data in Python is an object, which means that everything you have done in Python so far has been with the creation, access, and manipulation of objects. However, most of our coding lessons have either ignored or underutilized a whole set of useful programming features and functionality that lies just below the “programming surface”. So let us now dive even deeper into Python, and explore a whole additional world of programming by utilizing the OOP paradigm.

10.4. Accessing attributes and methods#

All data classes in Python can have attributes and methods. This includes the pre-existing, built-in data classes that you have already used! Let us revisit two data classes we have studied in previous lessons to highlight how to access attributes and methods.

10.4.1. Accessing attributes and methods for the complex data class#

Recall that we can create a complex object by entering the following to the terminal:

position = 7.3 + 1.8j

Here we create an object called position that has the value has the value 7.3 + 1.8j and is associated with the complex data class. The real term is 7.3 and the imaginary term is 1.8. We have seen this all before, but let us now explore what else is associated with position (i.e., an instanced version of the complex class). One way to do this is to use the builtin function dir(). Let us run help() on this function to see what it does:

help(dir)
Help on built-in function dir in module builtins:

dir(...)
    dir([object]) -> list of strings
    
    If called without an argument, return the names in the current scope.
    Else, return an alphabetized list of names comprising (some of) the attributes
    of the given object, and of attributes reachable from it.
    If the object supplies a method named __dir__, it will be used; otherwise
    the default dir() logic is used and returns:
      for a module object: the module's attributes.
      for a class object:  its attributes, and recursively the attributes
        of its bases.
      for any other object: its attributes, its class's attributes, and
        recursively the attributes of its class's base classes.

While somewhat hard to understand, dir() provides a list of all attributes and methods associated with any object. The code below runs this function on position:

dir(position)
['__abs__',
 '__add__',
 '__bool__',
 '__class__',
 '__delattr__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__format__',
 '__ge__',
 '__getattribute__',
 '__getnewargs__',
 '__gt__',
 '__hash__',
 '__init__',
 '__init_subclass__',
 '__le__',
 '__lt__',
 '__mul__',
 '__ne__',
 '__neg__',
 '__new__',
 '__pos__',
 '__pow__',
 '__radd__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__rmul__',
 '__rpow__',
 '__rsub__',
 '__rtruediv__',
 '__setattr__',
 '__sizeof__',
 '__str__',
 '__sub__',
 '__subclasshook__',
 '__truediv__',
 'conjugate',
 'imag',
 'real']

That is quite a list! While the number of entries may be different on your computer, Python version 3.9.12 has 49 unique entries of methods and attributes associated with position! Except for the names of these attributes and methods, little is known about them. If we run help() on position we can glean a bit more information:

help(position)
Help on complex object:

class complex(object)
 |  complex(real=0, imag=0)
 |  
 |  Create a complex number from a real part and an optional imaginary part.
 |  
 |  This is equivalent to (real + imag*1j) where imag defaults to 0.
 |  
 |  Methods defined here:
 |  
 |  __abs__(self, /)
 |      abs(self)
 |  
 |  __add__(self, value, /)
 |      Return self+value.
 |  
 |  __bool__(self, /)
 |      True if self else False
 |  
 |  __eq__(self, value, /)
 |      Return self==value.
 |  
 |  __format__(self, format_spec, /)
 |      Convert to a string according to format_spec.
 |  
 |  __ge__(self, value, /)
 |      Return self>=value.
 |  
 |  __getattribute__(self, name, /)
 |      Return getattr(self, name).
 |  
 |  __getnewargs__(self, /)
 |  
 |  __gt__(self, value, /)
 |      Return self>value.
 |  
 |  __hash__(self, /)
 |      Return hash(self).
 |  
 |  __le__(self, value, /)
 |      Return self<=value.
 |  
 |  __lt__(self, value, /)
 |      Return self<value.
 |  
 |  __mul__(self, value, /)
 |      Return self*value.
 |  
 |  __ne__(self, value, /)
 |      Return self!=value.
 |  
 |  __neg__(self, /)
 |      -self
 |  
 |  __pos__(self, /)
 |      +self
 |  
 |  __pow__(self, value, mod=None, /)
 |      Return pow(self, value, mod).
 |  
 |  __radd__(self, value, /)
 |      Return value+self.
 |  
 |  __repr__(self, /)
 |      Return repr(self).
 |  
 |  __rmul__(self, value, /)
 |      Return value*self.
 |  
 |  __rpow__(self, value, mod=None, /)
 |      Return pow(value, self, mod).
 |  
 |  __rsub__(self, value, /)
 |      Return value-self.
 |  
 |  __rtruediv__(self, value, /)
 |      Return value/self.
 |  
 |  __sub__(self, value, /)
 |      Return self-value.
 |  
 |  __truediv__(self, value, /)
 |      Return self/value.
 |  
 |  conjugate(self, /)
 |      Return the complex conjugate of its argument. (3-4j).conjugate() == 3+4j.
 |  
 |  ----------------------------------------------------------------------
 |  Static methods defined here:
 |  
 |  __new__(*args, **kwargs) from builtins.type
 |      Create and return a new object.  See help(type) for accurate signature.
 |  
 |  ----------------------------------------------------------------------
 |  Data descriptors defined here:
 |  
 |  imag
 |      the imaginary part of a complex number
 |  
 |  real
 |      the real part of a complex number

Here we actually get some information regarding position and many of its attributes and methods. First off, it tells us that position is part of the complex class. Next, we see the line entry,

|  
|  Methods defined here:
| 

followed by a long list of methods associated with position. Towards the end of the list is another section called,

|  ----------------------------------------------------------------------
|  Data descriptors defined here:
|

where two attributes are listed. This may seem confusing at first because the list says Data descriptors defined  here, but in Python a descriptor is a special type of attribute that has functionality tied to it (also allowing for a docstring to be associated with the attribute). For our learning purposes in this lesson, treat a descriptor as simply as an attribute.

According to the list, there are two attributes associated with our position object: .imag and .real, which are the imaginary and real parts of the complex number, respectively. We can access (also known as “get” in coding) these attributes with the following commands:

print(position.real)
print(position.imag)
7.3
1.8

Hey! Listen!

Above is an example of “getting” an attribute. We will hold off assigning a value to an attribute (also known as “setting”) until later in the lesson.

This format can be extended to call methods. Looking again at help(position) reveals that there is a method called .conjugate(). Let us see what the method does:

help(position.conjugate)
Help on built-in function conjugate:

conjugate() method of builtins.complex instance
    Return the complex conjugate of its argument. (3-4j).conjugate() == 3+4j.
position_conj = position.conjugate()
print(position_conj)
(7.3-1.8j)

This all can be done in the single command print(position.conjugate()), but this example also highlights that we can create new variables from attributes or methods. In general, the overall command structure to access an object’s method is object.method().

10.4.2. Dunder / magic methods#

Notice that many of the methods listed in help(position) have both leading and trailing double underscores next to the name (e.g., .__add__()). While these are also valid methods, they are formatted this way so that the programmer knows not to normally use them. These special methods are sometimes called “dunder methods” (for “double underscore methods”) or “magic methods”. These methods are often used for low-level programming needs. For example the .__add__() method is automatically called when the + operator is used. So when sum position with another complex number, like 2+2j, we would typically write the following command:

print(position + (2+2j))
(9.3+3.8j)

This is equivalent to using the .__add__() method on position via the following command:

print(position.__add__(2+2j))
(9.3+3.8j)

In short, while these methods are available, it is usually not recommended to directly call them. However, there are some exceptions to the rules here, and we will see one very important exception coming up soon!

10.4.3. Accessing attributes and methods for the str data class#

As another example, let us try accessing some methods associated with the str class. First, let us create a str object called text that has the following phrase:

text = "Secret Legend"

To look up the attributes and methods associated with the str class, we could run help(text). Another useful way to look up attributes and methods for classes is to do an internet search. For example, the official Python documentation has a section on the various methods associated with the str class. We will focus on three methods found in the documentation: .upper(), .swapcase(), and .split(). Running help() calls on each of these methods reveal:

help(text.upper)
Help on built-in function upper:

upper() method of builtins.str instance
    Return a copy of the string converted to uppercase.
help(text.swapcase)
Help on built-in function swapcase:

swapcase() method of builtins.str instance
    Convert uppercase characters to lowercase and lowercase characters to uppercase.
help(str.split)
Help on method_descriptor:

split(self, /, sep=None, maxsplit=-1)
    Return a list of the substrings in the string, using sep as the separator string.
    
      sep
        The separator used to split the string.
    
        When set to None (the default value), will split on any whitespace
        character (including \\n \\r \\t \\f and spaces) and will discard
        empty strings from the result.
      maxsplit
        Maximum number of splits (starting from the left).
        -1 (the default value) means no limit.
    
    Note, str.split() is mainly useful for data that has been intentionally
    delimited.  With natural text that includes punctuation, consider using
    the regular expression module.

According to both the help files and the online documentation, .upper() will return the string with all letters in uppercase, and .swapcase() will return the string with the casing of each letter flipped (S \(\rightarrow\) s and e \(\rightarrow\) E). The .split() method is a bit more complicated as it requires the user to provide input arguments. Here, .split() will separate the string into multiple parts based on the separation delimiter sep, and the number of possible separations maxsplit. At this point, do not worry about the self argument displayed in the help output, it is not needed as an input, and we will go over its meaning in the next section. Let us try issuing all three methods on text (for .split() we will use the space character, , as the separator):

print(text.upper())
print(text.swapcase())
print(text.split(sep = " "))
SECRET LEGEND
sECRET lEGEND
['Secret', 'Legend']

As seen in the output, all three methods work as intended. All in all, methods associated with the str class are very useful when trying to format a string in a particular way or trying to extract out a part (or multiple parts).

As these two examples highlight, the common data classes we have already encountered have attributes and methods associated with them. Being able to access attributes and methods can significantly increase the usefulness of these classes when programming. Furthermore, you will often import libraries to increase the capabilities of Python for a particular application. Many of these libraries will create data classes for you to use. Therefore, knowing how to access the attributes and methods from the Python standard library as well as addon libraries is vital to increasing your programming capabilities.

10.4.4. Example: The methods of lists#

The list class is commonly used in scientific Python to store sets of data. This class has numerous methods worth exploring. To see a few methods in action, first create a list called a that has the following values:

5.32, 1.2, 6.2, -7.2, 1.2, and 35.1

Next, complete each numbered objective below and print out the results to the terminal. You will need to use various list-based methods to complete these tasks. Information about methods associated with the list class can be found by entering the command help(list) to the Python terminal or visiting the official Python site page about lists.

  1. Find the first index value in a that has the number 1.2.

  2. Report the number of times the value of 1.2 is present in a.

  3. Append the number 23.8 to the end of the list.

  4. Create a copy of a and call it b.

  5. Insert the value 100.2 to the sixth index position of b (remember the first index position is zero!).

  6. Remove all items from b and confirm that a has not been affected by modifications to b.


Solution:

First let’s create a:

a = [5.32, 1.2, 6.2, -7.2, 1.2, 35.1]
print(a)
[5.32, 1.2, 6.2, -7.2, 1.2, 35.1]

Now let’s go through each objective…

1. Find the index value for the first entry in a that has the number 1.2.

Here we use the .index() method to find the first index value that has the value in question:

print(a.index(1.2))
1

2. Report the number of times the value of 1.2 is present in a.

The .count() method will count the number of times 1.2 is present:

print(a.count(1.2))
2

3. Append the number 23.8 to the end of the list.

The .append() method allows us to add a value to end of a list. We do this in two steps. First we append the value to a and then print out the results. This is a permanent change to a. The code is shown below:

a.append(23.8)
print(a)
[5.32, 1.2, 6.2, -7.2, 1.2, 35.1, 23.8]

4. Create a copy of a and call it b.

To create a copy of a, we need to use the .copy() method. This method is extremely useful as it copies the values over to a new variable, but dissociates the copy’s memory location from the original. If we simply wrote b = a, the two variables will share the same memory address (you can prove this using the id() function; see an earlier lesson about this function). This means that any change to a would also affect b and vise-versa. The .copy() method prevents this from happening, and is demonstrated below:

b = a.copy()
print(b)
[5.32, 1.2, 6.2, -7.2, 1.2, 35.1, 23.8]

5. Insert the value 100.2 to the sixth index position of b (remember the first index position is zero!).

Here we use the .insert() method to put 100.2 in the sixth position:

b.insert(6, 100.2)
print(b)
[5.32, 1.2, 6.2, -7.2, 1.2, 35.1, 100.2, 23.8]

6. Remove all items from b and confirm that a has not been affected by modifications to b.

Finally, we use the .clear() method to remove all contents from b. The block of code below first clears b, and then prints out both a and b to show that a is not affected by the changes in b (i.e., demonstrating the importance of the.copy() method!). We use two different string formats just for educational purposes.

b.clear()
print("a:", a)
print(f"b: {b}")
a: [5.32, 1.2, 6.2, -7.2, 1.2, 35.1, 23.8]
b: []

10.5. Creating classes#

Now that we know more about OOP, let us now revisit our earlier scenario. In order to create our desired sample database using OOP, we need to create our own data class and assign attributes and methods to it. To create a class in Python, we issue the class command in a way very reminiscent to using the command def for functions. The block of code below demonstrates how to create a class for our sample database:

There is a lot going on in this code block. Some of it probably looks familiar. Some of it probably looks completely new. Let us start with the first line of code:

class SampleBase:

This is the command that creates our custom class. The class command tells Python that we want to define a new object class. This is similar to issuing the def command when we want to create a function. The text SampleBase is the name of our custom class. The end of the first line has a : symbol that tells that informs Python that the information following this line is tied to defining the class and should be properly indented. The use of : is similar to when a function is defined or an if / for statement is used.

Tip

The Python Style Guide recommends that class names should have their first letter capitalized. CamelCase works well here if you also capitalize the first letter of the name. This helps distinguish a class name from a variable name, as variables should have the first letter of their name in lowercase.

After the first line we see a multi-line string with the following information:

"""Basic data class for studied samples.
Force units: N
Thickness units: mm
Width units: mm
"""

This is the docstring for the class. We use triple quotes here to allow for multiline doc strings. It is good practice to always include a docstring for a custom class to provide context on what it will be used for. This is especially important if you plan on releasing your class for others to use.

The next section of the code creates a class attribute called .device that has the value Tester 1:

 # Example of initializing a class attribute
device = "Tester 1"

Notice that a comment line was inserted prior to the command for code readability purposes only. Since this is a class attribute, all objects associated with the SampleBase class will have their .device attribute set to Tester 1 by default.

Next in the code block contains three functions associated with SampleBase, meaning that these are methods. The first method, .__init__(), is an extremely important method (also a dunder / magic method!) as it is the initialization method for objects in the class. This method is used to define the input arguments when we create a new object. For SampleBase, the .__init__() method is written out as,

def __init__(self, name, yield_force, thickness, width):
    """Initialization method for a SampleBase object."""
    self.name = name
    self.yield_force = yield_force
    self.thickness = thickness
    self.width = width

Notice that we first use def to define a new function (i.e., method) and call it using __init__(). There are five inputs when initializing a SampleBase object: self, name, yield_force, thickness, and width. Ignoring self for just a moment, we can see that when any object from the SampleBase class is created, it will require input values for name, yield_force, thickness, and width, which represent a sample’s name, measured yield force, cross-sectional thickness, and cross-sectional width, respectively. After this line of code is the docstring for the method, which follows a format similar to using a docstring for a function.

Definition: self

A keyword that references the current instance of the class (i.e., the current object). It is used to create, access, and modify instanced attributes and methods for an object.

In addition to the .__init__() method, two other instanced methods (due to the self argument) are created. The first method, .calc_yield_stress(), is used to calculate and store the yield stress for a sample. The code block for this method is below:

def calc_yield_stress(self):
    """Method to calculate yield stress, units: MPa."""
    self.yield_stress = self.yield_force / (self.thickness * self.width)

No input arguments are required (barring the self keyword when defining the method) as it uses the instanced attributes of .yield force, .thickness, and .width in the calculation. The calculated yield stress is stored in an instanced attribute called .yield_stress.

The last method for SampleBase is called .entry_date() and it is used to enter the measurement date for the sample. The code block is shown below:

def entry_date(self, date):
    """Method to enter date of measurement."""
    self.date = date

This method follows a similar structure to the previous two methods as it issues the self keyword in the argument field to state that it is an instanced method. The new feature in this method is that also required an additional input argument called date, which represents the testing date. The role of this method is to simply pass date to an instanced attribute called self.date for record keeping purposes.

10.6. Instantiating objects#

The process to create an instanced version of a class (i.e., an object!) is called instantiation. You have already done this with the built-in classes provided by Python’s standard library. For example, we have previously shown you how to create two ways to instantiate an int object by issuing the following commands:

a = 5
b = int(5)

print(type(a))
print(type(b))
<class 'int'>
<class 'int'>

The result of type() for both variables is int, which is expected. In the first example using variable a, Python assumes we want an int object because the value 5 has no decimal point. The second example using b is a more explicit call as we are specifically telling Python to ensure that b belongs to the int class.

Definition: instantiation

The process to create an instanced version of a class (i.e., an object).

Creating an object from a custom class is no different. For example, let us create an object for our first sample that has the following attributes:

  • Sample name: Sample 1

  • Thickness: 5.2 mm

  • Width: 2 mm

  • Instrument: Tester 1

  • Measurement date: 2022.10.25

  • Measured yield force: 1000 N

We can create an object called sample1 from our SampleBase class with the following command:

sample1 = SampleBase(name = "Sample 1", yield_force = 1000, thickness = 5.2, width = 2)

Hey! Listen!

Notice that the measurement date is not included since it is not part of the input arguments for .__init()__.

Congrats! You just created your first instanced version of SampleBase! To prove this, lets run type() on sample1:

print(type(sample1))
<class '__main__.SampleBase'>

Sure enough, the output here says that the class for sample1 is SampleBase (side note: the __main__ prefix is just stating that this exists in the current coding environment; you can ignore this keyword for the lesson). Since sample1 is an object of a class, it should have a set of attributes and methods associated with it. Running dir() shows us:

dir(sample1)
['__class__',
 '__delattr__',
 '__dict__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__format__',
 '__ge__',
 '__getattribute__',
 '__gt__',
 '__hash__',
 '__init__',
 '__init_subclass__',
 '__le__',
 '__lt__',
 '__module__',
 '__ne__',
 '__new__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__setattr__',
 '__sizeof__',
 '__str__',
 '__subclasshook__',
 '__weakref__',
 'calc_yield_stress',
 'device',
 'entry_date',
 'name',
 'thickness',
 'width',
 'yield_force']

As seen from this list, Python has automatically created a set of magic methods for low-level operations in the coding environment. Furthermore, you can see that the last entries in this list contain our instanced methods and attributes for sample1. While this list is nice, the help() function will provide more information on sample1’s attributes and methods:

help(sample1)
Help on SampleBase in module __main__ object:

class SampleBase(builtins.object)
 |  SampleBase(name, yield_force, thickness, width)
 |  
 |  Basic data class for studied samples.
 |  Force units: N
 |  Thickness units: mm
 |  Width units: mm
 |  
 |  Methods defined here:
 |  
 |  __init__(self, name, yield_force, thickness, width)
 |      Initialization method for a SampleBase object.
 |  
 |  calc_yield_stress(self)
 |      Method to calculate yield stress, units: MPa.
 |  
 |  entry_date(self, date)
 |      Method to enter date of measurement.
 |  
 |  ----------------------------------------------------------------------
 |  Data descriptors defined here:
 |  
 |  __dict__
 |      dictionary for instance variables (if defined)
 |  
 |  __weakref__
 |      list of weak references to the object (if defined)
 |  
 |  ----------------------------------------------------------------------
 |  Data and other attributes defined here:
 |  
 |  device = 'Tester 1'

Here the help output provides all of our docstrings and displays both our instanced attributes and methods! In particular, the .__dict__ attribute is a useful attribute created automatically by Python. Looking at the help()call above states that:

__dict__
dictionary for instance variables (if defined)

So if we call for .__dict__ we should get a dictionary of all instanced attributes. This can be done by issuing the command:

print(sample1.__dict__)
{'name': 'Sample 1', 'yield_force': 1000, 'thickness': 5.2, 'width': 2}

Notice that .tester is not displayed in this list because it is a class attribute and NOT an instanced attribute.

While a dict object is a nice way to summarize all instanced attributes, there are times when accessing a single attribute (again, sometimes called “get” in coding) is preferable. This can be done by using our previous command structure of object.attribute from earlier. The block of code below outputs the values of all attributes associated with Sample 1 individually:

print(sample1.name)
print(sample1.yield_force)
print(sample1.thickness)
print(sample1.width)
print(sample1.device)
Sample 1
1000
5.2
2
Tester 1
sample1.thickness = 4.3
print(sample1.thickness)
4.3

Therefore, we can access an attribute using object.attribute and modify an attribute using object.attribute = value.

Notice in the .__dict__ call there was no entry for .yield_stress which is the object that represents the calculated yield stress. This is because .yield_stress is not created with the .__init()__ method. If you try to access the .yield_stress attribute for sample1 you will get the following error:

print(sample1.yield_stress)
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Input In [33], in <cell line: 1>()
----> 1 print(sample1.yield_stress)

AttributeError: 'SampleBase' object has no attribute 'yield_stress'

In order to have our code calculate and store the yield stress we need to run the .calc_yield_stress() method. As we have discussed earlier, we call a method using the command structure object.method() (remember to include the ( ) symbols!). The code below the calls .calc_yield_stress() for sample1 and displays its value with three digits of precision:

sample1.calc_yield_stress()
print(round(sample1.yield_stress, 3))
116.279

Notice that we do not include the self command as an input argument. This only has to be done when we define the method.

The other method we created for the SampleBase class is to enter the measurement date for the sample. We call this method in a similar way to .calc_yield_stress(), but we also need to include the measurement date as an input parameter. The block of code below demonstrates how to do this using a measurement date of 2022.10.25 (Oct. 10th, 2022):

sample1.entry_date("2022.10.25")
print(sample1.date)
2022.10.25

Since both .yield_stress and .date are instanced attributes, if we rerun .__dict__, we should now see these entries in the output:

print(sample1.__dict__)
{'name': 'Sample 1', 'yield_force': 1000, 'thickness': 4.3, 'width': 2, 'yield_stress': 116.27906976744187, 'date': '2022.10.25'}

Congratulations! We are able to now create a database for our scenario that links all sample characteristics together and also can make future entries easy to implement. To prove this, let us create a new entry for Sample 2 that has the following characteristics:

  • Sample name: Sample 2

  • Thickness: 4.5 mm

  • Width: 2.2 mm

  • Instrument: Tester 1

  • Measurement date: 2022.10.27

  • Measured yield force: 1315 N

The code below demonstrates this by first creating an object for Sample 2 (called sample2), then creating entries for both the yield stress and the measurement date, and finally outputting all of the instanced attributes as a dict object:

sample2 = SampleBase(name = "Sample 2", yield_force = 1315, thickness = 4.5, width = 2.2)
sample2.calc_yield_stress()
sample2.entry_date("2022.10.27")

print(sample2.__dict__)
{'name': 'Sample 2', 'yield_force': 1315, 'thickness': 4.5, 'width': 2.2, 'yield_stress': 132.82828282828282, 'date': '2022.10.27'}

10.6.1. Example: The characteristics of a cylinder#

This exercise will help you practice creating classes, instantiating an object, and calling both attributes and methods. Recall that a cylinder is a three-dimensional object and is described via its height and radius. The volume, \(V\), of a cylinder is,

\[ V = \pi r^2 h \]

where \(r\) is the radius and \(h\) is the height. Furthermore, its surface area, \(A\), is,

\[ A = 2 \pi r (r + h) \]

In this exercise, first create a class called Cylinder the represents a cylindrical object. Have the height and radius of the cylinder be two input arguments. Assign these to attributes during initialization. The class should also have methods that calculate the volume and surface area. Provide docstrings for the class and all methods.

Next, create a Cylinder object that has a radius of 2 cm and a height of 3 cm. Confirm that your Cylinder class works by printing out the object’s height, radius, volume, and surface area. Finally, change the object’s radius to 4 cm and reprint the four metrics.

Note

The math library has a constant called .pi that represents the value for \(\pi\). Use this library when defining your class. Therefore, this class is dependent on the math library to work.


Solution:

The first step is to create the class. The code below generates the class Cylinder with the appropriate input arguments, assigns these arguments to attributes, and creates the necessary methods to calculate the volume and surface area.

from math import pi

class Cylinder:
    """Class that represents a cylinder. Requires the math library.
    radius units: mm
    height units: mm
    """
    def __init__(self, radius, height):
        """Initialization method for a Cylinder object."""
        self.radius = radius
        self.height = height
    
    def calc_volume(self):
        """Calculate the volume of a cylinder. Units is cm^3."""
        self.volume = pi * pow(self.radius,2) * self.height
        return self.volume
    
    def calc_surface_area(self):
        """Calculate the surface area of a cylinder. Units is cm^2."""
        self.surface_area = 2 * pi * self.radius * (self.radius + self.height)
        return self.surface_area

Next, we create a cylinder object called a with radius of 2 cm and height of 3 cm:

a = Cylinder(radius=2, height=3)

The code block below prints out the four metrics asked in the problem statement. Remember that when calling a method, you need to include the () characters (and any required arguments)! Here we demonstrate the use “getting” an attribute and calling a method.

print(f"Cylinder a's radius: {a.radius} cm")
print(f"Cylinder a's height: {a.height} cm")
print(f"Cylinder a's volume: {a.calc_volume():.1f} cm^3")
print(f"Cylinder a's height: {a.calc_surface_area():.1f} cm^2")
Cylinder a's radius: 2 cm
Cylinder a's height: 3 cm
Cylinder a's volume: 37.7 cm^3
Cylinder a's height: 62.8 cm^2

Finally, we “set” the radius attribute to 4 cm and reprint out all four metrics:

a.radius = 4

print(f"Cylinder a's radius: {a.radius} cm")
print(f"Cylinder a's height: {a.height} cm")
print(f"Cylinder a's volume: {a.calc_volume():.1f} cm^3")
print(f"Cylinder a's height: {a.calc_surface_area():.1f} cm^2")
Cylinder a's radius: 4 cm
Cylinder a's height: 3 cm
Cylinder a's volume: 150.8 cm^3
Cylinder a's height: 175.9 cm^2

10.7. Inheritance#

Many programming languages (like Python!) allow for a class to receive the attributes and methods of another class. This process is known as inheritance, and it is extremely powerful because it streamlines class creation in the situation where a new class is a modified version of another starting class. The starting class is commonly referred to as the “super-class” or the “parent class”, and new class that will inherit all the parent class’s attributes and methods is often called the “subclass” or “child class”. Inheritance is commonly used when we want to create a new class that is similar to the parent class but may have a few new attributes and methods. This way, we do not have to modify the parent class to add new functionality and features, as it may not be appropriate for the parent class to have these new features, or it may break programs that are already using the parent class.

Definition: inheritance

The process in which a new class receives the attributes and methods of another class.

Definition: parent and child classes

  • Parent class (a.k.a. super-class) - The starting class in which new classes are inheriting attributes and methods from.

  • Child class (a.k.a. subclass) - The new class that is inheriting the attributes and methods from the parent class.

Creating a child class is straightforward in Python. Following our previous example as a guide, let us imagine we now want to perform measurements at different temperatures. Therefore, we want to create a new data class (which we will call SampleNew) that also includes a temperature entry. The block of code below shows how the child class SampleNew inherits the attributes and methods of the parent class SampleBase:

class SampleNew(SampleBase):
    """Subclass of SampleBase class. Inherits all methods and attributes & 
    adds temperature attribute.
    """
    
    def __init__(self, name, yield_force, thickness, width, temperature):
        """Initialization method for a SampleNew object.
        Inherits all SampleBase attributes and adds new attribute temperature.
        Temperature units: K
        """
        super().__init__(name, yield_force, thickness, width)
        self.temperature = temperature

Notice that there are two new features in this code block. The first feature is in the class call for SampleNew (i.e., the first line of code):

class SampleNew(SampleBase):

Here, we use SampleBase as an argument when defining SampleNew. This command tells Python that SampleNew is a child class of SampleBase.

The second new feature is inside the .__init__() code block:

super().__init__(name, yield_force, thickness, width, temperature)
self.temperature = temperature

In addition to adding temperature as an input argument, notice the command super().__init__(), which tells Python that the instanced attributes listed as arguments inside of .__init__() should be set up the same way as the parent class. For our example, this means that the initialization of .name, .yield_force, .thickness, and .width are based on SampleBase and therefore we do not need to rewrite them. Only the new instanced attribute .temperature needs to be initialized, and this is done using a similar code structure from what was shown before with SampleBase.

With this block of code now implemented, we can create new samples that have the temperature attribute. For example, let us create a new entry with the following metrics:

  • Sample name: Sample 3

  • Thickness: 5.1 mm

  • Width: 1.8 mm

  • Instrument: Tester 1

  • Measurement date: 2022.10.29

  • Measured yield force: 1235.3 N

Shown below is the creation of Sample 3’s entry called sample3:

sample3 = SampleNew(name = "Sample 3", yield_force = 1235.3, thickness = 5.1, width = 1.8, temperature = 350)

Notice here we use SampleNew in our class call and also include the temperature as an input argument. If we run help() on sample3 we notice some additional information is provided:

help(sample3)
Help on SampleNew in module __main__ object:

class SampleNew(SampleBase)
 |  SampleNew(name, yield_force, thickness, width, temperature)
 |  
 |  Subclass of SampleBase class. Inherits all methods and attributes & 
 |  adds temperature attribute.
 |  
 |  Method resolution order:
 |      SampleNew
 |      SampleBase
 |      builtins.object
 |  
 |  Methods defined here:
 |  
 |  __init__(self, name, yield_force, thickness, width, temperature)
 |      Initialization method for a SampleNew object.
 |      Inherits all SampleBase attributes and adds new attribute temperature.
 |      Temperature units: K
 |  
 |  ----------------------------------------------------------------------
 |  Methods inherited from SampleBase:
 |  
 |  calc_yield_stress(self)
 |      Method to calculate yield stress, units: MPa.
 |  
 |  entry_date(self, date)
 |      Method to enter date of measurement.
 |  
 |  ----------------------------------------------------------------------
 |  Data descriptors inherited from SampleBase:
 |  
 |  __dict__
 |      dictionary for instance variables (if defined)
 |  
 |  __weakref__
 |      list of weak references to the object (if defined)
 |  
 |  ----------------------------------------------------------------------
 |  Data and other attributes inherited from SampleBase:
 |  
 |  device = 'Tester 1'

Here, help() provides information about the inheritance order for SampleNew and what instanced attributes and methods are inherited. Since sample3 is just another object in the Python environment, it supports all the features and functionalities associated with both the SampleNew and SampleBase classes. Therefore, we can access all of its attributes and methods like any other object. For example, we can calculate the yield stress and then check all of sample3’s instanced attributes using our normal commands:

sample3.calc_yield_stress()
print(sample3.__dict__)
{'name': 'Sample 3', 'yield_force': 1235.3, 'thickness': 5.1, 'width': 1.8, 'temperature': 350, 'yield_stress': 134.56427015250546}

Regardless if an object belongs to a child or parent data class, it is still just an object, and therefore we access and modify its attributes following our normal programming commands.

10.8. Polymorphism#

Sometimes it is useful for a child class to redefine or “morph” a parent class’s attribute or method into something different. Therefore, the name of the attribute or method is the same, but the functionality is different. This ability to redefine an attribute or method for a child class while still retaining the parent class’s name is called polymorphism. This helps bring new features and functionality to new classes while also still retaining backwards compatibility with code.

Definition: polymorphism

Object-oriented programming concept in which a child class’s attribute or method is functionally different from the parent class, while still sharing the same name.

Let us use our sample database scenario as an example. Imagine we want to modify the .entry_date() method, so it now outputs a return string that states when the sample was tested. We can go back to SampleBase and modify it, but this would then require us to re-enter all of our previously entered sample data. This will take time and depending on the situation may not be necessary for our previous samples. Instead, we can create a new child class called SampleNeo that polymorphs .entry_date(). The following code shows how this is done:

class SampleNeo(SampleBase):
    """Subclass of SampleBase class. Inherits all methods and attributes but 
    polymorphs device attribute and entry_date method.
    """
    
    def __init__(self, name, yield_force, thickness, width):
        """Initialization method for a SampleNeo object.
        Inherits all of SampleBase attributes.
        """
        super().__init__(name, yield_force, thickness, width)
        
    def entry_date(self, date):
        """Method to enter date of measurement.
        Polymorphed method from SampleBase"""
        self.date = date
        return print(f"Measurement date for {self.name} was {self.date}.")

As you can see in the above block of code, we have created the new child class using concepts taught in the inheritance section. The only new feature is that we have redefined .entry_date() to return an output string. By using the same method name as the parent class (i.e., in this case .entry_date(), Python registers that we are overwriting the parent class’s definition for this method.

From here we can simply create a new entry (let us call in sample4) with the following characteristics:

  • Sample name: Sample 4

  • Thickness: 5.4 mm

  • Width: 2.2 mm

  • Instrument: Tester 1

  • Measurement date: 2022.11.12

  • Measured yield force: 983.4 N

Let us now create a new entry called sample4, and run .entry_date() to see polymorphism in action:

sample4 = SampleNeo(name = "Sample 4", yield_force = 983.4, thickness = 5.4, width = 2.1)
sample4.entry_date("2022.11.12")
Measurement date for Sample 4 was 2022.11.12.

As seen above, we get our date string output for sample4. However, if we try to rerun .entry_date() for sample1, which belongs to parent class SampleBase, we simply get:

sample1.entry_date("2022.10.25")

which, in short, has no output because the parent class’s version of .entry_date() does not have this feature. Therefore, polymorphism allows one to modify a child class’s features and characteristics without impacting the parent class.

10.9. Concluding thoughts#

In this lesson we introduced the basic concepts of object-oriented programming (OOP). We started our lesson off by describing what is OOP, which is a programming paradigm that focuses more on what we can do with data objects rather than the values that they represent. Next we explained how to access an object’s attributes and methods by exploring two built-in classes in the Python standard library. Finally, we showed how to create custom data classes in Python, access and modify an object’s attributes, and how inheritance and polymorphism for child classes can be implemented.

This lesson covered a lot of ground in programming. At this point you may be asking yourself “When should I use OOP?” That does depend on a case by case basis. As seen in the example scenario, OOP is very powerful when you want to link data together. This may not be necessary for every situation. However, since all data in Python is an object, accessing an object’s attributes and methods is a very powerful tool to increase a code’s effectiveness. For a new user in scientific Python, knowing how to find and access an object’s attributes and methods is vital. For example, the upcoming lessons in this guide will cover the basics of the NumPy and Matplotlib libraries, which provide scientific computing and plotting capabilities to Python, respectively, heavily use attribute and method calls. For now though, enjoy accessing these new features and functionalities that have always been available to use, but have required us to dive a little deeper into Python.

10.10. Want to learn more?#

Python Software Foundation - Classes
TutorialsTeacher - Python Magic or Dunder Methods
Real Python - Python Class Constructors