The Novaverse Chronicles₈
A practical look at Python OOP: how class vs instance data works, how __repr__ and __str__ define object behavior, and how super() lets subclasses extend functionality without duplication. Clear patterns every real codebase uses.
Chapter Eight — Lesson 03: OOP Basics — The Architecture of Objects
Class variables, object string representations, and super()—the foundation for clean, reusable class design.
Nova: Teslanic, what do you think—should we write a clear instruction about OOP for our readers?
Teslanic: We should. We’ll start with three things that bite everyone: shared class state, how objects speak, and calling the parent without rewriting the world.
Nova: And if they need to back up, they review Error Handling first.
Teslanic: Exactly. Then come back here and build.
1) Class Variables — The Shared DNA
Class variables live on the class, not on each object. Edit at the class level ➜ all instances see the change (unless an instance shadows it).
class Person:
_species = "Homo sapiens" # class-level (shared)
def __init__(self, name, age=0):
self.name = name # instance-level (per object)
self._age = age # convention: underscore = internal use
def __repr__(self):
return f"<Person name={self.name!r} age={self._age}>"
print(Person._species) # Homo sapiens
p1 = Person("Nova")
p2 = Person("Teslanic")
print(p1._species, p2._species) # Homo sapiens Homo sapiens
# Change at the CLASS level:
Person._species = "Animals"
print(p1._species, p2._species) # Animals Animals
# Shadow at the INSTANCE level:
p1._species = "Cat"
print(p1._species) # Cat (instance shadow)
print(p2._species) # Animals (still class value)
Rule of thumb: Use class variables for true constants/config shared by all (e.g., currency = "USD"). Don’t use them for per-object data like balances or ages.
Inheritance: clean overrides, no confusion
class Creature:
_species = "Animal" # default for everyone
def __init__(self, name):
self.name = name
def __repr__(self):
return f"<{self.__class__.__name__} name={self.name!r} species={self._species}>"
class Dog(Creature):
_species = "Canis lupus familiaris" # override
class Cat(Creature):
_species = "Felis catus" # override
print(Dog("Rex")) # <Dog name='Rex' species=Canis lupus familiaris>
print(Cat("Luna")) # <Cat name='Luna' species=Felis catus>
Gotcha: If you assign obj._species = ... on a single instance, you’re not editing the class variable—you’re creating a new instance attribute that shadows the class one.
2) __repr__ & __str__ — Giving Objects a Voice
By default, printing an object gives you a memory address. Teach your objects to “speak” with __repr__ (developer view) and __str__ (user view).
class Creature:
_species = "Animal"
def __init__(self, name):
self.name = name
def __repr__(self):
# precise / dev-friendly
return f"<Creature name={self.name!r} species={self._species}>"
def __str__(self):
# friendly / user-facing
return f"{self.name} the {self._species}"
c = Creature("Bambam")
print(c) # Bambam the Animal (from __str__)
print(repr(c)) # <Creature name='Bambam' species=Animal> (from __repr__)
__repr__: unambiguous, used in debugging, shells, logs.__str__: readable summary for end users (print(obj)).- If
__str__isn’t defined, Python falls back to__repr__.
Real-world shapes
class User:
def __init__(self, username, email, active=True):
self.username = username
self.email = email
self.active = active
def __repr__(self):
return f"User(username={self.username!r}, email={self.email!r}, active={self.active})"
def __str__(self):
return f"{self.username} ({'active' if self.active else 'inactive'})"
u = User("nova", "nova@example.com")
print(u) # nova (active)
print(repr(u)) # User(username='nova', email='nova@example.com', active=True)
3) super() — Call the Parent, Then Continue
super() doesn’t “do magic.” It simply calls the parent’s version of the method so you can extend behavior without copy-pasting.
class Logger:
def log(self, message):
return f"[LOG] {message}"
class SecureLogger(Logger):
def log(self, message):
base = super().log(message) # calls Logger.log
return f"{base} [ENCRYPTED]"
l = Logger()
sl = SecureLogger()
print(l.log("System started")) # [LOG] System started
print(sl.log("System started")) # [LOG] System started [ENCRYPTED]
Mantra: super() = run the parent’s version, then come back here.
Multi-level handoff (grandma → mom → child)
class Grandmother:
def log(self, message):
return f"[GRANDMA] {message}"
class Logger(Grandmother):
def log(self, message):
base = super().log(message) # Grandmother.log
return f"{base} -> [MOTHER] {message}"
class SecureLogger(Logger):
def log(self, message):
base = super().log(message) # Logger.log
return f"{base} -> [CHILD] {message}"
sl = SecureLogger()
print(sl.log("System started"))
# [GRANDMA] System started -> [MOTHER] System started -> [CHILD] System started
Why this matters: frameworks (Flask/Django/FastAPI) lean on this pattern—parents define defaults, children extend behavior cooperatively.
Nova: So our objects can share what’s truly shared, speak clearly, and respect their parents—without becoming clones.
Teslanic: Exactly. Next, they learn to remember—files and logs.
Checkpoint — What to remember
- Class variables live on the class. Class-level edits affect everyone. Instance assignment shadows.
__repr__vs__str__: dev vs user. If no__str__, Python uses__repr__.super()calls the parent method so you can extend behavior cleanly.- Don’t store per-object data (e.g., balances) in class variables.
Note
If you find any part of this post unclear or technically inaccurate, I would appreciate hearing from you. Improving the precision of these explanations is an ongoing process, and your feedback helps strengthen the material.