Working with objects

Part of the free LogicWiz Python course, Module 9: Object-oriented programming. The examples and practice run in the app.

Shankar bhaiya wants the menu sorted by price. Meher typed sorted(menu) and Python shouted TypeError: '<' not supported between instances of 'Item' and 'Item'. Python doesn't know what "smaller" means for a dish, until you tell it.

You'll learn to

  • Keep many objects in a list and loop over them
  • Tell class attributes from instance attributes (and dodge the shared-list trap)
  • Make objects comparable and sortable with `__eq__` and `__lt__`
  • Write alternative constructors with `@classmethod`, and helpers with `@staticmethod`

Learn

A menu is a list of objects

class Item:
    def __init__(self, name, price):
        self.name = name
        self.price = price

menu = [Item("Samosa", 15), Item("Chai", 10), Item("Dosa", 60)]
for it in menu:
    print(it.name, it.price)

cheap = [it.name for it in menu if it.price < 20]
print(cheap)
print(sum(it.price for it in menu))
print(max(menu, key=lambda it: it.price).name)

key=lambda it: it.price tells max what to compare: the price of each item. Everything you learned about lists still works; the items are now objects.

Class attributes vs instance attributes

A name assigned in the class body belongs to the class: there is one copy, shared by every object. A name assigned with self.x = ... belongs to that one object.

When you read obj.x, Python looks on the object first, then on the class. When you assign obj.x = ..., Python always writes on the object, so it can hide ("shadow") the class value for that one object.

Watch the lookup order

class Item:
    gst = 5            # class attribute: one shared copy
    count = 0

    def __init__(self, name):
        self.name = name       # instance attribute: one per object
        Item.count += 1        # update the shared counter

a = Item("Samosa")
b = Item("Chai")
print(a.gst, b.gst, Item.count)
a.gst = 12             # makes a NEW attribute on a only
print(a.gst, b.gst, Item.gst)

The shared-list trap. class Order: items = [] gives every order the same list, so a chai added to one order shows up in all of them. Create mutable things per object inside __init__: self.items = [].

Also, self.count += 1 does not update a class counter. It reads the class value, then writes a new attribute on the object. Write Item.count += 1.

__eq__ and __lt__: teaching Python to compare

class Item:
    def __init__(self, name, price):
        self.name = name
        self.price = price

    def __eq__(self, other):
        return self.name == other.name and self.price == other.price

    def __lt__(self, other):
        return self.price < other.price

    def __repr__(self):
        return f"{self.name}({self.price})"

a = Item("Chai", 10)
b = Item("Chai", 10)
print(a == b, a is b)
menu = [Item("Dosa", 60), a, Item("Samosa", 15)]
print(sorted(menu))
print(min(menu))

Without __eq__, == falls back to is ("same object?"), so a == b would be False. sorted, min and max only need __lt__.

@classmethod and @staticmethod

A normal method gets the object as self.

A @classmethod gets the class as cls. Its classic use is an alternative constructor: build an object from a CSV line or a dict. Because it calls cls(...), it also works for subclasses.

A @staticmethod gets neither. It is a plain function kept inside the class because it belongs there, like a price check.

Building an Item from a line of text

class Item:
    def __init__(self, name, price):
        self.name = name
        self.price = price

    @classmethod
    def from_line(cls, line):
        name, price = line.split(",")
        return cls(name.strip(), int(price))

    @staticmethod
    def valid_price(p):
        return 0 < p <= 1000

    def __repr__(self):
        return f"Item({self.name!r}, {self.price})"

print(Item.from_line("Vada Pav, 20"))
print(Item.valid_price(20), Item.valid_price(-5))

Three kinds of method

KindFirst parameter and typical use
Instance methodself, the object. Reads or changes this object (total, discount)
@classmethodcls, the class. Alternative constructors (fromline), class-wide counters
@staticmethodNothing extra. A helper that belongs with the class (validprice)

In one line

Class attributes are shared, self.x is per object; __eq__/__lt__ make objects comparable and sortable; @classmethod gets cls (alternative constructors), @staticmethod gets nothing.

Interview corner

What is the difference between `@classmethod` and `@staticmethod`?

A classmethod receives the class (cls) and is used for alternative constructors or class-wide state; because it calls cls(...), it works correctly for subclasses. A staticmethod receives nothing implicit; it is a plain function namespaced inside the class. A normal method receives the instance (self).

What is the difference between a class attribute and an instance attribute?

A class attribute is defined in the class body and shared by all instances; an instance attribute is set on self and belongs to one object. Reading obj.x checks the instance first, then the class. Assigning obj.x = ... always creates or changes the instance attribute, which shadows the class one. Never use a mutable class attribute (like a list) for per-object data.

How do you make your objects sortable?

Define __lt__ (and usually __eq__), or decorate with functools.total_ordering to get the other comparisons from __eq__ plus one of them. Alternatively, leave the class alone and pass key= to sorted: sorted(menu, key=lambda i: i.price).

What happens to `__hash__` when you define `__eq__`?

Python sets __hash__ to None, so the objects can no longer be dict keys or set members (you get TypeError: unhashable type). If the object is effectively immutable, define __hash__ too, e.g. return hash((self.name, self.price)).