Python3面向对象编程:从类、继承到描述符的实战指南
2026/9/23 6:29:20 网站建设 项目流程

1. 为什么写了多年Python,还是会觉得面向对象"没必要"

先聊个我自己的经历。前两年维护一个数据处理的项目,早期只有几个脚本,函数套函数,跑得也挺好。后来需求越加越多:要多格式导入、多策略清洗、多维度报表,代码量从几百行膨胀到快五千行,这时候问题就来了——改一处清洗逻辑,得顺着调用链翻七八个函数;新增一种报表格式,又得复制粘贴一大段相似代码;最崩溃的是全局变量被好几个模块改来改去,运行结果开始变得"看运气"。

回头看,问题不在于"函数式写法"本身有错,而在于我根本没建立起"对象"的思维。函数确实能组织逻辑,但它组织不了"状态"。当你的数据和行为散落在各个模块里,彼此通过参数传来传去,耦合度会随着项目规模指数级上升。

面向对象编程解决的就是这件事:把相关的数据和操作这些数据的函数打包成一个整体,让代码结构跟现实问题结构对齐。Python3里写OOP尤其自然,因为它的动态特性让类、继承、多态这些东西用起来比Java、C++轻量得多,甚至可以说,Python在语法层面就鼓励你用OOP来组织中等以上规模的项目。

这篇内容我会基于《Python3 面向对象编程(第二版)》的核心脉络,配合我自己实际写过的代码,把Python3 OOP里最重要的几块拆开讲清楚:类与对象的底层记忆体机制、继承与多态的实用姿势、组合优于继承的设计判断、以及那些网上教程很少讲的坑。

适合谁看?如果你已经能熟练写Python脚本,但还没系统学过面向对象;或者学过类的基础语法,但不知道实际项目里该怎么设计类——这篇文章就是给你准备的。Java转Python的读者也能从中找到两边思维差异的对照。

2. 类与对象:把记忆体布局画出来,很多困惑瞬间就解开了

很多人学类的时候卡在self上,觉得这个参数神神叨叨的。我当年也困惑过:为什么定义方法的时候要写self,调用的时候又不传?其实这个问题一旦理解了对象在记忆体里的存在方式,就完全不是问题了。

2.1 类是模板,对象是模板造出来的实际个体

打个比方。类就像是一张房屋设计图,对象就是按照这张图盖出来的一个个具体的房子。设计图上写着"这里要有客厅,那里要有厨房",但它不是房子本身;只有当你真正动工"实例化"——也就是执行ClassName()——才得到一栋能住人的房子。

在Python里,这个"盖房子"的动作背后做了三件事:

  1. 分配一块记忆体空间,准备存放这个对象自己的数据;
  2. 调用__init__方法,把传入的参数初始化到这块空间里;
  3. 把这个对象的内存地址返回给你,赋值给变量。
class House: def __init__(self, rooms, color): self.rooms = rooms self.color = color house_a = House(3, "white") house_b = House(5, "gray")

这里house_ahouse_b是两个完全独立的对象,各自拥有自己的roomscolor。它们的内存地址不同,改一个不会影响另一个。这是理解面向对象的第一步:对象是独立的数据容器,方法只是附着在这些容器上的函数。

2.2 self到底在传什么

当你调用house_a.get_info()时,Python偷偷做了一件事:把house_a本身作为第一个参数传给get_info方法。所以def get_info(self)里的self,指的就是调用这个方法的那个对象。

换句话说,self不是Python语法故意为难你,它就是这个对象自己的引用。你写self.rooms,就是在说"我自己的rooms属性"。如果没有self,方法内部根本不知道要操作哪个对象的属性——因为同一个类可能创建了一百个对象,方法代码本身是共享的,只有通过self才能区分"我到底是在改哪套房子的颜色"。

class House: def __init__(self, rooms, color): self.rooms = rooms self.color = color def get_info(self): return f"{self.color} house with {self.rooms} rooms" house_a = House(3, "white") print(house_a.get_info()) # white house with 3 rooms

2.3 实例属性与类属性:查找顺序里藏着大坑

Python的属性查找有一个隐式规则:实例属性优先于类属性。什么意思?看下面这个例子:

class Dog: species = "Canis familiaris" # 类属性 def __init__(self, name): self.name = name # 实例属性 dog1 = Dog("Rex") dog2 = Dog("Bella") print(dog1.species) # Canis familiaris

dog1.species在dog1自己的__dict__里找不到species,Python就会去类Dog__dict__里找。这就是类属性可以被所有实例共享的原因。

但这里有个经典的坑:如果你通过实例给类属性赋值,Python不会修改类的属性,而是给这个实例创建了一个新的实例属性,把类属性"遮蔽"掉了。

dog1.species = "Canis lupus" print(dog1.species) # Canis lupus(新的实例属性) print(dog2.species) # Canis familiaris(仍然是类属性)

看起来好像在"修改类属性",实际上只是给dog1单独贴了个标签。很多初学者在这上面吃过亏——想改全局配置,结果只改了一个对象的局部。解决方案很简单:修改类属性要么通过类名Dog.species = "...",要么明确定义为实例属性。

2.4__init__不是构造函数

严格讲,Python里真正的构造函数是__new____init__只是初始化方法。__new__负责分配内存创建对象,__init__负责给这个新对象填入初始状态。这两个方法的调用顺序是:先__new__,后__init__

日常编程99%的场景只需要写__init__就行了,但理解这个顺序有个实际好处:当你想实现单例模式,或者需要控制对象的创建过程时,就该重写__new__而不是__init__。比如限制某个类只能创建一个实例:

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) # 2

注意看,尽管__init__被调用了两次,但由于__new__始终返回同一个实例,第二次的__init__value覆盖成了2。

3. 继承与多态:框架设计者是怎么用OOP思考的

继承是面向对象里最被人熟知、也最容易被滥用的特性。很多人喜欢"为了继承而继承",结果类爆炸,耦合越来越重。这一节我讲讲Python3里继承的正确打开方式,以及什么情况下该放弃继承。

3.1 子类到底从父类那里得到了什么

继承的语法很简单:class Child(Parent)。继承表达的是"是一种"的关系——子类是一种特殊的父类。员工是一种人,轿车是一种车,这种关系才适合用继承。

子类会获得父类的所有属性和方法,但可以覆盖(override)其中任何一个。这种覆盖机制就是多态的基础:同一个方法名,不同类的对象调用时表现出不同的行为。

class Animal: def speak(self): return "Some sound" class Dog(Animal): def speak(self): return "Woof!" class Cat(Animal): def speak(self): return "Meow" animals = [Dog(), Cat(), Animal()] for a in animals: print(a.speak()) # Woof! # Meow # Some sound

这段代码的关键在于:调用方根本不需要知道列表里的对象具体是什么类,只需要知道它们都有speak()方法。这就是多态的核心价值——让不同对象对同一个消息做出自己的响应,调用代码保持统一。

3.2 super() 的正确用法与MRO解析顺序

子类覆盖父类方法时,常常还需要调用父类的实现来完成"原有的逻辑",这时候就要用super()

class Employee: def __init__(self, name, salary): self.name = name self.salary = salary class Manager(Employee): def __init__(self, name, salary, team_size): super().__init__(name, salary) self.team_size = team_size

super()不只是语法糖,它在多重继承场景下承担着非常重要的责任:按MRO(Method Resolution Order,方法解析顺序)找到下一个应该调用的方法。

Python3的MRO基于C3线性化算法。简单说,它保证了:子类永远在父类之前;多个父类按照定义顺序从左到右;同时保证继承图里每个类只出现一次。想查看MRO,用ClassName.__mro__

class A: def who(self): print("A") class B(A): def who(self): print("B") super().who() class C(A): def who(self): print("C") super().who() class D(B, C): def who(self): print("D") super().who() d = D() d.who() # D # B # C # A

注意这里的调用链:D → B → C → A。这个顺序不是从左到右B再A就完了,而是C3算法计算出来的结果,确保了菱形继承中A只被调用一次(如果使用super()的话)。

实际写代码的时候,我建议你记住两点:

  1. 在多重继承中,尽量都使用super()而不是直接写父类名调用,否则容易打破MRO链;
  2. 设计父类时,即便是"基类",也最好把它当作可能被多重继承的一部分来设计,方法内部用super()串联协作。

3.3 抽象基类:用接口约束继承体系

继承最容易出的问题就是子类忘了实现某个方法,或者实现得五花八门。Python的abc模块提供了抽象基类,可以在语法层面强制子类实现指定方法。

from abc import ABC, abstractmethod class Shape(ABC): @abstractmethod def area(self): pass class Circle(Shape): def __init__(self, radius): self.radius = radius def area(self): return 3.14159 * self.radius ** 2 # 如果某个子类忘记实现area() class BrokenShape(Shape): pass # 实例化BrokenShape会直接报错 # b = BrokenShape() # TypeError: Can't instantiate abstract class BrokenShape with abstract method area

这个约束在团队协作和大型项目里特别有用。它相当于一份契约:继承我,你就必须提供这些能力。写测试的时候也省心,至少不会遇到"运行时才发现缺方法"的尴尬。

3.4 继承层级别超过三层

这是我的个人经验:继承层级一旦超过三层,代码就变得很难维护。原因是每一层都可能覆盖方法,最终一个方法的真实行为需要沿着MRO链查阅多个类才能搞清楚。新人接手这种代码,光理清调用链就要花半天。

限制继承深度最有效的手段不是纪律,而是设计。如果发现自己在做第四层继承,停下来问一句:这里是真的需要"是一个"的关系,还是只需要"有一个"的关系?后者通常用组合解决。

4. 组合优先、鸭子类型、描述符协议:比继承更常用的OOP套路

这一节的内容我觉得是《Python3 面向对象编程(第二版)》里含金量最高的部分,也是中文社区讨论最少的部分。掌握这些,你的设计水平会明显比"会用class"高一个档次。

4.1 组合:把对象当积木拼装

组合表达的是"有一个"的关系——汽车有一个发动机,一个人有一个名字。跟继承不同,组合不涉及类层次的绑定,它只是让一个对象持有另一个对象的引用。

class Engine: def start(self): return "Engine started" class Wheels: def rotate(self): return "Wheels rotating" class Car: def __init__(self): self.engine = Engine() self.wheels = [Wheels() for _ in range(4)] def drive(self): return [self.engine.start(), *[w.rotate() for w in self.wheels]]

为什么推荐组合?因为继承建立的是静态关系——一个类继承什么在定义那一刻就固定了;而组合是动态的,你可以在运行时替换组件对象。比如上面这辆车,我随时可以换个高性能发动机:

class TurboEngine(Engine): def start(self): return "Turbo engine started with roar" car = Car() car.engine = TurboEngine()

改一个属性就能让整车行为变化,这种灵活性继承很难给到。

行业里流传一句设计格言:"优先使用组合而非继承"(Favor composition over inheritance)。原因很简单:继承暴露了父类的内部细节,子类和父类高度耦合;组合则通过接口交互,双方都可以独立演化。

4.2 鸭子类型:不检查你是什么,只看你会不会叫

Python的鸭子类型来自一句老话:"如果它走起来像鸭子,叫起来像鸭子,那它就是鸭子。"意味着Python不关心对象的实际类型,只关心它是否有你想要调用的方法。

class Duck: def quack(self): return "Quack" class Robot: def quack(self): return "Beep boop I fake quack" def make_it_quack(obj): return obj.quack() print(make_it_quack(Duck())) print(make_it_quack(Robot()))

这种灵活性让Python代码写起来特别顺:不需要接口、不需要类型声明(除非用typing),只要对象有某个方法,就能参与对应的操作。这也是为什么Python能轻松对接各种第三方库——pandas的DataFrame、Django的QuerySet,它们内部大量运用鸭子类型思想,让不同的对象在统一的接口下协作。

不过要注意,鸭子类型是把双刃剑。运行时才发现方法不存在,调试成本会高一些。所以在关键节点用hasattr(obj, "quack")做检查,或者配合抽象基类做约束,是成熟项目的常见做法。

4.3 描述符协议:控制属性访问的底层机制

描述符是Python对象模型里最容易被忽略、却无处不在的机制。简单说:一个类只要实现了__get____set____delete__中的一个或多个方法,它就是一个描述符;把描述符的实例作为另一个类的类属性时,属性访问就会被描述符接管。

最常见的描述符是property。你以为@property只是装饰器魔法,其实它背后就是描述符协议在工作:

class Temperature: def __init__(self, celsius): self._celsius = celsius @property def fahrenheit(self): return self._celsius * 9 / 5 + 32 @fahrenheit.setter def fahrenheit(self, value): self._celsius = (value - 32) * 5 / 9 t = Temperature(25) print(t.fahrenheit) # 77.0 t.fahrenheit = 100 print(t._celsius) # 37.77...

fahrenheit不是一个普通属性,而是一个由property描述符管理的数据属性:读取时执行getter,赋值时执行setter

更进阶的用法是实现自定义描述符,比如用来验证类型:

class PositiveNumber: def __init__(self, name): self.name = name def __get__(self, instance, owner): return instance.__dict__[self.name] def __set__(self, instance, value): if value <= 0: raise ValueError("must be positive") instance.__dict__[self.name] = value def __delete__(self, instance): del instance.__dict__[self.name] class Order: quantity = PositiveNumber("quantity") def __init__(self, quantity): self.quantity = quantity order = Order(5) print(order.quantity) # 5 order.quantity = -1 # ValueError: must be positive

看到这里你可能觉得描述符复杂,但理解它之后,再看Django的模型字段、SQLAlchemy的Column,你会发现它们都是描述符的典型应用。框架作者通过描述符在属性赋值时执行校验、格式转换、懒加载等逻辑。如果你想读源码、理解框架的魔法,描述符是绕不开的一课。

4.4 用数据类补齐OOP的实用拼图

Python3.7引入了dataclasses,它在面向对象编程里扮演一个非常务实的角色:自动生成__init____repr____eq__这些样板代码,让普通的数据容器类写起来干净利落。

from dataclasses import dataclass @dataclass class Product: name: str price: float stock: int = 0 p1 = Product("laptop", 999.99) p2 = Product("laptop", 999.99) print(p1 == p2) # True print(p1) # Product(name='laptop', price=999.99, stock=0)

注意dataclass默认生成的是__eq__按属性逐一比较,这在测试场景里非常方便。相比手动写一堆样板方法,数据类省下的不只是时间,也减少了出错概率。

4.5 设计原则:SOLID在Python里的轻量实践

SOLID原则不是Java专属,Python同样适用。结合前面讲的机制,我挑几个最实用的展开:

单一职责原则(S):一个类只做一件事。判断标准很简单:如果你无法用一句话描述这个类"负责什么",它可能塞了太多职责。我见过一个类叫DataProcessor,里面既有格式解析、又有数据清洗、还有报表生成、甚至有邮件发送。这种类一旦要改其中一个环节,就得把整个类通读一遍。拆开成ParserCleanerReporterMailer,每个只做一件事,组合起来用,维护成本立刻降下来。

依赖倒置原则(D):高层模块不应该依赖低层模块,二者都应该依赖抽象。在Python里,这个"抽象"通常是一个协议或抽象基类。比如报表模块不该直接依赖MySQLDatabase,而应该依赖一个定义了fetch_data()方法的抽象接口;换数据库时,只需给新数据库类也实现fetch_data(),上层完全不用动。

接口隔离原则(I):不要把一堆不相关的方法塞进同一个抽象基类。Python的鸭子类型天然支持这个原则——你不需要让一个类"实现所有接口",只需要它有调用方关心的那部分方法就行。

5. 一个完整案例:从需求到OOP设计的推演过程

光讲原理容易飘,我拿一个实际的小项目走一遍完整设计流程。假设需求是:开发一个命令行图书管理系统,支持添加图书、借书、还书、查看所有图书、查看某本书当前是否可借。

5.1 第一步:识别核心对象

需求里反复出现的名词是"图书",围绕图书的动作是"添加、借出、归还、查询"。一个常见的设计错误是一上来就写一个大而全的Library类,把所有逻辑都塞进去。我建议先拆出两个核心类:BookLibrary

Book负责单本书的状态管理——书名、作者、是否可借、借给谁。

@dataclass class Book: isbn: str title: str author: str available: bool = True borrower: str | None = None def borrow(self, person: str): if not self.available: raise ValueError(f"Book {self.title} is already borrowed") self.available = False self.borrower = person def return_book(self): if self.available: raise ValueError(f"Book {self.title} is not borrowed") self.available = True self.borrower = None

Library负责管理图书的集合——添加、查找、列出所有书。

class Library: def __init__(self): self._books: dict[str, Book] = {} def add_book(self, book: Book): if book.isbn in self._books: raise ValueError(f"ISBN {book.isbn} already exists") self._books[book.isbn] = book def find_by_isbn(self, isbn: str) -> Book: try: return self._books[isbn] except KeyError: raise KeyError(f"No book with ISBN {isbn}") def list_books(self): return list(self._books.values())

5.2 第二步:判断需要用继承吗

很多初学者会把所有图书类型都做成继承——搞一个Book基类,然后EBookPaperBookAudioBook都继承它。但在这个需求里,不同类型的差异只是存储格式,并没有影响借还逻辑。盲目引入继承只会让代码更复杂。

正确的做法是组合:给Book增加一个format字段,或者用一个MediaType枚举来表示格式就够了。等到将来出现真正影响行为的需求差异(比如EBook只能同时借给一个账号、AudioBook允许流式试听),再考虑用继承或多态来扩展。这正是"组合优先于继承"的具体体现——不为不存在的需求提前设计。

5.3 第三步:测试驱动的面向对象

写OOP代码特别适合配合测试。因为类的方法边界清晰、状态可验证。我给Library写两个用例,设计过程就变得很有底气:

import pytest def test_borrow_flow(): lib = Library() lib.add_book(Book("978-1", "OOP Book", "Alice")) book = lib.find_by_isbn("978-1") book.borrow("Bob") assert book.available is False assert book.borrower == "Bob" book.return_book() assert book.available is True assert book.borrower is None def test_borrow_twice_raises(): lib = Library() lib.add_book(Book("978-1", "OOP Book", "Alice")) book = lib.find_by_isbn("978-1") book.borrow("Bob") with pytest.raises(ValueError): book.borrow("Charlie")

测试不是负担,它逼着你把类的行为定得更明确。写测试的过程中,你就自然发现:要不要在borrow_twice时抛出异常、要不要记录借阅历史、Library是否有必要维护"借出者"索引——这些设计决策在写测试时会逼你想清楚。

5.4 第四步:识别需要扩展的点,但不提前扩展

完工之后想一想未来可能变化的方向:

  • 如果未来要支持"逾期费"计算,Book是否需要记录借出日期?
  • 如果未来要支持"多馆藏"(同一本书有多本),ISBN还能作为唯一键吗?
  • 如果未来要支持"按作者搜索",Library是否要建索引?

这些问题现在不必实现,但要在设计里留下合理的位置。比如Book里留一个borrowed_at字段,现在不用也不碍事;将来算逾期费就能用上。这种"可扩展但不提前扩展"的平衡,是设计功力最直接的体现。

6. 学OOP最常见的坑:每一个我都踩过

这一节我专门整理自己在实际项目中踩过的坑。很多问题在教程里看不到,但真实项目里几乎都会遇到。

6.1 可变默认参数:一个对象,全员共享

经典中的经典:

class ShoppingCart: def __init__(self, items=[]): self.items = items

看起来每个购物车都应该有自己独立的列表,实际却是所有实例共享同一个列表。因为默认参数在函数定义时只被创建一次,之后每个新ShoppingCart都引用同一个列表对象。

cart1 = ShoppingCart() cart1.items.append("apple") cart2 = ShoppingCart() print(cart2.items) # ['apple']

正确的写法是用None占位,在__init__内部创建新列表:

class ShoppingCart: def __init__(self, items=None): self.items = items if items is not None else []

6.2 用is比较数字和字符串:Python的"陷阱"级行为

有人写过这样的代码:

if user.status is "active":

在小整数和短字符串场景下,Python会做对象缓存(intern机制),is碰巧为True。但当字符串内容稍长或由拼接产生,is就会返回False,逻辑莫名失效。

规则很简单:is只用来判断对象身份(是不是同一个对象),==才是判断值是否相等。字符串、数字、列表等值比较一律用==

6.3 滥用isinstance:把鸭子类型的手脚绑住了

前面讲了鸭子类型的好处,反过来说,滥用isinstance就是亲手放弃这种灵活性。

def process(data): if isinstance(data, list): # 处理列表 elif isinstance(data, tuple): # 处理元组

这个函数对list和tuple分别处理,但万一调用方传入的是一个自定义的可迭代对象呢?函数直接卡死。更好的方式是采用鸭子类型:只要对象可迭代,就统一处理:

def process(data): for item in data: ...

除非你需要精确区分某些类型,否则优先依赖行为而不是类型。判断行为用hasattr(data, "some_method"),或者直接用try-except异常控制流。

6.4 继承层级过深与"上帝类"

"上帝类"指的是一个类承担了太多职责,几乎所有逻辑都往里面塞。我见过一个App类,里面集成了配置读取、数据库连接、用户认证、业务逻辑、日志、邮件通知……上万行代码,改什么都得动它。

这种类一旦出现,重构的优先级应该排在加新功能之前。拆分的思路不是"把大文件分成小文件",而是重新识别职责边界,让每个类只有一个明确的身份。配合组合和依赖注入,把原本纠缠在一起的逻辑逐块拆出去。

6.5 忽略__repr____str__的差别

__str__面向用户,返回可读性好的字符串;__repr__面向开发者,最好能给出足够信息来重建这个对象。调试的时候,__repr__的含金量就体现出来了:

未定义__repr__时,print一个对象显示的是<__main__.Book object at 0x7f...>,完全看不出这本书是什么。定义了之后:

class Book: def __repr__(self): return f"Book(isbn={self.isbn!r}, title={self.title!r}, available={self.available!r})"

调试日志立刻变得有信息量。所以我有个习惯:写任何类都会顺手把__repr__补上,成本极低,收益长期可见。

7. 读源码是进阶OOP最有效的路径

理论学完,最后必须落到读代码上。我强烈建议你找几个以OOP设计见长的开源库,一行行读它的类设计。

7.1 推荐的阅读对象

首选是requests库(虽然它本身用了一些函数式风格,但会话、适配器、钩子等都涉及类设计);然后是Django的模型基类(你会看到描述符、元类、多重继承的大规模实战);再就是pandasDataFrameSeries的继承体系(虽然复杂,但对理解"什么时候该拆类"非常有启发)。

7.2 读源码时带着问题读

不要从头到尾一行行读,那样读不下去。我每次读源码只关注一个问题,比如:

  • 这个类为什么这样拆分?哪些职责放在一起,哪些被拆出去?
  • 这里用继承而不是组合,理由是什么?
  • 父类提供了哪些钩子方法让子类扩展?调用链是怎样的?
  • 有没有用描述符或元类进行"魔法"操作?具体解决了什么问题?

带着问题读,你很快会发现,那些你认为"厉害"的设计,本质都是对"变化点"的预判与封装。设计模式也好、SOLID也好,都是在回答一个问题:哪些东西是稳定的,哪些是会变的,怎么把稳定和可变隔离。

7.3 动手重构一个自己的小项目

如果你手头有一个用脚本方式写的旧项目,我给你的第一个建议:挑一个模块,用面向对象的方式重写一版。注意不是全部重写,只挑一个功能边界清晰的模块。然后对比两版代码:

  • 新增一个功能,哪个版本改动更小?
  • 修复一个bug,哪个版本更容易定位?
  • 测试哪个版本写起来更顺?

这种对比体验比读十本设计书都有用。我当年第一次认真重构一个爬虫模块后,才真正理解了"封装"和"职责分离"为什么重要。

《Python3 面向对象编程(第二版)》这本书我建议当成工具书用:先通读一遍理解全貌,写项目的时候遇到具体问题再回来翻对应章节。书里有不少Python3.7之后的新特性的讨论,比如数据类、类型注解的OOP用法,这些在实战里派得上用场。

最后说一点个人体会:面向对象不是银弹,不要为了"面向对象"而面向对象。脚本能解决的事,不必硬造类;但当你发现代码里状态散落、修改牵一发动全身的时候,OOP那套"封装变化、接口协作、职责单一"的思想,就是你最有用的工具箱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询