Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

12 Object-Oriented Programming: Advanced Getting and Setting

12.1Lesson goals

12.2Overview

Now that we know the basics in creating and using classes, let’s dive deeper into how attribute referencing works in Python. We will first compare and contrast the difference between instanced and class-based attributes. Then we will look at more advanced ways we can get and set object attributes.

12.3Instanced vs. class attributes

The last lesson spent some time covering the basic definitions of classes and objects. Recall that classes provide templates and means to create new objects through a process known as instantiation. Each instanced / created object is unique and can have attributes that a unique to itself (known as instanced attributes) or can share common attributes across the entire class (known as class attributes).

To see how this works out, let’s look at the following class:

class Sample():
    # class attribute
    material = "steel"

    # initialization of instanced object and attribute
    def __init__(self, yield_stress):
        self.yield_stress = yield_stress    #units: MPa

The Sample class described above has two attributes. The material attribute is a class attribute and is shared across objects that are instantiated from Sample. The yield_stress attribute, on the other hand, is an instanced attribute. Each Sample-based object will have its own unique value and memory location for .yield_stress. We know this because of the use of the self keyword in the __init__() method and in the line

self.yield_stress = yield_stress    #units: MPa

To see this in practice, let’s create two Sample-based objects and check their .yield_stress attributes:

# Create two objects
sample1 = Sample(yield_stress=281.2)
sample2 = Sample(yield_stress=322.5)

# Get the yield stresses
print("sample1's yield stress value:", sample1.yield_stress)
print("sample2's yield stress value:", sample2.yield_stress)
print("\n")

# Check memory location for the yield stress attributes
print("sample1's yield stress memory location:", id(sample1.yield_stress))
print("sample2's yield stress memory location:", id(sample2.yield_stress))
sample1's yield stress value: 281.2
sample2's yield stress value: 322.5


sample1's yield stress memory location: 140619139275280
sample2's yield stress memory location: 140619140540080

Each .yield_stress attribute has its own unique “value” (the data) and “identity” (memory location). Changing one object’s attribute does not affect the others:

# Change `sample2`'s yield stress (set command) and recheck
sample2.yield_stress = 351.6

print("sample1's yield stress value:", sample1.yield_stress)
print("sample2's yield stress value:", sample2.yield_stress)
print("\n")
print("sample1's yield stress memory location:", id(sample1.yield_stress))
print("sample2's yield stress memory location:", id(sample2.yield_stress))
sample1's yield stress value: 281.2
sample2's yield stress value: 351.6


sample1's yield stress memory location: 140619139275280
sample2's yield stress memory location: 140619139277840

Now let’s look at the shared class attribute .material:

print("sample1's material value:", sample1.material)
print("sample2's material value:", sample2.material)
print("\n")

print("sample1's material memory location:", id(sample1.material))
print("sample2's material memory location:", id(sample2.material))
sample1's material value: steel
sample2's material value: steel


sample1's material memory location: 140619109627776
sample2's material memory location: 140619109627776

Notice that both .material attributes share a common memory identity! Let’s also look at the memory location of .material for the Sample class (remember that all data in Python are objects, including classes!). We do this using the command structure:

Class.attribute

So we should be able to access the class attribute through the variable name Sample.material. Let’s compare the value and memory location between all three objects:

print("sample1's material value:", sample1.material)
print("sample2's material value:", sample2.material)
print("Class's material value:", Sample.material)
print("\n")

print("sample1's material memory location:", id(sample1.material))
print("sample2's material memory location:", id(sample2.material))
print("Class's material memory location:", id(Sample.material))
sample1's material value: steel
sample2's material value: steel
Class's material value: steel


sample1's material memory location: 140619109627776
sample2's material memory location: 140619109627776
Class's material memory location: 140619109627776

As seen above, all three attributes have the same value and memory location because they refer to a class attribute. Weird things can happen if you are not careful when setting class attributes. Below are two examples in which the .material attribute is changed:

# Example 1: Changing the class attribute for all objects
print("Example 1")
Sample.material = "copper"
print("Class's material value:", Sample.material)
print("sample1's material value:", sample1.material)
print("sample2's material value:", sample2.material)
print("\n")
print("Class's material memory location", id(Sample.material))
print("sample1's material memory location:", id(sample1.material))
print("sample2's material memory location:", id(sample2.material))

print("\n")

# Example 2: Changing a class attribute to an instanced attribute
print("Example 2")
sample2.material = "aluminum"
print("Class's material value:", Sample.material)
print("sample1's material value:", sample1.material)
print("sample2's material value:", sample2.material)
print("\n")
print("Class's material memory location", id(Sample.material))
print("sample1's material memory location:", id(sample1.material))
print("sample2's material memory location:", id(sample2.material))
Example 1
Class's material value: copper
sample1's material value: copper
sample2's material value: copper


Class's material memory location 140619109896032
sample1's material memory location: 140619109896032
sample2's material memory location: 140619109896032


Example 2
Class's material value: copper
sample1's material value: copper
sample2's material value: aluminum


Class's material memory location 140619109896032
sample1's material memory location: 140619109896032
sample2's material memory location: 140619109939696

The first example changes the value of .material at the class level. This causes all Sample-based objects to also change their .material value because they are referencing a class attribute. The second example is a bit odder as the change to .material is done at the sample2 level via the command:

sample2.material = "aluminum"

Naively, one could guess that if .material is a class attribute, this route should also change the value for all objects and the class. However, this instead converts sample2.material an instanced attribute. The change in the memory location for sample2.material compared to both sample1.material and Sample.material confirms this. Therefore we converted sample2.material to an instanced attribute by setting its value at the instanced object level. It is important to appreciate the separation between class-based and instanced-based attributes when programming using an OOP mindset.

12.4Advanced getting and setting using properties

So far, we have shown how to get an attribute using the command structure:

object.attribute

and we can set and attribute using:

object.attribute = value

These commands and short and easy to read. However, there is a lot more we can do with getting and setting. To see this, let’s create a class that represents a measurement device. The object-oriented programming paradigm works well when controlling instruments as get calls are used to have devices report a measurement and set calls are used to change the configuration of the device. Usually these instrument classes will have commands that control the instrument, but for this exercise let’s simply model an instrument’s behavior.

12.4.1The setup: A rotational viscometer class

Let’s model a rotational viscometer, which is a device that is often used to assess the viscosity of a liquid. The device does this by measuring the amount rotational torque needed to rotate a spindle at a desired rotation rate when immersed in a liquid. For this exercise, let’s imagine that our device does the following:

Our first objective should be to create a device class for the viscometer. For now, let’s simply create a class called SpinningBeagle with just an initialization method and a class docstring. From there, let’s create an object called viscometer that is based on this class. The code block below sets up the basic structure:

# Create the class and add a docstring:

class SpinningBeagle():
    """
    Class that represents an Electric Beagle Industries Spinning Beagle 
    rotational viscometer.
    """

    def __init__(self):
        pass

#-------------------------------------------------------------------------------
# Testing the instrument
#-------------------------------------------------------------------------------
viscometer = SpinningBeagle()

print(type(viscometer))
<class '__main__.SpinningBeagle'>

Great! We got a working object. Let’s now create an attribute called .rotation_speed that represents the rotation speed in units of rpm \text{rpm}. For this example, let’s require that the rotation speed must be initialized when creating a SpinningBeagle object. The code below adds this attribute to our current setup:

# Add a rotation speed attribute

class SpinningBeagle():
    """
    Class that represents an Electric Beagle Industries Spinning Beagle 
    rotational viscometer.
    """

    def __init__(self, speed):
        self.rotation_speed = speed

#-------------------------------------------------------------------------------
# Testing the instrument
#-------------------------------------------------------------------------------
viscometer = SpinningBeagle(speed=0.0)

print("Current rotation speed:", viscometer.rotation_speed, "rpm")
viscometer.rotation_speed = 10.0
print("Current rotation speed:", viscometer.rotation_speed, "rpm")
Current rotation speed: 0.0 rpm
Current rotation speed: 10.0 rpm

Now let’s add the measure torque functionality. Since it is based on the current rotation speed via an equation, we can’t use a simple data attribute based on what we know so far. So let’s add a method called .torque():

# Add the measure torque method attribute

class SpinningBeagle():
    """
    Class that represents an Electric Beagle Industries Spinning Beagle 
    rotational viscometer.
    """

    def __init__(self, speed):
        self.rotation_speed = speed
    
    def torque(self):
        return (9.3E-6 * self.rotation_speed)

#-------------------------------------------------------------------------------
# Testing the instrument
#-------------------------------------------------------------------------------
viscometer = SpinningBeagle(speed=0.0)

print("Current rotation speed:", viscometer.rotation_speed, "rpm")
viscometer.rotation_speed = 10.0
print("Current rotation speed:", viscometer.rotation_speed, "rpm")
print("Current torque:", round(viscometer.torque(),9), "N*m")
Current rotation speed: 0.0 rpm
Current rotation speed: 10.0 rpm
Current torque: 9.3e-05 N*m

Finally, let’s add a way for the instrument to identify itself with a method called .id(). We will need some class attributes for the manufacturer and model names and an instanced attribute for the serial number:

# Adding an id() command

class SpinningBeagle():
    """
    Class that represents an Electric Beagle Industries Spinning Beagle 
    rotational viscometer.
    """

    # Class attributes as all viscometers have same model and manufacturer
    manufacturer = "Electric Beagle Industries"
    model = "Spinning Beagle"

    def __init__(self, speed, serial="553205"):
        self.serial_no = serial  # Default value provided in argument
        self.rotation_speed = speed
    
    def torque(self):
        return (9.3E-6 * self.rotation_speed)
    
    def id(self):
        return (self.manufacturer + ", " + self.model + ", " + self.serial_no)

#-------------------------------------------------------------------------------
# Testing the instrument
#-------------------------------------------------------------------------------
viscometer = SpinningBeagle(speed=0.0)

print("ID:", viscometer.id())
print("Current rotation speed:", viscometer.rotation_speed, "rpm")
viscometer.rotation_speed = 10.0
print("Current rotation speed:", viscometer.rotation_speed, "rpm")
print("Current torque:", round(viscometer.torque(),9), "N*m")
print("Current rotation speed:", viscometer.rotation_speed, "rpm")
ID: Electric Beagle Industries, Spinning Beagle, 553205
Current rotation speed: 0.0 rpm
Current rotation speed: 10.0 rpm
Current torque: 9.3e-05 N*m
Current rotation speed: 10.0 rpm

Congrats! We have built a “working” fake viscometer! We will use this class for the rest of the lesson as we learn about more advanced ways to get and set attributes.

12.4.2Issues with simple attributes and methods

We currently have a perfectly cromulent fake instrument that can identity itself, get and set a rotation speed, and measure a torque. However, there are few issues though with our setup. For example, we can set a rotation speed outside of the 0200 rpm0 - 200 \text{ rpm} requirement:

viscometer.rotation_speed = 1.2E6
print("Current rotation speed:", viscometer.rotation_speed, "rpm")
viscometer.rotation_speed = -53.2
print("Current rotation speed:", viscometer.rotation_speed, "rpm")
Current rotation speed: 1200000.0 rpm
Current rotation speed: -53.2 rpm

We can even use non-numeric values for the rotation speed:

viscometer.rotation_speed = "Woof"
print("Current rotation speed:", viscometer.rotation_speed, "rpm")
Current rotation speed: Woof rpm

Furthermore, our measure torque command is a method right now. Even though this works, it would be more desirable to make this an attribute like .rotation_speed for consistency.

Let’s focus on the first two issues that “revolve” around the rotation speed issues. We need to create code that checks the rotation speed to make sure it is a number between 0200 rpm0 - 200 \text{ rpm}. Let’s first try this using methods:

class SpinningBeagle():
    """
    Class that represents an Electric Beagle Industries Spinning Beagle 
    rotational viscometer.
    """

    # Class attributes as all viscometers have same model and manufacturer
    manufacturer = "Electric Beagle Industries"
    model = "Spinning Beagle"

    def __init__(self, speed, serial="553205"):
        self.serial_no = serial     # Default value provided in argument
        self._rotation_speed = None # Hidden attribute for calcs.
        self.set_speed(speed)       # Check data type and value -> set speed
  
    def get_speed(self):
         return self._rotation_speed
    
    def set_speed(self, speed):
        # Check if data type is castable as float
        try:
            isinstance(float(speed), float)
        except:
            raise TypeError("Rotation speed needs to be a number between " \
            "0 - 200 rpm.")
        # Now check if value is between 0 - 200 rpm
        if (speed >= 0.0) and (speed <= 200.0):
            self._rotation_speed = speed
        else:
            raise ValueError("Rotation speed needs to be between 0 - 200 rpm.")
    
    def torque(self):
        return (9.3E-6 * self._rotation_speed)
    
    def id(self):
        return (self.manufacturer + ", " + self.model + ", " + self.serial_no)

Notice a hidden attribute (i.e., ._rotation_speed) is used to store the actual value. While not needed, we initialize ._rotation_speed to None for readability purposes. Let’s see if our class works:

#-------------------------------------------------------------------------------
# Testing the instrument - Wrong data type
#-------------------------------------------------------------------------------
viscometer2 = SpinningBeagle(speed="hi")
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[13], line 24, in SpinningBeagle.set_speed(self, speed)
     22             isinstance(float(speed), float)
     23         except:
---> 24             raise TypeError("Rotation speed needs to be a number between " \
     25             "0 - 200 rpm.")

ValueError: could not convert string to float: 'hi'

During handling of the above exception, another exception occurred:

TypeError                                 Traceback (most recent call last)
Cell In[14], line 4
      1 #-------------------------------------------------------------------------------
      2 # Testing the instrument - Wrong data type
      3 #-------------------------------------------------------------------------------
----> 4 viscometer2 = SpinningBeagle(speed="hi")

Cell In[13], line 14, in SpinningBeagle.__init__(self, speed, serial)
     11     def __init__(self, speed, serial="553205"):
     12         self.serial_no = serial     # Default value provided in argument
     13         self._rotation_speed = None # Hidden attribute for calcs.
---> 14         self.set_speed(speed)       # Check data type and value -> set speed

Cell In[13], line 24, in SpinningBeagle.set_speed(self, speed)
     20         # Check if data type is castable as float
     21         try:
     22             isinstance(float(speed), float)
     23         except:
---> 24             raise TypeError("Rotation speed needs to be a number between " \
     25             "0 - 200 rpm.")
     26         # Now check if value is between 0 - 200 rpm
     27         if (speed >= 0.0) and (speed <= 200.0):

TypeError: Rotation speed needs to be a number between 0 - 200 rpm.

Good, now let’s check if we put a value outside the 0200 rpm0 - 200 \text{ rpm} range:

#-------------------------------------------------------------------------------
# Testing the instrument - Outside setting range
#-------------------------------------------------------------------------------
viscometer3 = SpinningBeagle(speed=5000)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[15], line 4
      1 #-------------------------------------------------------------------------------
      2 # Testing the instrument - Outside setting range
      3 #-------------------------------------------------------------------------------
----> 4 viscometer3 = SpinningBeagle(speed=5000)

Cell In[13], line 14, in SpinningBeagle.__init__(self, speed, serial)
     11     def __init__(self, speed, serial="553205"):
     12         self.serial_no = serial     # Default value provided in argument
     13         self._rotation_speed = None # Hidden attribute for calcs.
---> 14         self.set_speed(speed)       # Check data type and value -> set speed

Cell In[13], line 30, in SpinningBeagle.set_speed(self, speed)
     26         # Now check if value is between 0 - 200 rpm
     27         if (speed >= 0.0) and (speed <= 200.0):
     28             self._rotation_speed = speed
     29         else:
---> 30             raise ValueError("Rotation speed needs to be between 0 - 200 rpm.")

ValueError: Rotation speed needs to be between 0 - 200 rpm.

Good! Final check, let’s try to enter a bad speed after instantiation:

#-------------------------------------------------------------------------------
# Testing the instrument - Bad speed update
#-------------------------------------------------------------------------------
viscometer4 = SpinningBeagle(speed=0.0)
print("Current rotation speed:", viscometer4.get_speed(), "rpm")

viscometer4.set_speed(23.3)
print("Current rotation speed:", viscometer4.get_speed(), "rpm")

viscometer4.set_speed(-64.2)
print("Current rotation speed:", viscometer4.get_speed(), "rpm")
Current rotation speed: 0.0 rpm
Current rotation speed: 23.3 rpm
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[16], line 10
      6 
      7 viscometer4.set_speed(23.3)
      8 print("Current rotation speed:", viscometer4.get_speed(), "rpm")
      9 
---> 10 viscometer4.set_speed(-64.2)
     11 print("Current rotation speed:", viscometer4.get_speed(), "rpm")

Cell In[13], line 30, in SpinningBeagle.set_speed(self, speed)
     26         # Now check if value is between 0 - 200 rpm
     27         if (speed >= 0.0) and (speed <= 200.0):
     28             self._rotation_speed = speed
     29         else:
---> 30             raise ValueError("Rotation speed needs to be between 0 - 200 rpm.")

ValueError: Rotation speed needs to be between 0 - 200 rpm.

Great! We have a method-based route to ensure that the correct rotation speeds are entered. We have made our code a bit more resilient to error.

While the code block above works, it is a bit cumbersome. The idea of adding “functionality” to our attributes (i.e., make our attributes behave more like functions), however, is useful. Python provides a means to do this through the use of the property() class.

A property can be thought of as a “managed attribute” where we control how to get and set an attribute’s value through the use of methods (i.e., functions!). The property() class has four arguments. The first argument, fget, declares the get function for the attribute. This function is used to recall the attribute’s value and provides us flexibility in how we want to present the value to the shell. The fset argument defines the set function. This function is used when changing the attribute’s value and is useful when we want to apply checks to a potential value to set. The third argument, doc, allows us to set a docstring for the attribute, which is a very nice addition for program documentation. The default value for all three arguments is None.

Let’s see how this works with our device:

# Using `property()` to manage attributes

class SpinningBeagle():
    """
    Class that represents an Electric Beagle Industries Spinning Beagle 
    rotational viscometer.
    """
    
   # Class attributes as all viscometers have same model and manufacturer
    manufacturer = "Electric Beagle Industries"
    model = "Spinning Beagle"

    def __init__(self, speed, serial="553205"):
        self.serial_no = serial     # Default value provided in argument
        self._rotation_speed = None # Hidden attribute for calcs.
        self.rotation_speed = speed
    
    def get_speed(self):
         return self._rotation_speed
    
    def set_speed(self, speed):
        try:
            isinstance(float(speed), float)
        except:
            raise TypeError("Rotation speed needs to be a number between " \
            "0 - 200 rpm.")
        if (speed >= 0.0) and (speed <= 200.0):
            self._rotation_speed = speed
        else:
            raise ValueError("Rotation speed needs to be between 0 - 200 rpm.")
    
    # Property call
    rotation_speed = property(fget=get_speed,
                              fset=set_speed,
                              doc="Rotation speed of the viscometer " \
                              "(units: rpm).")

    def torque(self):
        return (9.3E-6 * self._rotation_speed)
    
    def id(self):
        return (self.manufacturer + ", " + self.model + ", " + self.serial_no)

Again, the hidden attribute ._rotation_speed is key here as it acts as the actual location for the rotation speed. The attribute .rotation_speed is set and updated through the use of this hidden variable. This also prevents an infinite loop issue during the set command!

Let’s check to see our class works:

#-------------------------------------------------------------------------------
# Testing the instrument - Valid creation and update
#-------------------------------------------------------------------------------
viscometer = SpinningBeagle(speed=2.0)
print("Current rotation speed:", viscometer.rotation_speed, "rpm")
viscometer.rotation_speed = 11.5
print("Current rotation speed:", viscometer.rotation_speed, "rpm")

#-------------------------------------------------------------------------------
# Testing the instrument - Invalid speed
#-------------------------------------------------------------------------------
viscometer2 = SpinningBeagle(speed="hi")
Current rotation speed: 2.0 rpm
Current rotation speed: 11.5 rpm
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[18], line 25, in SpinningBeagle.set_speed(self, speed)
     23             isinstance(float(speed), float)
     24         except:
---> 25             raise TypeError("Rotation speed needs to be a number between " \
     26             "0 - 200 rpm.")

ValueError: could not convert string to float: 'hi'

During handling of the above exception, another exception occurred:

TypeError                                 Traceback (most recent call last)
Cell In[19], line 12
      8 
      9 #-------------------------------------------------------------------------------
     10 # Testing the instrument - Invalid speed
     11 #-------------------------------------------------------------------------------
---> 12 viscometer2 = SpinningBeagle(speed="hi")

Cell In[18], line 16, in SpinningBeagle.__init__(self, speed, serial)
     13     def __init__(self, speed, serial="553205"):
     14         self.serial_no = serial     # Default value provided in argument
     15         self._rotation_speed = None # Hidden attribute for calcs.
---> 16         self.rotation_speed = speed

Cell In[18], line 25, in SpinningBeagle.set_speed(self, speed)
     21     def set_speed(self, speed):
     22         try:
     23             isinstance(float(speed), float)
     24         except:
---> 25             raise TypeError("Rotation speed needs to be a number between " \
     26             "0 - 200 rpm.")
     27         if (speed >= 0.0) and (speed <= 200.0):
     28             self._rotation_speed = speed

TypeError: Rotation speed needs to be a number between 0 - 200 rpm.

Everything seems to work but now in a more “Pythonic” way of managing attributes!

12.4.4Decorators with property()

A more modern way to use property() is through the use of a decorator. A decorator is a change in the Python syntax to allow for a more convenient way to create and modify functions, methods, and classes. It is primarily done for readability purposes.

A decorator is noted with the @ character followed by object it will decorate (in our case property). It is easier to understand how this works by example. Let’s decorate our .rotation_speed attribute:

# Using decorators with `property()`

class SpinningBeagle():
    """
    Class that represents an Electric Beagle Industries Spinning Beagle 
    rotational viscometer.
    """
    # Class attributes as all viscometers have same model and manufacturer
    manufacturer = "Electric Beagle Industries"
    model = "Spinning Beagle"

    def __init__(self, speed, serial="553205"):
        self.serial_no = serial     # Default value provided in argument
        self._rotation_speed = None # Hidden attribute for calcs.
        self.rotation_speed = speed
    
    # Creation of the `rotation_speed` attribute via decorator
    # This is also the getter and note the docstring is entered here too!
    @property
    def rotation_speed(self):
        """Rotation speed of the viscometer (units: rpm)."""
        return self._rotation_speed
    
    # This is the setter for `rotation_speed`. Note the format of the decorator
    @rotation_speed.setter
    def rotation_speed(self, speed):
        try:
            isinstance(float(speed), float)
        except:
            raise TypeError("Rotation speed needs to be a number between " \
            "0 - 200 rpm.")
        if (speed >= 0.0) and (speed <= 200.0):
            self._rotation_speed = speed
        else:
            raise ValueError("Rotation speed needs to be between 0 - 200 rpm.")
        
    def torque(self):
        return (9.3E-6 * self._rotation_speed)
    
    def id(self):
        return (self.manufacturer + ", " + self.model + ", " + self.serial_no)

Again, let’s test this out:

#-------------------------------------------------------------------------------
# Testing the instrument - Valid creation and update
#-------------------------------------------------------------------------------
viscometer = SpinningBeagle(speed=2.0)
print("Current rotation speed:", viscometer.rotation_speed, "rpm")
viscometer.rotation_speed = 11.5
print("Current rotation speed:", viscometer.rotation_speed, "rpm")

#-------------------------------------------------------------------------------
# Testing the instrument - Invalid speed
#-------------------------------------------------------------------------------
viscometer2 = SpinningBeagle(speed="hi")
Current rotation speed: 2.0 rpm
Current rotation speed: 11.5 rpm
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[20], line 30, in SpinningBeagle.rotation_speed(self, speed)
     28             isinstance(float(speed), float)
     29         except:
---> 30             raise TypeError("Rotation speed needs to be a number between " \
     31             "0 - 200 rpm.")

ValueError: could not convert string to float: 'hi'

During handling of the above exception, another exception occurred:

TypeError                                 Traceback (most recent call last)
Cell In[21], line 12
      8 
      9 #-------------------------------------------------------------------------------
     10 # Testing the instrument - Invalid speed
     11 #-------------------------------------------------------------------------------
---> 12 viscometer2 = SpinningBeagle(speed="hi")

Cell In[20], line 15, in SpinningBeagle.__init__(self, speed, serial)
     12     def __init__(self, speed, serial="553205"):
     13         self.serial_no = serial     # Default value provided in argument
     14         self._rotation_speed = None # Hidden attribute for calcs.
---> 15         self.rotation_speed = speed

Cell In[20], line 30, in SpinningBeagle.rotation_speed(self, speed)
     26     def rotation_speed(self, speed):
     27         try:
     28             isinstance(float(speed), float)
     29         except:
---> 30             raise TypeError("Rotation speed needs to be a number between " \
     31             "0 - 200 rpm.")
     32         if (speed >= 0.0) and (speed <= 200.0):
     33             self._rotation_speed = speed

TypeError: Rotation speed needs to be a number between 0 - 200 rpm.

Everything seems to be working still and now with more readability!

12.5Creating read-only attributes

The property class route also allows us to create read-only attributes since we can control the set command. We can use this to create a read-only .torque attribute. The code block below demonstrates this:

# Creating an read-only `torque` attribute

class SpinningBeagle():
    """
    Class that represents an Electric Beagle Industries Spinning Beagle 
    rotational viscometer.
    """
    # Class attributes as all viscometers have same model and manufacturer
    manufacturer = "Electric Beagle Industries"
    model = "Spinning Beagle"

    def __init__(self, speed, serial="553205"):
        self.serial_no = serial     # Default value provided in argument
        self._rotation_speed = None # Hidden attribute for calcs.
        self.rotation_speed = speed
    
    # Creation of the `rotation_speed` attribute via decorator
    # This is also the getter and note the docstring is entered here too!
    @property
    def rotation_speed(self):
        """Rotation speed of the viscometer (units: rpm)."""
        return self._rotation_speed
    
    # This is the setter for `rotation_speed`. Note the format of the decorator
    @rotation_speed.setter
    def rotation_speed(self, speed):
        try:
            isinstance(float(speed), float)
        except:
            raise TypeError("Rotation speed needs to be a number between " \
            "0 - 200 rpm.")
        if (speed >= 0.0) and (speed <= 200.0):
            self._rotation_speed = speed
        else:
            raise ValueError("Rotation speed needs to be between 0 - 200 rpm.")

    @property
    def torque(self):
        """ The torque applied by the viscometer (units: N*m)."""
        return (9.3E-6 * self._rotation_speed)

    def id(self):
        return (self.manufacturer + ", " + self.model + ", " + self.serial_no)

So all we needed to do is create the getter using the property decorator and not include a .setter entry. This works because the default value in fset for property() is None. Let’s see it in action:

#-------------------------------------------------------------------------------
# Testing the instrument - read-only torque attribute
#-------------------------------------------------------------------------------
viscometer = SpinningBeagle(speed=20.0)
print("Current rotation speed:", viscometer.rotation_speed, "rpm")
print("Current torque value:", round(viscometer.torque,8), "N*m")

# Try to set the torque
viscometer.torque = 50.2
Current rotation speed: 20.0 rpm
Current torque value: 0.000186 N*m
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[23], line 9
      5 print("Current rotation speed:", viscometer.rotation_speed, "rpm")
      6 print("Current torque value:", round(viscometer.torque,8), "N*m")
      7 
      8 # Try to set the torque
----> 9 viscometer.torque = 50.2

AttributeError: property 'torque' of 'SpinningBeagle' object has no setter

Our torque command now acts like an attribute and we get an error if we try to set .torque. We can provide a more human readable error message by including a setter command with a raise and Exception statement:

# Making the `torque` setter error more readable

class SpinningBeagle():
    """
    Class that represents an Electric Beagle Industries Spinning Beagle 
    rotational viscometer.
    """
    # Class attributes as all viscometers have same model and manufacturer
    manufacturer = "Electric Beagle Industries"
    model = "Spinning Beagle"

    def __init__(self, speed, serial="553205"):
        self.serial_no = serial     # Default value provided in argument
        self._rotation_speed = None # Hidden attribute for calcs.
        self.rotation_speed = speed
    
    # Creation of the `rotation_speed` attribute via decorator
    # This is also the getter and note the docstring is entered here too!
    @property
    def rotation_speed(self):
        """Rotation speed of the viscometer (units: rpm)."""
        return self._rotation_speed
    
    # This is the setter for `rotation_speed`. Note the format of the decorator
    @rotation_speed.setter
    def rotation_speed(self, speed):
        try:
            isinstance(float(speed), float)
        except:
            raise TypeError("Rotation speed needs to be a number between " \
            "0 - 200 rpm.")
        if (speed >= 0.0) and (speed <= 200.0):
            self._rotation_speed = speed
        else:
            raise ValueError("Rotation speed needs to be between 0 - 200 rpm.")

    @property
    def torque(self):
        """ The torque applied by the viscometer (units: N*m)."""
        return (9.3E-6 * self._rotation_speed)

    @torque.setter
    def torque(self, value):
        raise Exception("The torque value can only be read.")

    def id(self):
        return (self.manufacturer + ", " + self.model + ", " + self.serial_no)

Let’s re-test one last time to see are more human-readable error.

#-------------------------------------------------------------------------------
# Testing the instrument - read-only torque attribute
#-------------------------------------------------------------------------------
viscometer = SpinningBeagle(speed=20.0)
print("Current rotation speed:", viscometer.rotation_speed, "rpm")
print("Current torque value:", round(viscometer.torque,8), "N*m")

# Try to set the torque
viscometer.torque = 50.2
Current rotation speed: 20.0 rpm
Current torque value: 0.000186 N*m
---------------------------------------------------------------------------
Exception                                 Traceback (most recent call last)
Cell In[25], line 9
      5 print("Current rotation speed:", viscometer.rotation_speed, "rpm")
      6 print("Current torque value:", round(viscometer.torque,8), "N*m")
      7 
      8 # Try to set the torque
----> 9 viscometer.torque = 50.2

Cell In[24], line 44, in SpinningBeagle.torque(self, value)
     42     @torque.setter
     43     def torque(self, value):
---> 44         raise Exception("The torque value can only be read.")

Exception: The torque value can only be read.

Great! Our class now provides a easier to read error message for this read-only attribute.

12.6Final thoughts

So when should you use properties over simple attributes? The choice really depends on your needs. One route is not always better for every application. Simple attributes are easy to implement but offer little control when setting and getting values. The simplicity does allow for easier to read code. Properties provide a means to manage getting, setting, and documentation. This structure is useful for situations in which you need control what type of data is stored and how will it be used. This does increase the complexity of the code. The choice ultimately depends on the complexity of the project and the importance of how the data is stored and accessed. However, knowing how to effectively use attributes and methods is a vital skill in object-oriented programming and dramatically increases your ability to create more complicated code.