基础

Python 中的类与面向对象编程:__init__、self、方法、继承与 dataclass

更新于: · Skillaria.Top

Python 的面向对象编程以类为基础:类描述对象拥有的字段和方法,对象(实例)通过调用类创建,__init__ 负责填充字段,self 指向当前对象。继承让子类复用父类的行为并重写部分方法;装饰器 @dataclass 为主要用于存储数据的类自动生成 __init__、__repr__ 和 __eq__。

Python 中的类是一张“图纸”:它描述对象保存哪些数据、能做哪些操作。对象(实例)则是按照图纸制造出的具体产品。Python 的面向对象编程依赖三样东西:带有方法的类、继承,以及 __init__、__repr__ 等特殊方法。下面通过一个贯穿始终的例子——记录支出——逐一讲解。

既然有字典,为什么还需要类

数据可以保存在字典中:{"title": "咖啡", "amount": 250}。问题在于,字典并不知道哪些键是正确的。拼写错误 expense.get("amout", 0) 不会报错——只会返回 0,报表就会悄悄地算错。

类的对象在声明时就规定了字段。访问不存在的属性会立即抛出 AttributeError,而现代 Python 还会提示正确的名字。此外,类把数据和对数据的操作放在一起:一笔支出自己就能计算含税金额,报表自己能算出合计。

如何在 Python 中创建类

用关键字 class 声明类,根据 PEP 8 的约定,类名使用 CamelCase 风格。最简单的类示例:

class Expense:
    def __init__(self, title, amount):
        self.title = title
        self.amount = amount

coffee = Expense("咖啡", 250)
taxi = Expense("打车", 300)
print(coffee.title, coffee.amount)   # 咖啡 250

调用 Expense("咖啡", 250) 会创建一个新的实例。coffee 和 taxi 是同一类型的两个不同对象,各自拥有独立的字段值。用 isinstance(coffee, Expense) 可以检查对象的类型,结果为 True。

构造器 __init__:初始化类的实例

人们常把 __init__ 称为类的构造器,但更准确地说,它是初始化器。对象本身由 __new__ 创建,__init__ 紧随其后调用,把传入的参数写入各个字段。它不需要返回任何东西(确切地说,它必须返回 None)。

在 __init__ 中验证输入数据很方便,这样程序中就不会出现明显错误的对象:

class Expense:
    def __init__(self, title, amount):
        if amount < 0:
            raise ValueError("金额不能为负数")
        self.title = title
        self.amount = amount

self 在 Python 中是什么

self 是对当前正在处理的对象的引用。当你写 coffee.with_tax() 时,Python 实际上调用的是 Expense.with_tax(coffee),并把对象作为第一个参数传入。因此在方法声明中 self 排在第一位,而调用时不需要写它。

self 不是关键字,而是约定:形参可以叫任何名字,但没有人这样做。新手常见的错误是在方法声明中忘记 self。这时调用会失败:TypeError: Expense.with_tax() takes 0 positional arguments but 1 was given。

类的方法与特殊方法

方法是在类中声明的函数,它通过 self 访问对象的字段:

class Expense:
    def __init__(self, title, amount):
        self.title = title
        self.amount = amount

    def with_tax(self, rate=0.2):
        return self.amount * (1 + rate)

    def __repr__(self):
        return f"Expense({self.title!r}, {self.amount})"

coffee = Expense("咖啡", 250)
print(coffee.with_tax())   # 300.0
print(coffee)              # Expense('咖啡', 250)

如果没有 __repr__,输出会是 <__main__.Expense object at 0x7f...>,对调试毫无帮助。两侧都带双下划线的方法称为特殊方法(dunder 方法),由 Python 自动调用:__str__ 负责面向用户的“友好”输出,__eq__ 负责 == 比较,__len__ 负责 len(obj)。

@classmethod 与 @staticmethod

普通方法作用于实例。使用装饰器 @classmethod 的方法,第一个参数是类本身(cls),常用作替代构造器。静态方法 @staticmethod 既不接收对象,也不接收类:它只是一个在逻辑上属于该类的普通函数。

class Expense:
    def __init__(self, title, amount):
        self.title = title
        self.amount = amount

    @classmethod
    def from_csv(cls, line):
        title, amount = line.split(";")
        return cls(title, float(amount))

    @staticmethod
    def is_valid_amount(value):
        return value >= 0

e = Expense.from_csv("午饭;520")
print(e.amount)                    # 520.0
print(Expense.is_valid_amount(-1)) # False

析构方法 __del__

Python 中有 __del__ 方法,它会在垃圾回收器销毁对象之前调用。不要依赖它来释放资源:调用时机无法保证。对于文件和连接,应使用上下文管理器 with,或显式调用 close()。

类属性与实例属性

通过 self.name = ... 设置的字段属于具体对象。直接写在类体中的变量是类属性,所有实例共享。通常用它保存类的常量。

class Expense:
    CURRENCY = "RUB"          # 类属性,常量

    def __init__(self, title, amount):
        self.title = title    # 实例属性
        self.amount = amount

a = Expense("咖啡", 250)
b = Expense("茶", 150)
print(a.CURRENCY, Expense.CURRENCY)   # RUB RUB

一个危险的陷阱是可变的类属性。如果在类体中写 rows = [],然后通过 self.rows.append(...) 添加内容,这个列表将被所有对象共享。可变字段应当始终在 __init__ 中创建。

要查看对象的字段,可以使用 vars(obj) 或 obj.__dict__;要按字符串名读取属性,使用 getattr(obj, "amount")。

类的属性:@property

属性(property)是一种方法,从外部读取时像普通字段一样,不需要括号。它适合用于计算得出的值:报表的合计不需要单独保存,也就不会忘记更新。

class Report:
    def __init__(self):
        self.rows = []

    def add(self, expense):
        self.rows.append(expense)

    @property
    def total(self):
        return sum(e.amount for e in self.rows)

r = Report()
r.add(Expense("咖啡", 250))
r.add(Expense("打车", 300))
print(r.total)   # 550

如果需要控制赋值,可以通过 @total.setter 添加设值器。没有设值器时,执行 r.total = 0 会抛出 AttributeError。

关于封装:Python 中没有严格意义上的私有字段。单下划线(_cache)是“请勿从外部使用”的约定;双下划线(__secret)会启用名称改写(name mangling),防止子类意外覆盖该字段。

Python 类的继承

继承允许基于父类创建子类:子类获得父类的所有字段和方法,并可以重写其中的一部分。父类写在括号中。

class MonthlyReport(Report):
    def __init__(self, month):
        super().__init__()        # 调用父类的 __init__
        self.month = month

    def add(self, expense):       # 重写方法
        print(f"[{self.month}] + {expense}")
        super().add(expense)      # 保留父类的行为

m = MonthlyReport("十月")
m.add(Expense("咖啡", 250))   # [十月] + Expense('咖啡', 250)
print(m.total)                # 250 — 属性已继承

super() 返回一个代理对象,用于调用父类的方法。在子类的 __init__ 中忘记写 super().__init__() 是经典错误:rows 字段不会被创建,第一次调用 add 就会抛出 AttributeError。

可以用 issubclass(MonthlyReport, Report) 检查继承关系。在 Python 3 中,所有类都隐式继承自 object,这也是基础方法 __repr__、__eq__ 等的来源。Python 也支持多重继承(class C(A, B)),方法的查找顺序可以通过 C.__mro__ 查看。

抽象类

抽象类定义了一套接口:子类必须实现哪些方法。抽象类不能直接实例化。这需要使用 abc 模块:

from abc import ABC, abstractmethod

class Exporter(ABC):
    @abstractmethod
    def export(self, report): ...

class CsvExporter(Exporter):
    def export(self, report):
        return "\n".join(f"{e.title};{e.amount}" for e in report.rows)

Exporter()   # TypeError: Can't instantiate abstract class Exporter ...

如果子类没有实现所有抽象方法,它仍然是抽象的,创建对象时会抛出同样的错误。

用组合代替深层继承

学会继承之后,人们很容易搭建层层继承的结构:基础支出、货币支出、人民币支出——一直延伸到五层。在 Python 中这种写法并不常见。继承描述的是“是一个”的关系,而组合描述的是“包含”的关系:报表不是一个列表,而是在字段 rows 中包含了一个列表。

实用的原则是:如果只需要使用另一个对象的功能,就把它放进字段中,而不是继承它。对于数据,dataclass 通常就足够了;业务逻辑则可以放在普通函数中。

Python 的 dataclass:不写样板代码的类

对于主要存储数据的类,手写 __init__、__repr__ 和 __eq__ 很繁琐。来自 dataclasses 模块的装饰器 @dataclass 会根据字段的注解自动生成它们:

from dataclasses import dataclass, field

@dataclass
class Expense:
    title: str
    amount: float
    tags: list[str] = field(default_factory=list)

    def __post_init__(self):
        if self.amount < 0:
            raise ValueError("金额不能为负数")

e = Expense("咖啡", 250)
print(e)                           # Expense(title='咖啡', amount=250, tags=[])
print(e == Expense("咖啡", 250))   # True

以前在 __init__ 中编写的验证,现在可以放入 __post_init__ 中——它会在自动生成的初始化器之后立即调用。可变的默认值只能通过 field(default_factory=list) 设置:写成 tags: list = [] 会被 dataclass 禁止,并抛出 ValueError。

frozen 与 slots

参数 frozen=True 让对象不可变,slots=True(自 Python 3.10 起)会生成 __slots__,从而节省内存。修改冻结对象的字段会引发 FrozenInstanceError。如果需要带有新值的对象,就创建一个副本:

from dataclasses import dataclass, replace

@dataclass(frozen=True, slots=True)
class Expense:
    title: str
    amount: float

e = Expense("咖啡", 250)
# e.amount = 300   -> dataclasses.FrozenInstanceError
e2 = replace(e, amount=300)
print(e, e2)   # Expense(title='咖啡', amount=250) Expense(title='咖啡', amount=300)

自 Python 3.13 起,通用函数 copy.replace() 也能做到同样的事。冻结的 dataclass 对象可以放入集合,也可以用作字典的键——它们有 __hash__ 方法。

类的新特性:Self、@override、__slots__

在类型标注方面,有两个有用的工具。typing.Self(Python 3.11,PEP 673)表示“返回同一个类的对象”,它在继承的方法中同样适用。装饰器 @override(Python 3.12,PEP 698)标记一个必须重写父类方法的方法:如果你把方法名拼错了,mypy 或 pyright 会报告父类中没有这个方法。

from typing import Self, override

class Report:
    def __init__(self):
        self.rows = []

    def add(self, e) -> Self:
        self.rows.append(e)
        return self              # 可以链式调用

class MonthlyReport(Report):
    @override
    def add(self, e) -> Self:
        return super().add(e)

在 Python 3.15 中取消了一个狭窄的限制:tuple 的子类现在可以使用非空的 __slots__,并且 __dict__ 与 __weakref__ 可以写入任何类的 __slots__。在 3.14 中,对元组子类做这样的声明会抛出 TypeError: nonempty __slots__ not supported for subtype of 'tuple'。

类的常见错误

错误现象如何修正
方法中忘记 selfTypeError: ... takes 0 positional arguments but 1 was given方法的第一个参数始终是 self
在 __init__ 中忘记 self.赋值的值在 __init__ 结束后消失写成 self.amount = amount
列表作为类属性数据在对象之间“串流”在 __init__ 中创建列表,或使用 default_factory
没有调用 super().__init__()访问父类字段时出现 AttributeError调用父类的初始化器
把属性当作方法调用TypeError: 'int' object is not callable不加括号读取属性:r.total

小结

Python 的面向对象编程从简单的内容开始:类描述字段和方法,__init__ 填充字段,self 指向当前对象。继承和 super() 用于扩展行为,抽象类用于定义接口,@property 用于计算字段。对于数据类,使用 @dataclass;对于深层层次结构,优先选择组合。这一主题在课程《Python 3.15:从新手到专家》中同样结合实践讲解,使用的是支出记录器的例子。

常见问题

类和对象在 Python 中有什么区别?

类是对类型的描述:它有哪些字段和方法。对象(实例)通过调用类创建,例如 Expense("咖啡", 250),它保存具体的字段值。

类中一定要写 __init__ 吗?

不一定。如果没有声明 __init__,就使用从 object 继承的版本,它不接受参数。在 dataclass 中,这个方法由装饰器生成。

__str__ 与 __repr__ 有什么区别?

__repr__ 面向开发者和调试器,应当无歧义地描述对象;__str__ 面向用户,由 print 和 f-字符串使用。如果没有定义 __str__,Python 会使用 __repr__。

什么时候使用 dataclass,什么时候使用普通类?

当类主要用来保存数据,并且需要自动生成 __init__、__repr__ 和比较方法时,使用 dataclass。当初始化逻辑复杂,或者行为比数据更重要时,使用普通类更合适。

Python 中有私有字段吗?

没有严格的私有性。名字开头的单下划线是“不要在外部使用”的约定;双下划线会启用名称改写,防止子类意外覆盖该字段。

通过视频学习本主题

课程“Python 3.15: от новичка до профи”结合实践详细讲解了这一主题。

前往课程