JEPA4Japan · 教程

类与对象

1,862字 6分钟阅读 #Python

通过实例、方法和定义清晰的初始化过程,将状态与行为结合起来。

课程进度 课程大纲 已发布 24/24 课

当数据有规则时,使用蓝图

字典通常足以表示一条记录。当一个概念具有状态、保持该状态有效的规则,以及与其相关的操作时,类就派上用场了。

  1. 类 共享的蓝图
  2. 实例 根据蓝图创建的一个对象
  3. 状态 该对象自己的值
类描述一类事物;每个实例都拥有自己特定的状态。
class StudySession:
    def __init__(self, topic, minutes):
        self.topic = topic
        self.minutes = minutes


first = StudySession("Python", 40)
second = StudySession("Git", 25)
print(first.topic)
print(second.minutes)

输出为 Python 和 25。调用类会创建一个实例并运行 __init__。初始化必须返回 None;它负责准备新对象,而不是返回一个替代对象。

self 背着当前对象的背包

实例方法将 self 声明为第一个参数。当你通过实例调用该方法时,Python 会自动传入这个实例:

class StudySession:
    def __init__(self, topic, minutes):
        self.topic = topic
        self.minutes = minutes

    def label(self):
        return f"{self.topic}: {self.minutes} minutes"


session = StudySession("Python", 40)
print(session.label())
print(StudySession.label(session))

两次调用都会打印 Python: 40 minutes。第一次使用绑定方法;第二次显式传入同一个实例。

  1. 通过实例调用 session.label()
  2. self 到达 它就是这个 session
  3. 读取它的状态 self.topic
  4. 返回结果 将显示操作留在外部
self 标识参与本次方法调用的实例。

不变量是每个可用实例都必须遵守的规则。在公开状态之前先进行验证:

class StudySession:
    def __init__(self, topic, minutes=0):
        topic = topic.strip()
        if not topic:
            raise ValueError("topic must not be empty")
        if isinstance(minutes, bool) or not isinstance(minutes, int) or minutes < 0:
            raise ValueError("minutes must be a non-negative integer")

        self.topic = topic
        self._minutes = minutes

    def add_minutes(self, amount):
        if isinstance(amount, bool) or not isinstance(amount, int) or amount <= 0:
            raise ValueError("amount must be a positive integer")
        self._minutes += amount
        return self._minutes

这个状态转换方法先检查,再修改状态。按照约定,名称开头的下划线表示“非公开的实现细节”;它并不会强制实现私有性。

看起来相同的对象仍然可能不同

first = StudySession("Python", 30)
second = StudySession("Python", 30)
alias = first

print(first is second)
print(first is alias)
print(first == second)

对于这个没有自定义相等性规则的普通类,输出为 False、True、False。is 判断两个名称是否指向同一个对象。== 判断是否相等,但普通的用户自定义类在定义值相等规则之前,会退回到按身份判断。应将 is 用于 value is None 之类的身份检查,而不要用它代替普通的值比较。

每个实例自己的可变数据必须在 self 上创建:

class StudyPlan:
    def __init__(self):
        self.sessions = []

如果把 sessions = [] 放在类体中,每个实例找到的都会是同一个共享列表。类属性适合保存共享常量,而不适合充当每个对象自己的私人笔记本。

计算视图并保护笔记本

属性可以用属性访问语法呈现计算成本较低的信息。类方法则提供另一条用途明确的构造路径。

  1. @property 只读的计算视图
  2. @classmethod 另一种构造方式
  3. 元组快照 可以查看,但不能追加
提供实用的视图和入口,同时不泄露内部的可变列表。
class StudyPlan:
    def __init__(self, sessions):
        self._sessions = list(sessions)

    @property
    def total_minutes(self):
        return sum(session.minutes for session in self._sessions)

    @property
    def sessions(self):
        return tuple(self._sessions)

    @classmethod
    def from_rows(cls, rows):
        return cls(StudySession(row["topic"], row["minutes"]) for row in rows)

plan.total_minutes 不需要括号,而且由于每次都会重新计算,所以不会过期。cls(...) 会保留调用 from_rows() 时所使用的类,同时普通构造函数仍会强制执行不变量。元组会阻止通过返回值调用 append,并捕获外层列表的当前状态,但它不会递归冻结其中的可变对象。

构建学习计划模型

将这个完整的、仅使用标准库的项目保存为 study_plan.py:

class StudySession:
    def __init__(self, topic, minutes=0):
        topic = topic.strip()
        if not topic:
            raise ValueError("topic must not be empty")
        if isinstance(minutes, bool) or not isinstance(minutes, int) or minutes < 0:
            raise ValueError("minutes must be a non-negative integer")
        self.topic = topic
        self._minutes = minutes

    @property
    def minutes(self):
        return self._minutes

    def add_minutes(self, amount):
        if isinstance(amount, bool) or not isinstance(amount, int) or amount <= 0:
            raise ValueError("amount must be a positive integer")
        self._minutes += amount
        return self._minutes


class StudyPlan:
    def __init__(self, title):
        title = title.strip()
        if not title:
            raise ValueError("title must not be empty")
        self.title = title
        self._sessions = []

    def add_session(self, session):
        if not isinstance(session, StudySession):
            raise TypeError("session must be a StudySession")
        self._sessions.append(session)

    @property
    def sessions(self):
        return tuple(self._sessions)

    @property
    def total_minutes(self):
        return sum(session.minutes for session in self._sessions)

    @classmethod
    def from_rows(cls, title, rows):
        plan = cls(title)
        for row in rows:
            plan.add_session(StudySession(row["topic"], row["minutes"]))
        return plan

    def report(self):
        lines = [self.title, f"Total: {self.total_minutes} minutes"]
        lines.extend(session.topic for session in self._sessions)
        return "\n".join(lines)


def main():
    first = StudySession(" Python ", 30)
    second = StudySession("Git", 25)
    assert first.add_minutes(10) == 40
    assert second.minutes == 25

    plan = StudyPlan("August plan")
    plan.add_session(first)
    snapshot = plan.sessions
    plan.add_session(second)
    assert len(snapshot) == 1 and len(plan.sessions) == 2

    rows = [{"topic": "Files", "minutes": 20}]
    assert StudyPlan.from_rows("Extra", rows).total_minutes == 20
    print("Tests passed.")
    print(plan.report())


if __name__ == "__main__":
    main()

输出:

Tests passed.
August plan
Total: 65 minutes
Python
Git

这两个学习时段彼此独立,构造和更新操作会保持状态有效,而且调用者永远不会获得计划内部的可变列表。

三个小任务与交接

  1. 阅读目标。 构建 ReadingGoal(title, target_pages),包含 record(pages) 和只读的 completed 属性。测试两个相互独立的目标,并测试恰好达到目标页数的情况。
  2. 共享列表侦探。 将 entries = [] 放入类体中,证明两个实例共享它;然后将它移到 __init__ 中的 self.entries 上,并证明两个实例相互独立。
  3. 文本入口。 添加 StudySession.from_text("Python:45")。在其中进行解析,但让普通构造函数负责强制执行关于主题和分钟数的所有不变量。

容易踩坑的地方:__init__ 不能返回其他值;直接修改可能绕过不变量;is 判断的是身份,而不是值是否相等;返回内部列表会泄露第二条修改路径。

  • 我能区分类和实例。
  • 我能解释 self 并初始化有效状态。
  • 我会将每个实例自己的可变数据保存在 self 上。
  • 我能区分对象身份与看起来相同的状态。
  • 我会使用属性和用途明确的类方法。
  • 我能返回内部列表安全的外层快照。
  • 我的完整模型通过了所有断言,并打印出 65 分钟。
  • 我完成了三个小任务。

接下来,对于灵活的“拥有一个”关系,你将选择组合;对于真正可替换的“是一个”关系,你将选择继承;对于共享行为,你将选择多态。