Object-Oriented Programming
Contents
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,
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
Notice the format used to access the attributes: we first issue the object’s name (position
), followed by the .
symbol, then the attribute’s name (e.g. real
or imag
). One way to think of the command position.real
is that we
are calling the real
attribute associated with position
. So the overall command structure to access an object’s
attribute is object.attribute
.
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.
So .conjugate()
will return the complex conjugate of a complex number. The format for accessing a method is similar to
an attribute, but since it is a function we need to include the ( )
symbols! Forgetting to do so a common coding
error for new programmers. The code below demonstrates how to use .conjugate()
on position
:
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.
Find the first index value in
a
that has the number 1.2.Report the number of times the value of 1.2 is present in
a
.Append the number 23.8 to the end of the list.
Create a copy of
a
and call itb
.Insert the value 100.2 to the sixth index position of
b
(remember the first index position is zero!).Remove all items from
b
and confirm thata
has not been affected by modifications tob
.
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:
class SampleBase:
"""Basic data class for studied samples.
Force units: N
Thickness units: mm
Width units: mm
"""
# Example of initializing a class attribute
device = "Tester 1"
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
def calc_yield_stress(self):
"""Method to calculate yield stress, units: MPa."""
self.yield_stress = self.yield_force / (self.thickness * self.width)
def entry_date(self, date):
"""Method to enter date of measurement."""
self.date = date
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.
Let us now talk about the first input argument called self
. The self
argument is an extremely important, but
somewhat confusing, keyword for new programmers as it represents the current instance of the class (i.e. the object).
Therefore, whenever self
is present in an attribute or method, this tells Python that the attribute or method is
an instanced version (i.e., unique to the object). This is seen in the subsequent lines of code in which the instanced
attributes for .name
, .yield_force
, .thickness
, and .width
are created (each have a leading self.
command).
If self
is not included, an attribute or method would be class-based and common across all objects (e.g., the
device
attribute).
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
We can modify the value of an attribute (called “set” in coding) by using the command object.attribute = value
. For
example, we can modify the thickness entry for Sample 1 and check for its acceptance using the commands:
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,
where \(r\) is the radius and \(h\) is the height. Furthermore, its surface area, \(A\), is,
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