Skip to content

Latest commit

 

History

History
251 lines (173 loc) · 9.11 KB

File metadata and controls

251 lines (173 loc) · 9.11 KB
layout default
title Python Data Model
parent Lessons
nav_order 1
permalink /lessons/python-data-model/
course_lesson true
course_index 01
previous_page /setup/
previous_title Setup
next_page /lessons/advanced-oop-design/
next_title Advanced Object-Oriented Design

01 - Python Data Model

The Python data model is the set of rules that lets your own objects work with familiar syntax such as len(value), value[index], for, in, +, and print().

A simple picture

Think of Python operations as questions on standard forms. len(box) asks, “How many items do you have?” A class can answer by implementing __len__. The caller does not need to know how the box stores its items.

len(value)     -> value.__len__()
iter(value)    -> value.__iter__()
value[key]     -> value.__getitem__(key)
left + right   -> left.__add__(right)
repr(value)    -> value.__repr__()

Methods surrounded by two underscores are called special methods or dunder methods. Usually call the public operation, such as len(value), instead of calling value.__len__() yourself.

Build a small protocol

A protocol is a behavior an object promises to support. This playlist supports length, iteration, and membership:

class Playlist:
    def __init__(self, songs):
        self._songs = list(songs)

    def __len__(self):
        return len(self._songs)

    def __iter__(self):
        return iter(self._songs)

    def __contains__(self, song):
        return song in self._songs


playlist = Playlist(["Sunrise", "Rain Song"])
print(len(playlist))
print("Sunrise" in playlist)

for song in playlist:
    print(song)

An iterable does not need to inherit from a special parent class. If it supports the operation correctly, Python can use it. This behavior-based style is called duck typing.

Implement only protocols that make sense. A Student does not need + merely because Python allows __add__.

Representation: repr and str

  • repr(value) is mainly for developers and debugging.
  • str(value) is mainly for readable user-facing text.
  • If __str__ is missing, str() falls back to __repr__.
class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    def __repr__(self):
        return f"Temperature(celsius={self.celsius!r})"

    def __str__(self):
        return f"{self.celsius} °C"


temperature = Temperature(24.5)
print(str(temperature))
print(repr(temperature))

!r asks an f-string to use repr() for the embedded value. Never include passwords, tokens, or private personal data in a representation that may enter logs.

Equality and hashing

Identity asks whether two names point to the same object. Equality asks whether two values should be considered equal.

class Money:
    def __init__(self, amount, currency):
        self.amount = amount
        self.currency = currency

    def __eq__(self, other):
        if not isinstance(other, Money):
            return NotImplemented
        return (self.amount, self.currency) == (
            other.amount,
            other.currency,
        )


first = Money(50, "INR")
second = Money(50, "INR")
print(first is second)  # False
print(first == second)  # True

Returning NotImplemented gives Python a chance to try the reflected comparison or produce the correct result. It is different from raising NotImplementedError.

A hashable object can be a dictionary key or set member. Its hash must not change while stored, and equal objects must have equal hashes. Therefore, mutable value objects usually should not define __hash__. A frozen dataclass is often a safer choice.

Ordering and reflected operations

__lt__ handles <. Python does not automatically invent a meaningful total order from equality. functools.total_ordering can create the remaining comparison methods from __eq__ and one ordering method, but generated methods add call overhead and may make tracebacks less direct.

For left + right, Python first tries left.__add__(right). If that returns NotImplemented, it can try right.__radd__(left). Do not silently accept unrelated types.

Context and container protocols

Common protocol groups include:

Operation Main methods
Context manager __enter__, __exit__
Callable object __call__
Subscription __getitem__, __setitem__
Attribute fallback __getattr__
Truth test __bool__, then __len__

__getattr__ runs only after normal attribute lookup fails. __getattribute__ runs for every attribute lookup and is easy to make recursive, so override it only with a strong reason.

Under the hood

In CPython, a name stores a reference to an object, not the object bytes directly. Most objects begin with runtime bookkeeping that includes a type reference and reference count. An operation such as len(value) asks the object's type for the relevant protocol implementation; special-method lookup is generally performed on the type, not through an arbitrary same-named instance attribute.

class Box:
    pass


box = Box()
box.__len__ = lambda: 99

# len(box) still raises TypeError because Box itself has no __len__.

This is one reason special methods belong on the class. CPython is an implementation of Python, so do not make application correctness depend on a particular reference count or memory address.

Practical example: indexed catalog

class Catalog:
    def __init__(self, books):
        self._books = {book["id"]: dict(book) for book in books}

    def __len__(self):
        return len(self._books)

    def __iter__(self):
        return iter(self._books.values())

    def __getitem__(self, book_id):
        return self._books[book_id]

    def __contains__(self, book_id):
        return book_id in self._books

    def __repr__(self):
        return f"Catalog(book_count={len(self)})"

The catalog returns copies during construction, but catalog[id] still returns a mutable internal dictionary. Decide and document whether callers may mutate it. A read-only view, immutable record, or copied result may be safer at a public boundary.

Bug Hunter

Bug 1: equal but inconsistent hashes

class User:
    def __eq__(self, other):
        return self.email.lower() == other.email.lower()

    def __hash__(self):
        return hash(self.email)

Bug 2: comparison hides unsupported data

def __eq__(self, other):
    if not isinstance(other, Money):
        return False

Bug 3: iteration resets shared state

def __iter__(self):
    self.position = 0
    return self
Show Bug Hunter fixes
  1. Hash exactly the immutable values used by equality, or make the mutable class unhashable.
  2. Return NotImplemented for an unsupported comparison type.
  3. Return a fresh iterator, such as iter(self._items), unless the object is intentionally a one-pass iterator.

Practice

  1. Create a Bag that supports len(), iteration, and membership.
  2. Make a memory-efficient RangeStep iterable without building a list.
  3. Add safe __repr__ and friendly __str__ methods to Temperature.
  4. Implement equality for an immutable Coordinate value.
  5. Create a Catalog that supports catalog["book-id"] and raises normal KeyError for a missing ID.
  6. Make a Score sortable by numeric value while rejecting unrelated types.
  7. Explain why mutable objects are dangerous dictionary keys.
  8. Create a callable TaxCalculator object with a configurable rate.
  9. Write a function that accepts any object with an area() method.
  10. Test every protocol in one class, including empty and unsupported cases.
Show hints
  1. Delegate to an internal collection. 2. Return an iterator or use yield. 3. Use !r in the developer representation. 4. A frozen dataclass can help. 5. Delegate subscription to a dictionary. 6. Return NotImplemented for an unrelated value. 7. Think about lookup after mutation. 8. Implement __call__. 9. Depend on behavior, not a concrete class. 10. Call public operations such as len() in tests.
Show solution ideas
  1. Store a list and implement the three smallest methods. 2. A generator loop can yield current values until the stop boundary. 3. Keep secrets out and make repr unambiguous. 4. @dataclass(frozen=True) supplies value equality and a compatible hash. 5. Let the internal dictionary preserve normal lookup behavior. 6. Compare numeric fields after a type check. 7. A changed hash can make the key impossible to find in its original bucket. 8. Return amount * rate from __call__. 9. Call shape.area() using duck typing or document it with a protocol. 10. Include normal, empty, wrong-type, and repeated-iteration tests.

Homework

Build a ReadingQueue that supports length, repeated iteration, membership, indexing, and safe developer representation. Document which mutations are allowed and test each public protocol.

Checkpoint

Explain how Python translates five familiar operations into protocols, why NotImplemented matters, and when equality makes hashing unsafe.