py4u guide

Building Custom Data Types with Python OOP

Python is renowned for its simplicity and readability, partly due to its rich set of built-in data types: `int`, `str`, `list`, `dict`, and more. These types handle most general-purpose tasks, but real-world problems often demand **domain-specific data structures** tailored to unique requirements. For example, a banking application might need a `BankAccount` type to manage balances and transactions, or a game might require a `Vector` type to handle 2D/3D movements. This is where Object-Oriented Programming (OOP) shines. By leveraging Python’s OOP features, you can design **custom data types** that encapsulate data (attributes) and behavior (methods) into reusable, self-contained units called *classes*. These custom types make code more intuitive, maintainable, and aligned with real-world entities. In this blog, we’ll explore how to build custom data types in Python using OOP. We’ll start with OOP fundamentals, then dive into core components like attributes, methods, and special "dunder" methods. We’ll walk through practical examples, discuss inheritance, and share best practices to ensure your custom types are robust and user-friendly.

Table of Contents

  1. Understanding Python OOP Basics
    • 1.1 Classes and Objects
    • 1.2 Attributes and Methods
  2. What Are Custom Data Types?
    • 2.1 Why Build Custom Data Types?
    • 2.2 Built-in vs. Custom Types
  3. Core Components of Custom Data Types
    • 3.1 Attributes: Storing Data
    • 3.2 Methods: Defining Behavior
    • 3.3 Special Methods (Dunder Methods): Emulating Built-ins
  4. Step-by-Step Examples
    • 4.1 Example 1: A 2D Point Type
    • 4.2 Example 2: A Validated Book Type
    • 4.3 Example 3: An Immutable Currency Type
  5. Inheritance: Extending Custom Data Types
    • 5.1 Inheriting from Built-in Types
    • 5.2 Creating Type Hierarchies
  6. Best Practices for Building Custom Data Types
    • 6.1 Encapsulation and Data Protection
    • 6.2 Documentation and Type Hints
    • 6.3 Immutability When Appropriate
    • 6.4 Testing
  7. Conclusion
  8. References

1. Understanding Python OOP Basics

Before building custom data types, let’s recap key OOP concepts in Python.

1.1 Classes and Objects

A class is a blueprint for creating objects. It defines the structure (attributes) and behavior (methods) that all objects of that class will have. An object is an instance of a class— a concrete realization of the blueprint.

For example, a Dog class might define attributes like name and breed, and methods like bark() or fetch(). An object of Dog could be my_dog = Dog(name="Buddy", breed="Golden Retriever").

1.2 Attributes and Methods

  • Attributes: Variables that store data about an object. They can be instance attributes (unique to each object) or class attributes (shared across all instances of the class).

    class Dog:  
        species = "Canis lupus familiaris"  # Class attribute (shared)  
    
        def __init__(self, name: str, breed: str):  # Constructor  
            self.name = name  # Instance attribute (unique to each Dog)  
            self.breed = breed  # Instance attribute  
  • Methods: Functions defined inside a class that operate on objects of that class. They can be instance methods (require self to access object data), class methods (use @classmethod and cls to access class data), or static methods (use @staticmethod and don’t require self or cls).

    class Dog:  
        # ... (attributes as above)  
    
        def bark(self) -> str:  # Instance method  
            return f"{self.name} says woof!"  
    
        @classmethod  
        def get_species(cls) -> str:  # Class method  
            return cls.species  
    
        @staticmethod  
        def is_puppy(age: int) -> bool:  # Static method  
            return age < 1  

2. What Are Custom Data Types?

A custom data type is a user-defined class that acts as a new type in Python. It bundles related data (attributes) and logic (methods) into a single entity, making it easier to model real-world concepts.

2.1 Why Build Custom Data Types?

  • Domain Modeling: Represent real-world entities (e.g., User, Order, Rectangle) directly in code, making logic more readable.
  • Encapsulation: Hide internal details and expose only necessary functionality, reducing complexity.
  • Reusability: Define a type once and reuse it across projects or modules.
  • Type Safety: Enforce validation (e.g., ensuring a BankAccount balance is never negative) at the type level.

2.2 Built-in vs. Custom Types

Built-in types (e.g., list, dict) are general-purpose and flexible, but they lack domain-specific logic. For example:

  • A list can hold any data, but you might need a TodoList that only accepts tasks and has methods like mark_complete().
  • A float can represent any number, but a Temperature type could enforce unit consistency (Celsius/Fahrenheit) and conversion logic.

3. Core Components of Custom Data Types

To build effective custom data types, you’ll need to master three key components: attributes, methods, and special “dunder” methods.

3.1 Attributes: Storing Data

Attributes hold the state of an object. Use:

  • Instance attributes for data unique to each object (e.g., Point.x, Point.y).
  • Class attributes for data shared across all instances (e.g., Temperature.default_unit = "C").
  • Private attributes (prefixed with _) to encapsulate internal state and prevent accidental modification.

3.2 Methods: Defining Behavior

Methods define what an object can do. Use:

  • Instance methods to manipulate object state (e.g., BankAccount.deposit(amount)).
  • Properties (via @property decorator) to control access to attributes and add validation.

Example: A BankAccount with a validated balance:

class BankAccount:  
    def __init__(self, account_holder: str, balance: float = 0.0):  
        self.account_holder = account_holder  
        self._balance = balance  # Private attribute  

    @property  
    def balance(self) -> float:  # Getter  
        return self._balance  

    @balance.setter  
    def balance(self, value: float) -> None:  # Setter with validation  
        if value < 0:  
            raise ValueError("Balance cannot be negative")  
        self._balance = value  

    def deposit(self, amount: float) -> None:  
        if amount <= 0:  
            raise ValueError("Deposit amount must be positive")  
        self._balance += amount  

3.3 Special Methods (Dunder Methods): Emulating Built-ins

Special methods (named with double underscores, e.g., __init__, __add__) let custom types mimic the behavior of built-in types. This makes them intuitive to use.

MethodPurposeExample Usage
__init__(self, ...)Constructor: Initialize a new objectp = Point(2, 3)
__repr__(self)Developer-friendly string representationprint(repr(p))Point(2,3)
__str__(self)User-friendly string representationprint(p)(2, 3)
__add__(self, other)Define additionp1 + p2
__eq__(self, other)Define equality checkp1 == p2
__len__(self)Define length (for container types)len(my_list)

4. Step-by-Step Examples

Let’s build three practical custom data types to solidify these concepts.

4.1 Example 1: A 2D Point Type

A Point type to represent 2D coordinates with arithmetic and comparison support.

from typing import Self  

class Point:  
    def __init__(self, x: float, y: float):  
        self.x = x  # Instance attribute  
        self.y = y  # Instance attribute  

    def __repr__(self) -> str:  
        """Developer-friendly string (unambiguous)."""  
        return f"Point(x={self.x!r}, y={self.y!r})"  

    def __str__(self) -> str:  
        """User-friendly string (readable)."""  
        return f"({self.x}, {self.y})"  

    def __add__(self, other: Self) -> Self:  
        """Add two Points (e.g., p1 + p2)."""  
        if not isinstance(other, Point):  
            raise TypeError(f"Can only add Point to Point, not {type(other)}")  
        return Point(self.x + other.x, self.y + other.y)  

    def __sub__(self, other: Self) -> Self:  
        """Subtract two Points (e.g., p1 - p2)."""  
        return Point(self.x - other.x, self.y - other.y)  

    def __eq__(self, other: object) -> bool:  
        """Check equality (e.g., p1 == p2)."""  
        if not isinstance(other, Point):  
            return False  
        return (self.x == other.x) and (self.y == other.y)  

    def distance_to_origin(self) -> float:  
        """Calculate distance from (0,0) using Pythagoras."""  
        return (self.x**2 + self.y**2) ** 0.5  
  

# Usage  
p1 = Point(1, 2)  
p2 = Point(3, 4)  

print(p1)  # Output: (1, 2) (uses __str__)  
print(repr(p1))  # Output: Point(x=1, y=2) (uses __repr__)  
p3 = p1 + p2  # (4, 6) (uses __add__)  
print(p3.distance_to_origin())  # ~7.21 (sqrt(4² + 6²))  
print(p1 == Point(1, 2))  # True (uses __eq__)  

4.2 Example 2: A Validated Book Type

A Book type with ISBN validation and metadata management.

class Book:  
    def __init__(self, title: str, author: str, isbn: str):  
        self.title = title  
        self.author = author  
        self.isbn = isbn  # Uses the @isbn.setter for validation  

    @property  
    def isbn(self) -> str:  
        """Get the ISBN (read-only via property)."""  
        return self._isbn  

    @isbn.setter  
    def isbn(self, value: str) -> None:  
        """Set the ISBN with validation (13-digit numeric string)."""  
        if not self._is_valid_isbn(value):  
            raise ValueError(f"Invalid ISBN: {value}. Must be 13 digits.")  
        self._isbn = value  

    def _is_valid_isbn(self, isbn: str) -> bool:  
        """Helper method to validate ISBN (simplified check)."""  
        return len(isbn) == 13 and isbn.isdigit()  

    def __repr__(self) -> str:  
        return f"Book(title={self.title!r}, author={self.author!r}, isbn={self.isbn!r})"  


# Usage  
try:  
    book = Book("1984", "George Orwell", "9780451524935")  # Valid ISBN  
    print(book)  # Output: Book(title='1984', author='George Orwell', isbn='9780451524935')  
    book.isbn = "invalid"  # Triggers validation error  
except ValueError as e:  
    print(e)  # Output: Invalid ISBN: invalid. Must be 13 digits.  

4.3 Example 3: An Immutable Currency Type

An immutable Currency type to ensure values can’t be modified after creation (useful for financial data).

from typing import Self  

class Currency:  
    def __init__(self, amount: float, code: str):  
        if amount < 0:  
            raise ValueError("Amount cannot be negative")  
        if code not in {"USD", "EUR", "GBP"}:  
            raise ValueError("Invalid currency code. Use USD, EUR, or GBP.")  
        self._amount = amount  # Private attribute (immutable)  
        self._code = code      # Private attribute (immutable)  

    @property  
    def amount(self) -> float:  
        return self._amount  

    @property  
    def code(self) -> str:  
        return self._code  

    def __repr__(self) -> str:  
        return f"Currency(amount={self._amount!r}, code={self._code!r})"  

    def __add__(self, other: Self) -> Self:  
        if self.code != other.code:  
            raise ValueError("Cannot add currencies with different codes")  
        return Currency(self._amount + other._amount, self._code)  

    def __eq__(self, other: object) -> bool:  
        if not isinstance(other, Currency):  
            return False  
        return (self._amount == other._amount) and (self._code == other._code)  


# Usage  
usd1 = Currency(100.0, "USD")  
usd2 = Currency(50.0, "USD")  
usd3 = usd1 + usd2  # Currency(150.0, "USD")  

try:  
    usd1.amount = 200  # Error: can't set attribute (no setter)  
except AttributeError as e:  
    print(e)  # Output: can't set attribute  

5. Inheritance: Extending Custom Data Types

Inheritance lets you create new types by extending existing ones, promoting code reuse.

5.1 Inheriting from Built-in Types

Extend built-in types to add domain-specific logic. For example, a PositiveInt that ensures values are always positive:

class PositiveInt(int):  
    def __new__(cls, value: int) -> Self:  
        """Override __new__ (since int is immutable) to validate before creation."""  
        if value <= 0:  
            raise ValueError("Value must be positive")  
        return super().__new__(cls, value)  # Create the int instance  


# Usage  
pos_int = PositiveInt(10)  
print(pos_int * 2)  # 20 (inherits all int methods)  

try:  
    invalid = PositiveInt(-5)  
except ValueError as e:  
    print(e)  # Output: Value must be positive  

5.2 Creating Type Hierarchies

Build hierarchies of custom types. For example, a Shape base class with Circle and Rectangle subclasses:

from math import pi  
from typing import Self  

class Shape:  
    """Base class for all shapes."""  
    def area(self) -> float:  
        """Calculate area (to be implemented by subclasses)."""  
        raise NotImplementedError("Subclasses must implement area()")  

class Circle(Shape):  
    def __init__(self, radius: float):  
        self.radius = radius  

    def area(self) -> float:  
        return pi * self.radius**2  

class Rectangle(Shape):  
    def __init__(self, width: float, height: float):  
        self.width = width  
        self.height = height  

    def area(self) -> float:  
        return self.width * self.height  


# Usage  
shapes = [Circle(2), Rectangle(3, 4)]  
for shape in shapes:  
    print(f"Area: {shape.area():.2f}")  # Polymorphism in action  
# Output:  
# Area: 12.57  
# Area: 12.00  

6. Best Practices for Building Custom Data Types

Follow these guidelines to ensure your custom types are robust and maintainable:

6.1 Encapsulation and Data Protection

  • Use private attributes (prefix with _) to hide internal state.
  • Expose controlled access via properties (e.g., @property and @x.setter for validation).

6.2 Documentation and Type Hints

  • Add docstrings to classes, methods, and attributes (use help(MyType) to verify).
  • Use type hints (e.g., def __init__(self, x: float, y: float)) for clarity and tooling support (e.g., VS Code autocompletion).

6.3 Immutability When Appropriate

  • Make types immutable (no setters for attributes) if their state shouldn’t change (e.g., Currency, Point). Immutable types are hashable (can be used as dict keys) and thread-safe.

6.4 Testing

  • Write unit tests to validate behavior (e.g., using pytest). Test edge cases like invalid inputs or arithmetic operations.

7. Conclusion

Custom data types are a cornerstone of Python OOP, enabling you to model complex real-world entities with clarity and precision. By combining attributes, methods, and special dunder methods, you can create types that feel as natural as Python’s built-ins—with the added benefit of domain-specific logic.

Whether you’re building a simple Point or a complex Order system, the principles covered here—encapsulation, reusability, and type safety—will help you write cleaner, more maintainable code.

8. References