Returning to Shapes: An Introduction to Methods

Setup

Create a new directory python/src/shapes_methods and add a new main.py file there. Since you already know @dataclass from Chapter 4, we will use it here. Methods work on dataclasses in exactly the same way as on any class. Copy the following starter code into your file.

from dataclasses import dataclass, field
import math

@dataclass
class Rectangle:
    width: float = 0.0
    height: float = 0.0
    x1: float = 0.0
    y1: float = 0.0
    rotation: float = 0.0

    def __post_init__(self) -> None:
        if self.width < 0.0 or self.height < 0.0:
            raise ValueError("width and height must be nonnegative.")

@dataclass
class Circle:
    x1: float = 0.0
    y1: float = 0.0
    radius: float = 0.0

    def __post_init__(self) -> None:
        if self.radius < 0.0:
            raise ValueError("radius must be nonnegative.")

def main() -> None:
    r = Rectangle(width=3.0, height=4.0)
    c = Circle(x1=0.0, y1=0.0, radius=2.0)
    print(r)
    print(c)

if __name__ == "__main__":
    main()

The __post_init__ method is called automatically by @dataclass immediately after the generated __init__ sets all the attributes. It is a convenient place to add validation logic that should run every time an object is created.

Area methods

In the previous code along, we wanted to compute the areas of our shapes, but since Python does not allow two functions in the same file to share the same name, we were forced to give our area functions different names.

def area_rectangle(r: Rectangle) -> float:
    """
    Compute the area of a given rectangle.
    """
    return r.width * r.height

def area_circle(c: Circle) -> float:
    """
    Compute the area of a circle.
    """
    return 3.0 * (c.radius ** 2)

This works, but it is inelegant. The fact that both shapes have an area is not captured anywhere in our code, and the function names grow unwieldy as we add more shape types. Instead, we will use methods, which are functions that belong to a class and are defined inside the class body. When we include a function inside a class definition, any instance of that class can call it directly using dot notation (e.g., r.area() rather than area_rectangle(r)). Every method takes self as its first parameter, where self refers to the object on which the method is being called. Since our shapes are dataclasses, @dataclass has already generated __init__ and __repr__ for us, so all we need to add are the methods themselves.

@dataclass
class Rectangle:
    width: float = 0.0
    height: float = 0.0
    x1: float = 0.0
    y1: float = 0.0
    rotation: float = 0.0

    def __post_init__(self) -> None:
        if self.width < 0.0 or self.height < 0.0:
            raise ValueError("width and height must be nonnegative.")

    def area(self) -> float:
        """Return the area of the rectangle."""
        return self.width * self.height

Let’s do this for circles also.

@dataclass
class Circle:
    x1: float = 0.0
    y1: float = 0.0
    radius: float = 0.0

    def __post_init__(self) -> None:
        if self.radius < 0.0:
            raise ValueError("radius must be nonnegative.")

    def area(self) -> float:
        """Return the area of the circle."""
        return math.pi * (self.radius ** 2)

Now return to def main(). Notice that when creating instances, we can specify each argument by name. These are called keyword arguments. Keyword arguments are convenient because we do not need to remember the order of the parameters; as long as we provide the names, Python will match them correctly. Also note how we call the area() method: we write r.area() rather than passing r as an argument to a standalone function.

def main() -> None:
    # Create one rectangle and one circle
    r = Rectangle(width=3.0, height=4.0)
    c = Circle(x1=0.0, y1=0.0, radius=2.0)

    # Print the shapes
    print(r)
    print(c)

    # Compute and print their areas
    print("Rectangle area:", r.area())
    print("Circle area:", c.area())

In fact, we have been calling methods throughout this course without necessarily thinking of them as such. When we draw something using pygame, for example, we write:

surface.fill(dark_gray)

pygame.draw.circle(surface, white, (int(x), int(y)), int(radius))

Here, surface.fill() is a method call: fill() is a function defined inside pygame’s Surface class. The second call, pygame.draw.circle(), follows a slightly different pattern: it means “go into the pygame module, then into its draw submodule, and call the circle() function there.” We also have seen this when calling append(), which is a built-in method of Python’s list class.

With methods in hand, we can now state the three key concepts of object-oriented programming.

Object: Collection of variables seen as a whole (noun) 

Attribute: A property of the object (adjective)

Method: An essential action of the object (verb).

Note: A method can also represent an inferable property: one that can be computed directly from the object’s attributes. area() is a good example: rather than storing area as a separate attribute (which would be redundant and could fall out of sync), we compute it on demand from width and height. The method syntax makes this feel just as natural as reading an attribute.

Methods become especially powerful when working with recursive data structures like trees. In the next code-along, every operation we perform on the evolutionary tree (finding the nearest pair of nodes, merging clusters, computing distances) will be a method on Node or Tree. Because each node stores references to its children, a method can call itself on its children: self.child1.some_method(). This is the natural shape of tree algorithms, and it is why object-oriented design and recursive data structures fit together so well.

More methods

Let’s add a translate() method to both classes. Translating a shape means shifting its position by a given offset in the x- and y-directions. Since the position of each shape is stored in its x1 and y1 attributes, we simply update those.

@dataclass
class Rectangle:
    width: float = 0.0
    height: float = 0.0
    x1: float = 0.0
    y1: float = 0.0
    rotation: float = 0.0

    def __post_init__(self) -> None:
        if self.width < 0.0 or self.height < 0.0:
            raise ValueError("width and height must be nonnegative.")

    def area(self) -> float:
        """Return the area of the rectangle."""
        return self.width * self.height

    def translate(self, a: float, b: float) -> None:
        """Move the rectangle by (a, b)."""
        self.x1 += a
        self.y1 += b

Now let’s do the same for circles.

@dataclass
class Circle:
    x1: float = 0.0
    y1: float = 0.0
    radius: float = 0.0

    def __post_init__(self) -> None:
        if self.radius < 0.0:
            raise ValueError("radius must be nonnegative.")

    def area(self) -> float:
        """Return the area of the circle."""
        return math.pi * (self.radius ** 2)

    def translate(self, a: float, b: float) -> None:
        """Move the circle by (a, b)."""
        self.x1 += a
        self.y1 += b

Now return to def main() and call our new methods. Will the shapes actually move?

def main() -> None:
    # Create one rectangle and one circle
    r = Rectangle(width=3.0, height=4.0, x1=0.0, y1=0.0)
    c = Circle(x1=0.0, y1=0.0, radius=2.0)

    # Print the shapes
    print("Original shapes:")
    print(r)
    print(c)

    # Compute and print their areas
    print("Rectangle area:", r.area())
    print("Circle area:", c.area())

    # Translate both shapes
    r.translate(10.0, -5.0)
    c.translate(2.5, 3.5)

    print("\nAfter translation:")
    print(r)
    print(c)
Exercise: Write scale(factor) methods for both Rectangle and Circle. For a rectangle, scale(factor) should multiply both width and height by factor; for a circle, it should multiply radius. In both cases, raise a ValueError if factor is negative.

Here is one solution.

@dataclass
class Rectangle:
    # ... previous attributes and methods ...
    def scale(self, factor: float) -> None:
        """Scale the rectangle's width and height by a given factor."""
        if factor < 0:
            raise ValueError("scale factor must be nonnegative.")
        self.width *= factor
        self.height *= factor
@dataclass
class Circle:
    # ... previous attributes and methods ...
    def scale(self, factor: float) -> None:
        """Scale the circle's radius by a given factor."""
        if factor < 0:
            raise ValueError("scale factor must be nonnegative.")
        self.radius *= factor

Add the following to def main() to test scaling.

    # Scale both shapes
    r.scale(2.0)
    c.scale(1.5)

    print("\nAfter scaling:")
    print(r)
    print(c)
    print("Scaled rectangle area:", r.area())
    print("Scaled circle area:", c.area())

All of Python is pass by object reference

The question is, why are some things pass by reference and others pass by value? The answer is, mutability. User-defined classes are mutable, as are lists and dictionaries, while built-in types like integers and floats and strings are immutable, as are tuples.

  • Mutable objects are those whose value can be modified in place after creation.Examples include:
    • Lists (list)
    • Dictionaries (dict)
    • user defined classes
  • Immutable objects are those whose value cannot be changed after creation. Any operation that appears to modify an immutable object actually creates a new object with the new value. Examples include:
    • Integers (int)
    • Floats (float)
    • Strings (str)
    • Tuples (tuple)

There is a single explanation for all of this: in Python, everything is an object, including immutable things. This is part of why Python is so slow.

Let’s confirm this with a list. Add a change_first() function and call it from main().

def change_first(lst: list[int]) -> None:
    print("Inside before:", lst, id(lst))
    lst[0] = 1
    print("Inside after:", lst, id(lst))

def main() -> None:
    a: list[int] = [0] * 5
    print("Outside before:", a, id(a))
    change_first(a)
    print("Outside after:", a, id(a))

The id of the list is identical inside the function and outside — Python passed a reference to the same object. Mutating lst[0] modifies the object in place, so the change is visible back in main(). The id never changes because we never replaced the list; we only changed its contents.

Python provides a built-in function id() that returns a unique integer identifier for any object, guaranteed to be constant for that object during its lifetime (and tied to its memory address in CPython). Strangely, even when we pass an integer into a function, the identity inside the function is the same as outside, confirming that Python truly passes everything by object reference.

def change_value(x: int) -> None:
    print("Inside before:", x, id(x))

def main() -> None:
    n: int = 5
    print("Outside before:", n, id(n))
    change_value(n)
    print("Outside after:", n, id(n))

But as soon as we modify x inside the function, the id changes. When we write x += 10, a new integer object (with value 15) is created and x is rebound to it. The original object that n refers to is left untouched.

def change_value(x: int) -> None:
    print("Inside before:", x, id(x))
    x += 10                # creates a NEW int object
    print("Inside after:", x, id(x))

def main() -> None:
    n: int = 5
    print("Outside before:", n, id(n))
    change_value(n)
    print("Outside after:", n, id(n))

Now add the following to main().

    y: int = 5
    if id(n) == id(y):
        print("No way, Python")

Even though n and y were declared independently, they share the same id, meaning they point to the exact same object in memory. This is not a coincidence. CPython (the standard Python interpreter, written in C) pre-allocates a fixed pool of small integer objects and reuses them. Whenever your code uses the integer 5, Python returns a reference to that single cached object rather than creating a new one. This optimization, baked into the C implementation itself, is part of why Python’s “everything is an object” model does not carry as heavy a memory cost as you might expect.

Looking ahead

In this lesson, we added methods to our shape dataclasses and explored Python’s pass-by-object-reference model. In the next code-along, we will put these ideas to work by designing the data structures we need to represent evolutionary trees.

Scroll to Top
Programming for Lovers banner no background
programming for lovers logo cropped

Join our community!

programming for lovers logo cropped
Programming for Lovers banner no background

Join our community!