类(一):对象与封装
Class I: Objects and Encapsulation
函数打包一段逻辑,类打包数据和作用于它的逻辑。当程序里的"东西"开始有自己的状态和行为时,就该用类了。
本讲结束后你应当能
- 说清楚类和对象的关系,以及为什么需要把数据和函数绑在一起
- 写出带 __init__ 和成员方法的类
- 解释 self 是什么、为什么必须写
- 区分实例变量和类变量
- 用单下划线表达"这是内部实现"
本页目录
先用你已有的工具试一次
假设要表示平面上的点,并算两点距离。用你现在会的东西:
def make_point(x: float, y: float) -> dict[str, float]:
return {"x": x, "y": y}
def distance(p1: dict[str, float], p2: dict[str, float]) -> float:
return ((p1["x"] - p2["x"]) ** 2 + (p1["y"] - p2["y"]) ** 2) ** 0.5
a = make_point(0, 0)
b = make_point(3, 4)
print(distance(a, b))
能用。但有几个地方让人不舒服:
- 数据和操作是分开的。
make_point和distance只是碰巧都处理点,语言层面没有任何东西表明它们相关 - 没有保护。 谁都能写
a["x"] = "abc",或者del a["y"],之后distance就崩了 - 调用方式别扭。
distance(a, b)读起来像”距离(a, b)“,而不是”a 到 b 的距离” - 写起来啰嗦。 每次都要
p["x"]
当程序里的一个”东西”既有自己的数据、又有专属的操作时,就该把它们打包在一起。这就是类。
类和对象
class Point:
pass
p = Point()
print(p)
print(type(p))
- 类(class)是模板,描述”这一类东西长什么样、能做什么”
- 对象(object)是按模板造出来的具体的一个,也叫实例(instance)
类和对象的关系,就是”狗”和”你家那只狗”的关系。Point 是概念,p 是一个具体的点。
其实你早就在用对象了——L2 说 type() 返回类型,而每个类型就是一个类:
print(type(42), type("abc"), type([1, 2]), type({}))
"abc".upper() 就是在一个 str 对象上调用它的方法。你只是没意识到而已。
__init__:造对象的时候做点什么
上面的 Point() 造出来的对象是空的。我们希望造的时候就带上坐标:
class Point:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
p = Point(3, 4)
print(p.x, p.y)
__init__ 是一个特殊方法,Python 在你写 Point(3, 4) 的时候会自动调用它。名字前后各两个下划线是 Python 的约定,表示”这是给解释器用的,不是给你直接叫的”。
它的作用是初始化这个新对象——把该有的属性都设上。
self 到底是什么
这是本讲最大的认知障碍,值得多花点时间。
观察一:定义时有 self,调用时不传
class Point:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
def show(self) -> None: # 定义时有 self
print(f"({self.x}, {self.y})")
p = Point(3, 4)
p.show() # 调用时不传
show(self) 定义了一个参数,调用 p.show() 却传了零个。参数去哪了?
观察二:p.show() 其实是 Point.show(p)
class Point:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
def show(self) -> None:
print(f"({self.x}, {self.y})")
p = Point(3, 4)
p.show() # 平常的写法
Point.show(p) # 完全等价,这才是真相
p.show() 是语法糖。 Python 把点号左边的对象,自动作为第一个参数传进去。
所以 self 就是**“调用这个方法的那个对象自己”**。
观察三:为什么必须要有
一个类的所有对象共用同一份方法代码:
class Point:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
def show(self) -> None:
print(f"({self.x}, {self.y})")
a = Point(0, 0)
b = Point(3, 4)
print("两个对象的 show 是同一个函数吗:", a.show.__func__ is b.show.__func__)
a.show()
b.show()
同一段代码,打印出不同的结果——因为 self 每次指向不同的对象。没有 self,方法就不知道自己在为谁工作。
self 这个名字不是关键字,理论上可以改。但所有人都用 self,换名字只会让读代码的人困惑——就像 L4 说的 args/kwargs。
把点写完整
class Point:
"""平面上的一个点。"""
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
def distance_to(self, other: "Point") -> float:
"""到另一个点的距离。"""
return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5
def move(self, dx: float, dy: float) -> None:
"""把这个点平移。"""
self.x += dx
self.y += dy
def show(self) -> None:
print(f"({self.x}, {self.y})")
a = Point(0, 0)
b = Point(3, 4)
print(a.distance_to(b))
a.move(1, 1)
a.show()
print(a.distance_to(b))
对比开头的字典版本:
a.distance_to(b)读起来就是”a 到 b 的距离”- 数据和操作在一起,一眼能看出这个类能做什么
- 加新功能只要加个方法,不用到处找
封装:数据和行为打包在一起
这个”把数据和作用于它的行为绑在一起”的做法,叫封装(encapsulation)。
它解决的核心问题是规模。一个几千行的程序,如果所有数据都是散落的字典和列表、所有函数都在同一个平面上,你没法知道改一处会影响哪里。
用类之后,边界清楚了:
- 想知道点能做什么 → 看
Point类 - 改点的实现 → 只改
Point里面 - 用点 → 只需要知道方法名,不需要知道内部怎么存的
这和 L3 讲函数时的理由是同一个,只是尺度更大了:函数打包一段逻辑,类打包一组相关的数据和逻辑。
一个更实在的例子
class BankAccount:
"""一个简化的银行账户。"""
def __init__(self, owner: str, balance: float = 0.0) -> None:
self.owner = owner
self.balance = balance
self.history: list[str] = []
def deposit(self, amount: float) -> None:
if amount <= 0:
print("存款金额必须为正")
return
self.balance += amount
self.history.append(f"存入 {amount}")
def withdraw(self, amount: float) -> bool:
if amount > self.balance:
print(f"余额不足:想取 {amount},只有 {self.balance}")
return False
self.balance -= amount
self.history.append(f"取出 {amount}")
return True
def statement(self) -> str:
lines = [f"{self.owner} 的账户,余额 {self.balance}"]
lines.extend(" " + h for h in self.history)
return "\n".join(lines)
acc = BankAccount("张三", 100)
acc.deposit(50)
acc.withdraw(30)
acc.withdraw(1000) # 余额不足
print(acc.statement())
注意 withdraw 里的检查——这正是封装的价值。如果余额只是一个散落在外面的变量,谁都能直接把它改成负数。放进类里,所有修改都必须经过方法,方法就能守住规则。
实例变量与类变量
到目前为止,self.x 这样的属性叫实例变量——每个对象一份,互不相干。
有时候你想要所有对象共享的东西,那就直接写在类里面:
class Dog:
species = "Canis familiaris" # 类变量:所有狗共享
def __init__(self, name: str) -> None:
self.name = name # 实例变量:每只狗自己的
a = Dog("旺财")
b = Dog("来福")
print(a.name, b.name) # 各不相同
print(a.species, b.species) # 一样
print(Dog.species) # 也可以直接从类访问
一个常见用途是计数:
class Student:
count = 0 # 一共创建了多少个学生
def __init__(self, name: str) -> None:
self.name = name
Student.count += 1 # 注意是 Student.count 不是 self.count
Student("张三"); Student("李四"); Student("王五")
print("学生总数:", Student.count)
class BadBasket:
items: list[str] = []
def add(self, x: str) -> None:
self.items.append(x)
class GoodBasket:
def __init__(self) -> None:
self.items: list[str] = []
def add(self, x: str) -> None:
self.items.append(x)
b1, b2 = BadBasket(), BadBasket()
b1.add("苹果")
print("BadBasket b2.items =", b2.items, "← 串了")
g1, g2 = GoodBasket(), GoodBasket()
g1.add("苹果")
print("GoodBasket g2.items =", g2.items, "← 正常")
私有:一条下划线的约定
有些属性是内部实现细节,不希望外面直接碰。Python 的做法是约定而不是强制:
class Temperature:
def __init__(self, celsius: float) -> None:
self._celsius = celsius # 单下划线:内部使用,请别直接改
def to_fahrenheit(self) -> float:
return self._celsius * 9 / 5 + 32
def set_celsius(self, value: float) -> None:
if value < -273.15:
print("低于绝对零度,忽略")
return
self._celsius = value
t = Temperature(25)
print(t.to_fahrenheit())
t.set_celsius(-300) # 被挡住了
t.set_celsius(100)
print(t.to_fahrenheit())
单下划线 _name 的意思是:“这是内部的,别指望它稳定。” Python 不会阻止你访问 t._celsius,但这么做等于无视作者的警告——将来作者改了内部实现,你的代码就坏了。
双下划线 __name 会触发名称改写(name mangling),访问起来更麻烦:
class Secret:
def __init__(self) -> None:
self.__hidden = 42
s = Secret()
print(s.__hidden)
class Secret:
def __init__(self) -> None:
self.__hidden = 42
s = Secret()
print(s._Secret__hidden) # 其实还是能拿到,只是名字被改了
所以它也不是真正的”私有”。实践中用单下划线就够了,双下划线主要用来避免子类意外覆盖(L10 讲继承时会再提)。
什么时候该用类
不是所有东西都需要类。判断依据:
| 用类 | 不用类 |
|---|---|
| 这个”东西”有自己的状态,而且状态会变 | 只是一次性的数据转换 |
| 有一组操作专门作用于这份数据 | 只有一个函数用得上 |
| 会存在很多个同类的实例 | 全局只有一份 |
| 需要保护数据的合法性 | 数据本来就是只读的 |
小结
- 类是模板,对象是按模板造出来的实例。
type(x)返回的就是 x 的类 __init__在创建对象时自动调用,负责初始化属性self是”调用这个方法的对象自己”。p.show()等价于Point.show(p)- 忘了写
self→TypeError: ... takes 0 positional arguments but 1 was given - 封装:把数据和作用于它的行为打包在一起,规则由方法守住
- 实例变量每个对象一份,类变量所有对象共享
- 类变量不要用可变对象——和可变默认参数是同一个坑
- 单下划线
_x表示内部实现,是约定不是强制 - 只有一个方法的类应该是函数;只有数据的类应该是字典
练习
- 写一个
Rectangle类,有width和height,提供area()、perimeter()和is_square()三个方法。 - 给
Rectangle加一个scale(factor)方法,把长宽都乘以factor。它应该修改自身还是返回新对象?说明你的选择。 - 写一个
Counter类,能add(item)计数、get(item)查询、most_common()返回出现最多的项。用实例变量存数据。 - 下面的类有什么问题?改对它:
class Playlist: songs = [] def add(self, song): self.songs.append(song) - 写一个
Stack类(L7 讲调用栈时提过栈的概念),提供push、pop、peek、is_empty。pop空栈时该怎么办?说明你的设计。 - 把 L6 练习里的”成绩单解析”改写成一个
Gradebook类,提供add(name, subject, score)、average(name)、top_student()。
本讲的配套上机题在 Lab 9。