CS1602计算导论
第 10 讲Part 4 抽象与组织AI Level 1

类(二)与模块

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 实际发生三件事:

  1. 找到这个模块对应的文件
  2. 执行它里面的所有代码(从上到下,一次)
  3. 把结果绑定到一个名字上

第 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 按顺序在里面找。大致顺序是:

  1. 当前脚本所在的目录
  2. PYTHONPATH 环境变量指定的目录
  3. 标准库目录
  4. 第三方包的安装目录(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 是解释型语言。现在看得细一点。

实际过程分两步:

  1. 编译成字节码(bytecode)——一种中间表示,比源码更接近机器,但仍不是机器码
  2. 虚拟机执行字节码
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__ 存的是缓存的字节码

练习

  1. 写一个 Shape 基类和 Circle、Rectangle、Triangle 三个子类,都实现 area() 和 perimeter()。写一个函数接收一个 Shape 列表,返回总面积。
  2. 给 L9 的 BankAccount 加一个子类 SavingsAccount,多一个 interest_rate 属性和 add_interest() 方法。用 super()。
  3. 给一个 Money 类实现 __add__、__sub__、__eq__、__lt__ 和 __repr__,让它支持 Money(10) + Money(5) 和 sorted([...])。
  4. 解释下面两段的输出为什么不同:
    a = [1, 2]; b = [1, 2]
    print(a == b, a is b)
    
    x = "hi"; y = "hi"
    print(x == y, x is y)
    (提示:小整数和短字符串会被 Python 缓存复用。这也说明为什么不该用 is 比较值。)
  5. 写一个模块 stats.py,含 mean、median、mode 三个函数,并用 if __name__ == "__main__": 加上自测代码。然后从另一个文件 import 它,确认自测代码没有被执行。
  6. 在你的目录里建一个 random.py,里面写 print("这是我的"),然后在同目录另一个文件里 import random; random.randint(1, 10)。记录发生了什么,并解释原因。

本讲的配套上机题在 Lab 10,个人项目 B 也在本周启动。