Table of Contents
- Understanding Python OOP Basics
- 1.1 Classes and Objects
- 1.2 Attributes and Methods
- What Are Custom Data Types?
- 2.1 Why Build Custom Data Types?
- 2.2 Built-in vs. Custom Types
- 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
- Step-by-Step Examples
- 4.1 Example 1: A 2D
PointType - 4.2 Example 2: A Validated
BookType - 4.3 Example 3: An Immutable
CurrencyType
- 4.1 Example 1: A 2D
- Inheritance: Extending Custom Data Types
- 5.1 Inheriting from Built-in Types
- 5.2 Creating Type Hierarchies
- 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
- Conclusion
- 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
selfto access object data), class methods (use@classmethodandclsto access class data), or static methods (use@staticmethodand don’t requireselforcls).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
BankAccountbalance 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
listcan hold any data, but you might need aTodoListthat only accepts tasks and has methods likemark_complete(). - A
floatcan represent any number, but aTemperaturetype 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
@propertydecorator) 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.
| Method | Purpose | Example Usage |
|---|---|---|
__init__(self, ...) | Constructor: Initialize a new object | p = Point(2, 3) |
__repr__(self) | Developer-friendly string representation | print(repr(p)) → Point(2,3) |
__str__(self) | User-friendly string representation | print(p) → (2, 3) |
__add__(self, other) | Define addition | p1 + p2 |
__eq__(self, other) | Define equality check | p1 == 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.,
@propertyand@x.setterfor 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 asdictkeys) 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.