Python面向对象编程入门:类、封装、继承与多态实战指南
1. 为什么学了大半个月函数还非要啃面向对象写 Python 前半个月我挺开心。函数一写重复代码一抽感觉既整洁又能跑。可到了半个月之后问题来了我写的一个爬虫脚本日志、请求、解析全堆在 main 函数里两百多行后面想加两个功能直接改不动了——不是崩在运行是崩在改代码的时候。改一处另外三处跟着乱。这时候我才意识到函数把“动作”抽出来了但“数据”还是满天飞的。面向对象编程OOP能解决的问题用一句话概括就是把数据和处理数据的方法绑在一起形成一个“对象”。你不需要关心对象内部怎么算你只需要告诉它“做什么”。就像去餐厅吃饭你不用管后厨怎么切菜备料你点菜就行。放到代码里对象自己记住自己的状态自己提供操作自己的方法调用方只负责指挥。这篇笔记是给和我一样自学 Python已经会基本的变量、列表、字典、函数准备开始啃类class的同学写的。我不会把 OOP 讲成神话我只记录我自己从“看不懂”到“能上手”的完整过程以及踩过的一些坑。如果你是想系统掌握 Python 面向对象编程或者面试前想快速把封装、继承、多态这几个概念落地这篇应该能给你一个比较踏实的参考。2. 类和对象先搞懂这四个最基础的概念2.1 类class和对象object到底是什么关系先说结论类是一张图纸对象是照着图纸造出来的实物。你想造一台汽车先得有设计图。这张图里定义了车有什么属性颜色、排量、座位数能做什么动作启动、刹车、转弯。这张图就是类。真正造出来的那台车才是对象。你可以照着同一张图纸造出一百台车每一台都有自己的颜色和车牌但它们的行为逻辑都来自同一张图纸。放在代码里看class Car: def __init__(self, color, seats): self.color color self.seats seats def start(self): print(f{self.color}的小车启动了) my_car Car(红色, 5) print(my_car.color) # 红色 my_car.start() # 红色的小车启动了Car是类my_car是对象。你每写一句Car(...)就是在照着图纸造一台新车。不同对象之间互不干扰car1.color改了不会影响car2.color这点和列表的浅拷贝是完全不同的思路。2.2init方法为什么每个类都要写它__init__是 Python 里的特殊方法名称前后各两个下划线读作“双下划线 init”。它会在对象被创建的那一刻自动执行也就是构造对象时的初始化逻辑。你可以把它理解成“入住新房时顺手把水电开通了”。它的第一个参数永远是self后面的参数才是调用方传进来的。通常我会把所有需要外部指定的初始值都放在__init__里class Student: def __init__(self, name, score): self.name name self.score score def info(self): return f{self.name}考了{self.score}分 stu Student(小张, 95) print(stu.info())有个新手容易忽略的问题并不是每个类都必须写__init__。如果你的对象创建时不需要设置任何初始状态不写也完全没问题Python 会使用默认的空构造逻辑。但一旦你写了__init__调用时就必须按它的参数个数传值参数个数对不上就直接报TypeError。2.3 self 到底是什么为什么方法第一个参数都是它self代表实例本身。调用stu.info()的时候Python 会自动把stu这个对象作为第一个参数传进去info里的self就指代stu。所以不需要手写info(stu)Python 帮你补齐了。有些同学刚学的时候会疑惑那我不写self行不行不行。Python 规定实例方法的第一个参数必须用来接收对象自身。名字可以随便换但约定俗成叫self我建议你跟着约定写否则别人看你代码会很难受。这里有个直观的理解方式把self.xxx看成“我这个对象自己的 xxx”。self.name name的意思是“把我这个对象的名字属性设置为传入的 name 值”。一旦这样设置了这个属性就跟着对象走对象存活期间随时可以通过self.name读出来。3. 封装、继承、多态笔试总考代码里到底怎么用3.1 封装属性前面加两个下划线不只是“装饰”封装的意思是把内部数据隐藏起来只留必要的接口给外部用。Python 里最常见的做法是在属性名前面加双下划线比如self.__balance。加了之后外部就不能直接访问了而是需要通过类里定义的方法来读写。直接看代码class BankAccount: def __init__(self, owner, balance): self.owner owner self.__balance balance # 私有属性 def deposit(self, amount): if amount 0: raise ValueError(存款金额必须大于0) self.__balance amount def withdraw(self, amount): if amount self.__balance: raise ValueError(余额不足) self.__balance - amount def get_balance(self): return self.__balance account BankAccount(张三, 1000) account.deposit(500) account.withdraw(200) print(account.get_balance()) # 1300 print(account.__balance) # 报错AttributeError你可能想问直接访问account.balance不好吗好是好但少了约束。比如存钱如果外部直接account.balance -100余额变负数了这明显不合逻辑。封装的意义就是把这些校验逻辑挡在方法内部让外部只能按规则操作。还要补充一句Python 的私有属性其实是“改名”实现的不是绝对不可访问。self.__balance在底层变成self._BankAccount__balance硬要访问也能访问到。所以 Python 的封装更像是一种“约定”告诉别人这些是内部数据不要乱动。真正的安全和约束靠的还是设计者的自律。3.2 继承写代码最省事的一招继承解决的是“多个类有公共逻辑”的问题。比如猫和狗都会吃、会叫如果每个类都写一遍吃和叫的方法代码就重复了。正确做法是把公共的部分放到父类基类把差异放到子类。class Animal: def __init__(self, name): self.name name def eat(self): print(f{self.name}正在吃东西) def speak(self): raise NotImplementedError(子类需要实现这个方法) class Dog(Animal): def speak(self): print(f{self.name}汪汪叫) class Cat(Animal): def speak(self): print(f{self.name}喵喵叫) dog Dog(旺财) cat Cat(咪咪) dog.eat() # 旺财正在吃东西用的父类方法 cat.eat() # 咪咪正在吃东西 dog.speak() # 旺财汪汪叫 cat.speak() # 咪咪喵喵叫子类里面不写eat但它天然就有eat方法这就是继承的威力。父类的speak故意只写了个raise NotImplementedError意思是告诉子类这个方法你要自己去实现。这是一种很常见的“约定父类接口”的写法。继承还有一个细节子类的__init__不会自动调用父类的__init__。如果你在子类里重写了__init__又想父类的属性初始化逻辑保留需要手动调用super().__init__(...)class Puppy(Dog): def __init__(self, name, age): super().__init__(name) # 先初始化父类的属性 self.age age不写super()的话self.name就没有被设置后面用到了就会报AttributeError。这是个很典型的坑。3.3 多态同一个方法名不同的对象有不同的表现多态在 Python 里的体现非常自然因为它本身是动态语言调用方法时不查“你是什么类型”只查“你有没有这个方法”。刚才的例子已经体现了多态dog.speak()和cat.speak()调用的是同一个方法名但打印结果完全不同。多态的好处是什么是让代码可以“面向父类写逻辑面向子类出结果”。比如写一个函数def make_animal_speak(animal): animal.speak() make_animal_speak(dog) # 旺财汪汪叫 make_animal_speak(cat) # 咪咪喵喵叫这个make_animal_speak不需要关心传入的是Dog还是Cat只要传入的对象有speak方法就行。这就是所谓的“鸭子类型”它走路像鸭子、叫起来像鸭子那它就是鸭子。Python 不强制继承关系只要行为对得上就能一起工作。4. 实操环节写一个简单的记账本程序4.1 需求整理光讲概念记不住我学这类东西的习惯是拿一个真实场景练手。这次我选的是记账本因为需求明确、逻辑简单又能把类、封装、继承、多态都用上。需求拆解每笔记录包含金额、类别收入/支出、备注、日期。支持添加记录、删除记录、查看全部记录。支持统计总收入、总支出和结余。把记录保存到文件里下次启动还能读回来。就这么几个功能用纯函数也能写但写出来你会发现问题账本的数据结构和操作它的函数是分离的多个函数都要传records这个列表传着传着就乱了。用类来做数据和方法天然在一个整体里清晰很多。4.2 定义账本类和记录类先定义一条记录的类再定义账本的类。记录类只管自己账本类负责管理一堆记录。import json from datetime import datetime class Record: def __init__(self, amount, category, note): if amount 0: raise ValueError(金额必须大于0) self.amount amount self.category category # income / expense self.note note self.time datetime.now().strftime(%Y-%m-%d %H:%M:%S) def to_dict(self): return { amount: self.amount, category: self.category, note: self.note, time: self.time, } class AccountBook: def __init__(self, file_pathrecords.json): self.file_path file_path self.records [] self.load() def add_record(self, amount, category, note): record Record(amount, category, note) self.records.append(record) self.save() def delete_record(self, index): if 0 index len(self.records): self.records.pop(index) self.save() def get_income_total(self): return sum(r.amount for r in self.records if r.category income) def get_expense_total(self): return sum(r.amount for r in self.records if r.category expense) def get_balance(self): return self.get_income_total() - self.get_expense_total() def show_all(self): for i, record in enumerate(self.records): print(f{i}. {record.time} | {record.category} | {record.amount} | {record.note}) def save(self): data [r.to_dict() for r in self.records] with open(self.file_path, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2) def load(self): try: with open(self.file_path, r, encodingutf-8) as f: data json.load(f) self.records [Record(d[amount], d[category], d[note]) for d in data] except FileNotFoundError: self.records []代码不复杂但有几个点值得细看。Record的__init__里对金额做了校验金额小于等于零直接抛异常这是封装的思路数据合法性在源头就把关而不是等用的时候才发现问题。to_dict方法负责把记录转成字典方便后面序列化成 JSON 保存到文件。AccountBook的save和load是一对一个往文件写一个从文件读启动时调用load自动恢复上次的数据。有个细节我踩过坑load里直接Record(d[amount], d[category], d[note])把时间丢了。文件里存的time字段没有读回来所以每次程序重启后所有记录的时间都变成了启动时的时间。这个问题在下面常见问题里我会专门说。4.3 跑通主流程写一个简单的交互入口def main(): book AccountBook() while True: print(\n 简易记账本 ) print(1. 添加收入) print(2. 添加支出) print(3. 查看全部) print(4. 删除记录) print(5. 统计) print(0. 退出) choice input(请选择) if choice 1: amount float(input(金额)) note input(备注) book.add_record(amount, income, note) elif choice 2: amount float(input(金额)) note input(备注) book.add_record(amount, expense, note) elif choice 3: book.show_all() elif choice 4: index int(input(要删除的记录序号)) book.delete_record(index) elif choice 5: print(f总收入{book.get_income_total()}) print(f总支出{book.get_expense_total()}) print(f结余{book.get_balance()}) elif choice 0: break if __name__ __main__: main()整个程序下来外部代码只和AccountBook打交道不需要关心它内部是怎么保存记录的。以后想加一个“按类别筛选”的功能只需要在AccountBook里加一个方法外部调用方式不变这就是封装带来的直接好处。5. 类里的“隐藏技能”类方法、静态方法和魔术方法5.1 类方法和静态方法什么时候用它们实例方法第一个参数是self只能通过实例调用。但有时候我们想在不创建实例的情况下直接用类调用一个方法。比如根据数据批量创建对象或者写一个和实例状态无关的工具函数这就用到了类方法和静态方法。class Student: total_count 0 def __init__(self, name): self.name name Student.total_count 1 classmethod def get_total_count(cls): return cls.total_count staticmethod def is_valid_name(name): return len(name) 1 print(Student.get_total_count()) # 0注意这里没有创建实例 s1 Student(小明) print(Student.get_total_count()) # 1 print(Student.is_valid_name(a)) # Falseclassmethod的第一个参数是cls指向类本身不是实例。staticmethod则完全不依赖类和实例就是一个普通的函数只是放在了类的命名空间里方便逻辑归类。新手阶段最容易踩的坑是把实例方法当成静态方法用直接类名.方法名()会报TypeError提示缺少self参数。遇到这个错说明要么把方法改造成类方法或静态方法要么就先创建实例再调用。5.2 几个高频魔术方法魔术方法就是双下划线包围的那些方法Python 在特定场景会自动调用它们。除了__init__实战中最常用的还有三个。__str__定义对象被打印或转字符串时的表现。不写它直接print(record)会输出像__main__.Record object at 0x...这种没意义的东西。写了之后就能按你想要的格式输出class Record: # ...省略其他代码 def __str__(self): return f[{self.category}] {self.amount} 元 - {self.note} ({self.time})__repr__给开发者看的表现形式一般会写得能直接重现这个对象。如果没有__repr__在交互式环境里敲对象名也是显示一堆地址信息。__len__让对象支持len()函数。比如给AccountBook加上def __len__(self): return len(self.records)加完以后直接len(book)就能得到记录条数代码读起来非常自然。以后读到别人写的代码里各种各样的魔术方法先不用慌本质上都是在“重载”某些内置操作的行为。5.3 单例模式真的需要吗单例模式是说一个类只能创建一个实例。典型场景是日志处理、数据库连接池、配置管理。如果程序里到处都是同一个配置文件的多个实例改一处忘一处就会出大问题。我用一个比较简单的写法class Config: _instance None def __new__(cls, *args, **kwargs): if cls._instance is None: cls._instance super().__new__(cls) return cls._instance def __init__(self): self.settings {} config1 Config() config2 Config() print(config1 is config2) # True__new__负责创建实例__init__负责初始化实例。这里在__new__里判断如果类已经创建过实例就直接返回旧实例。这样不管外部Config()多少次拿到的都是同一个对象。不过说实话普通脚本开发阶段单例模式用到的机会不多。但理解了__new__和__init__的分工对 Python 对象创建流程的理解会深一层碰巧遇到要用的时候也不至于手忙脚乱。6. 我现在踩过的一些坑和解决思路6.1 为什么类的属性改了别的地方还是旧值很典型的问题写了一个全局列表在类里 append 新数据以为外部能自动看到结果外部看到的还是空列表。原因是对对象赋值和修改内容操作的不是一个东西。全局变量指向一个列表对象你在类里写self.data []其实是产生了一个全新的列表和原来的没有任何关系。正确的做法是不要重新赋值而是操作原有对象class Manager: def __init__(self, shared_data): self.shared_data shared_data def add_item(self, item): self.shared_data.append(item) # 在原有对象上修改外部能看到简单说会换引用append、extend、update这些方法是在原对象上动手。如果你要跨类共享可变数据记得用“修改”而不是“赋值”。6.2 对象为什么打印出来是一堆看不懂的地址这是最常见的现象一行__main__.Record object at 0x000001C8A83F4B50看起来像报错其实只是你没写__str__。给类加上__str__就能解决。我一般会在调试阶段就先写上__str__虽然多写几行字但后面print调试的时候不知道能省多少时间。如果你懒得分__str__和__repr__最简单的方式是先只写一个__repr__它同时能兼容print的场景。6.3 文件读写导致数据丢失的坑回到前面记账本的例子load方法里没有恢复time字段。我一开始没觉得是问题直到有一天想按时间排序才发现所有记录时间都一样。这个问题的根源是保存到 JSON 文件时Record对象有time字段但重新从字典创建对象时意识上没有把字典里的所有字段重新对应回去。修复思路也很简单重新定义Record时加上时间参数class Record: def __init__(self, amount, category, note, timeNone): if amount 0: raise ValueError(金额必须大于0) self.amount amount self.category category self.note note self.time time if time else datetime.now().strftime(%Y-%m-%d %H:%M:%S) def to_dict(self): return { amount: self.amount, category: self.category, note: self.note, time: self.time, }然后load里把time也传进去self.records [ Record(d[amount], d[category], d[note], d[time]) for d in data ]这个坑再次提醒我to_dict和从字典重建对象是完全对称的两个操作改动一边的时候另一边也要同步检查否则就是悄悄埋 bug。6.4 调试类代码的一个小技巧写面向对象的代码遇到搞不懂的变量状态我不会靠猜直接在关键方法里打印__dict__print(self.__dict__)__dict__是一个字典里面装着这个对象所有的实例属性。打印一条就能清楚看到当前对象有哪些属性、各自的值是多少。这个技巧帮我排查过很多“属性怎么不见了”“名字怎么拼错了”之类的问题。在正式代码里记得删掉调试的 print或者用日志模块替代。7. 根据最近踩坑的经验补一个“类设计自查清单”学到这里我发现比起记住每一个语法细节更实用的是有一套写类之前的自查习惯。我现在的流程是这样先问自己这个类要封装什么数据不要一上来就写代码先用一两句话描述这个对象“是什么”。比如“账本是一堆记录的集合支持增加、删除、统计和持久化”。这句话写清楚之后成员属性基本就定了records、file_path。再问外部需要调用哪些方法这是接口设计。外部越少需要知道内部细节设计就越好。我希望调用方只用add_record、delete_record、show_all那就绝不强迫他们去操作self.records内部结构。最后问哪些东西必须私有比如记录类里的time字段最好只读外部不随意修改。那就不要提供直接赋值入口只提供to_dict或__str__这种只读表现形态。不提供修改方法从根上就堵住了误操作的路线。这套自查流程不一定是最优的但对刚学面向对象的人来说能有效避免“写出来的类其实就是一堆函数的壳子”这种尴尬。类和函数最大的区别不在于语法而在于你是否真的把数据和操作内聚在了一起。8. 最后分享一个我用的学习方法先抄后改再自己写我知道很多教程喜欢从概念讲起什么抽象、封装、继承、多态。我学这种内容特别容易看困所以我的办法很简单粗暴先从网上找一个可运行的面向对象代码比如记账本、学生管理系统一行一行抄下来。抄的过程中不理解的先标出来不用纠结只需要把这些代码在自己电脑上跑通。跑通之后就进入第二步改。改名字、改参数、加功能。比如把记账本支持的文件格式从 JSON 换成 CSV这个改动会逼你把序列化相关的代码好好读一遍因为不读懂你就改不动。第三步最有用就是合上别人的代码自己从头写一遍类似的程序。这个过程一定会卡壳卡壳的地方就是簿弱点。我曾经卡在“多个类之间怎么传数据”上好几天反复看别人的写法突然有一天就想通了类之间的协作就是靠对象引用把 A 对象传给 B 对象的方法参数就行不需要搞什么全局变量。说实话面向对象学完之后再回头看函数式写法会觉得各有各的适合场景。小脚本用函数就挺好但项目和功能一多类的优势立刻就能体会出来。这篇笔记是我自学阶段的记录里面的代码我都本地跑过你拿过去可以直接试。如果卡住多半是缩进或者参数传值的问题对照着检查一遍就好。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →