11 Object-Oriented Programming: Objects, Classes, Attributes, Methods
11.1Lesson goals¶
Understand the basic tenets of object-oriented programming (OOP).
Realize that all data in Python are objects.
Find an object’s attributes and methods.
Utilize attributes and methods when programming.
Create new classes.
Create (instantiate) new objects from a class.
Learn about class inheritance.
11.2Overview¶
Python refers to all data as objects. These objects can have certain features and functionalities associated with them. In this lesson, we will further explore this concept 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.
11.3The need for an object-oriented mindset¶
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 is the yield stress, is the force required for yielding (a.k.a., yield force), and is the cross-sectional area. If we assume that the sample has a rectangular cross-section, we can say that , where is the width and 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.
Let’s imagine we had the following information about a sample:
Sample name: Sample 1
Thickness: 5.2 mm
Width: 2 mm
Instrument: Tester 1
Measurement date: 2025.08.07
Measured yield force: 1000 N
Currently, there are two possible ways we can try to do this...
11.3.1Method 1: Independent variables¶
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. This could be implemented using the code block shown below:
# Method 1: Create independent variables for each characteristic
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):
"""Calculate the yield stress."""
yield_stress = force / (thickness * width) # Units: MPa
return round(yield_stress, 3)
sample1_yield_stress = calc_yield_stress(sample1_yield_force,
sample1_thickness,
sample1_width)
print("Name:", sample1_name)
print("Yield force:", sample1_yield_force, "N")
print("Thickness:", sample1_thickness, "mm")
print("Width:", sample1_width, "mm")
print("Device:", sample1_device)
print("Yield stress:", sample1_yield_stress, "MPa")Name: Sample 1
Yield force: 1000 N
Thickness: 5.2 mm
Width: 2 mm
Device: Tester 1
Yield stress: 96.154 MPa
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.
11.3.2Method 2: dict objects¶
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:
# Method 2: Using dict class
def calc_yield_stress(force, thickness, width):
"""Calculate the yield stress."""
yield_stress = force / (thickness * width) #Units: MPa
return round(yield_stress, 3)
sample1 = {
"name" : "Sample 1",
"yield_force" : 1000, # Units: N
"thickness" : 5.2, # Units: mm
"width" : 2, # Units: mm
"device" : "Tester 1",
}
yield_stress = calc_yield_stress(sample1["yield_force"], sample1["thickness"],
sample1["width"])
print("Name:", sample1["name"])
print("Yield force:", sample1["yield_force"], "N")
print("Thickness:", sample1["thickness"], "mm")
print("Width:", sample1["width"], "mm")
print("Device:", sample1["device"])
print("Yield stress", yield_stress, "MPa")Name: Sample 1
Yield force: 1000 N
Thickness: 5.2 mm
Width: 2 mm
Device: Tester 1
Yield stress 96.154 MPa
Functionally this is a better route as many of Sample 1’s characteristics are associated with the sample1 dictionary
object. However, if we wanted to create an entry for another sample, we would need to manually create a new dict
object. Furthermore, Sample’s 1 yield stress is not linked with sample1, which is not good for organizational
purposes. While not a big issue for this example, one can imagine a scenario where we would like to only have a
function operate only on a specific object rather than any object in the programming environment.
11.3.3Method 2a: Linking a function to dict object¶
We can fix the yield stress not being linked to sample1 by adding a new key-value pair after sample1 is created:
# Method 2a: Another using dict class example
def calc_yield_stress(force, thickness, width):
"""Calculate the yield stress."""
yield_stress = force / (thickness * width) #Units: MPa
return round(yield_stress, 3)
# First create the object
sample1 = {
"name" : "Sample 1",
"yield_force" : 1000, # Units: N
"thickness" : 5.2, # Units: mm
"width" : 2, # Units: mm
"device" : "Tester 1",
}
# Now update
sample1["yield_stress"] = calc_yield_stress(sample1["yield_force"],
sample1["thickness"],
sample1["width"])
print("Name:", sample1["name"])
print("Yield force:", sample1["yield_force"], "N")
print("Thickness:", sample1["thickness"], "mm")
print("Width:", sample1["width"], "mm")
print("Device:", sample1["device"])
print("Yield stress:", sample1["yield_stress"], "MPa")Name: Sample 1
Yield force: 1000 N
Thickness: 5.2 mm
Width: 2 mm
Device: Tester 1
Yield stress: 96.154 MPa
This route is still a bit clunky and does not resolve the issue about easily making new entries that follow a similar
structure to sample1. As this example illustrates, our current programming approach is not well suited for scenarios
where we want to link 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.
11.4The 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! All data in Python is represented by objects or by relationships between objects. Even code can be an object (i.e., a function).
We often assign a symbolic name to an object, which is called a variable, to help in our identification and tracking of an object. Therefore, variables are objects and objects are variables. We also group similar objects together in a unit called a class. While all objects in a class are unique, they share common behaviors and features.
There is much more “below” these surface definitions of objects and classes. For example, we have previously mentioned the concept of a method, which is a function that belongs to an object. Up to this point we never addressed this peculiar behavior that an object (i.e., a function) can be tied to another object. This behavior though is a manifestation of object-oriented programming which places an emphasis on what we can do with objects rather than the values that the objects represent. To explore this further, let’s try another thought experiment in which we focus more what an object can represent.
11.4.1Thought experiment: The temperature object¶
Imagine an object in Python 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 actual temperature object. For example, we may want
temperature to be able to report its current value in degrees Kelvin, or convert its temperature to either the
Celsius or Fahrenheit scale, or even multiply its temperature value by the
Boltzmann constant to estimate a thermal energy.
Therefore, temperature can be much more than just a variable name for the number 300, it can have
features and functionality.
11.4.2A change to our programming paradigm¶
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 of 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. 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.
**Object-oriented programming (OOP) focuses on what we can do with objects rather than what the objects represent. There is an emphasis on the objects themselves and the values associated with these objects are considered mutable. We may (and probably will) go back and change variables and re-run functions. The focus is not with the values stored in the objects, but rather the objects themselves.
11.5The object-oriented view of objects and classes¶
Objects in OOP have
three fundamental characteristics: (1)
an identity, (2) a type, and (3) a value. The identity of an object is
its memory location. We have seen this before using the built-in id() function to access an object’s
memory location. The type of an object is it’s class. This defines what
operations that that an object supports and what values (e.g., numbers, text) are allowed. We previously accessed this
using the built-in type() function. The value, as you can
guess, is the meaningful data. It can be mutable or immutable.
A class in OOP represents the “type” characteristic of an object and provides a means to bundle data and functionality together (as defined in the official Python documentation). As we will see going forward, classes are also objects! Classes provide a template and a process (a.k.a., instantiation) to create new objects. They are also the foundational basis for OOP since they represent the default version of all instanced (i.e., unique) objects.
In short, instances of classes are objects and objects are instanced versions of classes. These two statements are
equivalent. For example, the str class contains objects with text-based characters. While all str objects share
common features and behaviors, each object is unique. So each str-based object is an instanced (i.e., a unique)
version of the str class.
11.5.1The data of objects¶
Classes and objects refer to data in two ways. First, data that belongs to an object is called an attribute. This is also an object, so it is an object that describes an object. Using sentence structure as an analogy, this is a noun associated with a thing (e.g., height, length, thermal conductivity value).
A function that belongs to an object is known as a method. Methods are often used to access or modify attributes for a class or object. Using sentence structure as an analogy again, this is a verb acting on a thing (e.g., growing, elongating, measuring the thermal conductivity). Methods are technically also attributes, but we commonly refer to them as separate units. This stems from the idea that a function is also an object.
In short, attributes are the data and methods are often used to access, utilize, and manipulate the data.
11.5.2Operations that classes can perform¶
We can assign attributes and methods to individual objects or have them shared across an entire class. Classes, for example, have two main operations. The first operation, known as attribute referencing, allows classes to read, modify, and delete attributes and methods that are shared throughout the entire class. In practice, class attributes are commonly used but class methods are not used that often. The second operation for classes is the instantiation of new objects, which we previously defined. As we will see soon, instantiation will require a special function call in the class definition to allow for this.
11.5.3Operations that objects can perform¶
Objects on the other hand can only perform attribute referencing. However they are allowed to reference both class-based and instanced-based attributes and methods. This seems reasonable as instanced objects are typically used to handle all the unique data we need to process while classes set up the default versions of our data.
11.6Finding and using attributes and methods¶
So far we have covered a lot of the basic tenets of OOP, but it is equally important to see what we can actually do with OOP. The most common use of OOP is accessing and using attributes and methods of objects. 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’s revisit two data classes we have studied in previous lessons to highlight how to access attributes and methods.
11.6.1Attributes and methods for the complex data class¶
Recall that we can create a complex object by entering the following into the shell prompt:
position = 7.3 + 1.8j
print("value:", position)
print("class:", type(position))value: (7.3+1.8j)
class: <class 'complex'>
Our object called position has the value 7.3 + 1.8j and is of 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
built-in function dir(). Let’s 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 an object.
The code below runs this function on position:
dir(position)['__abs__',
'__add__',
'__bool__',
'__class__',
'__complex__',
'__delattr__',
'__dir__',
'__doc__',
'__eq__',
'__format__',
'__ge__',
'__getattribute__',
'__getnewargs__',
'__getstate__',
'__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.14.4 has 44 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 string or numbers.
|
| If a string is given, parse it as a complex number.
| If a single number is given, convert it to a complex number.
| If the 'real' or 'imag' arguments are given, create a complex number
| with the specified real and imaginary components.
|
| Methods defined here:
|
| __abs__(self, /)
| abs(self)
|
| __add__(self, value, /)
| Return self+value.
|
| __bool__(self, /)
| True if self else False
|
| __complex__(self, /)
| Convert this value to exact type complex.
|
| __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)
| 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, the
dir() tells return 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). We will learn about a
specialized descriptor called a property soon.
For now, treat a descriptor as simply as an attribute.
11.6.1.1Calling 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 use the following format to
call (i.e., to “get”) an attribute:
object.attributeNotice the . between the object’s name and attribute’s name. The Python interpreter understands this as we are
calling the attribute associated with a particular object. Let’s call the .real and .imag methods for position:
print(position.real)
print(position.imag)7.3
1.8
11.6.1.2Calling a method¶
We use a similar command structure to call (i.e., to “get”) a method:
object.method()Notice in this case we need to include the ( ) characters since we are calling a function.
Let’s see what the .conjugate() 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. Let’s try it on position:
print(position.conjugate())(7.3-1.8j)
Overall, calling attributes and methods are easy once you understand the notation!
11.6.2Side topic: 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 a 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, let’s look at
the .__add__():
help(position.__add__)Help on method-wrapper:
__add__(value, /) unbound builtins.complex method
Return self+value.
It is bit hard to read, but this adds a value to the object (i.e., it is the “adding” function). It requires an input
argument (value) that we add to the object:
print("original value:", position)
print("add 2+2j:", position.__add__(2+2j))original value: (7.3+1.8j)
add 2+2j: (9.3+3.8j)
In short, dunder methods are available but not often directly used. There are exceptions to this recommendation; we will see an important exception very soon!
11.6.3Attributes and methods for the str data class¶
The str class also has useful attributes and methods! Let’s explore a few with the following object:
text = "Secret Legend"
print(text)
print(type(text))Secret Legend
<class 'str'>
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.
Let’s 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) unbound builtins.str method
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.
-1 (the default value) means no limit.
Splitting starts at the front of the string and works to the end.
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.
Both the help returns and the online documentation state that .upper() will return the string with all letters in
uppercase, and .swapcase() will return the string with the casing of each letter flipped (S s and e
E). The .split() method is a bit more complicated as it requires input arguments.
Here, .split() will separate the string into multiple parts based on the separation delimiter input argument sep
and the number of possible separations argument 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’s try issuing all three methods on text (for .split() we will use the space character (" ") as the separator):
print(".upper():", text.upper())
print(".swapcase():", text.swapcase())
print(".split():", text.split(sep=" ")).upper(): SECRET LEGEND
.swapcase(): sECRET lEGEND
.split(): ['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).
These two examples highlight that 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 classes in programming. Furthermore, many external libraries also create additional classes that will have their own attributes and methods. Knowing how to access object attributes and methods will dramatically increase your ability to program.
11.7Example: 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:
, , , , , and
Next, complete each numbered objective and print out the results. 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
athat has the value1.2.Report the number of times the value of
1.2is present ina.Append the value
23.8to the end of the list.Create a copy of
aand call itb.Insert the value
100.2to the sixth indexed position ofb(remember the first indexed position is zero!).Remove all items from
band confirm thatahas 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:", a)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("Step 1 Solution:", a.index(1.2))Step 1 Solution: 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("Step 2 Solution:", a.count(1.2))Step 2 Solution: 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("Step 3 Solution:", a)Step 3 Solution: [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, and 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). 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("Step 4 Solution:", b)Step 4 Solution: [5.32, 1.2, 6.2, -7.2, 1.2, 35.1, 23.8]
5. Insert the value 100.2 to the sixth indexed position of b (remember the first index position is zero!).
Here we use the .insert() method to put 100.2 in the sixth indexed position:
b.insert(6, 100.2)
print("Step 5 Solution:", b)Step 5 Solution: [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("Step 6 Solution:")
print("a:", a)
print(f"b: {b}")Step 6 Solution:
a: [5.32, 1.2, 6.2, -7.2, 1.2, 35.1, 23.8]
b: []
11.8Creating 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 that is very
reminiscent to using the def command for functions. The block of code below demonstrates how
to create a class for our sample database:
class SampleBase:
"""
A docstring for a class!
Basic data class for studied samples.
Force units: N
Thickness units: mm
Width units: mm
"""
# Example of a class attribute
device = "Tester 1"
def __init__(self, name, yield_force, thickness, width):
"""
Initialization method for a SampleBase object. Used to instantiate an
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)
self.yield_stress = round(self.yield_stress, 3)
def entry_date(self, date):
"""Method to enter date of measurement."""
self.date = dateThere 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:The class command tells Python that
we want to define a new 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 code block 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.
After the first line we see a multi-line string with the following information:
"""
A docstring for a class!
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 include a docstring for a custom class so you can 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 a class attribute
device = "Tester 1"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 as it is the initialization method for the SampleBase objects:
def __init__(self, name, yield_force, thickness, width):
"""
Initialization method for a SampleBase object. Used to instantiate an
object.
"""
self.name = name
self.yield_force = yield_force
self.thickness = thickness
self.width = widthThe .__init__() is also a dunder / magic method. This method is used to instantiate new objects for
this class and defines the input arguments needed to create SampleBase objects. There are five input arguments for
instantiating a new object. Ignoring the self argument for just a moment we have four other arguments: name,
yield_force, thickness, and width represent a sample’s name, measured yield force, cross-sectional thickness, and
cross-sectional width, respectively.
Let’s now talk about the first input argument called self. The self argument is an
extremely important but confusing keyword for new programmers as it tells the
Python interpreter that the following attributes or methods will be instanced (i.e., unique to the instanced object).
This is the reason for the self.attribute-based commands issued in the subsequent lines of code below .__init__().
The attribute device does not have self keyword since it is a class attribute and all SampleBase objects get this
common attribute.
In short, self references an instanced attribute or method.
In addition to the .__init__() method, there are two other instanced methods (due to the self argument).
The first method, .calc_yield_stress(), calculates and storse 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)
self.yield_stress = round(self.yield_stress, 3)No input arguments are required barring the self keyword since this will be an instanced method. The attributes
.yield force, .thickness, and .width also have the self keyword indicating that the instanced version of these
attributes (i.e., associated with the instanced object) should be used 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 = dateThis 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.
11.9Instantiating objects¶
As we have discussed previously, the process to create an instanced version of a class (i.e., an object) is called
instantiation. You have done this many times already 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'>
Both routes instantiate an int object. The built-in
isinstance() function is a great way to check if an
object is part of a certain class. This function requires two arguments: (1) The object to be tested and (2) the class
to check. The function returns a bool object (True or False) as its output. Below are a few examples of
using isinstance() with some simpler objects:
a = 4
print("Is a an int?:", isinstance(a, int))
b = False
print("Is b a bool?:", isinstance(b, bool))
c = 2.324
print("Is c a float?:", isinstance(c, float))
d = "Hello MATS 5000!"
print("Is d an str?:", isinstance(d, str))Is a an int?: True
Is b a bool?: True
Is c a float?: True
Is d an str?: True
We can use the built-in types library to test on other classes
like None, Function, and more!
import types
# Function instance test
def testfunc():
print("Hi")
print("testfunc's class:", type(testfunc))
print("Is testfunc a function?:", isinstance(testfunc, types.FunctionType))
print("Is testfunc an int?:", isinstance(testfunc, int))
print("\n")
# None instance test
e = None
print("e's class:", type(e))
print("Is e a None?:", isinstance(e, types.NoneType))testfunc's class: <class 'function'>
Is testfunc a function?: True
Is testfunc an int?: False
e's class: <class 'NoneType'>
Is e a None?: True
Let’s instantiate a SampleBase object. The process is no different than calling a function. Let’s create the
following sample (these are the same characteristics from the start of the lesson):
Sample name: Sample 1
Thickness: 5.2 mm
Width: 2 mm
Instrument: Tester 1
Measurement date: 2025.08.07
Measured yield force: 1000 N
We issue the following command to create an object called sample1 from our SampleBase class:
sample1 = SampleBase(name="Sample 1", yield_force=1000, thickness=5.2, width=2)Congrats! You just created your first instanced version of SampleBase! Let’s confirm sample1’s data type:
print(type(sample1))
print(isinstance(sample1, SampleBase))<class '__main__.SampleBase'>
True
Sure enough, both outputs state that the class for sample1 is SampleBase (side note: the __main__ prefix from the
type() return 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__',
'__firstlineno__',
'__format__',
'__ge__',
'__getattribute__',
'__getstate__',
'__gt__',
'__hash__',
'__init__',
'__init_subclass__',
'__le__',
'__lt__',
'__module__',
'__ne__',
'__new__',
'__reduce__',
'__reduce_ex__',
'__repr__',
'__setattr__',
'__sizeof__',
'__static_attributes__',
'__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)
|
| A docstring for a class!
|
| 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. Used to instantiate an
| 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
|
| __weakref__
| list of weak references to the object
|
| ----------------------------------------------------------------------
| 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 calling .__dict__ should get us 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.
11.10Getting attributes¶
Recall that to access (i.e., to “get”) an attribute we use the following command structure:
object.attributeLet’s get sample1’s attributes:
print("Name:", sample1.name)
print("Yield force:", sample1.yield_force, "N")
print("Thickness:", sample1.thickness, "mm")
print("Width:", sample1.width, "mm")
print("Device:", sample1.device)Name: Sample 1
Yield force: 1000 N
Thickness: 5.2 mm
Width: 2 mm
Device: Tester 1
11.11Setting attributes¶
We can modify the value of an attribute (called “set” in coding) by using the command structure,
object.attribute = valueFor example, let’s change the thickness of sample1 to 4.3 mm:
sample1.thickness = 4.3
print("Sample 1 thickness:", sample1.thickness)Sample 1 thickness: 4.3
Notice that we do not include the self command as an input argument. This is only done when we define the method.
Getting and setting attributes is a key tenet to OOP. We will revisit these concepts in an upcoming lesson !
11.12Calling methods¶
Let’s try to get the yield stress from the .yield_stress attribute:
print(f"Yield stress: {sample1.yield_stress} MPa")---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
Cell In[36], line 1
----> 1 print(f"Yield stress: {sample1.yield_stress} MPa")
AttributeError: 'SampleBase' object has no attribute 'yield_stress'The yield stress was not automatically calculated when sample1 was created since yield_stress was not part of
.__init__(). We need to run the .yield_stress() method first!
sample1.calc_yield_stress()
print(f"Yield stress: {sample1.yield_stress} MPa")Yield stress: 116.279 MPa
A similar issue exists for the .date attribute. We need to call .entry_date() method first!
sample1.entry_date("2025.08.07")
print("Sample 1 entry date:", sample1.date)Sample 1 entry date: 2025.08.07
11.13Creating more objects¶
Congratulations! We are able to now create a database for our scenario that links all sample characteristics together and easily create additional objects. To prove this, let’s 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: 2025.08.12
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("2025.08.12")
print(sample2.__dict__){'name': 'Sample 2', 'yield_force': 1315, 'thickness': 4.5, 'width': 2.2, 'yield_stress': 132.828, 'date': '2025.08.12'}
11.13.1Example: The characteristics of a cylinder¶
A cylinder is a simple three-dimensional object that is described with its height and radius. The volume, , of a cylinder is,
where is the radius and is the height. Furthermore, its surface area, , is,
This exercise will have you practice creating classes, instantiating an object, and calling both attributes and
methods. First, create a class called Cylinder that represents a cylindrical object. Have the height and radius of
the cylinder be two input arguments assigned 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 and a height of . 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
and reprint the four metrics.
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.
Parameters
----------
radius : float
The radius of the cylinder. Units: cm
height : float
The height of the cylinder. Units: cm
"""
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.
Return
------
volume : float
Volume of the cylinder. Units: 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.
Return
------
surface_area : float
Surface area of the cylinder. Units: cm^2
"""
self.surface_area = 2 * pi * self.radius * (self.radius + self.height)
return self.surface_areaNext, 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 getting attributes 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 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
11.14Creating classes from classes: inheritance¶
Many programming languages (including 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 for situations where a new class is a modified version of a 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.
Creating a child class is straightforward in Python. Following our previous example as a guide, let’s 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):
"""
Child class 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 = temperatureNotice 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 = temperatureIn 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: 2025.08.23
Measured yield force: 1235.3 N
Measurement temperature: 350 K
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)
|
| Child class 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
|
| __weakref__
| list of weak references to the object
|
| ----------------------------------------------------------------------
| 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()
sample3.entry_date("2025.08.23")
print(sample3.__dict__){'name': 'Sample 3', 'yield_force': 1235.3, 'thickness': 5.1, 'width': 1.8, 'temperature': 350, 'yield_stress': 134.564, 'date': '2025.08.23'}
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.
11.15Polymorphism¶
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.
Let’s 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 create a new entry (let’s call in sample4) with the following characteristics:
Sample name: Sample 4
Thickness: 5.4 mm
Width: 2.2 mm
Instrument: Tester 1
Measurement date: 2025.09.03
Measured yield force: 983.4 N
The code block below creates sample4 and runs .entry_date() to demonstrate polymorphism in action:
sample4 = SampleNeo(name="Sample 4", yield_force=983.4, thickness=5.4, width=2.1)
sample4.entry_date("2025.09.03")Measurement date for Sample 4 was 2025.09.03.
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("2025.08.07")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.
11.16Final 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 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, 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. 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.