类(二)与模块
Class II and Module
本讲分两段。前半节讲继承与多态——让类之间产生关系;后半节讲模块,把一堆类和函数装进文件,并看一眼 Python 是怎么把你的代码跑起来的。
本讲结束后你应当能
- 用继承复用代码,并用重写改变子类的行为
- 让自己的类支持 +、[]、print() 等内置操作
- 说清楚 == 和 is 的区别
- 解释 import 做了什么、Python 到哪里找模块
- 说明 __name__ == "__main__" 的作用
本页目录
上半节:继承与多态
类之间也有关系
L9 里每个类都是孤立的。但现实中的概念是有层次的:狗是动物,猫也是动物;正方形是矩形;管理员是用户。
继承(inheritance)就是表达这种”是一种”关系的机制。
class Animal:
def __init__(self, name: str) -> None:
self.name = name
def speak(self) -> str:
return "..."
def introduce(self) -> str:
return f"我是 {self.name},我说:{self.speak()}"
class Dog(Animal): # Dog 继承 Animal
def speak(self) -> str:
return "汪"
class Cat(Animal):
def speak(self) -> str:
return "喵"
for a in [Animal("动物"), Dog("旺财"), Cat("咪咪")]:
print(a.introduce())
看清楚发生了什么:
Dog没有定义__init__和introduce,但它有——从Animal继承来的Dog定义了自己的speak,覆盖了父类的版本introduce的代码只写了一遍,但它调用的self.speak()会根据实际对象走不同的分支
最后这一点是关键,我们马上会回到它。
术语:Animal 叫父类(parent / base class),Dog 叫子类(child / derived class)。
重写与 super()
子类重新定义父类已有的方法,叫重写(override)。
重写 __init__ 时经常需要”先做父类那套,再加自己的”:
class Person:
def __init__(self, name: str, age: int) -> None:
self.name = name
self.age = age
def describe(self) -> str:
return f"{self.name},{self.age} 岁"
class Student(Person):
def __init__(self, name: str, age: int, school: str) -> None:
super().__init__(name, age) # 先让父类初始化 name 和 age
self.school = school # 再加自己的
def describe(self) -> str:
return super().describe() + f",就读于{self.school}"
s = Student("张三", 18, "交大")
print(s.describe())
print(s.name, s.age, s.school)
super() 的意思是”父类的那个版本”。它有两个好处:
- 不用重复写父类已有的初始化代码
- 父类改了,子类自动跟着改
多态:同一个调用,不同的行为
回到刚才那个关键点。看这段代码:
class Shape:
def area(self) -> float:
raise NotImplementedError("子类必须实现 area")
class Circle(Shape):
def __init__(self, r: float) -> None:
self.r = r
def area(self) -> float:
return 3.14159 * self.r ** 2
class Square(Shape):
def __init__(self, side: float) -> None:
self.side = side
def area(self) -> float:
return self.side ** 2
class Rectangle(Shape):
def __init__(self, w: float, h: float) -> None:
self.w, self.h = w, h
def area(self) -> float:
return self.w * self.h
shapes: list[Shape] = [Circle(1), Square(2), Rectangle(2, 3)]
total = 0.0
for s in shapes:
print(f"{type(s).__name__:10} 面积 {s.area():.4f}")
total += s.area()
print(f"总面积 {total:.4f}")
那个 for 循环根本不知道自己在处理圆还是方——它只知道”每个东西都有 area()”。这就是多态(polymorphism):同一个调用,作用在不同类型上有不同的行为。
它的价值在于扩展:以后要加三角形,只要写一个新类,上面那段循环一个字都不用改。
鸭子类型:Python 不看血统
上面的 Shape 基类其实是可选的。Python 只关心对象有没有那个方法,不关心它继承自谁:
class Circle: # 没有继承任何东西
def area(self) -> float:
return 3.14159
class Square: # 也没有
def area(self) -> float:
return 4.0
for s in [Circle(), Square()]: # 照样工作
print(s.area())
这叫鸭子类型(duck typing),来自一句英文谚语:
如果它走起来像鸭子、叫起来像鸭子,那它就是鸭子。
Python 判断”能不能这么用”的依据是”有没有这个方法”,而不是”是不是那个类型”。
这也是为什么 len() 能用在字符串、列表、字典、集合上——它们没有共同的父类,只是都实现了 __len__。
让你的类支持内置操作
Python 有一批特殊方法(名字前后两个下划线,俗称 dunder),实现它们,你的对象就能用内置语法操作。
__str__ 和 __repr__:怎么打印
class Point:
def __init__(self, x: float, y: float) -> None:
self.x, self.y = x, y
p = Point(3, 4)
print(p) # 默认的样子,没什么用
class Point:
def __init__(self, x: float, y: float) -> None:
self.x, self.y = x, y
def __str__(self) -> str:
"""给人看的"""
return f"({self.x}, {self.y})"
def __repr__(self) -> str:
"""给开发者看的,最好能复制粘贴回去重建对象"""
return f"Point({self.x}, {self.y})"
p = Point(3, 4)
print(p) # 用 __str__
print(str(p)) # 用 __str__
print(repr(p)) # 用 __repr__
print([p, p]) # 容器里的元素用 __repr__
运算符重载
class Vector:
def __init__(self, x: float, y: float) -> None:
self.x, self.y = x, y
def __repr__(self) -> str:
return f"Vector({self.x}, {self.y})"
def __add__(self, other: "Vector") -> "Vector":
return Vector(self.x + other.x, self.y + other.y)
def __sub__(self, other: "Vector") -> "Vector":
return Vector(self.x - other.x, self.y - other.y)
def __mul__(self, k: float) -> "Vector":
return Vector(self.x * k, self.y * k)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Vector):
return NotImplemented
return self.x == other.x and self.y == other.y
def __abs__(self) -> float:
return (self.x ** 2 + self.y ** 2) ** 0.5
a = Vector(1, 2)
b = Vector(3, 4)
print(a + b)
print(b - a)
print(a * 3)
print(abs(b))
print(a == Vector(1, 2))
print(a == b)
常用的一批:
| 你写的 | 调用的方法 |
|---|---|
a + b | __add__ |
a - b | __sub__ |
a * b | __mul__ |
a == b | __eq__ |
a < b | __lt__ |
len(a) | __len__ |
a[i] | __getitem__ |
a[i] = v | __setitem__ |
x in a | __contains__ |
for x in a | __iter__(L13 讲) |
print(a) | __str__ |
abs(a) | __abs__ |
让对象支持下标
class Playlist:
def __init__(self) -> None:
self._songs: list[str] = []
def add(self, song: str) -> None:
self._songs.append(song)
def __len__(self) -> int:
return len(self._songs)
def __getitem__(self, i: int) -> str:
return self._songs[i]
def __contains__(self, song: str) -> bool:
return song in self._songs
pl = Playlist()
pl.add("歌一"); pl.add("歌二"); pl.add("歌三")
print(len(pl))
print(pl[0], pl[-1])
print("歌二" in pl)
for song in pl: # 有了 __getitem__,for 也能用
print(" -", song)
== 与 is
这两个经常被混淆,而 L5 的引用模型正好解释它们。
==比较值——两个东西内容一样吗(可以由__eq__定义)is比较身份——两个名字指向的是不是同一个对象(等价于id(a) == id(b))
a = [1, 2, 3]
b = [1, 2, 3]
c = a
print("a == b:", a == b) # 内容一样
print("a is b:", a is b) # 但是两个不同的列表
print("a is c:", a is c) # c 和 a 是同一个
print(id(a), id(b), id(c))
内省:问对象你是谁
class Animal: pass
class Dog(Animal): pass
d = Dog()
print(type(d)) # 它是什么类型
print(isinstance(d, Dog)) # 是 Dog 吗
print(isinstance(d, Animal)) # 也是 Animal(因为继承)
print(issubclass(Dog, Animal)) # Dog 是 Animal 的子类吗
print(Dog.__mro__) # 方法查找顺序
isinstance 会考虑继承关系,type() == 不会:
class Animal: pass
class Dog(Animal): pass
d = Dog()
print(isinstance(d, Animal)) # True
print(type(d) == Animal) # False
判断类型时优先用 isinstance,它对子类也成立,这通常是你想要的。
class Point:
def __init__(self, x: float, y: float) -> None:
self.x, self.y = x, y
def show(self) -> None: pass
p = Point(1, 2)
print(p.__dict__) # 这个对象有哪些实例变量
print([n for n in dir(p) if not n.startswith("_")]) # 有哪些公开成员
dir() 和 __dict__ 在调试时很有用——不确定一个对象有什么,直接问它。
万物皆对象
Python 里一切都是对象,包括函数和类本身:
def greet(name: str) -> str:
return f"你好,{name}"
print(type(greet))
print(greet.__name__)
f = greet # 函数可以赋给变量
print(f("张三"))
def apply(func, value): # 函数可以当参数传
return func(value)
print(apply(greet, "李四"))
print(apply(str.upper, "abc"))
这一点在 L14 会展开成”函数是一等公民”,是函数式编程的基础。
下半节:模块与解释器
一个文件装不下
程序变大之后,把所有类和函数堆在一个文件里就不行了。模块(module)就是一个 .py 文件,里面装着一组相关的定义。
你已经在用了:
import math
print(math.sqrt(16), math.pi)
math 就是一个模块。
import 的几种写法
# 1. 导入整个模块,用「模块名.成员」访问
import math
print(math.sqrt(16))
# 2. 起别名
import math as m
print(m.sqrt(16))
# 3. 只导入需要的名字,直接用
from math import sqrt, pi
print(sqrt(16), pi)
# 4. 导入并起别名
from math import sqrt as square_root
print(square_root(16))
# 5. 全部导入 —— 不要用
from math import *
print(sqrt(16))
import 做了什么
import mymodule 实际发生三件事:
- 找到这个模块对应的文件
- 执行它里面的所有代码(从上到下,一次)
- 把结果绑定到一个名字上
第 2 点很重要——import 会真的运行那个文件。如果模块里有一句裸露的 print("加载了"),import 的时候就会打印出来。
而且只执行一次:
import math
import math # 第二次 import 不会重新执行
import math
print("math 被加载了几次?只有第一次真的执行了")
print(math.pi)
Python 把已加载的模块记在 sys.modules 里,之后的 import 直接取缓存。
import sys
print("已加载的模块数量:", len(sys.modules))
print("math 在里面吗:", "math" in sys.modules)
Python 到哪里找模块
import sys
for p in sys.path[:5]:
print(repr(p))
sys.path 是一个目录列表,Python 按顺序在里面找。大致顺序是:
- 当前脚本所在的目录
PYTHONPATH环境变量指定的目录- 标准库目录
- 第三方包的安装目录(
site-packages)
__name__ 和 __main__
每个模块都有一个 __name__ 变量:
- 直接运行这个文件时,
__name__是"__main__" - 被别人 import 时,
__name__是模块名
print("当前的 __name__ 是:", __name__)
由此产生一个非常常见的写法:
# mymath.py
def add(a: int, b: int) -> int:
return a + b
def main() -> None:
print(add(2, 3))
print(add(10, 20))
if __name__ == "__main__":
main()
它的意思是:“只有当这个文件被直接运行时,才执行 main()。”
为什么需要它?因为别人 import mymath 只是想用 add 函数,不希望你的测试代码跟着跑起来。
包:模块的目录
一个目录里放多个模块,就构成包(package):
mypackage/
├── __init__.py # 有它才被当作包(现代 Python 可省略)
├── geometry.py
└── stats.py
from mypackage import geometry
from mypackage.stats import mean
pip:安装别人的包
pip install numpy # 安装
pip install numpy==1.26.0 # 指定版本
pip list # 看装了什么
pip uninstall numpy # 卸载
python3 -m pip install numpy # 推荐写法,确保装到当前解释器
几个你会用到的库:
| 库 | 干什么 |
|---|---|
| NumPy | 数值计算,多维数组。团队项目就是做一个简化版 |
| Matplotlib | 画图 |
| SciPy | 科学计算:优化、积分、统计 |
| pandas | 表格数据处理 |
| pytest | 测试(L11 会用) |
你的代码是怎么跑起来的
L1 说过 Python 是解释型语言。现在看得细一点。
实际过程分两步:
- 编译成字节码(bytecode)——一种中间表示,比源码更接近机器,但仍不是机器码
- 虚拟机执行字节码
import dis
def add(a, b):
return a + b
dis.dis(add)
上面就是 add 函数的字节码。大致读法是:先把两个参数取出来放到一个栈上,再做一次二元运算(BINARY_OP),最后把栈顶的值返回(RETURN_VALUE)。
具体的指令名字每个 Python 版本都在变——你不需要记它们,看出「取值、运算、返回」这个骨架就够了。
这解释了你在项目目录里见过的 __pycache__ 文件夹——里面的 .pyc 就是缓存下来的字节码。有了它,下次 import 同一个模块就不用重新编译了。
小结
继承与多态
- 继承表达”是一种”关系,子类自动获得父类的属性和方法
- 重写改变子类的行为;
super()调用父类版本 - 子类
__init__里第一件事通常是super().__init__(...) - 多态:同一个调用作用在不同类型上有不同行为,让代码可扩展
- 鸭子类型:Python 看有没有那个方法,不看是不是那个类型
- 特殊方法让你的类支持
+、[]、len()、print()等语法 - 只写一个就写
__repr__;容器里显示的是它 ==比值,is比身份。只有和None比较才用is- 判断类型用
isinstance,它对子类也成立
模块与解释器
- 模块就是一个
.py文件;import会执行它,且只执行一次 - 永远不要
from module import * - Python 按
sys.path找模块,当前目录优先——别用标准库的名字命名自己的文件 if __name__ == "__main__":让文件既能直接跑,又能被 import 复用- 用
python3 -m pip install确保装到正确的解释器 - Python 先编译成字节码,再由虚拟机执行;
__pycache__存的是缓存的字节码
练习
- 写一个
Shape基类和Circle、Rectangle、Triangle三个子类,都实现area()和perimeter()。写一个函数接收一个Shape列表,返回总面积。 - 给 L9 的
BankAccount加一个子类SavingsAccount,多一个interest_rate属性和add_interest()方法。用super()。 - 给一个
Money类实现__add__、__sub__、__eq__、__lt__和__repr__,让它支持Money(10) + Money(5)和sorted([...])。 - 解释下面两段的输出为什么不同:
(提示:小整数和短字符串会被 Python 缓存复用。这也说明为什么不该用a = [1, 2]; b = [1, 2] print(a == b, a is b) x = "hi"; y = "hi" print(x == y, x is y)is比较值。) - 写一个模块
stats.py,含mean、median、mode三个函数,并用if __name__ == "__main__":加上自测代码。然后从另一个文件 import 它,确认自测代码没有被执行。 - 在你的目录里建一个
random.py,里面写print("这是我的"),然后在同目录另一个文件里import random; random.randint(1, 10)。记录发生了什么,并解释原因。
本讲的配套上机题在 Lab 10,个人项目 B 也在本周启动。