Python 学到第 14 天,我的进度正好卡在“面向对象高级”这一节。说实话,刚接触 Python 的时候,我也以为面向对象就是写个 class、弄几个方法、调一调对象就完事了。直到自己写的东西开始变多、变复杂,才发现类与对象之间的水比想象中深很多。继承、多态、封装、魔法方法、描述符、元类,这些东西平时可能用不上,但一旦需要,它们解决的就是那些用“普通代码”绕不过去的结构性问题。这篇博文就是把我这一天的学习笔记和踩坑记录重新整理了一遍,适合已经从入门语法走过来的读者,尤其是准备做中型项目、想理清设计思路的人。
1. 面向对象高级到底在解决什么问题
1.1 类与对象的本质:命名空间与属性查找
很多人写类写了很多年,但没仔细想过一个问题:一个对象和一个字典,本质区别到底在哪里?
其实 Python 里的对象,本质上就是一个带有“属性查找规则”的命名空间。实例的 __dict__ 就是它的命名空间,类的 __dict__ 是另一个命名空间。当我们写 obj.attr 的时候,Python 的查找顺序是:实例自己的 __dict__,然后是类的 __dict__,再往上是父类的 __dict__,直到 object 为止。
python复制class Animal:
kind = "animal"
def __init__(self, name):
self.name = name
a = Animal("旺财")
b = Animal("来福")
print(a.name) # 旺财,实例命名空间
print(a.kind) # animal,自己没找到,去类里找
Animal.kind = "pet"
print(b.kind) # pet
这个例子看起来简单,但理解了它,你就能解释很多诡异现象。比如为什么给实例赋值一个和类同名的方法名,不会影响其他实例;为什么 obj.method 和 Class.method(obj) 是等价的。说白了,属性查找就是一条链,谁在先谁就“说话算数”。
我在初学阶段踩过一个大坑:当时我想给一个实例动态挂载自定义方法,直接写了 obj.run = run_func,结果调用 obj.run() 的时候报错,提示参数不对。后来又查了半天才明白,直接赋给实例的函数并不会被当作绑定方法处理,只有通过类定义里的函数,才会在访问时自动绑定 self。这个细节,在理解“描述符协议”之前,真的只能靠死记硬背。
1.2 类变量与实例变量:最容易翻车的区域
类变量和实例变量的区别,是被问烂了但又很难讲透的问题。简单说,类变量共享给所有实例,实例变量每个实例独立。但如果你用的数据类型是可变对象,问题就来了。
python复制class Student:
scores = []
def add_score(self, s):
self.scores.append(s)
s1 = Student()
s2 = Student()
s1.add_score(90)
print(s1.scores) # [90]
print(s2.scores) # [90],被共享了
如果我的本意是所有学生分开记分,上面的代码就是灾难。原因在于,s1 没有自己的 scores,所以它读的是类的 scores,然后在上面直接做 append,这个操作修改的是类变量本身。
正确的做法是在 __init__ 里创建实例变量:
python复制class Student:
def __init__(self):
self.scores = []
def add_score(self, s):
self.scores.append(s)
这里有一个判断技巧:如果类变量是不可变类型,比如数字、字符串、元组,共享一般没有副作用;如果是列表、字典、集合,就要非常小心,尽量放到 __init__ 里初始化。我后来写项目的时候,基本遵循一个原则:类里只放常量、枚举映射、工具方法,所有实例状态都放在 __init__ 中显式定义。这样代码虽然啰嗦一点,但逻辑非常清晰,排查问题的时候一眼就能看出来每个实例拥有什么数据。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 继承体系深挖:多继承、MRO 与 super() 的真相
2.1 多继承与 C3 线性化
Python 支持多继承,这是很多其他面向对象语言没有的。多继承听起来很强大,但用不好就是雷区。当两个父类都定义了同名方法,子类调用时到底走哪一个?这取决于 MRO(Method Resolution Order,方法解析顺序)。
python复制class A:
def who(self):
print("A")
class B:
def who(self):
print("B")
class C(A, B):
pass
c = C()
c.who() # A
print(C.__mro__)
# (<class '__main__.C'>, <class '__main__.A'>, <class '__main__.B'>, <class 'object'>)
MRO 的算法叫 C3 线性化,它保证两点:子类永远在父类之前,基类 object 永远在最后。这个顺序是把继承层级“压平”之后的结果,你可以通过 类名.__mro__ 直接查看。
很多初学者觉得 MRO 很难背,我的经验是:遇到多继承问题,先别急着推理,直接打印 __mro__,看实际顺序。代码里的判断一般不会错,真正容易错的是在复杂继承体系里,super() 的调度路径会和你想的不一样。这时候,__mro__ 才是唯一可信的依据。
2.2 super() 不是“调用父类方法”
这是我这天学习中印象最深的一个认知刷新。传统理解里,super() 只是用来调用父类同名方法,方便做扩展。但在多继承场景下,super() 并不是“直接找父类”,而是按照 MRO 顺序继续往后找。这意味着,super() 的调用链可能跨越多个类,而不仅仅是当前类的直接父类。
python复制class A:
def __init__(self):
print("A init")
super().__init__()
class B:
def __init__(self):
print("B init")
super().__init__()
class C(A, B):
def __init__(self):
print("C init")
super().__init__()
c = C()
执行结果:
text复制C init
A init
B init
注意,A 的 super().__init__() 调用的并不是 object.__init__(),而是继续调到了 B.__init__()。因为 C 的 MRO 是 C -> A -> B -> object,A 里的 super() 在 MRO 里找到的下一个类就是 B。
这个行为在写多个 mixin 类的时候特别有用。比如一个日志能力、一个权限校验能力,两者通过 super() 串联起来,各自的初始化逻辑都能被执行,不需要手工逐个调用。
不过这也带来一个硬性要求:使用 super() 的类,必须保持参数签名一致。不然 MRO 里下一个类接收参数不一样,就会出现难以追踪的报错。我建议在协作项目中,大家约定初始化方法统一使用 *args, **kwargs 传递多余参数,能省掉一堆麻烦。
2.3 抽象基类与鸭子类型
Python 是动态语言,默认不强制接口约束。但你可以在设计层面使用抽象基类(ABC)来约定子类必须实现的方法。
python复制from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
pass
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side ** 2
# class Triangle(Shape):
# pass
# t = Triangle() # TypeError: Can't instantiate abstract class Triangle
抽象基类最大的价值,是让多人协作时“接口”变得显式。你不必等到运行到某个方法才报 AttributeError,而是实例化阶段就能发现类没有实现必要方法。
但也要注意,Python 里更地道的风格往往是“鸭子类型”:不强制继承抽象类,只要你实现了我需要的方法,就能传进来用。比如文件对象和 StringIO,它们没有公共基类,但都有 read、write 方法,所以能用于同一套处理函数。我在实际项目里的做法是:内部底层模块用鸭子类型,保持灵活;对外 API 或者插件系统用 ABC,保证扩展稳定性。两者并不冲突,关键看场景。
3. 封装的高级形态:属性装饰器、slots 与描述符
3.1 @property 让代码兼具安全与优雅
Java 里有 getter 和 setter,Python 里更优雅的做法是用 @property 装饰器。它把一个方法伪装成属性,访问时不用加括号,赋值时还能自动做校验。
python复制class Order:
def __init__(self, price, count):
self._price = price
self._count = count
@property
def total(self):
return self._price * self._count
@property
def price(self):
return self._price
@price.setter
def price(self, value):
if value <= 0:
raise ValueError("价格必须大于0")
self._price = value
order = Order(15.5, 3)
print(order.total) # 46.5
order.price = 20
print(order.total) # 60.0
order.price = -1 # 抛异常
很多初学者会问:为什么不直接写一个方法 get_price() 和 set_price()?原因在于,@property 让调用方式变成自然属性访问,日后想加校验逻辑,也不影响外部代码的调用方式。这是典型的“先写简单属性,后续加约束”的平滑升级路径。
还有一个容易被忽略的用法:用 @property 实现只读属性。如果我只定义了 @property,不定义 @setter,外部赋值就会报 AttributeError。这在模型类里非常常见,比如订单总价、用户年龄等不应被直接修改的计算字段,可以直接通过 private 加 @property 只读暴露。
3.2 slots 节省内存的利器
默认情况下,每个实例都有一个 __dict__ 字典来存储属性。字典方便,但内存开销大。如果你有成千上万个对象,每个都维护一个字典,内存占用会非常可观。
__slots__ 的作用是:声明一个固定的属性名列表,实例不再创建 __dict__,属性改为用“描述符”的方式存储在固定位置上。这样内存占用能明显下降,属性访问速度也会略微提升。
python复制class Point:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(1, 2)
# p.z = 3 # AttributeError: 'Point' object has no attribute 'z'
我实测过一个场景:内存里存 100 万个坐标点,普通类的内存占用大概在 80MB 上下,加了 __slots__ 后能降到 50MB 左右。代价是实例不能再动态添加新属性。这个特性适合数据类、DAG 节点、坐标点等“属性集合固定”的对象。
使用 __slots__ 有几点要注意。第一,如果类有继承,子类也要定义自己的 __slots__,否则子类实例仍然会有 __dict__,内存优化就失效了。第二,不能和 __dict__ 同时存在(除非你在 __slots__ 里显式加入 "__dict__",但那就失去意义了)。第三,__slots__ 不会自动包含父类的 __slots__,需要手动把父类字段也写进去,或者通过元类做合并。
3.3 描述符协议
描述符是 Python 属性管理的最底层机制,也是理解 @property、classmethod、staticmethod 内部原理的关键。一个实现了 __get__、__set__ 或 __delete__ 方法的类,就是描述符。
python复制class PositiveNumber:
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__[self.name]
def __set__(self, obj, value):
if value <= 0:
raise ValueError("必须是正数")
obj.__dict__[self.name] = value
class Product:
price = PositiveNumber()
p = Product()
p.price = 99
print(p.price)
# p.price = -1 # ValueError
这里有个细节:__set_name__ 方法是在类创建后,解释器发现类属性是描述符时自动调用的,用来把描述符的名字告诉描述符对象。如果没有它,你需要手动设置 self.name,否则 __set__ 里不知道用哪个键存数据。
描述符的使用频率不高,但一旦你需要复用同一套属性校验逻辑,它就比重复写 @property 优雅得多。我能想到的典型场景是 ORM,比如 SQLAlchemy 的字段类型,本质上就是一组描述符把 Python 属性映射到数据库列。理解了描述符,再去看 ORM 源码,会顺畅很多。
4. 魔法方法实操清单:从 new 到上下文管理器
4.1 new 与 init 的分工
__init__ 是初始化方法,负责给实例赋初值。但真正创建实例的是 __new__,它是一个静态方法,返回一个新的实例。__init__ 在 __new__ 返回实例之后才被调用。
python复制class SingleTon:
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, value):
self.value = value
a = SingleTon(1)
b = SingleTon(2)
print(a is b) # True
print(a.value, b.value) # 2 2
这个例子是实现单例模式的经典方法。每次 SingleTon(...) 都返回同一个实例。但要注意,__init__ 每次都会被调用,所以后一次传入的参数会覆盖前一次的属性。如果你想禁止重复初始化,需要在 __init__ 里加一个标志位判断。
__new__ 还有一个使用场景是:当你不希望类返回自身实例,而是返回其他对象。比如有些库为了兼容性,会用一个统一的工厂类,__new__ 里根据参数返回不同子类的实例。这个叫“类作为工厂”,属于比较高级的玩法,实际工作中用得不多,但遇到别人的代码这样写时,你能看懂就很关键。
4.2 str 与 repr 的差别
__str__ 和 __repr__ 看起来都返回字符串,但定位不同。__str__ 面向最终用户,追求可读性;__repr__ 面向开发者,追求准确、尽量能还原对象。如果只实现一个,建议实现 __repr__,因为 print(obj) 在没有 __str__ 时会回退到 __repr__。
python复制class Money:
def __init__(self, amount, currency="CNY"):
self.amount = amount
self.currency = currency
def __repr__(self):
return f"Money({self.amount!r}, {self.currency!r})"
def __str__(self):
return f"{self.amount} {self.currency}"
m = Money(100, "USD")
print(str(m)) # 100 USD
print(repr(m)) # Money(100, 'USD')
在调试的时候,repr 能直接告诉你这个对象是怎么构造的,省去很多猜测。我习惯在写业务模型类时,至少把 __repr__ 补上,这样日志打印对象时,不会满屏都是 <__main__.Money object at 0x...>。
__eq__ 也是一个高频魔法方法。默认情况下,两个对象相等判断的是身份(内存地址)。如果你想按业务字段判断,就要重写它。同时,为了保持哈希一致性,重写 __eq__ 的类通常也要重写 __hash__,否则对象放进集合或者作为字典键时,会得到“不可哈希”的错误。
4.3 自定义上下文管理器
with 语句的底层就是 __enter__ 和 __exit__。文件自动关闭、数据库连接自动提交或回滚,都是靠它实现的。
python复制class ManagedFile:
def __init__(self, path, mode="r"):
self.path = path
self.mode = mode
def __enter__(self):
self.file = open(self.path, self.mode)
return self.file
def __exit__(self, exc_type, exc_val, exc_tb):
self.file.close()
if exc_type is not None:
print(f"发生异常:{exc_val}")
return False
with ManagedFile("hello.txt", "w") as f:
f.write("hello")
__exit__ 的返回值很关键:返回 True 表示异常已经被处理,不会再向外抛出;返回 False(或不返回)表示异常继续向上传播。这个设计在处理特定异常、记录日志然后重新抛出的场景下非常灵活。
如果你只是想临时给某段代码加个计时、加个日志,不必每次写一个类。Python 标准库 contextlib 提供了 contextmanager 装饰器,可以把生成器函数直接变成上下文管理器。
python复制from contextlib import contextmanager
@contextmanager
def time_block(label):
import time
start = time.perf_counter()
try:
yield
finally:
print(f"{label}: {time.perf_counter() - start:.4f}s")
with time_block("批量导入"):
data = [i for i in range(100000)]
这种写法的可读性非常好,代码量也少。我后来写脚本时,90% 的上下文管理器都是用 contextmanager 实现的,只有需要精细控制异常时才会写完整的 __enter__/__exit__ 类。
4.4 call 与运算符重载
让实例像函数一样被调用,靠的是 __call__。这个能力在实现可配置回调、装饰器类时特别有用。
python复制class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, x):
return x * self.factor
double = Multiplier(2)
print(double(5)) # 10
运算符重载通过 __add__、__sub__、__mul__、__lt__ 等实现。这个在数值科学计算里很常见,比如自定义向量类,可以直接用 v1 + v2 而不是手动解包。
我记得看到过一个挺有意思的小练习“李白打酒”,用状态模式或者简单的运算符重载来模拟“遇店加一倍,遇花喝一斗”的递归过程。虽然它本身的题材是数学题,但用 Python 实现时,正好可以把“类作为状态节点”的思想体现出来。实际上,面向对象高级阶段,很多练习都可以从数学题里拉出来重写一遍,写着写着,你会慢慢找到“建模”的感觉。
5. 类级工具:classmethod、staticmethod、装饰器与元类
5.1 三种方法的区别
普通实例方法、@classmethod、@staticmethod 这三者的区别,面试被问频率很高,实际使用也容易混淆。
- 普通实例方法:第一个参数是
self,只能通过实例调用,能访问实例属性和类属性。 @classmethod:第一个参数是cls,无论通过实例还是类调用,拿到的都是类本身。适合做“备选构造函数”。@staticmethod:参数里既没有self也没有cls,就是个放在类命名空间里的普通函数。适合做和类相关但不需要访问类状态的工具函数。
python复制class Date:
def __init__(self, year, month, day):
self.date = (year, month, day)
@classmethod
def from_string(cls, s):
year, month, day = map(int, s.split("-"))
return cls(year, month, day)
@staticmethod
def is_valid(s):
return len(s.split("-")) == 3
d = Date.from_string("2025-01-01")
print(d.date) # (2025, 1, 1)
print(Date.is_valid("2025-1-1")) # True
from_string 这种写法比手工 Date(*parts) 更直观,而且子类继承后调用 from_string 时,cls 会自动指向子类,从而创建子类实例。这是 classmethod 最经典的使用场景。
5.2 装饰器在类上的用法
装饰器本身不算是面向对象的专属概念,但它在类中的应用非常广泛。你可以用装饰器为方法添加缓存、记录日志、控制重试,也可以用装饰器装饰整个类。
python复制import inspect
def validate_call(func):
def wrapper(*args, **kwargs):
print(f"调用 {func.__name__},参数 {args[1:]}")
return func(*args, **kwargs)
return wrapper
class Service:
@validate_call
def run(self, task):
print(f"执行 {task}")
s = Service()
s.run("清洗数据")
装饰器的核心逻辑很简单:用一个新的函数替换原来的函数,外层函数接受原函数,内层函数负责增强逻辑。理解这一层后,再看 functools.wraps 就顺理成章——它用来把原函数的 __name__、__doc__ 复制给包装函数,否则元信息会丢失。
在类内部,装饰器的执行时机是类定义阶段,而不是每次调用方法时才执行。这意味着装饰器只会被解析一次,如果装饰器在导入时做了耗时的初始化,会影响模块导入速度。这一点在做大型项目时需要注意,常见的“装饰器里读配置”应该延迟到首次调用时再进行。
5.3 元类:制造类的类
元类是理解 Python 类机制最深处的一道门槛。简单说,类是对象,创建普通对象的是类,创建类的是元类。默认情况下,所有类的元类是 type。
python复制class Meta(type):
def __new__(mcs, name, bases, namespace):
print(f"创建类 {name}")
return super().__new__(mcs, name, bases, namespace)
class MyService(metaclass=Meta):
pass
# 输出:创建类 MyService
元类能做很多事情:自动注册子类、校验类属性、注入方法、修改属性访问规则。比如很多插件系统会用元类在导入时自动把所有插件类注册到仓库里,这样新增插件不需要手工修改注册表。
但元类也是 Python 里“不用就别碰”的典型代表。滥用元类会让代码变得极难理解和调试。我的建议是:优先用装饰器、类装饰器、描述符解决;只有当需要拦截“类创建”这个动作本身时,才考虑元类。而且一定要给元类写清楚注释,因为半年后再看你可能也想不起来当初在干什么。
6. 常见问题排查实录:我在实践中踩过的坑
6.1 可变对象作为默认参数
这个问题老生常谈,但每次遇到都值得复查。函数定义时默认参数只计算一次,所以如果你用 [] 或 {} 作为默认值,后续调用会共享同一个可变对象。
python复制def add_item(item, lst=[]):
lst.append(item)
return lst
print(add_item(1)) # [1]
print(add_item(2)) # [1, 2] 而不是 [2]
修正方式是默认值设为 None,函数内部再创建新列表:
python复制def add_item(item, lst=None):
if lst is None:
lst = []
lst.append(item)
return lst
这个坑在面向对象里同样存在:类的 __init__ 默认参数千万不要用可变对象。我曾经因为一个默认字典参数,导致多个实例共享了同一份配置,排查了整整一个下午。现在我的编码习惯是:所有默认参数,只要是容器类型,一律写 None 并在函数体里初始化。
6.2 浅拷贝与深拷贝
面向对象编程里,对象赋值是引用传递。如果你想把一个对象完整复制一份,用到 copy.copy 和 copy.deepcopy。
python复制import copy
class Config:
def __init__(self):
self.options = {"debug": True}
c1 = Config()
c2 = copy.copy(c1)
c2.options["debug"] = False
print(c1.options["debug"]) # False,因为浅拷贝只复制了引用
c3 = copy.deepcopy(c1)
c3.options["debug"] = True
print(c1.options["debug"]) # False,深拷贝完全独立
浅拷贝只复制最外层,嵌套对象仍然是同一个引用。深拷贝是递归复制所有嵌套对象。在业务代码里,默认配置和用户自定义配置合并时,最容易出现这类问题。如果你不确定对象内部复杂程度,涉及到嵌套字典、嵌套类实例,就用 deepcopy,但也要注意性能,大量对象的深拷贝会很耗时。
6.3 MRO 冲突与继承滥用
多继承虽然有 MRO 算法保障,但设计上还是要克制。我见过一些代码,为了复用几个工具方法,硬生生搞出一个继承链,结果后续需求一变,改一个父类方法影响了所有子类,牵一发动全身。
一个更稳妥的思路是“组合优先于继承”。把一个工具类实例作为当前类的属性,而不是从它继承。
python复制class LoggerMixin:
def log(self, msg):
print(f"[{self.__class__.__name__}] {msg}")
class Service:
def __init__(self, logger):
self.logger = logger
def run(self):
self.logger.log("start")
像上面这样,把 LoggerMixin 拆成组合,Service 只依赖一个 logger 对象,后面如果你想换日志库,只需要替换传入的 logger,类本身不需要改动。继承适合“is-a”关系,组合适合“has-a”关系。很多实际场景其实都是“has-a”,所以优先组合是更省心的选择。
6.4 循环导入与延迟导入
当你的类开始放到不同模块时,循环导入是个大概率会遇到的问题。模块 A 导入模块 B,模块 B 又导入模块 A,Python 在导入阶段就会报错。
解决方式有很多种:第一种,把共享的类型定义放到独立的公共模块,双方都只依赖它,不互相依赖;第二种,在方法内部延迟导入,也就是用的时候再 import,避免模块加载期的耦合。
python复制class ServiceA:
def run(self):
from module_b import ServiceB # 延迟导入
b = ServiceB()
return b.work()
延迟导入会让代码可读性稍微下降,但确实能切断循环依赖。我更推荐在设计时就用“依赖倒置”的思路,让高层模块依赖抽象接口而不是具体实现,这样模块之间的依赖关系会清晰很多。
最后的几条经验
学完 Day14 的面向对象高级,最直接的感受是:Python 的这些高级特性,不是为了炫技,而是为了让你能够“编写可以演进的代码”。最初你可能只写了简单类,后面业务复杂了,不重写整个系统也能通过装饰器、属性控制、描述符、上下文管理这些机制一点点扩展。但务必要把握一个度,不要为了“高级”而高级,我的评判标准很简单:一个特性如果能明显减少重复代码、提升可维护性,就用;如果只是让代码看起来更“炫”,果断放弃。
另外再分享一个小习惯:每学一个面向对象的高级特性,我都会做两个验证。第一个是把官方文档里的最小示例跑通;第二个是自己写一个贴合实际业务的小场景,把它当作练习。Day14 我额外写了一个迷你订单系统,把 @property、抽象基类、上下文管理器、classmethod 全部串了起来。代码不算多,但跑通后,对这些特性的理解瞬间扎实了很多。建议你也找一个小项目练手,光看笔记和代码示例,印象真的不够深。
