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

类(一):对象与封装

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 表示内部实现,是约定不是强制
  • 只有一个方法的类应该是函数;只有数据的类应该是字典

练习

  1. 写一个 Rectangle 类,有 width 和 height,提供 area()、perimeter() 和 is_square() 三个方法。
  2. 给 Rectangle 加一个 scale(factor) 方法,把长宽都乘以 factor。它应该修改自身还是返回新对象?说明你的选择。
  3. 写一个 Counter 类,能 add(item) 计数、get(item) 查询、most_common() 返回出现最多的项。用实例变量存数据。
  4. 下面的类有什么问题?改对它:
    class Playlist:
        songs = []
        def add(self, song):
            self.songs.append(song)
  5. 写一个 Stack 类(L7 讲调用栈时提过栈的概念),提供 push、pop、peek、is_empty。pop 空栈时该怎么办?说明你的设计。
  6. 把 L6 练习里的”成绩单解析”改写成一个 Gradebook 类,提供 add(name, subject, score)、average(name)、top_student()。

本讲的配套上机题在 Lab 9。