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

类(一):对象与封装

Class I: Objects and Encapsulation

用类组织相关的数据与操作,分析初始化、实例方法和接收者绑定。区分类属性、实例属性与共享引用,解释属性查找、类方法与非公开接口。

本讲结束后你应当能

  • 区分面向过程与面向对象,并比较同一任务的两种实现
  • 区分类与实例,用属性保存每个实例的状态
  • 解释初始化、默认参数及普通实例方法中的 self
  • 分别核对方法的返回值、输出与状态变化
  • 解释公开方法与内部属性的关系,区分实例方法、类方法与静态方法
  • 区分实例属性、类属性、共享集合及类方法
  • 根据状态、接口与生命周期选择类、函数或记录
本页目录

本讲项目:双角色探险模拟器

今天从一个明确任务出发:用 Python 实现两个角色 a、b,各自保存坐标、金币和背包。课堂实现 Python 角色的核心逻辑,用命令和结果验证行为;另有图形演示将同样的状态变化显示到地图上。

初始状态为 a = (0, 0)、b = (3, 4),各有 100 金币和空背包。任务是让 a 移动 (1, 1)、付款 30、拾取一个苹果。最后 a 应为 (1, 1)、70 金币和 ['apple'],b 应保持 (3, 4)、100 金币和 []。

打开分阶段图形演示。可以先看完整任务,再切换位置、金币、背包三个阶段,观察新增属性、方法与共享引用。下载包中的 rover.html 可直接离线打开,Python 文件是对应的核心逻辑。

已有知识能实现,为什么还引入类?

字典已经能保存角色,函数也能实现移动与付款。完整的字典版本说明不使用类也可以正确完成任务。扩展时,如果多处手写创建字典,添加字段容易漏改;make_rover 函数可以先统一创建。类进一步让初始化、保存的状态和使用这些状态的动作集中到一个 Rover 接口,通过 a.move(...) 明确对哪个角色发出命令。

类提供组织方式,不会自动验证输入、避免共享列表或提高速度。规则和隔离仍由实现决定,下面逐步建立并检查它们。

面向过程与面向对象

面向过程:围绕任务步骤和函数组织程序,函数接收数据并完成相应操作。 角色可以用字典保存,移动、付款和拾取由接收角色记录的函数实现。

面向对象:围绕具有状态和行为的对象组织程序,通过对象的接口完成操作与协作。 本讲用类定义 Rover 的初始化与操作,每个实例保留自己的状态,普通方法通过接收者访问这些状态。类共享方法定义,两个实例无需分别复制方法代码。我们已经使用过 items.append(...),现在开始定义自己的对象接口。

下面两个完整程序使用相同的初始坐标与移动算法。面向过程的写法:

def move(r, dx, dy):
    r["x"] += dx
    r["y"] += dy
a = {"x": 0, "y": 0}
b = {"x": 3, "y": 4}
move(a, 1, 1)
print("a:", a["x"], a["y"])
print("b:", b["x"], b["y"])

面向对象的写法:

class Rover:
    def __init__(self, x, y):
        self.x, self.y = x, y
    def move(self, dx, dy):
        self.x += dx
        self.y += dy
a, b = Rover(0, 0), Rover(3, 4)
a.move(1, 1)
print("a:", a.x, a.y)
print("b:", b.x, b.y)

两者均输出 a: 1 1 和 b: 3 4。比较时分别观察:数据在哪里建立、操作在哪里定义、调用时怎样选中角色。初始化、移动和查询逐步完成后,再用这三个问题回顾 Rover 的组织方式。

C、Pascal 是面向过程的常见代表,Java、Smalltalk 是面向对象的常见代表;Python、C++ 支持多种范式。同一个 Python 程序可以组合函数与对象,依据所需状态、操作和维护需求选择接口。可参考 Python 官方文档对编程范式的说明。

固定的计算或数据处理流程通常用函数组织很直接;多个持续保存状态、具有相关操作的实体,可以考虑对象接口。Rover 集中了创建、坐标、付款与背包规则;输入校验和状态独立仍由实现保证。本讲学习类、对象与封装,继承和多态在下一讲继续。

第一版:创建、移动与测距

下载位置版。__init__ 为每个实例建立坐标;普通方法绑定当前实例为 self;move 修改位置,distance_to 同时读取两个角色而不修改它们。构造和调用的逐帧图使用这份接口的较小测试程序。

第二版:实例状态与方法

下载金币版。继续扩展同一个 Rover,在初始化中增加 _coins,用 spend 修改当前实例的属性,用 coins_left 读取它。方法里的 self 选中接收者,amount 是本次调用的局部参数;调用结束后,实例字段继续保存修改结果。

下面的完整最小例子只保留金币成员。主项目中的坐标与背包成员仍保留在完整版本中。

class Rover:
    def __init__(self, coins=100):
        self._coins = coins
    def spend(self, amount):
        self._coins -= amount
        return True
    def coins_left(self):
        return self._coins

a, b = Rover(), Rover()
print(a.spend(30))
print(a.coins_left(), b.coins_left())

输出为 True 和 70 100。两次初始化分别建立两份 _coins 属性;只调用 a 的方法,所以 b 保持 100。True 是调用结果,70 是保存在 a 中的字段值。这里使用一次正常付款观察属性与方法;完整项目中的输入处理可在下载版本中查阅。

第三版:独立背包

先运行共享背包与修复示例,观察只让 a 捡苹果时两人的背包。类体中的 items = [] 使两个实例通过属性查找得到同一个列表。append 修改该列表;a.items = [] 则为 a 建立自己的字段。最终版本在每次初始化时建立列表;传入初始物品时复制外层列表。物品为字符串,未承诺递归复制嵌套可变对象。

哪些属于整个角色类型?

created 记录累计成功创建次数,属于类级共享信息;删除实例引用时,该字段保持原值。from_pair 是接收类的命名创建入口,用地图坐标创建角色后仍复用相同初始化。valid_coins 只看参数,不依赖某个实例,因此可以是静态方法;放在类外的普通函数也是可行设计。

完整项目与任务结果

下面保留可独立运行的完整版本。阅读时先定位初始化、实例字段和相关方法,再按需要查看输入处理。

展开完整 Rover 程序与运行结果

下载完整探险程序或全部阶段 ZIP。以下代码可独立运行,无图形库依赖:

class Rover:
    created = 0

    def __init__(self, x=0, y=0, coins=100, items=None):
        if not Rover.valid_coins(coins):
            raise ValueError("invalid coins")
        self.x = x
        self.y = y
        self._coins = coins
        if items is None:
            self.items = []
        else:
            self.items = list(items)
        Rover.created += 1

    def move(self, dx, dy):
        self.x += dx
        self.y += dy

    def distance_to(self, other):
        dx = self.x - other.x
        dy = self.y - other.y
        return (dx * dx + dy * dy) ** 0.5

    def spend(self, amount):
        if (type(amount) is not int
                or amount <= 0
                or amount > self._coins):
            return False
        self._coins -= amount
        return True

    def coins_left(self):
        return self._coins

    def pick(self, item):
        self.items.append(item)

    @classmethod
    def from_pair(cls, pair):
        return cls(pair[0], pair[1])

    @staticmethod
    def valid_coins(value):
        return type(value) is int and value >= 0

a = Rover(0, 0)
b = Rover.from_pair((3, 4))
print("start distance:", a.distance_to(b))
a.move(1, 1)
print("pay:", a.spend(30))
a.pick("apple")
print("a:", a.x, a.y, a.coins_left(), a.items)
print("b:", b.x, b.y, b.coins_left(), b.items)

实际输出:

start distance: 5.0
pay: True
a: 1 1 70 ['apple']
b: 3 4 100 []

后面的 Point、账户、温度及其他类是独立扩展例子,可在掌握角色程序后按需要查阅。

Rover 的定义与运行机制

结合课件中同一个角色,分别核对定义、存放位置和调用过程。以下机制示例均可从本讲代码包下载,文件以 mechanism- 开头;其中三个文件用于复现预期异常。

定义与执行

class Rover: 后的缩进区域是类体。类体中的 unit = "grid" 建立类属性,def move(self, dx, dy): 定义方法。方法体中的 self.x = x 在执行时保存当前实例的属性。执行 class 语句时运行类体;执行 def 时建立函数;调用方法时运行函数体。Rover 绑定到类对象,a = Rover() 使 a 绑定到新实例。

__init__ 接收已经创建的实例,建立初始状态并返回 None。类调用的结果是实例。初始化的默认值、位置参数与关键字参数沿用函数参数规则,例如 Rover(3)、Rover(y=4)。mechanism-init_return.py 展示错误返回实例时的 TypeError。

属性放在哪里

对于课件中的普通实例,vars(a) 显示其自己的属性,例如 {'x': 3};Rover.__dict__ 展示类命名空间,其中保存类属性和方法函数。读取普通数据字段 a.unit 时,先看实例,再看类。a.unit = "pixel" 给实例建立同名字段,del a.unit 删除后又能读到类上的值。描述符与继承参与的完整规则在 L10 继续讨论。

方法如何绑定

a.move(1, 1) 使底层函数收到 a, 1, 1。通过实例取得的普通方法包含函数和接收者两部分:a.move.__self__ is a,a.move.__func__ is Rover.move。action = a.move 保存这一绑定,稍后 action(1, 1) 仍修改 a。缺少接收者形参会导致实参数量不匹配,可运行 mechanism-bad_receiver.py 查看实际报错。

方法内部通过 self.coins_left() 调用同一对象的方法。直接写 coins_left() 会查找普通名字;类命名空间不充当方法的外层函数作用域。mechanism-unqualified_method.py 用最小完整程序复现这一 NameError。

保存的方法与重新绑定名字

绑定方法保存函数与接收者。下面先保存 a.move,再让变量 a 指向 b:

class Rover:
    def __init__(self, x):
        self.x = x
    def move(self, dx):
        self.x += dx

a, b = Rover(0), Rover(10)
step = a.move
a = b
step(2)
print(step.__self__.x, a.x)

实际输出 2 10。step 的接收者仍是第一个实例,a = b 只改变名字 a 的绑定。调用 step 后,第一个实例的 x 为 2,当前 a 指向的第二个实例仍为 10。可用 step.__self__ 直接检查方法保存的接收者。

哪些数据会共享

类体中建立的列表、保存的同一个输入引用,以及 items=[] 这样的可变默认参数,都可能使两个实例共享物品。默认值在函数定义时创建。默认用 None,再在方法体中建立新列表,可以使省略参数的两次初始化分别取得新列表。list(items) 复制外层;内层可变对象继续共享,复制深度需要符合数据所有权约定。

对整数计数器,self.created += 1 从类读到旧值,加一后给实例写入同名字段。共享计数应显式更新 Rover.created。__coins 在类内部会改写成 _Rover__coins,改写后的名字仍可被外部访问。

三种方法接收谁

普通实例方法自动接收实例;@classmethod 自动接收类,通过类或实例访问都遵守这一规则。工厂中的 cls(...) 创建新实例并复用初始化。@staticmethod 只接收括号内显式给出的参数,通过类和实例调用都不会补入接收者。先确定操作需要哪份状态,再选择接口。

I. 对象与初始化

数据与相关操作

上一讲用 AI 辅助审查代码,并借助调试器观察实际执行。本讲从双人小游戏出发:两个角色分别保存坐标,移动其中一个不应改变另一个;如果一个角色捡起苹果,另一个背包也出现苹果,就需要追踪共享引用。

本讲依次回答四个问题:如何创建状态 → 方法究竟操作谁 → 如何维持有效状态 → 哪些引用会共享。课件中的连续状态图与这里的完整程序互相配合。

平面点需要保存坐标,可以显示位置、平移或计算到另一点的距离。使用字典与普通函数已经能够完成任务:

def make_point(x: float, y: float) -> dict[str, float]:
    return {"x": x, "y": y}

def distance(p: dict[str, float], q: dict[str, float]) -> float:
    dx = p["x"] - q["x"]
    dy = p["y"] - q["y"]
    return (dx * dx + dy * dy) ** 0.5

a = make_point(0, 0)
b = make_point(3, 4)
print(distance(a, b))

结果为 5.0。这个实现并没有因为未使用类而不正确。函数已经能隔离局部参数,模块也能组织代码;类提供的是另一种组织方式:把一类实例的相关数据与操作放在同一个接口中。

当程序需要保存多份记录、执行多种相关操作或约定状态更新规则时,类通常有助于说明这些关系。类不会自动验证公式、阻止非法赋值,也不保证比函数版本更快。

类、实例与已有类型

类(class)定义一类对象的属性与行为;一个该类的具体对象称为实例(instance)。学生已经使用过对象:整数、字符串、列表、字典都有运行时类型,"abc".upper() 则是通过字符串对象访问方法。

print(type(42), type(3.5), type("abc"), type([1, 2]))
text = "hello world"
print(text.count("o"))
items = [3, 1, 2]
print(items.reverse())
print(items)

reverse() 修改列表并返回 None。调用的返回值与原对象的变化需要分别观察。

下面定义一个尚未增加属性或方法的类。pass 是空操作语句,用于需要语句块但暂不执行操作的位置。

class Point:
    pass

p = Point()
print(type(p))
print(type(p) is Point)

执行 class 语句建立类,Point() 创建该类的一个实例。type(p) 为 Point,身份比较为 True。默认显示中的模块名与执行环境有关;对象地址也会随运行改变,不用固定地址判断程序正确性。

类本身也是对象,可以由名字 Point 引用。内置类型的空构造例子放在参考区。

属性与普通变量

本讲使用的普通自定义类允许通过赋值增加实例属性。p.x 表示对象 p 的属性,单独的变量 x 则是另一个名字:

class Point:
    pass

x = 100
p = Point()
p.x = 3
p.y = 4
print(p.x, p.y, x)

结果为 3 4 100。设置 p.x 没有修改外部变量 x。

同类的另一个实例不会自动获得刚才添加的属性。以下程序有意触发 AttributeError:

class Point:
    pass

p = Point()
p.x = 3
q = Point()
print(p.x)
print(q.x)

先打印 3,随后报错。实例属于同一个类,不表示程序已经给每个实例建立了相同的数据字段。

初始化与默认参数

在普通类的创建流程中,__init__ 初始化新实例。它的第一个参数接收该实例,惯用名称为 self。

class Point:
    def __init__(self, x: float = 0, y: float = 0) -> None:
        self.x = x
        self.y = y

for p in [Point(), Point(3), Point(3, 4)]:
    print(p.x, p.y)

三行分别为 0 0、3 0、3 4。参数 x 是当前调用中的局部名字;self.x = x 将其值绑定到新实例的属性。默认参数规则与普通函数相同。

这里零坐标是合法默认值。其他类的默认值仍须符合自己的任务约定,例如三条零边不能表示一个合法三角形。

类体和方法体的执行时机

class 语句执行时运行类体;类体中的 def 建立函数,并不立即执行函数体。先预测下面的打印顺序:

class Trace:
    print("class body")
    def __init__(self) -> None:
        print("init")

a = Trace()
b = Trace()

结果是 class body、init、init。这个类定义只执行了一次,随后创建两个实例,分别初始化。若程序再次执行同一段类定义,会重新建立类;不能把“本例类体执行一次”理解为所有程序中的类体永远只执行一次。

__init__ 必须返回 None,通常不写显式 return。类调用 Point(...) 返回实例;__init__ 完成初始化并返回 None。创建与初始化的区别及错误返回值例子在参考区说明。

别名和独立实例

赋值仍然绑定引用,不会因为使用类而自动复制对象:

class Point:
    def __init__(self, x: float = 0, y: float = 0) -> None:
        self.x = x
        self.y = y

p = Point(3, 4)
alias = p
other = Point(3, 4)
alias.x = 8
print(p.x, other.x)
print(alias is p, other is p)

结果为 8 3、True False。alias 与 p 引用同一个对象;other 是另一个实例,尽管初始坐标相同。is 检查对象身份,不检查两个点的几何坐标是否相等。自定义值相等协议留到 L10。

初始化时应明确必需属性、输入范围、默认值与失败行为。属性已经建立,不代表它的数值符合约定;类型提示也不会自动进行这种检查。

II. 实例方法与接收者

定义中的 self 与调用中的对象

普通实例方法的第一个参数接收调用者选择的实例。

class Point:
    def __init__(self, x: float = 0, y: float = 0) -> None:
        self.x = x
        self.y = y

    def show(self) -> None:
        print(self.x, self.y)

p = Point(3, 4)
p.show()
Point.show(p)

两次都打印 3 4。对这个普通 Python 方法,p.show() 绑定 p,也可以显式写 Point.show(p)。self 是普通形参的惯用名;换名在语法上可行,实际编程应遵守通用命名惯例。

这一规则不能直接推广到所有方法类别。后面会看到类方法传入类,静态方法不自动传入接收者。

以下程序故意漏掉接收者参数,实例调用时产生 TypeError:

class Point:
    def show():
        print("hi")

p = Point()
p.show()

通过实例访问方法后,调用会自动传入该实例;这里的函数定义没有相应形参,因此参数数量不匹配。

局部参数和实例属性

同名的参数与属性可以有不同用途:

class Point:
    def __init__(self, x: int) -> None:
        self.x = x
    def shift(self, x: int) -> None:
        self.x += x

x = 100
p = Point(3)
p.shift(2)
print(p.x, x)

结果为 5 100。shift 的局部参数 x 是 2,self.x 原来是 3,外部变量 x 仍为 100。方法中的每个裸名字不会自动变成实例属性。

同一个函数,不同的接收者

类中定义的普通方法函数可以由多个实例使用;每次调用仍有自己的局部状态。

class Point:
    def __init__(self, x: float = 0, y: float = 0) -> None:
        self.x = x
        self.y = y

    def show(self) -> None:
        print(self.x, self.y)

a = Point(0, 0)
b = Point(3, 4)
a.show()
b.show()

先打印 0 0,再打印 3 4。不同结果来自两次不同的 self。这不意味着实例引用的全部数据都不共享;共享集合的情况在第四节讨论。

状态变化与返回值

class Point:
    def __init__(self, x: float = 0, y: float = 0) -> None:
        self.x = x
        self.y = y

    def move(self, dx: float, dy: float) -> None:
        self.x += dx
        self.y += dy

p = Point(3, 4)
result = p.move(1, 0)
print(p.x, p.y, result)

结果为 4 4 None。move 修改接收者,结束时返回 None。返回 None 不表示调用没有副作用;返回数值的方法也不必然是只读的。

方法内部要选择当前实例的其他方法,仍须明确访问路径:

def message() -> str:
    return "global function"

class Record:
    def __init__(self, text: str) -> None:
        self.text = text
    def message(self) -> str:
        return self.text
    def report(self) -> str:
        return self.message()

r = Record("instance method")
print(message())
print(r.report())

message() 与 self.message() 在本例访问不同的函数。完整的名称与作用域规则留到 L14;此处先正确区分局部名字和对象属性。

Point 的完整接口

distance_to 需要另一个 Point。Python 3.13 在执行类体时,类名尚未完成绑定,因此这里用字符串 "Point" 作为前向类型引用。可选的 future 功能在参考区介绍。

class Point:
    def __init__(self, x: float = 0, y: float = 0) -> None:
        self.x = x
        self.y = y

    def show(self) -> None:
        print(self.x, self.y)

    def move(self, dx: float, dy: float) -> None:
        self.x += dx
        self.y += dy

    def distance_to(self, other: "Point") -> float:
        dx = self.x - other.x
        dy = self.y - other.y
        return (dx * dx + dy * dy) ** 0.5

a = Point(0, 0)
b = Point(3, 4)
print(a.distance_to(b))
a.move(1, 1)
a.show()
print(a.distance_to(b))

本例依次得到 5.0、1 1 和约 3.605551275463989。使用小规模有限数值说明几何和接口,不将该实现当作处理任意尺度的数值几何库。

操作状态变化返回或输出
Point(x, y)初始化新实例的坐标类调用返回实例;init 自身返回 None
show()不修改坐标打印坐标,返回 None
move(dx, dy)修改接收者坐标返回 None
distance_to(other)本实现不修改两个点返回距离

接口文档与测试共同说明这些行为,方法名、返回注解或采用类都不能替代核验。

III. 封装与对象接口

公开方法与内部存储

把相关的数据和操作组织到一个接口中称为封装(encapsulation)。调用者通过公开方法使用对象,方法通过 self 访问实例保存的属性。

Rover 的 _coins 保存金币,spend 更新它,coins_left 读取并返回它。调用者可以通过这些操作使用角色,无需依赖字段的具体存储方式。单下划线约定将 _coins 标作内部实现;普通 Python 对象仍允许直接读写该属性。

单下划线与名称改写

_name 表示内部实现,调用者不应依赖其结构或绕过公开接口直接更新。Python 不会凭这个前缀强制禁止访问。

类体中的双前导下划线名称,如 __hidden,会发生名称改写(name mangling)。下面的普通属性确实可以通过改写后的名字读取:

class Secret:
    def __init__(self) -> None:
        self.__hidden = 42

s = Secret()
print(s._Secret__hidden)

结果为 42。下面独立程序故意使用未改写名字,得到 AttributeError:

class Secret:
    def __init__(self) -> None:
        self.__hidden = 42

s = Secret()
print(s.__hidden)

名称改写主要帮助避免继承中的无意同名冲突,外部仍可访问改写后的名字;继承例子留到 L10。

形式本讲含义
_x非公开接口的命名约定
__x类体中的名称改写
__init__Python 文档定义的特殊方法名,不按 __x 的规则改写

解释器只为明确规定的特殊方法名提供相应行为。

IV. 共享数据与方法类别

实例属性与类属性

实例属性通常保存该实例的数据;类属性由类体或对类的赋值建立。对本讲没有描述符或继承参与的普通属性,实例没有对应绑定时,可以访问类上的绑定。

class Point:
    dimensions = 2
    def __init__(self, x: float, y: float) -> None:
        self.x = x
        self.y = y

a = Point(0, 0)
b = Point(3, 4)
print(a.x, b.x)
print(a.dimensions, b.dimensions, Point.dimensions)

坐标不同,三种维度访问均为 2。实例写入同名普通属性则可以遮蔽类上的绑定:

class Record:
    label = "class"

a = Record()
b = Record()
a.label = "only A"
print(a.label, b.label, Record.label)

结果为 only A class class。a.label = ... 没有修改类上的 Record.label。这里讨论普通字段;数据描述符、property 和继承参与时的属性查找在 L10 继续讨论。

背包共享:属性存放在哪里?

沿用课堂的 Rover。这个最小程序只保留背包和拾取方法,方便直接运行。创建 a、b 后,只让 a 捡苹果,再分别查看两人的背包:

class Rover:
    items = []
    def pick(self, item):
        self.items.append(item)

a = Rover()
b = Rover()
a.pick("apple")
print("a:", a.items)
print("b:", b.items)
print(a.items is b.items)
a: ['apple']
b: ['apple']
True

a.items is b.items 为 True,说明两次访问得到的是同一个列表对象。执行 class Rover: 时,类体中的 items = [] 创建一份列表,并把它保存为类属性 Rover.items。本例的 a、b 自己都没有 items 属性,读取时会找到类上的绑定;self.items.append(...) 修改了这份共享列表,因此 b 也能看到苹果。

我们的目标是让每个角色有独立背包。把列表创建移进初始化方法,保持 pick 不变:

class Rover:
    def __init__(self):
        self.items = []
    def pick(self, item):
        self.items.append(item)

a = Rover()
b = Rover()
a.pick("apple")
print("a:", a.items)
print("b:", b.items)
print(a.items is b.items)
a: ['apple']
b: []
False

每次执行 Rover(),初始化方法都会为当前 self 重新求值 [],再用 self.items = ... 保存为实例属性。a、b 各有一份列表,所以给 a 加苹果后 b 仍为空,身份比较为 False。

两种写法的区别在于属性的绑定位置和列表的创建时机:类体中的 items = [] 随本次类体执行创建一份列表;__init__ 中的 self.items = [] 在每次初始化时创建新的列表,并保存到当前实例。实例属性也可以在其他方法中建立;放在 __init__ 中,可以让新角色从一开始就有完整的初始状态。

还要区分修改列表与重新绑定:a.items.append("apple") 修改已有列表;a.items = [] 为 a 建立或更新自己的绑定,b 的读取结果保持不变。类属性也适合有意共享的信息,例如全体角色的共同登记表。背包的设计要求是每个角色分别保存物品。

输入共享与可变默认参数

即使字段写在 init 内,直接绑定同一个输入仍会共享:

class Bucket:
    def __init__(self, items: list[str]) -> None:
        self.items = items

shared = []
a = Bucket(shared)
b = Bucket(shared)
a.items.append("apple")
print(b.items)
print(a.items is b.items)

结果仍为 ['apple']、True。需要隔离外层列表时,可以按接口选择 items.copy();如果元素本身可变,浅拷贝不保证内层独立。

可变默认参数是第三种来源,沿用 L04 的规则:

class Bucket:
    def __init__(self, items: list[str] = []) -> None:
        self.items = items

a = Bucket()
b = Bucket()
a.items.append("apple")
print(b.items)
print(a.items is b.items)

默认表达式在定义函数时求值,两个不传参数的调用使用同一个默认列表。类级字段、默认参数和重复传入的输入,必须分别分析。

类级计数与类方法

共享创建计数应明确更新类上的绑定:

class Counter:
    total = 0
    def add(self) -> None:
        self.total += 1

a = Counter()
b = Counter()
a.add()
print(a.total, b.total, Counter.total)

结果为 1 0 0。self.total += 1 在本例读取类上的 0,再把 1 绑定到 a.total,没有更新共享的 Counter.total。

类方法由内置标记 @classmethod 定义。它自动接收类,惯用参数名为 cls;通过类调用时无需先创建实例。这一节先学习该标记的具体作用,通用装饰器机制仍在 L14 讨论。

class Student:
    count = 0
    def __init__(self, name: str) -> None:
        self.name = name
        Student.count += 1

    @classmethod
    def created_count(cls) -> int:
        return cls.count

print(Student.created_count())
a = Student("Alex")
b = Student("Blake")
print(Student.created_count(), a.created_count())

结果为 0、2 2。从实例访问这个类方法,自动传入的仍是类。计数记录执行这个初始化过程的累计次数;删除引用时,该字段保持原值。若初始化可能失败,应明确计数更新放在成功校验之后。

类别自动参数用途
普通实例方法实例,惯用名 self操作指定实例
类方法 @classmethod类,惯用名 cls类级操作或查询
静态方法 @staticmethod无自动接收者与类有关但不需实例/类状态的操作

静态方法例子在参考区先解释标记,再展示完整程序。某个函数被放进类中,并不意味着它必须访问实例状态。

从坐标对创建 Point

类方法还可以提供有名称的创建入口。cls 自动接收 Point 类,cls(...) 再按普通方式创建并初始化实例。这里约定 pair 包含两个坐标。

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    @classmethod
    def from_pair(cls, pair):
        return cls(pair[0], pair[1])

p = Point.from_pair((3, 4))
print(type(p) is Point, p.x, p.y)

输出为 True 3 4。from_pair 返回新实例,而 __init__ 本身返回 None。

参考:完整例子与核验

课后选读:账户与温度的完整实现

下面的独立例子保留输入处理和完整接口,供课后运行查阅。课堂主线集中在初始化、实例属性、方法接收者与共享关系。

账户的受支持操作

教学账户使用整数分表示金额。初始余额是非负整数;存取金额是正整数。存款不返回余额,取款用布尔值报告成功;拒绝操作不修改余额或历史。账单只生成字符串。

class BankAccount:
    """A teaching account; amounts are integer cents."""
    def __init__(self, owner: str, balance: int = 0) -> None:
        if type(balance) is not int or balance < 0:
            raise ValueError("invalid opening balance")
        self.owner = owner
        self.balance = balance
        self.history: list[tuple[str, int]] = []

    def deposit(self, amount: int) -> None:
        if type(amount) is not int or amount <= 0:
            return
        self.balance += amount
        self.history.append(("deposit", amount))

    def withdraw(self, amount: int) -> bool:
        if type(amount) is not int or amount <= 0 or amount > self.balance:
            return False
        self.balance -= amount
        self.history.append(("withdraw", amount))
        return True

    def statement(self) -> str:
        lines = [f"{self.owner}: {self.balance} cents"]
        for action, amount in self.history:
            lines.append(f"  {action}: {amount} cents")
        return "\n".join(lines)

a = BankAccount("Alex", 100)
b = BankAccount("Blake")
a.deposit(50)
print(a.withdraw(30), a.balance)
print(a.withdraw(1000), a.balance)
print(a.withdraw(-20), a.balance)
print(b.balance, b.history)
print(a.statement())

三次取款分别为 True 120、False 120、False 120。第二个账户仍为余额 0、空历史。每个初始化都新建自己的历史列表。

构造、存款和取款都验证各自接受的输入。这里显式检查整数类型,bool 不被当作金额;不能把这些检查归功于类型提示。真实金融软件还有审计、并发、货币与持久化等要求,本例不提供这些保证。

温度的初始值与更新

math.isfinite(value) 判断数值是否有限。这里用 float("inf") 构造正无穷,以检验这个条件:

import math
print(math.isfinite(25.0))
print(math.isfinite(float("inf")))

结果为 True、False。下例约定输入为实数,值须有限且不低于 −273.15°C。构造失败抛 ValueError,更新失败返回 False 并保留旧值。

内部字段 _celsius 使用单下划线,表示非公开实现约定;前缀本身不会阻止读取或写入。

import math

class Temperature:
    """Minimal lecture interface, not the full lab interface."""
    def __init__(self, celsius: float) -> None:
        if not math.isfinite(celsius) or celsius < -273.15:
            raise ValueError("invalid starting temperature")
        self._celsius = celsius

    def get_celsius(self) -> float:
        return self._celsius

    def to_fahrenheit(self) -> float:
        return self._celsius * 9 / 5 + 32

    def set_celsius(self, value: float) -> bool:
        if not math.isfinite(value) or value < -273.15:
            return False
        self._celsius = value
        return True

t = Temperature(25)
print(t.to_fahrenheit())
print(t.set_celsius(-300), t.get_celsius())
print(t.set_celsius(100), t.to_fahrenheit())

输出为 77.0、False 25、True 212.0。构造也须检查初值,不能只给 setter 加条件。实验在这个最小接口基础上另要求摄氏度、华氏度和 Kelvin 三种查询,名称以实验页为准。

内置构造器与类型

__name__ 给出这里的类名,repr() 显示对象的表示,便于看出空字符串与空容器:

classes = [int, float, complex, str,
           list, tuple, dict, set]
for cls in classes:
    value = cls()
    print(cls.__name__, repr(value))

这些构造产生 0、0.0、0j、空字符串和相应空容器。名字重新绑定到另一类型的对象,不表示原对象的类型被修改。

Triangle:验证、面积与方法内调用

以下版本默认三边为 1。初始三边须有限、正且满足严格三角不等式。更新失败返回 False,不改变旧边;area 仍检查当前状态,因为外部直接改公开字段可能绕过 setter。示例使用尺度适中的有限数值,未提供通用几何数值稳定性保证。

import math

class Triangle:
    def __init__(self, a: float = 1, b: float = 1, c: float = 1) -> None:
        self.a, self.b, self.c = a, b, c
        if not self.is_valid():
            raise ValueError("invalid triangle")

    def is_valid(self) -> bool:
        for value in [self.a, self.b, self.c]:
            if not math.isfinite(value) or value <= 0:
                return False
        return (self.a + self.b > self.c and self.b + self.c > self.a
                and self.c + self.a > self.b)

    def set_edges(self, a: float, b: float, c: float) -> bool:
        for value in [a, b, c]:
            if not math.isfinite(value) or value <= 0:
                return False
        if not (a + b > c and b + c > a and c + a > b):
            return False
        self.a, self.b, self.c = a, b, c
        return True

    def area(self) -> float:
        if not self.is_valid():
            raise ValueError("invalid current edges")
        s = (self.a + self.b + self.c) / 2
        product = s * (s - self.a)
        product *= s - self.b
        product *= s - self.c
        return product ** 0.5

    def print(self) -> None:
        print(f"Triangle: {self.a}, {self.b}, {self.c}")

    def print_area(self) -> None:
        print(f"Area: {self.area():.3f}")

a = Triangle(3, 4, 5)
b = Triangle(3, 3, 3)
a.print()
a.print_area()
print(b.area())
print(a.set_edges(1, 1, 3), a.area())

三四五三角形面积为 6;等边长 3 的面积约为 3.897114317029974。set_edges(1,1,3) 被拒绝,原面积仍为 6。旧材料中零边默认值虽能建立字段,却不表示合法三角形。

print_area 使用 self.area() 调用当前实例的方法。外部也可以定义名为 area 的普通函数,两个接口仍须通过明确的名字或对象访问路径区分。

Dog:字段、外部函数与方法

保留往年的记录例子,用来比较接口组织,不需要依赖动物照片或名称类比。

class Dog:
    def __init__(self, name: str, color: str, age: int, weight: float) -> None:
        self.name = name
        self.color = color
        self.age = age
        self.weight = weight

    def set_name(self, name: str) -> None:
        self.name = name

    def set_color(self, color: str) -> None:
        self.color = color

    def set_age(self, age: int) -> None:
        self.age = age

    def set_weight(self, weight: float) -> None:
        self.weight = weight

    def print_dog(self) -> None:
        print(
            self.name, self.color,
            self.age, self.weight,
        )

def train_dog(dog: Dog) -> None:
    dog.name += " 1st"

dog = Dog("Fox", "Red", 3, 4)
dog.print_dog()
dog.age += 1
dog.weight /= 2
train_dog(dog)
dog.print_dog()
dog.set_name("Tiger")
dog.print_dog()

外部函数也能接受对象,方法是另一种相关操作的组织方式。setter 这个名字本身不保证验证;本例只展示记录更新,没有宣称检查年龄或体重的业务规则。

HappyNumber:数位平方和与循环检测

正整数反复替换为各十进制数位的平方和,最终达到 1 则为快乐数;出现重复值且未到 1,之后会重复同一过程。集合保存已经处理的数,局部变量保存本次查询的工作状态。

class HappyNumber:
    def __init__(self, number: int) -> None:
        if number <= 0:
            raise ValueError("positive integer required")
        self.number = number

    def sum_of_digit_squares(self) -> int:
        return self._digit_square_sum(self.number)

    def _digit_square_sum(self, value: int) -> int:
        total = 0
        while value > 0:
            digit = value % 10
            total += digit * digit
            value //= 10
        return total

    def is_happy(self) -> bool:
        current = self.number
        seen = set()
        while current not in seen:
            if current == 1:
                return True
            seen.add(current)
            current = self._digit_square_sum(current)
        return False

    def print_HN(self) -> None:
        print(self.number)

for number in [19, 2, 1234567890987654321]:
    h = HappyNumber(number)
    print(h.number, h.sum_of_digit_squares(), h.is_happy())
    print(h.number == number)

三次首步分别为 82、4、570;判定为 True、False、False,原 number 都保持。19 的轨迹为 19→82→68→100→1;2 经过 2→4→16→37→58→89→145→42→20→4 出现重复。

旧候选的 is_happy 更新 self.number,对 19 返回 True 时字段已经变成 100。方法名是查询形式,并不使它自动只读。把这一行为与本版比较时,应分别核对返回值和原始字段,不能只比较 True/False。

这个判定也可写成普通函数。采用类时,应考虑是否需要保留原数、提供相关查询与长期接口;循环检测本身可以用普通函数实现。

Vector:长度、方向、点积与伸缩

使用 n 维标量列表。构造复制外层列表;length 查询范数,dot 要求维度一致,scale 原地修改。angle() 定义为与各坐标轴的夹角列表,单位为弧度;零向量方向未定义,抛 ValueError。

math.acos(x) 是主值反余弦,输入在 −1 到 1,结果在 0 到 π。各轴方向余弦为坐标分量除以长度。这里将舍入导致的微小越界钳位到定义域,示例仍限于尺度适中的有限标量,不承诺极端数值下的通用稳定性。

import math

class Vector:
    def __init__(self, values: list[float]) -> None:
        self.values = values.copy()

    def length(self) -> float:
        total = 0.0
        for value in self.values:
            total += value * value
        return math.sqrt(total)

    def print_length(self) -> None:
        print(f"Length: {self.length():.3f}")

    def dot(self, other: "Vector") -> float:
        if len(self.values) != len(other.values):
            raise ValueError("different dimensions")
        total = 0.0
        for i in range(len(self.values)):
            total += self.values[i] * other.values[i]
        return total

    def scale(self, ratio: float) -> None:
        for i in range(len(self.values)):
            self.values[i] *= ratio

    def angle(self) -> list[float]:
        length = self.length()
        if length == 0:
            raise ValueError("zero vector")
        angles = []
        for value in self.values:
            direction = value / length
            cosine = max(-1.0, min(1.0, direction))
            angles.append(math.acos(cosine))
        return angles

    def print_angle(self) -> None:
        for angle in self.angle():
            print(f"Angle: {angle:.4f} rad")

values = [3.0, 4.0]
v = Vector(values)
u = Vector([1.0, 2.0])
v.print_length()
print(v.dot(u))
v.print_angle()
print(v.scale(2))
print(v.values, values, v.length())

长度为 5.0,点积为 11.0;两轴夹角约为 0.9273、0.6435 弧度。伸缩返回 None,v 的分量变成 [6.0,8.0],输入 values 保持 [3.0,4.0],新长度 10.0。

浅拷贝足以隔离本例的外层标量列表;不能推广为任意嵌套可变元素都独立。空向量长度为 0;不等维 dot 与零向量 angle 的失败行为需分别测试。

同名操作与函数对象

下面两个 area 名称有明确访问路径。普通函数接收三条边,实例方法从自身字段取得参数,再调用外部函数;使用类没有把裸名字 area 自动改成 self.area。

def area(a: float, b: float, c: float) -> float:
    s = (a + b + c) / 2
    return (s * (s - a) * (s - b) * (s - c)) ** 0.5

class Shape:
    def __init__(self, a: float, b: float, c: float) -> None:
        self.a, self.b, self.c = a, b, c
    def area(self) -> float:
        return area(self.a, self.b, self.c)

x = Shape(3, 4, 5)
print(area(3, 4, 5), x.area())
f = print
f("function object")

两个面积都是 6.0。f 绑定函数对象而非 print 的返回值,最后的调用正常打印字符串。函数与类都是组织工具,不能把局部参数隔离归为类独有的能力。

绑定方法的元数据与函数对象

对本例中 Python 定义的普通方法,__self__ 给出绑定接收者,__func__ 给出类中底层函数。先理解这两个属性,再查看结果:

class Point:
    def __init__(self, x: float = 0, y: float = 0) -> None:
        self.x = x
        self.y = y
    def show(self) -> None:
        print(self.x, self.y)

a = Point(0, 0)
b = Point(3, 4)
print(a.show.__self__ is a)
print(a.show.__func__ is b.show.__func__)
f = a.show
a.x = 8
f()

身份比较均为 True,最后打印 8 0。保存的是绑定当前对象的方法,之后对象状态的变化仍能被调用观察。这些元数据不直接推广到所有内置方法。

函数本身也是对象。f = print 是把同一个函数对象绑定给另一个名字,f("hello") 可以调用它;这里没有执行 print 并保存其返回值。

无隐式接收者的操作

先解释标记:@staticmethod 使函数通过类或实例访问时不自动绑定接收者。

class Test:
    @staticmethod
    def add(u: int, v: int) -> int:
        return u + v

print(Test.add(1, 1))
print(Test().add(3, 4))

输出为 2、7。若去掉标记而仍定义 add(u,v),Test.add(1,1) 可以显式调用,但 Test().add(3,4) 会额外传入实例而参数过多。不能笼统声称“函数未写 self 就完全无法使用”。

不依赖实例或类状态的工具函数,也可以放在类外。根据函数与类的关系选择放置位置,再按需要的接收者选择方法类别。

前向注解与初始化失败

字符串前向引用是本讲主线用法。from __future__ import annotations 从 Python 3.7 起是可选功能,Python 3.13 中仍需显式启用;它用于推迟注解求值,实参校验仍需显式实现。

普通创建流程通常先由 __new__ 创建实例,再由 __init__ 初始化。这里不实现这些创建钩子;有的资料将 init 简称构造函数,阅读时应注意两阶段区别。

以下程序有意触发 TypeError:

class Wrong:
    def __init__(self):
        return 1

Wrong()

另两种常见问题是把 __init__ 拼成普通名字,以及忘记接收者参数。前者不会自动执行该初始化代码,后者造成参数数量不匹配。省略自定义 init 仍可有默认创建行为,但不会替程序添加预期的 x/y 等字段。

组织方式与选择依据

面向对象思想在 20 世纪 60 年代的 Simula 等语言中形成重要机制,Smalltalk 在 70 年代推动其发展。不能据此声称 OO 取代了全部编程方式。Python 同时支持对象与过程式组织;C++、Java 是其他常见相关语言。

决定是否使用类时,考虑状态是否跨调用保留、操作是否紧密相关、生命周期与有效性规则是否值得一个明确接口。函数、字典、元组与结构化记录也可能合适。只读数据或只有一个实例不自动排除类,只有一个方法也不自动证明类多余。

NamedTuple 是结构化记录的一种候选,此处只介绍名称与适用性,不提前使用该 API。

小结与练习

  • 实例属性、局部参数与类属性有不同绑定位置。
  • 普通实例方法绑定对象;类方法绑定类;静态方法没有自动接收者。
  • 初始化、更新、返回值、输出及副作用分别核验。
  • 类级列表、可变默认参数与重复传入的列表是三种不同共享来源。
  • 下划线约定与名称改写都保留了外部访问的可能。
  • 函数、记录与类是组织方式的候选,选择需要接口和状态依据。

练习:

  1. 给 Point 增加返回新点的平移方法,说明它与原地 move 的字段变化与返回值。
  2. 保存 a.move 后执行 a = b,调用保存的方法,解释接收者与当前变量 a 的关系。
  3. 在实例上建立与类属性同名的字段,再删除该字段,逐步说明读取结果。
  4. 比较类级列表、可变默认参数和重复传入的列表,解释每种共享从哪里产生。
  5. 分别通过类和实例调用 classmethod 与 staticmethod,说明自动传入哪些参数。
  6. 比较数据转换函数、牌组与持有对话历史的程序,说明哪些状态需要跨调用保留。

Lab 9 保留 Rectangle、Stack、WordCounter、Temperature、Gradebook 和设计判断六项任务,具体方法名及失败行为以实验页为准。

来源