最近重新整理 Python 学习笔记翻到 part4 这一篇正好是面向对象编程。第一次自学的时候我其实直接跳过了类因为前面用函数写脚本已经能解决不少问题直到开始做一个小项目数据到处传、功能越写越乱才意识到该系统地补课。这份笔记就是给自己的一个梳理如果你是跟我差不多进度的人——已经掌握列表、字典、循环、函数但对 class 还停留在“见过但用不上的阶段”那这篇应该能帮你跨过那个坎。1. 我这份笔记的来历面向对象到底解决什么问题1.1 为什么我会把类的部分留到后面才学很多入门教程的前面几章都在讲变量、分支、循环、函数这些内容足以应付“读取文件、处理数据、输出结果”这类小脚本。我当时的感觉就是函数用得好好的干嘛要引入一个看起来更啰嗦的 class每个方法还要写 self调用的时候又和普通函数不一样心智负担明显变大。真正让我改变想法的是一个半吊子项目。我要维护一批图书信息最初用字典存数据写了一个函数来打印某本书的信息后来又加了一个函数修改书名再加一个函数统计作者写了多少本。随着函数越来越多我发现几个问题每个函数都要把字典作为参数传进来传出去少传一个字段、字典结构一变所有相关函数都要跟着改。同一个字典可能在好几个地方被修改出问题时很难定位是谁把它改坏的。数据和操作数据的函数放在两个地方看代码时要在函数定义和数据定义之间来回跳。这就是典型的“数据和逻辑分离”带来的维护痛苦。面向对象编程做的事情本质上是把数据和操作这些数据的函数封装到同一个结构里让对象对自己负责。1.2 面向对象到底在“面向”什么如果非要用一句话总结面向对象编程程序的主角不再是“函数”和“全局数据”而是一群“对象”。每个对象持有自己的属性并且能对外提供方法程序运行的过程就是对象之间互相调方法、互相传递消息的过程。生活化一点理解命令式写法是“按下按钮(灯)”你调用一个函数传入灯对象面向对象写法是“灯.按下()”灯自己知道按下之后该怎么亮、该改变自己的哪个状态。数据不再被外部函数随便操作而是由对象自己管理外界只能通过公开的方法和属性接触它。这个思路的价值在代码量小的时候看不出来一旦模块变多、多人协作或者需求频繁变更封装带来的好处会越来越明显。2. 类与实例把数据和操作绑到同一个对象里2.1 从“字典函数”到“类”一个让人恍然大悟的转折先看一段最典型的“函数式”写法book {title: 三体, author: 刘慈欣, price: 30} def print_book(book): print(f{book[title]} - {book[author]} - {book[price]}) def change_price(book, new_price): if new_price 0: raise ValueError(价格不能为负数) book[price] new_price这样写没问题但每个函数都要记得“book 字典的键有哪些”一旦哪天把键名从title改成name所有函数都要改一遍。换成类之后数据结构和操作绑定在一起class Book: def __init__(self, title, author, price): self.title title self.author author self.price price def print_info(self): print(f{self.title} - {self.author} - {self.price}) def change_price(self, new_price): if new_price 0: raise ValueError(价格不能为负数) self.price new_price book Book(三体, 刘慈欣, 30) book.print_info() book.change_price(40) book.print_info()注意change_price的签名变了不再需要传入book因为调用者已经通过book.change_price(...)指定了操作目标是book。这样外部代码更简洁校验规则也放在了离数据最近的地方。2.2 self 到底是什么为什么 Python 要显式传 self新手最容易卡住的问题就是 self。其实 self 就是“当前这个对象本身”的引用。当你调用book.change_price(40)时Python 实际执行的是Book.change_price(book, 40)也就是说点号前面的对象会被自动当成第一个参数传给方法。Python 选择了显式写出这个参数而不是像 Java 那样隐式地使用this。这背后的设计哲学是“显式优于隐式”看到方法签名里的 self就知道这个方法是绑定在实例上的也能猜到调用它至少需要一个实例对象。你可以把方法理解成“一个需要对象作为第一参数的函数”。平时你看到的def change_price(self, new_price)不过是在明确地声明hello我第一个参数是 obj 本身。建议永远不要改名虽然语法上你可以把 self 换成别的名字但所有 Python 开发者、IDE、调试工具都默认self没必要特立独行。2.3 实例对象其实就是一个独立的命名空间理解实例最直观的办法是看它内部的数据。每个实例都有一个__dict__字典专门保存实例自己的属性class Person: def __init__(self, name): self.name name self.age 0 p1 Person(小明) p2 Person(小红) print(p1.__dict__) # {name: 小明, age: 0} print(p2.__dict__) # {name: 小红, age: 0}p1和p2是两个完全独立的对象你有你的 name我有我的 name互不干扰。self.name name这个写法本质上就是在往当前实例的__dict__里放键值对。弄懂这一点之后很多问题都好解释了为什么两个实例改自己的属性不影响另一个实例因为它们的__dict__不是同一个字典。为什么直接访问不存在的属性会报AttributeError因为__dict__里根本没有这个键。这个心智模型非常重要后面第 6 部分讲类属性和实例属性的坑时还会用到。3. 四种“方法”别搞混实例方法、类方法、静态方法与魔法方法3.1 一张表和一段代码看清它们的调用差异Python 类里能定义的方法类型不止一种初学阶段建议把下面这四类分清楚。我曾经就因为在静态方法里写 self 被报错报得莫名其妙。方法类型定义要点调用方式典型场景实例方法第一个参数写 self实例.方法()最常用操作实例数据类方法用classmethod装饰第一个参数写 cls类.方法() 或 实例.方法()作为工厂方法创建实例静态方法用staticmethod装饰不自动传任何参数类.方法() 或 实例.方法()逻辑上属于类但不依赖类状态魔法方法双下划线命名如__init__、__str__由 Python 在特定时机自动调用定制对象的初始化、打印、比较等行为一个简单的例子把前三种方法放在同一个类里class Student: total_count 0 def __init__(self, name, score): self.name name self.score score Student.total_count 1 classmethod def from_string(cls, text): name, score text.split(,) return cls(name, float(score)) staticmethod def is_pass(score): return score 60 s Student.from_string(张三,85) # 类方法当构造函数用 print(s.name, s.score) print(Student.is_pass(60)) # 静态方法不依赖实例 print(s.is_pass(40)) # 通过实例调用也没问题类方法from_string的妙处是它拿到的是cls也就是类本身因此cls(name, float(score))会调用__init__创建一个新实例。将来如果Student被继承子类调用from_string时返回的会是子类实例而不是父类实例这个特性在写工厂方法时非常有用。静态方法更像是一个“放在类命名空间里的工具函数”不需要访问类里的任何状态。3.2 property把方法伪装成属性初学面向对象时很容易走两个极端要么什么属性都直接暴露要么模仿 Java 写一堆 getter/setter。Python 更优雅的做法是用property装饰器在不改变外部调用方式的前提下让属性访问具备校验和计算能力。class Student: def __init__(self, name, score): self.name name self._score score property def score(self): return self._score score.setter def score(self, value): if not 0 value 100: raise ValueError(成绩必须在0到100之间) self._score value s Student(张三, 80) s.score 90 # 走 setter 校验 print(s.score) # 走 getter返回 _score外部开发者根本感觉不到score是方法算出来的他们只是像访问普通属性一样s.score。好处是将来你想加日志、缓存、格式转换都不需要改外部调用代码只需要调整 property 对应的 getter/setter。3.3 为什么说__init__不是构造函数很多教学里都管__init__叫构造函数严格来说这是不准确的。__init__是在实例已经创建出来之后负责初始化属性的初始化方法。真正负责“创建空白实例”的是__new__它是一个类方法返回实例本身。class Demo: def __new__(cls, *args, **kwargs): print(先执行 new) return super().__new__(cls) def __init__(self, value): print(再执行 init) self.value value d Demo(10) # 输出 # 先执行 new # 再执行 init对 99% 的日常开发来说你只需要重写__init__就够了。知道__new__的存在主要是为了理解 Python 对象的创建流程以及将来看单例模式、不可变对象时不会一头雾水。4. 继承与 MRO先学会单继承再搞明白钻石4.1 继承解决的实际问题继承的核心价值是复用和扩展。假设你要管理两种学生普通学生和研究生。研究生在普通学生的基础上多一个“研究方向”字段。如果不继承就得把普通学生的方法复制一遍继承之后可以在父类基础上做增量开发class Student: def __init__(self, name, score): self.name name self.score score def get_info(self): return f{self.name}: {self.score} class GradStudent(Student): def __init__(self, name, score, research_field): super().__init__(name, score) self.research_field research_field def get_info(self): base super().get_info() return f{base}, 研究方向: {self.research_field} g GradStudent(李四, 88, 自然语言处理) print(g.get_info())GradStudent不需要重新写一遍姓名和成绩的存储逻辑而是通过super().__init__(name, score)调用父类的初始化方法。get_info也从“完全重写”变成了“在父类结果上追加内容”用super().get_info()拿到父类返回的字符串再拼接研究方向的字段。这里我想强调一件重要的事继承不是“为了继承而继承”而是父类确实表达了一个更通用的概念子类确实属于“父类的一种”。如果只是想要复用两个方法组合往往比继承更合适。4.2 super() 为什么不能省初学时候最容易犯的错误是子类重写了__init__却不调用super().__init__()。结果实例创建之后父类负责的属性根本没有初始化一访问就报错。class Student: def __init__(self, name): self.name name class BadGrad(Student): def __init__(self, name, field): self.field field # 忘了调 super().__init__(name) bg BadGrad(王五, 计算机视觉) print(bg.name) # AttributeError: BadGrad object has no attribute name你当然可以自己在子类里写self.name name但那样就破坏了父类封装的逻辑如果父类将来在__init__里加了其他属性的初始化或者加了参数校验子类复制出来的代码就滞后了。正确的做法是让父类负责父类关心的属性子类只补充自己的部分。4.3 多重继承的 MRO 与那个诡异的输出顺序Python 支持多重继承这是特点也是坑源。最常见的坑就是“钻石继承”多个父类又继承自同一个祖先。看看这个例子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): pass d D() d.who()直觉可能会猜输出 A、B、C 之类的顺序实际结果是B C A原因是 Python 用 C3 线性化算法计算了类的方法解析顺序也就是 MRO。D的 MRO 依次是D - B - C - A - object。super()并不是简单指向“父类”而是沿着当前对象真实的 MRO 继续往后找下一个类。当B.who里的super().who()执行时它跳过了 A先去调用了C.who因此形成了B - C - A的调用链。你可以用D.__mro__查看解析顺序print(D.__mro__) # (class __main__.D, class __main__.B, class __main__.C, class __main__.A, class object)我的建议是多继承能不用就不用。现实中大多数业务场景单继承加组合就能解决。如果你确实需要复用多个独立能力优先考虑把对象作为属性放进来而不是让一个类同时继承两个基类。理解 MRO 的意义更多是为了排查奇怪的方法调用问题而不是为了在代码里炫技。5. 把学过的类串起来一个可以直接运行的图书管理 Demo5.1 Book 类与 Library 类完整代码知识要到真实场景里走一遍才踏实。我用图书借阅这个小管理程序把类、实例、方法、魔法方法都串起来。直接复制就能跑class Book: def __init__(self, title, author, isbn): self.title title self.author author self.isbn isbn self.is_borrowed False def __repr__(self): status 已借出 if self.is_borrowed else 在馆 return f《{self.title}》{self.author} ISBN:{self.isbn} [{status}] class Library: def __init__(self): self.books [] def add_book(self, book): self.books.append(book) def borrow_book(self, isbn): for book in self.books: if book.isbn isbn and not book.is_borrowed: book.is_borrowed True return f借出成功: {book} return 书不存在或已被借出 def return_book(self, isbn): for book in self.books: if book.isbn isbn and book.is_borrowed: book.is_borrowed False return f归还成功: {book} return 找不到这本被借出的书 def list_books(self): for book in self.books: print(book) # 使用示例 lib Library() lib.add_book(Book(三体, 刘慈欣, 978-7-5366-9293-0)) lib.add_book(Book(百年孤独, 加西亚·马尔克斯, 978-7-5442-4528-7)) print( 初始状态 ) lib.list_books() print(lib.borrow_book(978-7-5366-9293-0)) print(lib.borrow_book(978-7-5366-9293-0)) # 再次借同一本 print(lib.return_book(978-7-5366-9293-0))这里面__repr__是魔法方法作用是定义对象被打印时的显示文本。print(book)实际上会去调用book.__repr__()所以你能看到友好的人类可读信息而不是一串object at 0x...的地址。这样每次操作之后状态都能一眼看出。5.2 为什么这样设计而不是把逻辑全部塞进 main你可能觉得这个程序用函数也能写维护一个全局列表books再写几个函数操作它。确实可以但用类的优势在需求变复杂之后会明显放大。举例将来要支持“逾期费用计算”为了算逾期费需要知道借出日期。如果你在Book里加一个borrow_date属性再在Book里写一个calculate_fine(days_late)方法所有借书相关的逻辑都内聚到一个类里。如果借书的是人而不是简单一个总列表你可以新增一个Reader类把读者的借书历史、可借数量上限放进去。新功能不需要大规模改动现有 Book 和 Library而是在原有结构上做增量扩展这就是封装和职责分离带来的好处。给一个扩展思路borrow_book目前直接返回字符串工程上更适合返回一个结果对象包含成功与否、书籍信息、失败原因Library内部也完全可以换个数据结构存储比如用 isbn 当 key 的字典。只要对外方法签名不变内部怎么改调用方都不受影响。6. Python 类独有的几个坑我基本都踩过6.1 可变默认参数这个坑一定要当场堵住Python 有一个经典陷阱跟类特别相关在方法签名里直接写可变对象作为默认值。比如class BadStudent: def __init__(self, name, scores[]): self.name name self.scores scores a BadStudent(张三) b BadStudent(李四) a.scores.append(100) print(b.scores) # [100]明明 b 没往自己的 scores 里加过任何东西结果却被 a 影响到了。原因在于Python 的函数默认值在定义时就计算并保存之后每次都复用这个同一个列表对象。所有不传scores参数的实例共享的是同一个列表。正确写法是把默认值设为 None在方法内部新建列表class GoodStudent: def __init__(self, name, scoresNone): self.name name self.scores [] if scores is None else scores这是 Python 面试和实际代码 review 中的高频问题自己写类时多半也会撞上趁早养成习惯。6.2 类属性与实例属性看着一样其实是两份在类内部直接赋值的变量是类属性self.xxx xxx设定的是实例属性。两者可以“同名并存”但语义完全不同class Counter: total 0 def __init__(self): self.total 0 def current(self): print(类属性:, Counter.total) print(实例属性:, self.total) c1 Counter() c1.total 1 Counter.total 5 c1.current()这里的c1.total 1并不会修改Counter.total而是创建一个新的实例属性total并赋值为 1把类属性给遮蔽了。想修改真正的类属性应该显式通过类名Counter.total 5。这个坑最容易出现在“用类属性统计数据”的场景比如所有实例共享一个计数器。困惑的根源是通过实例访问属性时Python 会先查实例的__dict__查不到再查类属性。一旦实例里有了同名属性类属性就不可见了。6.3 单下划线和双下划线Python 的“私有”方式和账本Python 没有其他语言那种严格意义的 private它用命名约定来表达访问级别_name单下划线开头约定“这是内部成员外部不要直接访问”但不强制。__name双下划线开头触发名称改写在类内部会被改名为_ClassName__name。看个例子class Person: def __init__(self): self._age 20 self.__password secret p Person() print(p._age) # 可以打印只是不太应该这么写 print(p.__password) # AttributeError print(p._Person__password) # secret名称改写的结果双下划线的本意不是安全而是防止子类不小心覆盖父类的同名属性。实际开发中我基本上只用单下划线_作为“内部实现外部别碰”的约定信号很少用__因为它会让调试和序列化变得麻烦。真正的隐私保护要靠上层协议和访问控制靠 Python 命名约定是拦不住刻意行为的。6.4 重写__eq__后 Hash 居然是 None自定义类如果重写了__eq__却不重写__hash__实例会变得不可哈希。直接看效果class Point: def __init__(self, x, y): self.x x self.y y def __eq__(self, other): return self.x other.x and self.y other.y p1 Point(1, 2) p2 Point(1, 2) print(p1 p2) # True print(hash(p1)) # TypeError: unhashable type: PointPython 的规则是如果两个对象相等那么它们的哈希值也必须相等。你自定义了__eq__但 Python 无法自动推导哈希函数于是干脆把hash设为 None防止你把对象放进 set 或当 dict 的 key 时产生错误行为——如果两个相等的对象哈希值不同查找时会出大乱子。解决办法是同时实现__hash__class Point: def __init__(self, x, y): self.x x self.y y def __eq__(self, other): return self.x other.x and self.y other.y def __hash__(self): return hash((self.x, self.y))如果这个类将来会被放进可变容器并且属性会变最好干脆别重写__eq__默认按对象地址比较最省心。7. 抽象基类与多态的日常用法附上我的上手建议7.1 用 abc 模块写一个接口级抽象基类当多个类需要遵循同一套对外接口时可以用抽象基类来约束。Python 的abc模块提供了ABC和abstractmethodfrom abc import ABC, abstractmethod class Pet(ABC): abstractmethod def speak(self): ... class Dog(Pet): def speak(self): return 汪汪 class Cat(Pet): def speak(self): return 喵喵抽象基类本身不能实例化子类如果没有实现speak也不能实例化。这相当于一份“合同”告诉后续的维护者只要是 Pet 的子类就必须实现speak方法。dog Dog() print(dog.speak())7.2 从“该不该用类”的角度谈多态多态的好处在于调用方不需要关心对象具体是什么类型只需要知道它支持什么方法。比如定义一个播放动物声音的函数def make_sound(pet): print(pet.speak()) make_sound(Dog()) # 汪汪 make_sound(Cat()) # 喵喵将来新增一个Bird类只要实现了speak方法make_sound一行都不用改。这个“对扩展开放、对修改关闭”的效果就是多态的核心价值。对初学者来说多态的重点不是背名词而是形成“面对接口编程”的意识外部依赖某个行为而不是依赖某个具体类。你写函数时可以先想想我需要这个对象具备什么能力然后把参数约定成接口或基类将来替换实现就很容易。7.3 我自己的一个朴素判断标准前面讲了这么多最后说点大实话。面向对象是一种组织代码的思路不是每个脚本都必须套的模板。我自己现在写代码前会快速判断三件事同一份数据是不是会被多个函数反复操作如果是封装成类可以减少参数传递把操作集中到对象身上。是不是会存在许多结构相同的实体只是属性值不一样比如多个学生、多本书、多个订单这时候类几乎是必然选择因为你需要模板批量创建对象并且每个对象独立保存状态。将来是不是很可能要新增变体比如“学生”下面还要细分“小学生”“大学生”或者“支付方式”下面要扩展不同渠道这时候继承、抽象基类能降低扩展成本。还有一个朴素的经验阈值如果你发现自己的脚本里有五个以上函数都在操作同一个列表或字典那就该考虑把这个数据结构升级成类了。如果只是十几行的一次性脚本直接写函数完全没问题强行上类反而增加阅读负担。最后的几句体己话写这份笔记时我最大的体会是面向对象编程的学习重点不在语法而在建模思维。光背会class、self、super的用法不跑几个小项目是没感觉的。建议你先把我上面那个图书管理的 Demo 从头敲一遍跑通之后试着加一个“读者”类再给 Book 加上借出日期和逾期费计算。每加一个功能你都会更理解为什么数据和行为要绑定在一起。踩过几次坑之后你会发现面向对象不再是一个抽象概念而是写代码时顺手就用的工具。