news 2026/10/3 10:03:46

Python面向对象编程入门:从类、self到继承与多态

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python面向对象编程入门:从类、self到继承与多态

最近重新整理 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. 把学过的类串起来:一个可以直接运行的图书管理 Demo

5.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, scores=None): 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: 'Point'

Python 的规则是:如果两个对象相等,那么它们的哈希值也必须相等。你自定义了__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和abstractmethod:

from 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 加上借出日期和逾期费计算。每加一个功能,你都会更理解为什么数据和行为要绑定在一起。踩过几次坑之后,你会发现面向对象不再是一个抽象概念,而是写代码时顺手就用的工具。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 10:03:23

地理知识图谱毕业设计源码:从爬虫到Neo4j图嵌入的完整实现

简介&#xff1a;面向地理信息、知识图谱与机器学习方向的开发者&#xff0c;这是一份可复现的完整项目包&#xff0c;适用于毕业设计、课程设计或项目实践。项目围绕地理知识图谱的构建与应用展开&#xff0c;基于Jena Fuseki等开源工具实现本体建模、数据导入、SPARQL查询与结…

作者头像 李华
网站建设 2026/10/3 10:02:53

OSS模型加载与端点性能成本深度优化指南

1. 项目概述&#xff1a;这不是在聊“云存储”&#xff0c;而是在拆解AI服务的底层成本结构很多人看到“OSS 模型端点速度与定价讨论”这个标题&#xff0c;第一反应是&#xff1a;“OSS不是对象存储吗&#xff1f;怎么和模型端点扯上关系&#xff1f;”——这恰恰是当前大量工…

作者头像 李华
网站建设 2026/10/3 10:02:52

计及充电负荷空间可调度的分布式电源与充电站联合配置方法

在做配电网规划课题时&#xff0c;我遇到过一类很典型的问题&#xff1a;区域内要新建一批电动汽车充电站&#xff0c;同时又想配置分布式电源。两个决策各算各的很简单&#xff0c;合在一起就麻烦了。问题关键就在"充电负荷空间可调度特性"——用户在某个时间可以选…

作者头像 李华
网站建设 2026/10/3 10:02:52

场景生成与削减:Matlab下新能源不确定性建模实战

很多做新能源调度、储能配置、微电网规划的朋友&#xff0c;一开始接触“场景生成与削减”这个概念时&#xff0c;容易把它当成一个单纯的统计工具&#xff0c;觉得无非就是抽样、聚类、算距离。但真正上手用Matlab实现一遍后会发现&#xff0c;这套流程实际上是整个不确定性建…

作者头像 李华
网站建设 2026/10/3 10:02:39

Baostock五大静默失败原因与服务端机制解析

1. 为什么你用baostock总拿不到数据&#xff1f;这根本不是代码问题&#xff0c;而是认知偏差我第一次用baostock抓沪深A股日线数据时&#xff0c;连续三天没跑通。不是报错&#xff0c;是静默失败——程序跑完没任何输出&#xff0c;连个空DataFrame都不给。翻遍文档、查遍Sta…

作者头像 李华
网站建设 2026/10/3 10:02:20

GD32 BOOT0引脚误配导致程序跑飞的排查与解决

1. 项目概述&#xff1a;GD32主程序跑飞&#xff1f;别急着查代码&#xff0c;先摸摸BOOT0引脚GD32主程序莫名其妙“跑飞”——刚烧录完能跑几秒&#xff0c;接着就卡死、复位不定、串口吐乱码、调试器连不上、甚至J-LINK报SWD/JTAG communication failure——这种问题我过去三…

作者头像 李华