The Classes & Objects page covered defining a class, storing data on each object, and writing methods that use it. This page covers data shared across every object of a class, methods that control how objects print and compare, classes that hold collections of other objects, and building one class on top of another.

Class Variables

An attribute assigned through self belongs to one object. A variable assigned directly inside the class body instead belongs to the class, and every object shares the same single copy:

class SpacecraftPart:
    part_count = 0   # a CLASS variable: one copy, shared by every instance

    def __init__(self, name, mass):
        self.name = name    # instance variables: one copy per object
        self.mass = mass
        SpacecraftPart.part_count += 1


p1 = SpacecraftPart("proptank", 1200.0)
p2 = SpacecraftPart("engine", 450.0)
p3 = SpacecraftPart("fairing", 900.0)

print(SpacecraftPart.part_count)   # read from the class
print(p1.part_count)               # readable from an instance too
print(p1.mass, p2.mass)            # instance variables stayed separate

3
3
1200.0 450.0

part_count is a class variable, so incrementing it in __init__ counts every part ever created, no matter which object triggered it. name and mass are instance variables, so each object keeps its own.

A class variable is read as SpacecraftPart.part_count from the class itself, or p1.part_count through any instance. Write to it through the class name, as done above, since assigning self.part_count = ... would create a new instance variable that shadows the shared one rather than updating it.

Private Attributes by Convention

Prefixing an attribute name with two underscores marks it as internal to the class:

class SpacecraftPart:
    def __init__(self, name, mass):
        self.name = name
        self.__mass = mass   # leading double underscore: "private" by convention

    def get_mass(self):
        return self.__mass

    def set_mass(self, new_mass):
        if new_mass < 0:
            print("Error: mass cannot be negative.")
            return
        self.__mass = new_mass


part = SpacecraftPart("proptank", 1200.0)
print(part.get_mass())

part.set_mass(1000.0)
print(part.get_mass())

part.set_mass(-50)          # rejected by the setter
print(part.get_mass())

1200.0
1000.0
Error: mass cannot be negative.
1000.0

Python does not truly enforce privacy the way some languages do. The double underscore triggers name mangling, which makes the attribute awkward to reach from outside rather than impossible. The point is to route changes through methods like set_mass, which can reject values that don’t make sense, instead of letting any code assign whatever it wants directly.

Controlling How an Object Prints

Printing an object with no instructions gives its type and memory address, which says nothing useful about its contents. Defining a __repr__ method replaces that with whatever text you choose:

class PlainPart:
    def __init__(self, name, mass):
        self.name = name
        self.mass = mass


class LabeledPart:
    def __init__(self, name, mass):
        self.name = name
        self.mass = mass

    def __repr__(self):
        return f"LabeledPart(name={self.name!r}, mass={self.mass})"


print(PlainPart("proptank", 1200.0))   # the default, without __repr__

labeled = LabeledPart("proptank", 1200.0)
print(labeled)
print([labeled, labeled])   # __repr__ is used inside lists too

<__main__.PlainPart object at 0x7f433fe95fa0>
LabeledPart(name='proptank', mass=1200.0)
[LabeledPart(name='proptank', mass=1200.0), LabeledPart(name='proptank', mass=1200.0)]

__repr__ is used by print() and also whenever the object appears inside a list or dictionary, which is what makes a list of objects readable. The !r inside the f-string formats the value the way Python would display it, which is why the name comes out with quotes around it.

Methods with two leading and trailing underscores, like __init__ and __repr__, are called special methods. Python calls them for you in response to specific syntax, rather than you calling them by name.

Comparing Objects

By default, two separate objects are considered unequal even when they hold identical data, and Python has no way to sort them at all. Defining __eq__ and __lt__ supplies those rules:

class Part:
    def __init__(self, name, mass):
        self.name = name
        self.mass = mass

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

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

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


a = Part("proptank", 1200.0)
b = Part("proptank", 1200.0)
c = Part("engine", 450.0)

print(a == b)   # True, because __eq__ compares the contents
print(a == c)
print(c < a)    # uses __lt__

print(sorted([a, c, Part("fairing", 900.0)]))

True
False
True
[Part('engine', 450.0), Part('fairing', 900.0), Part('proptank', 1200.0)]

__eq__ defines what == means for the class, and __lt__ defines what < means. Once __lt__ exists, sorted() can order a list of these objects without any extra arguments, since sorting is built on repeated less-than comparisons.

Objects That Hold Other Objects

An attribute can hold anything, including a list of other objects, which is how a larger assembly is represented:

class Part:
    def __init__(self, name, mass):
        self.name = name
        self.mass = mass


class Spacecraft:
    def __init__(self, name):
        self.name = name
        self.parts = []      # each Spacecraft gets its own list

    def add_part(self, part):
        self.parts.append(part)

    def total_mass(self):
        return sum(part.mass for part in self.parts)

    def heaviest_part(self):
        if not self.parts:
            return None
        return max(self.parts, key=lambda p: p.mass)


sc = Spacecraft("Orion")
sc.add_part(Part("proptank", 1200.0))
sc.add_part(Part("engine", 450.0))
sc.add_part(Part("heat shield", 1600.0))

print(sc.total_mass())
print(sc.heaviest_part().name)

3250.0
heat shield

self.parts = [] inside __init__ gives each Spacecraft its own empty list, so parts added to one vehicle never appear in another. total_mass and heaviest_part then work across everything the vehicle currently holds, whatever that happens to be.

Example: Spacecraft Mass Budget

Question

A spacecraft is assembled from parts, each belonging to a subsystem. Write classes that track the parts making up a vehicle and can report the total mass, the mass belonging to each subsystem, and the parts ordered from heaviest to lightest.

Solution

Part defines __repr__ so a list of parts prints readably, and __lt__ so parts can be sorted by mass directly. Spacecraft holds the parts and provides the three summaries, reusing the grouping pattern from the Lists of Dictionaries page.

class Part:
    def __init__(self, name, subsystem, mass):
        self.name = name
        self.subsystem = subsystem
        self.mass = mass

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

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


class Spacecraft:
    def __init__(self, name):
        self.name = name
        self.parts = []

    def add_part(self, part):
        self.parts.append(part)

    def total_mass(self):
        return sum(part.mass for part in self.parts)

    def mass_by_subsystem(self):
        totals = {}
        for part in self.parts:
            totals[part.subsystem] = totals.get(part.subsystem, 0.0) + part.mass
        return totals

    def parts_heaviest_first(self):
        return sorted(self.parts, reverse=True)


sc = Spacecraft("Orion")
sc.add_part(Part("proptank", "propulsion", 1200.0))
sc.add_part(Part("engine", "propulsion", 450.0))
sc.add_part(Part("heat shield", "structure", 1600.0))
sc.add_part(Part("avionics bay", "avionics", 220.0))

print(f"Total mass: {sc.total_mass()} kg")
print(sc.mass_by_subsystem())
print(sc.parts_heaviest_first())

Total mass: 3470.0 kg
{'propulsion': 1650.0, 'structure': 1600.0, 'avionics': 220.0}
[heat shield (1600.0 kg), proptank (1200.0 kg), engine (450.0 kg), avionics bay (220.0 kg)]

Because Part defines __lt__, parts_heaviest_first just calls sorted(self.parts, reverse=True) without repeating what “heavier” means.

Inheritance

A class can be built on top of another, taking everything the original defines and then adding to or changing it:

class Part:
    def __init__(self, name, mass):
        self.name = name
        self.mass = mass

    def describe(self):
        print(f"{self.name}: {self.mass} kg")


class Engine(Part):
    def __init__(self, name, mass, thrust):
        super().__init__(name, mass)   # run the parent's __init__
        self.thrust = thrust

    def describe(self):               # override the parent's method
        print(f"{self.name}: {self.mass} kg, {self.thrust} N thrust")


bracket = Part("bracket", 5.0)
engine = Engine("RS-25", 3177.0, 1859000)

bracket.describe()
engine.describe()

print(isinstance(engine, Engine))
print(isinstance(engine, Part))   # an Engine is also a Part

bracket: 5.0 kg
RS-25: 3177.0 kg, 1859000 N thrust
True
True

Engine(Part) means Engine inherits from Part, so it starts with Part’s attributes and methods. super().__init__(name, mass) runs the parent’s setup before Engine adds its own thrust attribute, which avoids repeating the lines that were already written once. Defining describe again in Engine overrides the inherited version, so each class prints in the way that suits it.

isinstance(engine, Part) is True because an Engine is a kind of Part. That relationship is the test for whether inheritance is the right tool: use it when the new class genuinely is a more specific version of the original, not merely when it would be convenient to reuse a few methods.

Reading Questions

  1. What is the difference between a class variable and an instance variable?
  2. Why should a class variable be updated through the class name rather than through self?
  3. What does a leading double underscore on an attribute name signal, and does Python enforce it?
  4. What does __repr__ control, and where is it used besides a direct print() call?
  5. What do __eq__ and __lt__ define for a class?
  6. Once a class defines __lt__, what else becomes possible without writing any extra code?
  7. Why is self.parts = [] written inside __init__ rather than in the class body?
  8. What does super().__init__(...) do in a child class?
  9. What question should you ask to decide whether inheritance is the right tool?