ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Python类和对象完全指南:从基础语法到封装继承多态实战

2026/9/14 19:45:06 拓冰建站 浏览量
Python类和对象完全指南:从基础语法到封装继承多态实战 1. 从面向过程写到怀疑人生类和对象到底解决了什么问题我最早写Python的时候其实很不理解为什么要搞类和对象。当时我写的代码长这样一个脚本里放七八个函数每个函数接收几个参数处理完再返回几个值主流程从上往下调用。刚开始感觉挺顺的直到某个脚本的功能越来越多我开始频繁地在函数之间传同一个字典。今天要加一个字段明天要改一个字段结果这个字典被五个函数共享每个函数改一点点。最崩溃的一次我在A函数里给字典加了个name键B函数里读的却是username程序跑起来不报错只是结果全是错的——因为字典缺键的时候取值只能拿到None而None不会让你立刻发现哪里出了问题它只是安静地让逻辑越来越歪。面向对象解决的就是这个痛点把数据和处理数据的函数绑在一起。类Class是对一类事物的抽象描述对象Object是这个抽象描述的具体实例。说得更直白一点类是图纸对象是按图纸造出来的房子。你手里有一张Student图纸就可以批量造出张三、李四、王五这些具体的对象每个对象自己带着自己的数据也带着操作这批数据的方法。这个思路和面向过程的本质区别在于面向过程关心按什么顺序做什么操作面向对象关心谁手里有什么数据、谁负责处理自己的数据。比如要打印一个学生的成绩单面向过程的写法是把成绩字典传给打印函数面向对象的写法是让这个学生对象调用自己的print_report()方法。数据和方法不再散落各处而是收拢在同一个结构里。很多人会问那我用字典加几个函数不也一样吗短期看确实差不多但一旦项目规模上来类的价值就会体现出来它提供了清晰的边界让代码的可读性、可维护性、可扩展性都上了一个台阶。这也是为什么几乎所有主流语言——从Python到Java到C——都把类和对象作为核心教学内容。本文就从零开始把类和对象掰开揉碎了讲一遍里面有不少是我自己写过烂代码之后总结出来的教训希望能帮你少踩几个坑。2. 敲下第一段类代码类定义、属性与方法的基础用法在Python里定义一个类极其简单一个class关键字就够了。但简单归简单里面有几个概念如果一开始没理清楚后面很容易绕晕。2.1 class关键字与类名的规范class Student: pass每个类都有一个名字Python社区约定类名使用大驼峰命名法CapWords也就是每个单词首字母大写比如Student、BankAccount、HttpRequestHandler。变量名和函数名才用小写下划线风格snake_case比如student_name、get_score。这个规范不是语法强制的但几乎所有Python开发者都遵守你在看别人代码或写给别人看的代码时保持这个习惯会舒服很多。类定义一行写完后下面缩进的代码块就是类体。类体里可以放两种东西属性变量和方法函数。属性描述这个类有什么方法描述这个类能做什么。2.2 属性类属性与实例属性class Student: # 类属性所有实例共享 school 阳光中学 def __init__(self, name, score): # 实例属性每个实例各自拥有 self.name name self.score score这里出现了一个非常关键也最容易搞混的点类属性和实例属性是两回事。school是类属性它定义在类体内部是整个班级共享的。不管是张三还是李四只要他们没有单独修改school访问到的都是同一个阳光中学。self.name和self.score是实例属性它们在__init__里通过self绑定到具体对象上。每个学生对象都有自己独立的name和score张三改了分数不影响李四的分数。用代码验证一下zhangsan Student(张三, 88) lisi Student(李四, 95) print(zhangsan.school) # 阳光中学 print(lisi.school) # 阳光中学 print(zhangsan.name) # 张三 print(lisi.name) # 李四实际开发中我见过不少人把类属性当实例属性用结果一个对象改了值其他对象全变了。这是个非常隐蔽的坑后面我会专门讲。2.3 方法实例方法、类方法、静态方法的区别初学者最先接触的是实例方法也就是定义时第一个参数写self的方法。self代表当前这个对象本身调用时Python会自动把对象传进去。你写zhangsan.get_score()Python实际执行的是Student.get_score(zhangsan)。class Student: def __init__(self, name, score): self.name name self.score score def get_score(self): return f{self.name}的分数是{self.score} zhangsan Student(张三, 88) print(zhangsan.get_score()) # 张三的分数是88除了实例方法还有类方法classmethod和静态方法staticmethod。它们用装饰器标记区别在于第一个参数类方法接收当前类习惯命名为cls静态方法不自动接收任何特殊参数。class Student: total_count 0 def __init__(self, name): self.name name Student.total_count 1 classmethod def get_total_count(cls): return f总共创建了{cls.total_count}个学生对象 staticmethod def validate_name(name): return len(name) 0什么时候用哪种方法我的经验是方法里需要访问实例数据如self.name就用实例方法只需要访问类本身的数据如total_count就用类方法跟类和实例数据都无关只是逻辑上归类到当前类的工具函数用静态方法。这个划分不是强制规则而是为了让代码意图更清晰。3. 实例化的底层逻辑init、self 和名字空间的真相很多教材会把实例化简单描述为创建对象的过程然后让你直接背下来对象 类名(参数)。但如果你只背到这个层面遇到复杂一点的场景还是会卡壳。这一节我把实例化背后发生的事情讲透。3.1 实例化时解释器到底做了什么当你执行Student(张三, 88)这句代码时Python解释器实际上分两步走调用__new__(cls, ...)方法分配一块内存创建一个空的对象。调用__init__(self, ...)方法把传入的参数绑定到这个新对象上完成初始化。__new__负责造出这个对象__init__负责给这个对象填数据。平时我们几乎不需要自己写__new__它由object基类提供默认实现但理解这两步的区分对调试很有帮助——比如你遇到对象创建了但属性没初始化的诡异问题时就要想到是不是__init__里漏了赋值或者__new__被重写后绕过了初始化逻辑。class Student: def __new__(cls, *args, **kwargs): print(1. 创建对象) return super().__new__(cls) def __init__(self, name): print(2. 初始化对象) self.name name s Student(张三) # 输出 # 1. 创建对象 # 2. 初始化对象3.2 self 到底是谁self是Python里最容易让新手困惑的东西之一。很多初学Python的人看完这句还会问一句self能不能不写答案是不能。这是Python语法规则实例方法的第一个参数必须显式地表示对象自身。当你调用zhangsan.get_score()时Python自动把zhangsan传给get_score的第一个参数。你可以把这个参数命名为任意名字比如this或者obj但强烈建议永远命名为self这是约定俗成的规则改了反而会让所有读你代码的人难受。还容易忽略的一点是self不是定义在类里面的全局变量它是调用方法时才注入的参数。同一个方法张三调用时self是张三李四调用时self是李四。方法本身只有一份但self让它在不同对象身上操作不同数据。这就是方法复用的底层机制。3.3 实例的名字空间dict与属性查找顺序每个Python对象内部都维护着一个字典叫__dict__对象的所有实例属性都存在这里面。你可以直接打印看看s Student(张三, 88) print(s.__dict__) # {name: 张三, score: 88}当你访问s.name时Python的查找顺序是这样的先查s.__dict__实例属性查不到再查Student.__dict__类属性再查不到就沿着继承链往上找父类的属性最后都找不到就抛AttributeError。这个顺序非常关键。如果实例属性和类属性同名实例属性会覆盖类属性的访问结果但不会真的修改类属性——它只是在实例自己的__dict__里新增了一条。理解这个查找顺序后很多诡异的问题都能解释。比如某个对象访问属性时拿到的是类属性一旦给对象赋了同名的值后面拿到的又变成了实例属性好像属性变了。其实没变只是查找链命中位置不同。3.4 类AttributeError排查的完整链路我之前在一个项目里就踩过这种坑花了好几个小时才定位。现在我把整个排查思路写出来你遇到类似问题可以直接照着走。现象是某个配置对象在运行到某个模块时报AttributeError: Config object has no attribute timeout。一开始我以为是对象没初始化好检查__init__发现timeout确实赋值了。后来又以为是导入顺序问题也没找到原因。最后我打印了config.__dict__发现timeout键根本不存在反而是time_out——原来是另一个模块在导入这个配置对象后动态给对象加了time_out属性而原始代码里写的是timeout。这种问题的排查链路可以总结为先确认报错对象是什么类型是类还是实例。打印对象.__dict__看看实例属性到底有哪些。如果实例属性里没有再看类属性有没有。如果都没有去查是不是被其他代码在运行时动态修改或删除了属性。用dir(对象)看所有可访问的名称列表对比一下目标属性是否存在。Python是动态语言对象属性可以在运行时任意增删改这带来了灵活性但也带来了调试难度。排查这类问题时__dict__是破案的关键指纹。4. 从含混到清晰封装、继承、多态在真实项目里的落地姿势面向对象有三大特性封装、继承、多态。几乎所有教材都会讲但教材里给的例子常常过于干净导致你学完了还是不知道怎么用。这一节我结合实际场景说清楚。4.1 封装让外部别乱动我的内部数据封装的意思是把数据和对数据的操作打包在一起同时控制外部对内部数据的直接访问权限。在Python里没有一个严格的private关键字约定俗成地用一个下划线_name表示这是内部属性请勿直接访问用两个下划线__name表示我不想让你从外部访问。但Python的__name并不是真的私有它做的是名字重整name mangling。解释器会把__name变成_ClassName__name从外部直接访问对象.__name会报错但访问对象._ClassName__name还是能拿到的。class BankAccount: def __init__(self, balance): self.__balance balance def get_balance(self): return self.__balance def deposit(self, amount): if amount 0: raise ValueError(存钱金额必须大于0) self.__balance amount acc BankAccount(1000) # print(acc.__balance) # AttributeError print(acc._BankAccount__balance) # 1000理论上还是能拿到比起绝对防御更合理的做法是把它当成明确提示你看到带下划线的属性就知道这不是公共接口不该在外部直接操作。在实际项目中最有用的封装手段是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(张三, 88) s.score 95 # 走setter检查合法性 # s.score 200 # ValueError这样做的好处是外部看起来就像在直接操作属性score实际上赋值时会经过校验逻辑数据安全性和代码可读性都保住了。4.2 继承复用父类的能力扩展子类自己的版本继承解决的是多个类有共同行为的问题。比如你有一个Animal父类里面有eat()和sleep()方法子类Dog和Cat就不用重复写这些方法只需关注自己的特有行为。class Animal: def __init__(self, name): self.name name def eat(self): print(f{self.name}正在吃东西) class Dog(Animal): def bark(self): print(f{self.name}汪汪叫) class Cat(Animal): def meow(self): print(f{self.name}喵喵叫) dog Dog(旺财) dog.eat() # 旺财正在吃东西方法从父类继承 dog.bark() # 旺财汪汪叫继承的关键是super()。子类重写父类方法时经常需要先让父类的逻辑执行一遍再补充自己的逻辑class Dog(Animal): def __init__(self, name, breed): super().__init__(name) # 先执行父类的__init__初始化name self.breed breed # 再补子类特有属性如果不调用super().__init__()父类的self.name就不会被初始化子类对象访问name时会直接报错。这是新手写继承最常见的错误之一。Python还支持多继承但多继承会引入方法解析顺序MRO的问题。MRO决定了当多个父类都有同名方法时子类继承谁的版本。你可以用类名.__mro__查看解析顺序。多继承不是不能用但很容易把代码搞复杂我个人的建议是能用组合就少用多继承如果非用不可要特别留意MRO的顺序。Python的MRO基于C3线性化算法保证每个类在顺序中只出现一次且保持子类优先于父类的原则。4.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.14 * self.radius * self.radius # shape Shape() # TypeError抽象类不能实例化 circle Circle(5) print(circle.area()) # 78.5它解决的实际问题是当你有多个子类都来自同一个父类但每个子类的实现细节不同父类自己又不适合被直接使用时抽象类就能强制约束所有子类必须实现某些方法。比如Shape这个抽象概念没有一个通用的area算法但你希望所有形状都能算面积就用抽象方法把这个要求写死。4.4 多态同一个方法在不同对象上有不同表现多态在Python里其实是最自然的事情。它指的是不同类的对象如果实现了相同签名的方法你就可以用相同的方式调用它们而不关心它们具体是哪个类。class Dog(Animal): def speak(self): print(汪汪) class Cat(Animal): def speak(self): print(喵喵) def let_them_speak(animal): animal.speak() let_them_speak(Dog(旺财)) # 汪汪 let_them_speak(Cat(咪咪)) # 喵喵let_them_speak函数不知道也不关心传入的是Dog还是Cat只要它有s speak()方法就行。这就是著名的鸭子类型如果一只鸟走起来像鸭子、游泳像鸭子、叫起来像鸭子那它就是鸭子。Python不检查对象类型只看你有没有实现对应的方法。这种设计让代码非常灵活。5. 类与对象相关的常见报错与排查经验汇总写类和对象过程中有几类报错是出现频率极高的。我把它们集中列一下每条附上排查思路方便你以后遇到了直接对照。报错信息常见原因排查方向TypeError: __init__() missing 1 required positional argument实例化时少传了参数检查类名后的参数数量和__init__签名AttributeError: X object has no attribute y属性不存在或初始化顺序问题打印__dict__检查属性查找链TypeError: Student.get_score() takes 1 positional argument but 2 were given定义方法时忘了写self检查方法第一个参数是否为selfNameError: name self is not defined在类体内但方法外使用selfself只存在于方法内部类体顶层不能用TypeError: Student object is not callable把实例当函数调用了检查代码里是否漏写了()误把实例当类用5.1 忘记写self引发的连锁反应这个错误特别有意思因为它经常不是立刻报错的。比如class Student: def get_score(name): return f{name}的分数是88 s Student() # s.get_score() # TypeError: get_score() takes 1 positional argument but 2 were given你调用s.get_score()时Python把s作为第一个参数传给了get_score于是get_score收到两个参数一个是s一个是空但签名只写了一个name报错。如果你改成Student.get_score(张三)反而能正常运行因为这时候没有实例参与name就是张三。这种代码能跑但已经完全违背了实例方法的语义。所以看到takes 1 positional argument but 2 were given的报错时第一反应就应该是方法签名里少了self。5.2 可变默认参数这个经典坑放在类里也一样虽然它更多出现在普通函数里但在类的方法里同样常见。请看这段代码class ShoppingCart: def __init__(self, items[]): self.items items def add(self, item): self.items.append(item) cart1 ShoppingCart() cart1.add(苹果) cart2 ShoppingCart() print(cart2.items) # [苹果]你新建的cart2里居然有cart1加的苹果。原因在于默认参数[]在定义时只会创建一次之后所有调用如果没传items拿到的都是同一个列表对象。解决办法是改成def __init__(self, itemsNone)函数内部再判断并创建新列表。这个坑在类里容易被忽视因为每个对象应该有自己的属性这种直觉会让你默认它是独立的。但实际上可变对象作为默认参数共享的是同一个对象引用。所有涉及默认参数为[]、{}、set()的地方都要警惕。5.3 类属性被共享的坑前面提过类属性和实例属性的区别。再补一个更隐蔽的案例class Student: tags [] def __init__(self, name): self.name name s1 Student(张三) s1.tags.append(优秀) s2 Student(李四) print(s2.tags) # [优秀]这里tags是类属性所有学生共享同一个列表。也许你本意是给张三打标签结果所有学生都有了优秀标签。如果希望每个学生有自己的标签列表应该在__init__里用self.tags []创建实例属性。需要特别强调的是类属性如果是不可变类型如整数、字符串共享问题不严重因为你给某个实例赋值时会直接在实例__dict__里新建属性不会影响类属性但可变类型列表、字典、集合被共享后通过增删改操作会影响所有实例这是最危险的场景。5.4 与其他语言类机制对比理解动态的差异我在热搜词里看到不少人同时搜索其他语言的类问题比如Java的找不到或无法加载主类、C的类前置声明、表达式必须包含类类型这些。这里顺手做个小对比能帮你更清楚Python的特殊性。Java和C是编译型语言类的结构在编译期就固定了成员变量必须在类体里声明编译器会检查你访问的属性和方法是否存在很多错误在编译阶段就暴露了。Python则完全是运行时动态类定义执行完毕后你还能往类或实例上随便挂新属性、新方法。这意味着Python更灵活但也意味着很多错误要等运行到那一行才会暴露出来。C的类前置声明问题本质是编译期依赖管理在某个头文件里用到了某个类但还没有看到类的完整定义只能先声明一下。Python没有这个概念因为Python的import和名字绑定都是运行时的类只需在使用前定义好就行。表达式必须包含类类型这类C错误对应到Python里就是你尝试用实例访问类的静态属性却写错了语法或者把示例当类名用了。对比下来你会发现Python把很多编译期检查省掉了所以你必须更仔细地观察运行时行为。6. 一个贴近实战的完整案例用类和对象重构重复代码理论讲了一堆最后用一个完整的实战案例把前面内容串起来。我选一个大家都能理解的场景银行账户管理系统。用这个案例走一遍从过程式到面向对象的重构过程。6.1 痛点过程式写法的混乱假设初始需求很简单支持存钱、取钱、查余额。过程式的写法可能长这样accounts {} def create_account(account_id, balance0): accounts[account_id] balance def deposit(account_id, amount): if amount 0: raise ValueError(金额必须大于0) accounts[account_id] amount def withdraw(account_id, amount): if amount 0: raise ValueError(金额必须大于0) if accounts[account_id] amount: raise ValueError(余额不足) accounts[account_id] - amount def get_balance(account_id): return accounts[account_id]看起来还不错对吧但等需求升级就麻烦了每个账户要记录流水要区分活期和定期要有不同的利率定期账户提前支取还要扣手续费。这些逻辑往一个全局字典里塞函数会越来越臃肿每个函数需要额外判断账户类型参数也越来越多。6.2 重构从字典到类改成面向对象的思路后先设计一个BankAccount基类再派生出SavingsAccount活期和FixedDepositAccount定期。from abc import ABC, abstractmethod class BankAccount(ABC): def __init__(self, account_id, balance0): self.account_id account_id self._balance balance self.transactions [] def deposit(self, amount): if amount 0: raise ValueError(存款金额必须大于0) self._balance amount self.transactions.append((deposit, amount)) def withdraw(self, amount): if amount 0: raise ValueError(取款金额必须大于0) if amount self._balance: raise ValueError(余额不足) self._balance - amount self.transactions.append((withdraw, amount)) property def balance(self): return self._balance abstractmethod def calculate_interest(self): 计算利息每种账户实现自己的规则 def get_statement(self): lines [f账户 {self.account_id} 当前余额{self._balance}] for action, amount in self.transactions: lines.append(f {action}: {amount}) return \n.join(lines) class SavingsAccount(BankAccount): def __init__(self, account_id, balance0, rate0.003): super().__init__(account_id, balance) self.rate rate def calculate_interest(self): return self._balance * self.rate class FixedDepositAccount(BankAccount): def __init__(self, account_id, balance0, rate0.02, penalty_rate0.01): super().__init__(account_id, balance) self.rate rate self.penalty_rate penalty_rate def calculate_interest(self): return self._balance * self.rate def early_withdraw(self, amount): if amount 0: raise ValueError(取款金额必须大于0) penalty amount * self.penalty_rate total_deduct amount penalty if total_deduct self._balance: raise ValueError(余额不足含提前支取手续费) self._balance - total_deduct self.transactions.append((early_withdraw, amount, penalty, penalty))这段代码里用到了前面所有知识点abstractmethod让BankAccount成为抽象类强制子类实现calculate_interestproperty保护_balance不被外部直接修改super().__init__保证父类初始化逻辑先执行不同账户的利率和支取逻辑不同通过继承和多态来区分。6.3 使用效果对比savings SavingsAccount(SA001, 10000) savings.deposit(5000) savings.withdraw(2000) print(savings.balance) # 13000 print(savings.calculate_interest()) # 39.0 fixed FixedDepositAccount(FD001, 50000) print(fixed.calculate_interest()) # 1000.0 fixed.early_withdraw(10000) print(fixed.balance) # 3990010000本金 100手续费 print(fixed.get_statement())如果以后要新增一种账户比如零存整取账户只需要再写一个BankAccount的子类实现自己的calculate_interest其他代码不用动。这就是面向对象带来的扩展性。过程式写法在这个场景下每加一种账户类型就要往全局字典加字段、往每个函数加if判断改起来牵一发动全身。写类和对象还有一个容易被忽略的好处天然适合团队协作。每个人负责一个类的开发类的接口定义清楚后互相之间不必关心细节实现。这在真实项目中非常关键。我个人在实际使用中的体会是类和对象不需要在所有场景里都用。一个几十行的脚本面向过程完全够用但一旦代码量上来、逻辑层次变多、要反复维护修改类和对象的优势就会越来越明显。判断标准很简单如果你发现自己频繁地在函数之间传递相同的数据结构而且这些函数总是对同一份数据做操作那就该把这份数据和这些函数收进一个类里了。另外学习类和对象的过程有点像学骑自行车光看不骑永远学不会。我建议你找一个小项目比如待办清单、记账本、学生成绩管理系统用类的方式从零写一遍。遇到AttributeError不要慌打印__dict__看看遇到方法调用报错先检查self写没写。踩过几次坑之后你对类和对象的理解就会真正落地了。