资讯动态

Python3面向对象编程:从类、继承到描述符的实战指南

发布时间:2026/9/23 6:29:21 来源:尧图企业网站定制
1. 为什么写了多年Python还是会觉得面向对象没必要先聊个我自己的经历。前两年维护一个数据处理的项目早期只有几个脚本函数套函数跑得也挺好。后来需求越加越多要多格式导入、多策略清洗、多维度报表代码量从几百行膨胀到快五千行这时候问题就来了——改一处清洗逻辑得顺着调用链翻七八个函数新增一种报表格式又得复制粘贴一大段相似代码最崩溃的是全局变量被好几个模块改来改去运行结果开始变得看运气。回头看问题不在于函数式写法本身有错而在于我根本没建立起对象的思维。函数确实能组织逻辑但它组织不了状态。当你的数据和行为散落在各个模块里彼此通过参数传来传去耦合度会随着项目规模指数级上升。面向对象编程解决的就是这件事把相关的数据和操作这些数据的函数打包成一个整体让代码结构跟现实问题结构对齐。Python3里写OOP尤其自然因为它的动态特性让类、继承、多态这些东西用起来比Java、C轻量得多甚至可以说Python在语法层面就鼓励你用OOP来组织中等以上规模的项目。这篇内容我会基于《Python3 面向对象编程第二版》的核心脉络配合我自己实际写过的代码把Python3 OOP里最重要的几块拆开讲清楚类与对象的底层记忆体机制、继承与多态的实用姿势、组合优于继承的设计判断、以及那些网上教程很少讲的坑。适合谁看如果你已经能熟练写Python脚本但还没系统学过面向对象或者学过类的基础语法但不知道实际项目里该怎么设计类——这篇文章就是给你准备的。Java转Python的读者也能从中找到两边思维差异的对照。2. 类与对象把记忆体布局画出来很多困惑瞬间就解开了很多人学类的时候卡在self上觉得这个参数神神叨叨的。我当年也困惑过为什么定义方法的时候要写self调用的时候又不传其实这个问题一旦理解了对象在记忆体里的存在方式就完全不是问题了。2.1 类是模板对象是模板造出来的实际个体打个比方。类就像是一张房屋设计图对象就是按照这张图盖出来的一个个具体的房子。设计图上写着这里要有客厅那里要有厨房但它不是房子本身只有当你真正动工实例化——也就是执行ClassName()——才得到一栋能住人的房子。在Python里这个盖房子的动作背后做了三件事分配一块记忆体空间准备存放这个对象自己的数据调用__init__方法把传入的参数初始化到这块空间里把这个对象的内存地址返回给你赋值给变量。class House: def __init__(self, rooms, color): self.rooms rooms self.color color house_a House(3, white) house_b House(5, gray)这里house_a和house_b是两个完全独立的对象各自拥有自己的rooms和color。它们的内存地址不同改一个不会影响另一个。这是理解面向对象的第一步对象是独立的数据容器方法只是附着在这些容器上的函数。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 rooms2.3 实例属性与类属性查找顺序里藏着大坑Python的属性查找有一个隐式规则实例属性优先于类属性。什么意思看下面这个例子class Dog: species Canis familiaris # 类属性 def __init__(self, name): self.name name # 实例属性 dog1 Dog(Rex) dog2 Dog(Bella) print(dog1.species) # Canis familiarisdog1.species在dog1自己的__dict__里找不到speciesPython就会去类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_sizesuper()不只是语法糖它在多重继承场景下承担着非常重要的责任按MROMethod 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()的话。实际写代码的时候我建议你记住两点在多重继承中尽量都使用super()而不是直接写父类名调用否则容易打破MRO链设计父类时即便是基类也最好把它当作可能被多重继承的一部分来设计方法内部用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: Cant 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(namelaptop, price999.99, stock0)注意dataclass默认生成的是__eq__按属性逐一比较这在测试场景里非常方便。相比手动写一堆样板方法数据类省下的不只是时间也减少了出错概率。4.5 设计原则SOLID在Python里的轻量实践SOLID原则不是Java专属Python同样适用。结合前面讲的机制我挑几个最实用的展开单一职责原则S一个类只做一件事。判断标准很简单如果你无法用一句话描述这个类负责什么它可能塞了太多职责。我见过一个类叫DataProcessor里面既有格式解析、又有数据清洗、还有报表生成、甚至有邮件发送。这种类一旦要改其中一个环节就得把整个类通读一遍。拆开成Parser、Cleaner、Reporter、Mailer每个只做一件事组合起来用维护成本立刻降下来。依赖倒置原则D高层模块不应该依赖低层模块二者都应该依赖抽象。在Python里这个抽象通常是一个协议或抽象基类。比如报表模块不该直接依赖MySQLDatabase而应该依赖一个定义了fetch_data()方法的抽象接口换数据库时只需给新数据库类也实现fetch_data()上层完全不用动。接口隔离原则I不要把一堆不相关的方法塞进同一个抽象基类。Python的鸭子类型天然支持这个原则——你不需要让一个类实现所有接口只需要它有调用方关心的那部分方法就行。5. 一个完整案例从需求到OOP设计的推演过程光讲原理容易飘我拿一个实际的小项目走一遍完整设计流程。假设需求是开发一个命令行图书管理系统支持添加图书、借书、还书、查看所有图书、查看某本书当前是否可借。5.1 第一步识别核心对象需求里反复出现的名词是图书围绕图书的动作是添加、借出、归还、查询。一个常见的设计错误是一上来就写一个大而全的Library类把所有逻辑都塞进去。我建议先拆出两个核心类Book和Library。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(fBook {self.title} is already borrowed) self.available False self.borrower person def return_book(self): if self.available: raise ValueError(fBook {self.title} is not borrowed) self.available True self.borrower NoneLibrary负责管理图书的集合——添加、查找、列出所有书。class Library: def __init__(self): self._books: dict[str, Book] {} def add_book(self, book: Book): if book.isbn in self._books: raise ValueError(fISBN {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(fNo book with ISBN {isbn}) def list_books(self): return list(self._books.values())5.2 第二步判断需要用继承吗很多初学者会把所有图书类型都做成继承——搞一个Book基类然后EBook、PaperBook、AudioBook都继承它。但在这个需求里不同类型的差异只是存储格式并没有影响借还逻辑。盲目引入继承只会让代码更复杂。正确的做法是组合给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, itemsNone): 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 fBook(isbn{self.isbn!r}, title{self.title!r}, available{self.available!r})调试日志立刻变得有信息量。所以我有个习惯写任何类都会顺手把__repr__补上成本极低收益长期可见。7. 读源码是进阶OOP最有效的路径理论学完最后必须落到读代码上。我强烈建议你找几个以OOP设计见长的开源库一行行读它的类设计。7.1 推荐的阅读对象首选是requests库虽然它本身用了一些函数式风格但会话、适配器、钩子等都涉及类设计然后是Django的模型基类你会看到描述符、元类、多重继承的大规模实战再就是pandas的DataFrame和Series的继承体系虽然复杂但对理解什么时候该拆类非常有启发。7.2 读源码时带着问题读不要从头到尾一行行读那样读不下去。我每次读源码只关注一个问题比如这个类为什么这样拆分哪些职责放在一起哪些被拆出去这里用继承而不是组合理由是什么父类提供了哪些钩子方法让子类扩展调用链是怎样的有没有用描述符或元类进行魔法操作具体解决了什么问题带着问题读你很快会发现那些你认为厉害的设计本质都是对变化点的预判与封装。设计模式也好、SOLID也好都是在回答一个问题哪些东西是稳定的哪些是会变的怎么把稳定和可变隔离。7.3 动手重构一个自己的小项目如果你手头有一个用脚本方式写的旧项目我给你的第一个建议挑一个模块用面向对象的方式重写一版。注意不是全部重写只挑一个功能边界清晰的模块。然后对比两版代码新增一个功能哪个版本改动更小修复一个bug哪个版本更容易定位测试哪个版本写起来更顺这种对比体验比读十本设计书都有用。我当年第一次认真重构一个爬虫模块后才真正理解了封装和职责分离为什么重要。《Python3 面向对象编程第二版》这本书我建议当成工具书用先通读一遍理解全貌写项目的时候遇到具体问题再回来翻对应章节。书里有不少Python3.7之后的新特性的讨论比如数据类、类型注解的OOP用法这些在实战里派得上用场。最后说一点个人体会面向对象不是银弹不要为了面向对象而面向对象。脚本能解决的事不必硬造类但当你发现代码里状态散落、修改牵一发动全身的时候OOP那套封装变化、接口协作、职责单一的思想就是你最有用的工具箱。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价