Python 中的类与面向对象编程:__init__、self、方法、继承与 dataclass
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'。
类的常见错误
| 错误 | 现象 | 如何修正 |
|---|---|---|
方法中忘记 self | TypeError: ... 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 中有私有字段吗?
没有严格的私有性。名字开头的单下划线是“不要在外部使用”的约定;双下划线会启用名称改写,防止子类意外覆盖该字段。