第 09 章
面向对象:从元组、字典到自己的类
同一个「学生」问题,从元组、列表、字典一路尝试到类:用 __init__ 构造与校验,用 raise 拒绝非法数据,用 __str__ 控制打印,用 property 守住属性,用 classmethod 表示「只此一个」的排序帽,再用继承和 super() 消除重复,最后用 __add__ 让两个金库相加。
Object-Oriented Programming: From Tuples and Dicts to Your Own Class
One 'student' problem, taken from tuples through lists and dictionaries to a class: __init__ constructs and validates, raise rejects bad data, __str__ controls printing, properties guard attributes, classmethods model a single Sorting Hat, inheritance with super() removes duplication, and __add__ lets two vaults be added.
篇内目录
- A concrete problem: storing a student
- Timeline
- Returning several values: tuples
- Dictionaries: better semantics, still loose
- A class: inventing a data type
- __init__: construct and validate
- __str__: control printing
- Methods: objects that act
- property: guarding the attribute
- Python's honour system
- You have been using classes all along
- classmethod and class variables
- Inheritance: removing duplication
- Operator overloading
- Takeaways
- Comprehension check
- How it connects
这是长视频(约 16 小时)的第 9 章,对应 CS50P 第 8 次讲座「Object-Oriented Programming」,时间区间 10:37:47–13:29:08。前面几章以过程式写法为主:把步骤自上而下排好,再抽出函数。这一章引入第三种范式——面向对象,用「类」把相关的数据和行为打包在一起。
一个具体问题:存一个学生
程序要从用户那里拿到姓名和学院,再打印出来。
name = input("Name: ")
house = input("House: ")
print(f"{name} from {house}")
它是对的,但没有任何结构。把输入抽成函数:
def main():
name = get_name()
house = get_house()
print(f"{name} from {house}")
def get_name():
return input("Name: ")
def get_house():
return input("House: ")
函数只是一层抽象。真正的麻烦出现在「一个函数要返回两个值」的时候。
时间轴
| 时间 | 内容 |
|---|---|
| 10:37:47 | 三种编程范式:过程式、函数式、面向对象 |
| 10:39:43 | 从 student.py 开始,逐步加功能 |
| 10:44:34 | get_student 要返回两个值:元组 |
| 10:47:22 | 元组不可变;改 Padma 的学院报 TypeError |
| 10:54:06 | 改成列表:可变,但只能按位置访问 |
| 10:56:25 | 改成字典:用键访问,语义更好 |
| 11:05:46 | class Student:自己发明数据类型 |
| 11:17:00 | __init__、self、实例变量与构造调用 |
| 11:30:23 | 在类里校验:raise ValueError("missing name") |
| 11:40:30 | __str__:让 print 输出可读内容 |
| 11:46:36 | 自定义方法 charm |
| 12:00:48 | @property 与 setter:防止绕过校验 |
| 12:18:26 | Python 只有「荣誉制」,_house 仍可被改写 |
| 12:20:04 | int、str、list、dict 本来就是类 |
| 12:29:15 | @classmethod 与类变量:排序帽 |
| 12:59:02 | 继承:Wizard → Student / Professor,super().__init__ |
| 13:13:02 | 运算符重载:__add__ 让两个金库相加 |
返回多个值:元组
def get_student():
name = input("Name: ")
house = input("House: ")
return name, house
def main():
name, house = get_student()
print(f"{name} from {house}")
return name, house 看起来返回两个值,实际上返回一个元组(tuple)。元组是有序、不可变的序列,可以解包,也可以按索引访问:student[0]、student[1]。
不可变是设计特性:
student = ("Padma", "Gryffindor")
student[1] = "Ravenclaw"
运行会得到
TypeError: 'tuple' object does not support item assignment
想要「可修改」,就换成列表 ["Padma", "Gryffindor"]。但列表仍有问题:student[0] 是名字、student[1] 是学院,这个约定只存在于程序员的记忆里。字段一多,必然记错。
字典:语义更好,但仍然松散
def get_student():
name = input("Name: ")
house = input("House: ")
return {"name": name, "house": house}
def main():
student = get_student()
print(f"{student['name']} from {student['house']}")
用键代替下标,可读性立刻提升。注意 f-string 的引号:外层已是双引号,键要用单引号,否则 Python 无法判断字符串在哪里结束。
字典的问题在于它来者不拒。任何键、任何值都能放进去,没有校验,也没有行为。想要「学生只能来自四个学院」「名字不能为空」,字典本身给不了约束。
类:自己发明数据类型
class Student:
...
def get_student():
student = Student()
student.name = input("Name: ")
student.house = input("House: ")
return student
class Student: 定义了一个新类型;Student() 创建它的一个对象(也叫实例)。student.name = ... 在对象上挂了一个属性(实例变量)。
术语对照:
| 术语 | 含义 |
|---|---|
| 类(class) | 蓝图、模具,定义这类对象长什么样 |
| 对象 / 实例 | 按蓝图造出来的具体东西 |
| 属性 / 实例变量 | 对象内部的数据 |
| 方法 | 定义在类里的函数 |
__init__:构造与校验
手工逐个挂属性太松散。__init__ 让对象在创建时就确定内容:
class Student:
def __init__(self, name, house):
self.name = name
self.house = house
def get_student():
name = input("Name: ")
house = input("House: ")
return Student(name, house)
几个容易困惑的点:
Student(name, house)是构造调用(constructor call),Python 会创建空对象,再自动调用__init__;self是 Python 自动传入的第一个参数,指向刚创建的那个对象,所以不用在调用时写它;self.name = name把参数存进对象,形成实例变量。
现在可以在 __init__ 里做校验:
class Student:
def __init__(self, name, house):
if not name:
raise ValueError("Missing name")
if house not in ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"]:
raise ValueError("Invalid house")
self.name = name
self.house = house
raise 是主动抛出异常。这里不能只 print 然后继续——对象已经造出来了,程序必须让调用方知道「这个对象不合法」。也不能 sys.exit():那会终止整个程序,而调用方可能只想捕获这个错误、继续处理下一条数据。
try:
student = Student(name, house)
except ValueError as e:
print(e)
__str__:控制打印
直接 print(student) 会输出
<__main__.Student object at 0x102733e80>
这是对象的默认表示:类型名加内存地址。定义 __str__ 可以覆盖:
class Student:
def __init__(self, name, house):
...
self.name = name
self.house = house
def __str__(self):
return f"{self.name} from {self.house}"
现在 print(student) 调用 __str__,输出 Harry from Gryffindor。__str__ 是「给用户看」的表示;还有一个 __repr__ 是「给开发者看」的表示,通常包含类型信息。
方法:对象会做事
class Student:
...
def charm(self):
match self.patronus:
case "Stag":
return "🐴"
case "Otter":
return "🦦"
case "Jack Russell terrier":
return "🐶"
case _:
return "🪄"
方法就是类里的函数,第一个参数永远是 self。student.charm() 时 Python 自动把 student 作为 self 传入。与 __init__、__str__ 不同,这个方法不会自动被调用,需要显式写 student.charm()。
property:把校验守住
上面的校验有一个漏洞。即使 __init__ 拒绝了非法值,程序之后仍能直接改写:
student = Student("Harry", "Gryffindor")
student.house = "Number Four, Privet Drive" # 绕过了校验
因为 __init__ 只在创建时执行一次。要堵住这个口子,用属性(property):
class Student:
def __init__(self, name, house):
self.name = name
self.house = house # 注意:这里也走 setter
@property
def house(self):
return self._house
@house.setter
def house(self, house):
if house not in ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"]:
raise ValueError("Invalid house")
self._house = house
机制如下:
@property把house定义成 getter,读取student.house时调用它;@house.setter定义 setter,写入student.house = ...时调用它;- 真正的存储放在
self._house,下划线是为了避免与属性同名冲突; __init__里写self.house = house(不带下划线),于是初始化也要经过 setter,校验只写一遍。
Python 的「荣誉制」
即便如此,仍可以直接写 student._house = "..." 绕过一切。Python 没有 Java 那种 private 关键字,下划线只是约定:一个下划线表示「请不要碰」,两个下划线表示「更强烈地请不要碰」,但语言层面并不阻止。
这不是漏洞,而是设计取向:Python 假设使用者是成年人。需要真正的封装时,靠的是约定、代码评审与文档,而不是编译器。
你早就在用类
print(type(50)) # <class 'int'>
print(type("hello")) # <class 'str'>
print(type([])) # <class 'list'>
print(type({})) # <class 'dict'>
int、str、list、dict 全都是类。"Hello".lower() 是在调用 str 类的方法,list.append() 是 list 类的方法。前八章一直在用面向对象,只是没有命名它。
内置类的命名是小写,自定义类按约定用大写开头(Student),这是 PEP 8 的风格区分。
classmethod 与类变量
排序帽(Sorting Hat)在故事里只有一个,不需要造很多个对象。这时用类方法:
import random
class Hat:
houses = ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"]
@classmethod
def sort(cls, name):
print(name, "is in", random.choice(cls.houses))
Hat.sort("Harry")
houses写在类里、不在__init__里,是类变量,所有实例共享同一份;@classmethod让方法接收cls(类本身)而不是self(某个对象);- 调用时直接用
Hat.sort(...),不需要先Hat()。
这解决了一个「鸡生蛋」问题:如果 get_student 需要先有学生对象才能调用,逻辑就绕回去了。类方法可以在没有任何实例的情况下工作。
@staticmethod 是另一种:既不接收 self 也不接收 cls,纯粹是「放在类命名空间里的普通函数」,本章没有展开。
继承:消除重复
学生和教授都有名字,校验逻辑也一样。复制粘贴是坏味道:
class Wizard:
def __init__(self, name):
if not name:
raise ValueError("Missing name")
self.name = name
class Student(Wizard):
def __init__(self, name, house):
super().__init__(name)
self.house = house
class Professor(Wizard):
def __init__(self, name, subject):
super().__init__(name)
self.subject = subject
class Student(Wizard)表示Student继承Wizard,自动获得它的属性与方法;super().__init__(name)调用父类的初始化,名字校验只写一次;- 子类可以添加自己的字段(
house、subject),也可以覆盖父类方法。
继承是有层级的:孙类会继承祖父类的功能。多继承在 Python 中可行,但本章只演示单继承链。
补充:Python 的异常体系本身就是继承的——ValueError 的父类是 Exception,再往上是 BaseException。所以 except Exception 能捕获几乎所有异常,而 except ValueError 只捕获其中一类。自定义异常通常继承已有的异常类,而不是从零开始。
运算符重载
两个金库相加,朴素写法要手动取三个字段再构造新对象:
total = Vault(
potter.galleons + weasley.galleons,
potter.sickles + weasley.sickles,
potter.knuts + weasley.knuts,
)
希望直接写 total = potter + weasley,就要实现 __add__:
class Vault:
def __init__(self, galleons=0, sickles=0, knuts=0):
self.galleons = galleons
self.sickles = sickles
self.knuts = knuts
def __str__(self):
return f"{self.galleons} Galleons, {self.sickles} Sickles, {self.knuts} Knuts"
def __add__(self, other):
return Vault(
self.galleons + other.galleons,
self.sickles + other.sickles,
self.knuts + other.knuts,
)
potter + weasley 时 Python 调用 potter.__add__(weasley),self 是左边,other 是右边。没有 __add__ 时,Vault + Vault 会报 TypeError: unsupported operand type(s)。
+ 对字符串表示拼接、对列表表示合并,也是同一机制:str 与 list 类各自实现了 __add__。可以重载的运算符不止 +,还有 -、<、== 等,但不能发明新符号。
本章结论
- 元组有序且不可变,列表可变但只能按位置访问,字典用键访问但无约束。
- 类让数据与行为绑定,
class定义类型,调用类名创建实例。 __init__在构造时初始化并校验,self指向当前对象。- 校验失败用
raise ValueError,让调用方决定如何处理,而不是print或sys.exit。 __str__决定print(obj)的输出;__repr__面向开发者。@property+@x.setter把读写都送进你的代码;真实存储在_x。- Python 的私有性是约定而非强制,下划线是「请勿触碰」的信号。
@classmethod接收cls,适合「全类共享一份数据」或「无需实例即可调用」的场景。- 继承通过
class Child(Parent)与super().__init__(...)复用代码;异常体系同样是继承结构。 __add__等特殊方法实现运算符重载,+的语义由类自己定义。
理解检验
1. return name, house 到底返回了什么?为什么 student[1] = "Ravenclaw" 会报错?
参考答案:逗号返回的是一个元组 (name, house),不是两个独立的值;接收方用 name, house = ... 是解包。元组是不可变序列,创建后不能修改元素,所以对下标赋值会抛出 TypeError: 'tuple' object does not support item assignment。如果需要可修改,应返回列表 [name, house];如果希望语义更清楚,应使用字典或自定义类。
2. 为什么把校验写在 __init__ 里还不够?@property 补上了什么?
参考答案:__init__ 只在对象创建时执行一次。创建之后,代码仍可以写 student.house = "非法值" 直接改属性,绕过全部校验。@property 把 house 变成一个由 getter/setter 控制的属性:读取走 getter,赋值走 setter。setter 里重新执行校验,非法值会抛异常。真正的存储放在 self._house,从而避免属性名与实例变量名冲突。这样无论是初始化还是事后修改,都必须经过同一段校验代码。
3. 既然有 @property,为什么还能用 student._house = ... 绕过?这说明 Python 的什么设计取向?
参考答案:Python 没有 private 这类语言级访问控制,下划线只是命名约定:_house 表示「内部使用,请不要碰」,但它仍是普通属性,可以直接赋值。Python 选择「荣誉制」(honor system):信任使用者遵守约定,而不是让编译器强制。封装靠约定、代码评审与文档保证,而不是语言机制。这与 Java 的 private 有本质区别。
4. @classmethod 与普通实例方法有什么区别?排序帽为什么适合用类方法?
参考答案:实例方法第一个参数是 self,指向某个具体对象,必须先创建实例才能调用;类方法用 @classmethod 装饰,第一个参数是 cls,指向类本身,可以直接用 Hat.sort(...) 调用而无需 Hat()。排序帽在设定中只有一个,也不需要保存每个帽子的独立状态,它需要的是一份共享的学院列表和一个排序动作——这正是类变量加类方法的场景。此外,如果「获取学生」的逻辑写成实例方法,就得先有学生对象才能调用它,形成鸡生蛋的循环;类方法避开了这个问题。
5. class Student(Wizard) 加 super().__init__(name) 解决了什么重复?如果不写 super().__init__ 会怎样?
参考答案:Student 与 Professor 都需要保存并校验 name,这段逻辑原本要在两个类里各写一遍。把 name 与校验上移到父类 Wizard,子类通过 super().__init__(name) 复用,只保留各自的字段(house、subject)。如果不调用 super().__init__(name),父类的初始化不会执行,self.name 根本不会被设置,校验也不会发生;访问 student.name 会得到 AttributeError。继承只提供方法,不会自动替你调用父类构造。
6. potter + weasley 为什么需要 __add__?self 与 other 分别对应什么?
参考答案:+ 对自定义类型没有默认语义,Python 会尝试调用左操作数的 __add__ 方法;若未定义,就抛出 TypeError: unsupported operand type(s) for +: 'Vault' and 'Vault'。定义 __add__(self, other) 后,potter + weasley 等价于 potter.__add__(weasley):self 是左边的 potter,other 是右边的 weasley。方法内部读取两者的三个字段相加,返回一个新的 Vault。字符串的拼接、列表的合并,底层也是各自类实现了 __add__。
7. 校验失败时为什么用 raise ValueError,而不是 print("Invalid") 或 sys.exit()?
参考答案:print 只是输出一行文字,程序会继续执行,调用方无法知道对象是否合法,可能把错误数据存进数据库。sys.exit() 会终止整个程序,代价过大——批量处理数据时,一条坏记录不应该让整个任务停下。raise ValueError("Invalid house") 把错误交给调用方:它可以立刻处理,也可以用 try/except 跳过这一条继续处理下一条数据。异常是「可恢复的错误信号」,退出程序不是。
与前后章的关系
第 8 章用正则清洗用户输入,本章把清洗后的值装进类,并让类自己负责校验。第 10 章会把多个类与函数组织成命令行程序,届时 argparse、文件读写、异常处理与类会一起出现。更往后,任何稍大的 Python 项目都会用类来建模实体,本章的 __init__、property、继承与特殊方法是最常用的四个抓手。
This is chapter 9 of the long video (about 16 hours), corresponding to CS50P lecture 8, "Object-Oriented Programming", covering 10:37:47–13:29:08. Earlier chapters were largely procedural: order the steps, then extract functions. This chapter adds a third paradigm — object orientation — bundling related data and behaviour into a class.
A concrete problem: storing a student
The program reads a name and a house, then prints them.
name = input("Name: ")
house = input("House: ")
print(f"{name} from {house}")
It works but has no structure. Extract the input into functions:
def main():
name = get_name()
house = get_house()
print(f"{name} from {house}")
def get_name():
return input("Name: ")
def get_house():
return input("House: ")
Functions add a layer of abstraction. The real trouble starts when one function has to return two values.
Timeline
| Time | Topic |
|---|---|
| 10:37:47 | Three paradigms: procedural, functional, object-oriented |
| 10:39:43 | Starting student.py and adding features step by step |
| 10:44:34 | get_student must return two values: a tuple |
| 10:47:22 | Tuples are immutable; editing Padma's house raises TypeError |
| 10:54:06 | Switching to a list: mutable, but positional |
| 10:56:25 | Switching to a dictionary: keyed, more expressive |
| 11:05:46 | class Student: inventing a data type |
| 11:17:00 | __init__, self, instance variables, constructor calls |
| 11:30:23 | Validation in the class: raise ValueError("missing name") |
| 11:40:30 | __str__: making print show something readable |
| 11:46:36 | A custom method: charm |
| 12:00:48 | @property and setters: closing the bypass |
| 12:18:26 | Python runs on an honour system; _house is still writable |
| 12:20:04 | int, str, list, dict were classes all along |
| 12:29:15 | @classmethod and class variables: the Sorting Hat |
| 12:59:02 | Inheritance: Wizard → Student / Professor, super().__init__ |
| 13:13:02 | Operator overloading: __add__ adds two vaults |
Returning several values: tuples
def get_student():
name = input("Name: ")
house = input("House: ")
return name, house
def main():
name, house = get_student()
print(f"{name} from {house}")
return name, house looks like two values but returns one tuple — an ordered, immutable sequence. It can be unpacked or indexed: student[0], student[1].
Immutability is a feature:
student = ("Padma", "Gryffindor")
student[1] = "Ravenclaw"
produces
TypeError: 'tuple' object does not support item assignment
To allow modification, use a list ["Padma", "Gryffindor"]. But lists still carry the problem that student[0] is the name and student[1] the house — a convention that lives only in the programmer's memory. With more fields, someone will get it wrong.
Dictionaries: better semantics, still loose
def get_student():
name = input("Name: ")
house = input("House: ")
return {"name": name, "house": house}
def main():
student = get_student()
print(f"{student['name']} from {student['house']}")
Keys replace indices and readability improves at once. Mind the quoting in the f-string: with double quotes outside, the keys must use single quotes, or Python cannot tell where the string ends.
The dictionary's weakness is that it accepts anything. Any key, any value, no validation, no behaviour. "A student must belong to one of four houses" or "the name cannot be empty" is simply not expressible as a constraint on a dict.
A class: inventing a data type
class Student:
...
def get_student():
student = Student()
student.name = input("Name: ")
student.house = input("House: ")
return student
class Student: defines a new type; Student() creates an object (also called an instance). student.name = ... attaches an attribute (instance variable).
| Term | Meaning |
|---|---|
| class | the blueprint or mould defining what such objects look like |
| object / instance | one concrete thing made from the blueprint |
| attribute / instance variable | data stored inside the object |
| method | a function defined in the class |
__init__: construct and validate
Attaching attributes by hand is loose. __init__ fixes an object's contents at creation:
class Student:
def __init__(self, name, house):
self.name = name
self.house = house
def get_student():
name = input("Name: ")
house = input("House: ")
return Student(name, house)
Points that confuse beginners:
Student(name, house)is a constructor call: Python creates an empty object, then calls__init__on it automatically;selfis the first parameter Python passes in, referring to the newly created object, which is why the caller does not supply it;self.name = namestores the argument inside the object as an instance variable.
Now validation can live in __init__:
class Student:
def __init__(self, name, house):
if not name:
raise ValueError("Missing name")
if house not in ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"]:
raise ValueError("Invalid house")
self.name = name
self.house = house
raise signals an error deliberately. Printing and continuing is not enough — the object already exists, and the caller must learn that it is invalid. sys.exit() is too much: it kills the whole program when the caller might want to catch the error and process the next record.
try:
student = Student(name, house)
except ValueError as e:
print(e)
__str__: control printing
print(student) by default shows
<__main__.Student object at 0x102733e80>
the default representation: type name plus memory address. Define __str__ to override:
class Student:
def __init__(self, name, house):
...
self.name = name
self.house = house
def __str__(self):
return f"{self.name} from {self.house}"
Now print(student) calls __str__ and prints Harry from Gryffindor. __str__ is the user-facing representation; __repr__ is the developer-facing one, usually including the type.
Methods: objects that act
class Student:
...
def charm(self):
match self.patronus:
case "Stag":
return "🐴"
case "Otter":
return "🦦"
case "Jack Russell terrier":
return "🐶"
case _:
return "🪄"
A method is a function inside a class whose first parameter is always self. Calling student.charm() makes Python pass student as self. Unlike __init__ and __str__, this method is not called automatically; you write student.charm() yourself.
property: guarding the attribute
The validation above has a hole. Even if __init__ rejects bad values, later code can still overwrite them:
student = Student("Harry", "Gryffindor")
student.house = "Number Four, Privet Drive" # bypasses validation
__init__ runs only once. A property closes the gap:
class Student:
def __init__(self, name, house):
self.name = name
self.house = house # note: this also goes through the setter
@property
def house(self):
return self._house
@house.setter
def house(self, house):
if house not in ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"]:
raise ValueError("Invalid house")
self._house = house
How it works:
@propertydefines a getter, called onstudent.house;@house.setterdefines a setter, called onstudent.house = ...;- the real storage is
self._house, with an underscore to avoid name collision; __init__assignsself.house = housewithout the underscore, so initialisation goes through the setter and validation is written once.
Python's honour system
Even so, student._house = "..." bypasses everything. Python has no private keyword as Java does. A leading underscore is a convention: one underscore means "please don't touch", two means "really, don't touch", but nothing enforces it.
This is a design stance rather than an oversight: Python assumes adults are using it. Real encapsulation relies on convention, review and documentation rather than the compiler.
You have been using classes all along
print(type(50)) # <class 'int'>
print(type("hello")) # <class 'str'>
print(type([])) # <class 'list'>
print(type({})) # <class 'dict'>
int, str, list and dict are all classes. "Hello".lower() calls a method of str; list.append() is a method of list. Eight chapters have been object-oriented without naming it.
Built-in classes are lowercase; custom classes are capitalised (Student) by PEP 8 convention.
classmethod and class variables
The Sorting Hat exists once in the story, so there is no reason to create many hats. A class method fits:
import random
class Hat:
houses = ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"]
@classmethod
def sort(cls, name):
print(name, "is in", random.choice(cls.houses))
Hat.sort("Harry")
houseslives in the class, not in__init__: it is a class variable, shared by every instance;@classmethodmakes the method receivecls(the class) instead ofself(an object);- call it as
Hat.sort(...); noHat()needed.
This also solves a chicken-and-egg problem: if "get a student" were an instance method, you would need a student object before you could create one. A class method works with no instance at all.
@staticmethod is a third kind: it receives neither self nor cls and is just an ordinary function in the class namespace. The lecture does not develop it.
Inheritance: removing duplication
Students and professors both have names, and the validation is identical. Copy-paste is a smell:
class Wizard:
def __init__(self, name):
if not name:
raise ValueError("Missing name")
self.name = name
class Student(Wizard):
def __init__(self, name, house):
super().__init__(name)
self.house = house
class Professor(Wizard):
def __init__(self, name, subject):
super().__init__(name)
self.subject = subject
class Student(Wizard)makesStudentinherit fromWizard, gaining its attributes and methods;super().__init__(name)runs the parent's initialiser, so name validation is written once;- subclasses add their own fields (
house,subject) and may override parent methods.
Inheritance is hierarchical: a grandchild inherits from the grandparent as well. Multiple inheritance is possible in Python, though the lecture keeps a single chain.
Note: Python's exception hierarchy is itself inheritance — ValueError descends from Exception, which descends from BaseException. That is why except Exception catches almost everything while except ValueError catches one family. Custom exceptions usually subclass an existing one rather than starting from scratch.
Operator overloading
Adding two vaults naively means pulling out three fields and building a new object:
total = Vault(
potter.galleons + weasley.galleons,
potter.sickles + weasley.sickles,
potter.knuts + weasley.knuts,
)
To write total = potter + weasley, implement __add__:
class Vault:
def __init__(self, galleons=0, sickles=0, knuts=0):
self.galleons = galleons
self.sickles = sickles
self.knuts = knuts
def __str__(self):
return f"{self.galleons} Galleons, {self.sickles} Sickles, {self.knuts} Knuts"
def __add__(self, other):
return Vault(
self.galleons + other.galleons,
self.sickles + other.sickles,
self.knuts + other.knuts,
)
potter + weasley calls potter.__add__(weasley), with self on the left and other on the right. Without __add__, Vault + Vault raises TypeError: unsupported operand type(s).
The same mechanism makes + concatenate strings and merge lists: str and list each implement __add__. Other operators can be overloaded too — -, <, == and many more — but new symbols cannot be invented.
Takeaways
- Tuples are ordered and immutable; lists are mutable but positional; dictionaries are keyed but unconstrained.
- A class binds data to behaviour;
classdefines the type and calling the class name creates instances. __init__initialises and validates at construction time;selfis the current object.- Failed validation should
raise ValueError, letting callers decide, notprintorsys.exit. __str__decides whatprint(obj)shows;__repr__is for developers.@propertyplus@x.setterroutes both read and write through your code; the real store is_x.- Python's privacy is convention, not enforcement; the underscore is a "do not touch" signal.
@classmethodreceivescls, suiting shared-per-class data or calls that need no instance.class Child(Parent)plussuper().__init__(...)reuses code; the exception hierarchy works the same way.- Special methods such as
__add__implement operator overloading, letting a class define what+means.
Comprehension check
1. What does return name, house actually return, and why does student[1] = "Ravenclaw" fail?
Reference answer: The comma returns one tuple, (name, house), not two independent values; the receiver's name, house = ... is unpacking. A tuple is an immutable sequence, so assigning to an index raises TypeError: 'tuple' object does not support item assignment. Return a list [name, house] if mutation is needed, or a dictionary or class if clearer semantics are wanted.
2. Why is validation in __init__ not enough, and what does @property add?
Reference answer: __init__ runs only when the object is created. Afterwards code can write student.house = "invalid" and bypass all checks. @property turns house into an attribute controlled by a getter and setter: reads call the getter, assignments call the setter, and the setter re-runs validation, raising on bad values. The real value is stored in self._house to avoid a name collision. Both construction and later mutation therefore pass through the same validation code.
3. With properties in place, why can student._house = ... still bypass them? What does that say about Python's design?
Reference answer: Python has no language-level access control such as private; a leading underscore is only a naming convention meaning "internal, please don't touch". _house remains an ordinary attribute that can be assigned. Python chooses an honour system — trusting users to follow convention — rather than compiler enforcement. Encapsulation is maintained through convention, review and documentation, unlike Java's private.
4. How does @classmethod differ from an instance method, and why does the Sorting Hat suit it?
Reference answer: An instance method's first parameter is self, the specific object, so an instance must exist before it can be called. A class method is decorated with @classmethod, receives cls — the class itself — and can be called as Hat.sort(...) without creating a Hat(). The Sorting Hat exists once and holds no per-hat state; what it needs is a shared house list and a sorting action, exactly the class-variable-plus-classmethod shape. It also avoids a chicken-and-egg problem: if "get a student" were an instance method, you would need a student before you could create one.
5. What duplication does class Student(Wizard) with super().__init__(name) remove? What happens without the super() call?
Reference answer: Both Student and Professor need to store and validate name. Moving that to a parent Wizard and calling super().__init__(name) reuses it, leaving each subclass with only its own field (house, subject). Without the call, the parent initialiser never runs: self.name is never set and validation never happens, so accessing student.name raises AttributeError. Inheritance provides the method but does not call the parent constructor for you.
6. Why does potter + weasley need __add__, and what do self and other refer to?
Reference answer: + has no default meaning for a custom type; Python tries the left operand's __add__ and, if missing, raises TypeError: unsupported operand type(s) for +: 'Vault' and 'Vault'. Defining __add__(self, other) makes potter + weasley equivalent to potter.__add__(weasley): self is potter on the left, other is weasley on the right. The method reads both objects' three fields, adds them, and returns a new Vault. String concatenation and list merging use the same machinery.
7. Why raise ValueError on invalid data instead of printing a message or calling sys.exit()?
Reference answer: print only outputs text and execution continues, so the caller never learns the object is invalid and may store corrupt data. sys.exit() terminates the whole program — too heavy when processing many records, where one bad row should not stop the batch. raise ValueError("Invalid house") hands the error to the caller, which can handle it immediately or use try/except to skip the record and continue. An exception is a recoverable error signal; exiting the program is not.
How it connects
Chapter 8 used regex to clean user input; this chapter stores the cleaned values in a class that owns its own validation. Chapter 10 assembles classes and functions into a command-line program where argparse, file I/O, exceptions and classes all appear together. Beyond that, almost any larger Python project models its entities with classes, and the four handles introduced here — __init__, properties, inheritance and special methods — are the ones used most.