PCPT Teaser

Overview and Learning Objectives

This unit introduces the fundamentals of Python classes, a key concept in object-oriented programming used to group data and functionality. Classes are essential when working with modern libraries such as PyTorch, where neural network modules are built as class objects. The learning objectives of this unit are as follows:

  • Define your own Python classes and create class instances using the special __init__() method to initialize attributes.
  • Use and distinguish between instance attributes and class attributes.
  • Write and call methods (functions within classes).
  • Understand how class inheritance works, including the role of super().

This unit includes three hands-on exercises:

  • Exercise 1: Define a simple class for rectangles.
  • Exercise 2: Explore the difference between class and instance attributes.
  • Exercise 3: Build a subclass that extends the functionality of a parent class.

For more details, see The Python Tutorial (Chapter 9).

In [1]:
# --- Custom utilities (Unit 2) ---
from libpcpt.unit02 import (
    exercise_class_rectangle,
    exercise_attributes,
    exercise_inheritance_shape
)

Basic Class Definition¶

In object-oriented programming (OOP) such as Python, a class provides a structured way to combine data and related functionality. It can be thought of as a blueprint for creating objects that share the same properties and behaviors. When you create an object from a class, Python automatically runs a special setup process called the constructor, which prepares the new object and gives it its initial state. In this sense, the class defines how its objects are built, while an object (or instance) represents a concrete example of that class.

  • Python uses the CamelCase naming convention for classes, where each word starts with a capital letter and words are written together (e.g., MyFirstClass).
  • The constructor method, called __init__(), is automatically executed when a new instance is created. It defines what happens during object creation, for example, initializing attributes or setting default values.
  • An instance attribute belongs to a specific object and is defined inside the constructor method __init__() using the self keyword. Each object can have its own values for these attributes.
  • A class attribute belongs to the class itself and is shared across all its instances. It is defined directly inside the class body, outside any method.

The following example illustrates how to define a simple class in Python, demonstrating the use of class attributes and instance attributes. The Circle class below is used to compute the area of a circle based on a given radius and a shared class-level attribute for pi.

In [2]:
class Circle:
    """Circle class representing a circle with a given radius."""
    
    # Class attribute shared by all instances
    pi = 3.14159
    
    def __init__(self, r=1):
        # Instance attribute unique to each object
        self.radius = r
        
    def area(self):
        """Compute the area of the circle using the class attribute pi."""
        return self.radius ** 2 * Circle.pi

This example shows that a class can have different types of attributes. For instance, the Circle class includes the attribute Circle.pi, which holds a floating-point value; the attribute Circle.area, which refers to a method (a function object); and the attribute Circle.__doc__, which stores the docstring, a short description defined at the beginning of the class. The next code segment demonstrates how these attributes can be accessed and modified, and how such changes affect different instances of the class.

In [3]:
# Display the class docstring
print(f"Class docstring: {Circle.__doc__}\n")

# Create two Circle instances
c1 = Circle(3)
c2 = Circle(1)

# Show initial pi values and computed areas
print(f"c1.pi = {c1.pi}, c2.pi = {c2.pi}")
print(f"Area for r={c1.radius}: {c1.area()}, Area for r={c2.radius}: {c2.area()}\n")

# Modify the class attribute 'pi'
Circle.pi = 3
print(f"c1.pi = {c1.pi}, c2.pi = {c2.pi}")
print(f"Area for r={c1.radius}: {c1.area()}, Area for r={c2.radius}: {c2.area()}\n")

# Override 'pi' for instance c1 only
c1.pi = 2
print(f"c1.pi = {c1.pi}, c2.pi = {c2.pi}")
print(f"Area for r={c1.radius}: {c1.area()}, Area for r={c2.radius}: {c2.area()}")
print("Note: The method area() refers to Circle.pi, not the instance attribute c1.pi.")

# Restore original value for consistency
Circle.pi = 3.14159
Class docstring: Circle class representing a circle with a given radius.

c1.pi = 3.14159, c2.pi = 3.14159
Area for r=3: 28.27431, Area for r=1: 3.14159

c1.pi = 3, c2.pi = 3
Area for r=3: 27, Area for r=1: 3

c1.pi = 2, c2.pi = 3
Area for r=3: 27, Area for r=1: 3
Note: The method area() refers to Circle.pi, not the instance attribute c1.pi.

In Python, an instance method is a function defined within a class that operates on a specific object (instance) of that class. Its first parameter refers to the calling object and is conventionally named self. This name is not a keyword and can technically be anything, but it must always appear first in the method definition. Using alternative names like self_different_name is valid syntax but strongly discouraged, as it reduces readability and breaks with standard Python style. The example below shows that the code still works with non-standard names, though it is less clear to human readers.

In [4]:
class Circle:
    """Circle class"""

    pi = 3.14159                              
    
    def __init__(self_different_name, r=1):
        # Setting an instance attribute using a non-standard name
        self_different_name.radius = r        
    
    def area(self_another_name):
        """Compute area"""
        return self_another_name.radius ** 2 * Circle.pi

c = Circle(2)
print(c.area())  
12.56636

In general, a function is a block of code that performs a specific task and can be called with inputs (arguments). In Python, when a function is defined inside a class, it is typically referred to as a method. The key difference is:

  • A function is independent and not associated with any particular object.
  • A method is a function that is associated with an object and can access and modify that object's internal data.

For example, Circle.area is a function defined inside the class—it needs to be told explicitly which object to operate on. However, when you create an instance like c = Circle(1), the expression c.area refers to a method—it is the same function, but now automatically linked (or bound) to the object c.

In other words, Circle.area and c.area are not the same: the first is a plain function, while the second is a bound method. Behind the scenes, the instance object c is automatically passed as the first argument to the function, making the following two calls equivalent:

In [5]:
c = Circle(2)
print(f"Call for method object: {c.area()}")
print(f"Call for function object: {Circle.area(c)}")
Call for method object: 12.56636
Call for function object: 12.56636

Both instance and class attributes can be modified after an object is created. Changing an instance attribute affects only that specific object, while changing a class attribute affects all instances—but only if they haven't overridden the attribute at the instance level. The example below shows how changing radius (an instance attribute) and pi (a class attribute) influences the output of the area() method:

In [6]:
c = Circle(1)  # Create an instance with radius = 1

# Set the class attribute pi to 3.14159
Circle.pi = 3.14159

# Change the instance attribute radius to 3
c.radius = 3
print(f"Area with radius={c.radius}, pi={Circle.pi}: {c.area()}")

# Change radius back to 1
c.radius = 1
print(f"Area with radius={c.radius}, pi={Circle.pi}: {c.area()}")

# Change the class attribute pi to 3
Circle.pi = 3
print(f"Area with radius={c.radius}, pi={Circle.pi}: {c.area()}")
Area with radius=3, pi=3.14159: 28.27431
Area with radius=1, pi=3.14159: 3.14159
Area with radius=1, pi=3: 3

In Python, you can delete individual attributes (properties) from an object or delete the entire object itself. Removing an attribute makes it unavailable for further access, while deleting the object frees the memory it occupies. The example below demonstrates deleting an instance attribute and then the object:

In [7]:
c = Circle(3)
print(f"Before deletion: {c.__dict__}")  # Shows instance attributes

# Delete the 'radius' attribute from the object
del c.radius
print(f"After deleting radius: {c.__dict__}")

# Delete the entire object
del c
Before deletion: {'radius': 3}
After deleting radius: {}

The pass statement in Python is a do-nothing placeholder. It allows you to write syntactically correct code where Python expects a block of code but you don't want to do anything yet. This is useful when defining empty classes, functions, or control structures during early development. For example, you can define a class without any attributes or methods like this:

In [8]:
class Nothing:
    pass  # Placeholder for future class content

a = Nothing()
print(a.__dict__)  # Shows an empty dictionary since no attributes are defined
{}

Now that we have seen how classes are defined in Python, let us take a step back to reflect on the broader principles of object-oriented design.

Background: Object-Oriented Thinking

In programming, one powerful way to understand a program is not simply as a sequence of instructions, but as a collection of objects. These objects are self-contained units that store information and know how to perform specific tasks. This way of thinking is known as object-oriented programming (OOP). It encourages organizing code by grouping related data and behavior, making programs easier to design, understand, and extend.

An object combines two key elements: attributes, which describe the object's state or data, and methods, which define its possible actions. For example, consider a smartphone. It has a color, a brand, and a screen size as attributes, and it can take photos, make calls, or play music using its methods. Each individual phone is an object, while the general idea of what makes up a phone is captured in a class, which serves as a blueprint for creating objects.

More generally, the core concept in OOP is the class. A class defines the structure and capabilities of its objects. All instances of the same class follow this structure but may hold different data. Furthermore, classes can use inheritance to build on other classes. This allows a new class to reuse and extend existing functionality, promoting code reuse and conceptual clarity.

Python follows the object-oriented paradigm in a clear and consistent way: almost everything in Python is an object—including numbers, strings, functions, and even classes themselves. Each object holds data (attributes) and defines behavior (methods), which makes Python well-suited for writing structured and reusable code. This design plays a key role in libraries like PyTorch, where neural networks are defined as class-based models. A model typically inherits from torch.nn.Module, with layers and parameters stored as attributes, and actions like forward() implemented as methods. The modular structure allows for flexible composition and extension of models.

By understanding how classes work in Python, you gain a solid foundation for building and customizing deep learning architectures, managing model components, and navigating real-world codebases more effectively.

Special Class Attributes in Python¶

Python classes and objects include several built-in attributes that provide useful information or functionality. These attributes are created automatically and can be accessed using dot notation. Below are some of the most commonly used attributes:

  • __dict__: A dictionary containing all attributes (variables and methods) of a class or instance.
  • __doc__: A string containing the documentation (docstring) of a class, function, or method.
  • __name__: The name of the class.
  • __class__: The class from which an object was created (i.e., its type).

The following example illustrates how some of these attributes work:

In [9]:
# Print the __dict__ of the class (shows methods and class attributes)
print(f"__dict__ for class:\n{Circle.__dict__}\n")

# Create an instance
c = Circle(3)

# Print the __dict__ of the instance (shows instance attributes only)
print(f"__dict__ for instance:\n{c.__dict__}\n")
__dict__ for class:
{'__module__': '__main__', '__doc__': 'Circle class', 'pi': 3, '__init__': <function Circle.__init__ at 0x000001F2C915E980>, 'area': <function Circle.area at 0x000001F2C915F380>, '__dict__': <attribute '__dict__' of 'Circle' objects>, '__weakref__': <attribute '__weakref__' of 'Circle' objects>}

__dict__ for instance:
{'radius': 3}

You can also inspect documentation and class names:

In [10]:
print(Circle.__doc__)         # Docstring of the class
print(c.__doc__)              # Same result (inherits the class docstring)
print(Circle.area.__doc__)    # Docstring of the method
print(c.area.__doc__)         # Same result (method bound to instance)
print(Circle.__name__)        # Name of the class
Circle class
Circle class
Compute area
Compute area
Circle

Finally, the __class__ attribute tells us which class an object was created from. Every Python object uses this information to know what kind of object it is and which methods it can access. For example, an instance of Circle belongs to the class Circle, while the class Circle itself is an object of the special built-in class type. In Python, everything is an object, including classes themselves. The following examples show this for a user-defined class and for some built-in data types.

In [11]:
print(Circle.__class__)  # The class of the class itself (always <class 'type'>)
print(c.__class__)       # The class of the instance (Circle)

value = 10
print(value.__class__)   # int

value = 1.1
print(value.__class__)   # float
<class 'type'>
<class '__main__.Circle'>
<class 'int'>
<class 'float'>

Note: <class 'type'> is a special built-in class called a metaclass. In Python, classes are themselves objects, and every class you define is an instance of the type class.

Inheritance Concepts¶

In object-oriented programming, inheritance is a way to create a new class that reuses code from an existing class. The new class is called the child class (or derived class), and the class it inherits from is called the parent class (or base class). The child class automatically has all the methods and attributes of the parent class. This avoids code duplication and makes programs easier to manage. To define a child class in Python, you write the parent class name in parentheses after the child class name:

In [12]:
class Person:
    # Class attribute shared by all instances
    current_year = 2025

    def __init__(self, first_name, last_name, birth_year):
        self.first_name = first_name
        self.last_name = last_name
        self.birth_year = birth_year

    def age(self):
        return Person.current_year - self.birth_year

    def print_info(self):
        print(f"{self.first_name} {self.last_name} was born in {self.birth_year}.")

# Student inherits everything from Person
class Student(Person):
    pass

# Create a Person instance
p = Person("Michael", "Smith", 1953)
p.print_info()
print(f"Age in {Person.current_year}: {p.age()}\n")

# Create a Student instance (inherits from Person)
s = Student("David", "Wilson", 2003)
s.print_info()
print(f"Age in {Student.current_year}: {s.age()}")
Michael Smith was born in 1953.
Age in 2025: 72

David Wilson was born in 2003.
Age in 2025: 22

Note that a child class can override any method inherited from its parent class. For example, if the child class defines its own __init__() method, it replaces the parent's version. To still make use of the parent's logic while adding extra behavior, you can call the parent's method using the super() function. This ensures the child class inherits and extends the functionality of the parent class, rather than completely replacing it.

In [13]:
class Student(Person):
    def __init__(self, first_name, last_name, birth_year, university):
        # Call the parent class's __init__ method
        super().__init__(first_name, last_name, birth_year)
        # Add a new attribute specific to Student
        self.university = university

    def print_info(self):
        # Call the parent class's print_info method
        super().print_info()
        # Add extra information specific to Student
        print(f"{self.first_name} ({self.age()} years) studies at {self.university}.")
        
# Create a Student object and display its info
s = Student("David", "Wilson", 2003, "FAU")
s.print_info()
David Wilson was born in 2003.
David (22 years) studies at FAU.

Exercise 1: Simple Class

Define a class Rectangle with instance attributes width and height. Add the following methods:

  • area(): returns the area of the rectangle
  • diagonal(): returns the length of the diagonal
  • is_square(): returns True if the rectangle is a square, otherwise False

Test your class with various examples and verify the correctness of each method.

In [14]:
# Your Solution
In [15]:
# Run and show output of the reference solution
exercise_class_rectangle()
Input: r1 = Rectangle(4, 5)
r1.area(): 20
r1.diagonal(): 6.4031242374328485
r1.is_square(): False

Input: r2 = Rectangle(3, 3)
r2.area(): 9
r2.diagonal(): 4.242640687119285
r2.is_square(): True

Exercise 2: Class vs. Instance Attributes

Create a class Counter with a class attribute count initialized to 0, and an instance attribute name that is set when a new instance is created. Each time a new instance is created, increase Counter.count by 1 and print the instance's name along with the current value of Counter.count. Create several instances and observe how the class attribute count is shared by all instances, while the instance attribute name is different for each one.

In [16]:
# Your Solution
In [17]:
# Run and show output of the reference solution
exercise_attributes()
Creating: c1 = Counter("Alpha")
Instance name: Alpha, Counter.count = 1
Creating: c2 = Counter("Beta")
Instance name: Beta, Counter.count = 2
Creating: c3 = Counter("Gamma")
Instance name: Gamma, Counter.count = 3

You can access the class attribute from any instance or directly via the class.
Access via instance (c1.count): 3
Access via class (Counter.count): 3

Exercise 3: Using Inheritance

Create a class Shape and a subclass Circle to explore inheritance and the use of super().

  • Create a class Shape with an attribute name and a method describe() that prints: This is a shape called <name>.
  • Create a subclass Circle that inherits from Shape.
  • In the initialization method (__init__), use super() to initialize name from the parent class, and define an additional attribute radius.
  • Override the describe() method in Circle. Use super().describe() to call the parent method, then print the radius and the computed area of the circle.

This exercise demonstrates how super() is used in two different ways: once to reuse the initialization from the parent class, and once to extend a method.

In [18]:
# Your Solution
In [19]:
# Run and show output of the reference solution
exercise_inheritance_shape()
Input: c = Circle("MyCircle", 2)

Output of c.describe():
This is a shape called 'MyCircle'.
It is a circle with radius 2 and area 12.56636.
PCPT License