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

类(一):对象与封装

Class I: Objects and Encapsulation

用类组织相关的数据与操作,分析初始化、实例方法和接收者绑定。检查对象状态的有效性,区分类属性、实例属性与共享引用,并理解类方法及非公开接口的边界。

本讲结束后你应当能

  • 区分类与实例,用属性保存每个实例的状态
  • 解释初始化、默认参数及普通实例方法中的 self
  • 分别核对方法的返回值、输出与状态变化
  • 在构造及更新入口验证数据,解释下划线约定的限制
  • 区分实例属性、类属性、共享集合及类方法
  • 根据状态、接口与生命周期选择类、函数或记录
本页目录

I. 对象与初始化

数据与相关操作

上一讲从规格、接口、边界和副作用检查程序。本讲进一步研究怎样组织这些接口和数据。

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

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__ 的返回值。创建与初始化的区别及错误返回值例子在参考区说明。

别名和独立实例

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

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()

问题是绑定后传入了一个实例,而函数定义不接收参数,不是解释器要求参数必须拼写为 self。

局部参数和实例属性

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

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)。公开接口说明调用者可使用哪些操作、需要提供什么输入、操作后会得到什么状态或结果。

不变量是约定操作应保持的状态条件。例如,账户余额非负。检查构造后的初始状态,再检查每个公开更新操作,才能说明在这些操作下规则得到保持。

普通 Python 属性可以直接赋值。类不会自动阻止 account.balance = -10 或修改公开的历史列表。这里的保证以遵守接口约定为前提,不是权限隔离或安全沙箱。

审查取款候选

下面是错误候选,不是推荐实现。先预测负取款后的结果:

class CandidateAccount:
    def __init__(self, balance: int) -> None:
        self.balance = balance
    def withdraw(self, amount: int) -> bool:
        if amount > self.balance:
            return False
        self.balance -= amount
        return True

a = CandidateAccount(100)
print(a.withdraw(-20), a.balance)

实际为 True 120。候选只检查余额不足,没有拒绝非正金额。类定义和注解没有补上缺失的规则。

账户的受支持操作

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

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 表示内部实现,调用者不应依赖其结构或绕过公开接口直接更新。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 继续讨论。

修改列表与重新绑定属性

下面的类变量只引用一个列表:

class Basket:
    items: list[str] = []
    def add(self, item: str) -> None:
        self.items.append(item)

a = Basket()
b = Basket()
a.add("apple")
print(b.items)
print(a.items is b.items)

结果为 ['apple']、True。self.items.append(...) 修改被找到的列表,并没有自动建立一个独立的实例列表。如果接口要求不同篮子独立,这就是错误;如果设计本来需要共同登记表,共享类属性也可能合理。

每次初始化创建新列表,可满足此处独立篮子的约定:

class Basket:
    def __init__(self) -> None:
        self.items: list[str] = []
    def add(self, item: str) -> None:
        self.items.append(item)

a = Basket()
b = Basket()
a.add("apple")
print(a.items, b.items)
print(a.items is b.items)

得到 ['apple'] []、False。独立性来自分别创建新列表,不来自参数名字 self。

输入共享与可变默认参数

即使字段写在 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。从实例访问这个类方法,自动传入的仍是类,而不是 a。计数表示执行这个初始化过程的次数,不是当前存活对象数;删除引用不会自动减少这个普通类字段。若初始化可能失败,应明确计数更新放在成功校验之后。

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

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

参考:完整例子与核验

内置构造器与类型

__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. 用 Triangle 的两个实例验证合法/非法 set_edges,并检查失败后原三边不变。
  3. 验证 HappyNumber 查询前后 number,记录实际有序轨迹,不依赖 set 的显示顺序。
  4. 检查 Vector 的零向量、维度不等、输入列表与原地伸缩。说明 direction 的弧度约定。
  5. 保存一次绑定方法后修改实例状态,再调用它,解释接收者和字段变化。
  6. 比较转换函数、牌组、回文判定和持有对话历史的程序的组织需求;不实现外部模型调用。

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

来源