不少刚接触Python的朋友都有一个共同的困惑:学了列表、字典、函数之后,感觉自己能写点小脚本了,可一看到“面向对象”这几个字,再看那些带着class、self的代码,整个人就懵了。网上教程倒是多,但要么通篇抽象概念,要么直接甩一段复杂的例子,完全看不出这东西到底有什么用。这篇东西我想用最直白的方式,把这层窗户纸捅破。我会从“为什么要面向对象”讲起,用一个真实的管理场景演示从函数到类的演进,再手把手拆解Python里类的语法细节,最后聊聊继承、封装、多态这些特性在业务代码里到底扮演什么角色。不管你是刚学完基础语法的新手,还是第一次尝试用类写项目的初学者,读完应该能理解面向对象究竟在解决什么问题,以及怎么在自己的代码里落地。
1. 为什么学Python的人大多卡在“面向对象”这堵墙上
我发现一个规律:Python基础语法的学习曲线其实很平缓,print、if、for、def这些内容,认真学一个星期就能上手干活。但到了类(class)这里,很多人突然就卡住了,而且卡的时间还不短。问题出在哪?我复盘了自己的学习经历,也和不少初学者聊过,发现一个共性原因——大部分教程都在讲“面向对象是什么”,但很少有人讲清楚“为什么代码写得好好的,非要换成面向对象”。
1.1 不是语法难,而是思维方式的转变
学函数的时候,你的心智模型是这样的:输入数据,经过函数处理,输出结果。这很符合我们日常处理事情的逻辑,就像做菜:洗好菜(输入),下锅炒(函数处理),装盘(输出)。
但面向对象的心智模型完全不同:你不再关注“怎么做”,而是关注“数据是什么、它有什么行为”。比如你在写一个学生成绩管理系统,用函数式的思路,你会这样想——“我需要一个函数来计算总成绩,需要一个函数来打印成绩单,需要一个函数来排序”。用面向对象的思路,你会换成这样想——“学生是一个对象,他有姓名、有各科成绩,他会计算自己的总分,他能展示自己的成绩单”。
这种转变本身就是反直觉的,一开始觉得别扭特别正常。但这种别扭恰恰说明你正在进入一个新的层次,就像学了几年素描突然让你画抽象画一样,不是画法变了,是看世界的方式变了。
1.2 概念名词带来的劝退效应
“封装”“继承”“多态”这三个词,几乎出现在每一本Python教材的面向对象章节里。我承认,这三个词确实精确描述了面向对象的三个核心特征,但问题在于——对新手来说,这三个词本身就构成了一堵墙。
“封装”听起来像某种加密技术,“继承”让人联想到家族遗产,“多态”更是让人一头雾水。但实际上,它们对应的概念都非常朴素:
- 封装:把相关的数据和操作打包在一起,外面的人只能通过固定的入口来访问。
- 继承:新定义的类可以复用已有类的属性和方法,不用从头写。
- 多态:同一个方法名在不同类的对象上执行时,会有不同的表现。
后面我会结合具体代码逐一拆解,这里先记住一句话:先不要纠结名词的精确含义,而是在代码里感受它们带来的实际效果。一旦你用代码验证过一遍,这些名词会自动融入你的语言体系。
1.3 缺乏一个“不得不这样做”的场景
说白了,如果你只写几十行的脚本去处理单一任务,面向对象几乎没有用武之地。你完全可以用函数写得又快又清楚。很多初学者在这时候接触面向对象,当然会觉得这是多余的东西——“我函数用得挺好的,为什么要引入class?”
这个想法非常合理。我不建议你在学Python第一周就去钻研面向对象,因为你还没有遇到它要解决的那个痛点。但反过来说,一旦你的项目开始变复杂——数据变多、功能变多、多人协作——函数的局限性就会暴露出来。下一章我就用一个具体的例子,带你体验一下这种“从能用变成难维护”的崩溃过程。
2. 从一个通信录例子看面向过程的崩溃过程
再好的理论,也不如一个正在腐烂的代码项目有说服力。这一章我准备了一个很常见的场景:写一个简单的通信录管理程序。我先用纯函数的方式实现,然后让需求逐步增加,你看看这个过程是怎么一步步走向崩溃的。
2.1 第一版需求:存联系人,能查电话
最朴素的需求:有几个联系人,每个联系人有姓名和电话号码。我可以用两个列表来搞定。
names = ["张三", "李四", "王五"] phones = ["13800138001", "13800138002", "13800138003"] def find_phone(name): for i, n in enumerate(names): if n == name: return phones[i] return "未找到" print(find_phone("李四"))这段代码很好懂,逻辑也没问题。但如果联系人信息不只是姓名和电话呢?再加一个邮箱?
names = ["张三", "李四", "王五"] phones = ["13800138001", "13800138002", "13800138003"] emails = ["zhangsan@example.com", "lisi@example.com", "wangwu@example.com"] def find_email(name): for i, n in enumerate(names): if n == name: return emails[i] return "未找到"问题出现了:每次新增一个字段,就要新增一个列表,还要新增一个查找函数。三个列表之间靠索引位置来关联,一旦中间插入或删除一个联系人,三个列表就全乱了。这就像你拿了三张独立的纸质卡片记录信息,卡片之间没有装订在一起,其中一张的顺序打乱了,整个档案就废了。
2.2 用字典改善数据组织,但函数仍然在膨胀
有经验一点的开发者会说:“用字典不就好了?”确实,用字典能把数据聚合起来:
contacts = [ {"name": "张三", "phone": "13800138001", "email": "zhangsan@example.com"}, {"name": "李四", "phone": "13800138002", "email": "lisi@example.com"}, {"name": "王五", "phone": "13800138003", "email": "wangwu@example.com"}, ] def find_contact(name, contacts): for contact in contacts: if contact["name"] == name: return contact return None这一步确实解决了数据分散的问题,每个联系人是一个独立的字典,增删一个联系人不会影响到其他人。但随着需求继续增加,局势又开始不对了。
2.3 需求膨胀:校验、展示、导入导出全都要
现在产品提了新需求:
- 电话号码必须是11位,且以1开头。
- 联系人的信息要能格式化成一行文本,方便打印。
- 要把所有联系人保存成CSV文件。
- 要从CSV文件加载联系人。
- 要对联系人按姓名排序。
于是你写了一套函数:
def validate_contact(contact): if not (contact["phone"].startswith("1") and len(contact["phone"]) == 11): return False return True def format_contact(contact): return f"{contact['name']},{contact['phone']},{contact['email']}" def save_to_csv(contacts, filename): with open(filename, "w", encoding="utf-8") as f: for contact in contacts: f.write(format_contact(contact) + "\n") def load_from_csv(filename): contacts = [] with open(filename, "r", encoding="utf-8") as f: for line in f: parts = line.strip().split(",") contacts.append({"name": parts[0], "phone": parts[1], "email": parts[2]}) return contacts def sort_contacts(contacts): return sorted(contacts, key=lambda c: c["name"])到这里,你发现一个尴尬的事实:format_contact、validate_contact、save_to_csv这些函数,本质上都在操作同一份数据——联系人的字典结构。但这个结构和操作它的函数是脱节的:数据是散落的字典,函数是散落的函数,两者之间靠“约定”来关联。你约定字典里有一个名为“name”的键,于是所有函数都要按这个约定来编写。
如果有一天你决定把“name”改成“username”,你就得把所有函数里的contact["name"]全部改一遍。如果这个项目有十个函数,你就要改十处。如果在改的过程中漏掉了一处,运行时就等着报错吧。这种“约定驱动”的代码,规模一大就是维护的噩梦。
2.4 崩溃点:数据与操作开始互相打架
真正让人抓狂的场景是:不同函数对同一份数据的解释开始冲突。比如validate_contact要求手机号是11位的字符串,但load_from_csv从文件读回来的手机号可能因为Excel导出变成了科学计数法格式;又比如sort_contacts直接按字典键排序,但联系人里混入了一个没有“name”键的脏数据。这些错误不会在写函数的时候暴露,而是在运行某个操作、处理某条数据的时候突然蹦出来。
你开始意识到:问题的根源不在于某个函数写得不好,而在于数据和操作数据的逻辑是分离的。数据放在一组字典里,操作逻辑放在一组函数里,两者之间没有任何强制性的约束关系,全靠程序员“记得住”。
这种规模和复杂度下,面向对象的价值终于显现了:它把数据和操作打包在一起,让“校验一个联系人”不再是某个游离函数的事情,而是联系人对象自己的职责。下一章我们来看看实际的写法,你会发现语法本身并不难,难的是理解为什么struct要这样设计。
3. 亲手定义一个类:核心语法背后的设计逻辑
好了,前面的铺垫已经足够,现在进入正题。我会用通信录这个例子,重新用面向对象的方式实现一遍,同时把Python类的核心语法逐个拆开,告诉你每个部分到底在干什么、为什么非这样写不可。
3.1 定义一个联系人类:从class到__init__
class Contact: def __init__(self, name, phone, email): self.name = name self.phone = phone self.email = email这段代码只有短短五行,但对第一次接触类的人来说,里面全是问题。第一个问题:__init__是什么?为什么前后要加两个下划线?
__init__是创建对象时自动执行的一个初始化方法。每当你执行Contact("张三", "138...", "zhangsan@example.com")时,Python会自动调用__init__,把传入的参数存到这个对象内部。名字特殊是因为Python规定了一类“魔术方法”,用双下划线包裹来和普通方法区分,后面我们还会见到几个。
第二个问题:self是什么?为什么不写def __init__(name, phone, email)?
self代表当前创建的这个对象本身。你可以这样理解:类是一张设计图纸,self是对照着这张图纸造出来的那一个具体的房子。当代码执行Contact("张三", ...)时,Python在背后做了一件事——创建了一个对象,然后把这个对象当成第一个参数传给了__init__。所以self.name = name的含义是:给这个新对象挂一个名为name的属性,值为传入的参数。
3.2 给类添加方法:让操作和数据住在一起
有了类,我可以把之前游离在外的函数全部搬进来。比如校验手机号、格式化联系人信息:
class Contact: def __init__(self, name, phone, email): self.name = name self.phone = phone self.email = email def validate(self): if not (self.phone.startswith("1") and len(self.phone) == 11): return False return True def format(self): return f"{self.name},{self.phone},{self.email}"两种写法的差异在思维方式上:函数式写法的validate_contact(contact),是由一个外部角色来检查一个被动对象;面向对象写法的contact.validate(),是对象自己主动去校验自己。这两种方式在效果上几乎等价,但后者让代码的自洽性更强——所有和联系人相关的操作,都收纳在Contact这个类里,你不需要在项目里到处寻找操作联系人的函数,它们全都待在同一个地方。
我特别想再强调一下这个收纳的价值。当你在写函数式版本的时候,你脑子里得有一张清单:数据在哪些列表/字典里,函数有哪些,哪个函数处理哪份数据。而当代码规模到几千行、上万行的时候,这张清单根本无法靠记忆维持。改成类之后,清单变成了类型本身——你看到一个Contact对象,就知道它一定有一系列和联系人相关的方法,不需要全局搜索。
3.3 实例化对象:把设计图纸变成真实数据
定义好类之后,我们创建几个联系人对象:
contact1 = Contact("张三", "13800138001", "zhangsan@example.com") contact2 = Contact("李四", "13800138002", "lisi@example.com") print(contact1.validate()) print(contact2.format())这里的contact1和contact2被称为类的实例。每一个实例虽然有相同的结构和行为(因为都来自Contact类),但它们的数据是相互独立的——contact1改了phone,contact2完全不受影响。
这就像一个生产线的模板:Contact是模具本身,contact1和contact2是从模具里倒出来的两个塑料杯,它们的形状一样,但你是你、我是我,互不干扰。
3.4 再谈self:为什么每个方法都要收第一个参数
这是初学者最容易踩坑的地方。我见过不少人刚学类的时候,写方法忘记加self参数,然后报错TypeError: method() takes 0 positional arguments but 1 was given,立刻懵掉。
让我把这里的机制讲透。Python里调用contact.validate(),其实等价于调用Contact.validate(contact)。换句话说,Python会自动把点号左边的对象,作为第一个参数传给方法。所以你的方法定义里必须留一个位置来接收它。这个位置就命名为self。
也就是说,self不是一个语法关键字——它只是一个普通参数名。Python官方只是约定俗成地让大家用self,你用this、用me甚至用x,程序都不会报错。但我强烈建议你永远遵守惯例,因为全世界的Python开发者都在用self,这是代码可读性的底线。
3.5 类的属性可以动态添加和修改
Python的类非常灵活,甚至有点太灵活了。类的属性不一定要在__init__里全部定义好,你可以在运行过程中随时给对象挂上新属性:
contact1 = Contact("张三", "13800138001", "zhangsan@example.com") contact1.address = "北京市海淀区" print(contact1.address) # 北京市海淀区这在C++或Java里是不可想象的,但在Python里完全合法。这带来便利的同时也埋了一个坑:如果你在代码的不同地方给对象挂了不同的属性,等到某个地方需要访问这个属性时,它有可能是缺失的,直接报AttributeError。所以我的建议是:所有属性尽量在__init__里统一声明,哪怕暂时没有值,也先赋一个None,这样每个对象的结构都是确定的,后续代码不会因为某个属性没定义而崩溃。
3.6 魔术方法:让对象融入Python语言
除了__init__,Python类还提供了一组双下划线方法,被称为魔术方法(magic methods)。它们的特点是:不需要你手动调用,而是由Python在特定时机自动触发。我挑两个最常用的来讲。
__str__(读作dunder str)控制的是print(obj)输出什么:
class Contact: def __init__(self, name, phone, email): self.name = name self.phone = phone self.email = email def __str__(self): return f"联系人:{self.name},电话:{self.phone}" contact = Contact("张三", "13800138001", "zhangsan@example.com") print(contact) # 联系人:张三,电话:13800138001如果你不定义__str__,print(contact)输出的是一段看不懂的内存地址,比如<__main__.Contact object at 0x7f8b6c1c6e50>。这显然对调试没有任何帮助。所以我在实际开发中几乎都会为数据类定义__str__,方便调试时直接打印对象查看状态。
__repr__(dunder repr)控制的是交互式环境里输入对象名时的显示。它和__str__有些重叠,但更偏向于给开发人员看,一般建议__repr__输出能直接用于重建对象的字符串。这里不展开,先记住这个区分即可。
4. 封装、继承、多态:三大特性在真实项目里是怎么用的
现在你已经能定义一个简单的类了,接下来该理解“封装”“继承”“多态”这三个概念。我准备每个都结合通信录管理场景来演示,让你看到它们是怎么帮我们解决实际问题的,而不是听我背教科书。
4.1 封装:对外只暴露该暴露的接口
封装的核心思想是:对象的内部状态(属性)应该由对象自己管理,外部代码不应该直接修改这些状态,而是通过对象提供的方法来操作。
举一个具体问题:我们要求手机号必须是11位且以1开头。如果不封装,外部代码可以随便写:
contact = Contact("张三", "12345", "zhangsan@example.com") contact.phone = "111" # 直接破坏数据完整性外部代码绕过任何校验直接改属性,程序的稳定性就无从谈起。封装的做法是:把属性设置为“私有”(通过命名约定),只允许通过方法来修改:
class Contact: def __init__(self, name, phone, email): self.name = name self._phone = phone # 下划线开头,约定为私有 self.email = email def set_phone(self, phone): if not (phone.startswith("1") and len(phone) == 11): raise ValueError("无效的手机号") self._phone = phone def get_phone(self): return self._phone注意Python没有真正意义上的私有变量,_phone只是通过命名约定提醒大家“这个变量不应该被直接访问”,它实际上仍然可以被外部访问。如果你强制禁止外部访问,需要用到__phone这种双下划线开头的名字,Python会做名称改写(name mangling),但这种方式在实践中有争议,一般项目里约定用单下划线就够了。
封装带来的实际收益是:所有对数据的修改都经过同一个校验入口,你不需要在每个调用方重复写手机号校验逻辑,也不可能出现“某个地方改了属性但没校验”的漏网之鱼。这就像小区只有一个大门进出,保安只需要守一门就能保证所有人进出都登记。
4.2 继承:复用代码,提取公共逻辑
假设现在通信录里除了普通联系人,还有一类VIP联系人。VIP联系人有普通联系人的所有信息,还多一个“折扣等级”字段,并且格式化显示的方式不太一样。
如果用函数式的写法,你可能会复制一份format_contact,改一改逻辑。但繁衍两个相似但不完全相同的函数,就埋下了隐患——哪天你改了其中一个的格式,忘了改另一个,两边输出就不一致了。
继承的写法是这样的:
class VIPContact(Contact): def __init__(self, name, phone, email, level): super().__init__(name, phone, email) self.level = level def format(self): return f"VIP[{self.level}],{self.name},{self.phone},{self.email}"VIPContact(Contact)的意思就是:VIPContact继承自Contact。子类自动拥有了父类所有的属性和方法,不需要重新声明。super().__init__(...)是调用父类的初始化方法,先让父类把自己的属性初始化好,再补充子类特有属性。
这里format方法在子类里被重新定义了,这叫做“方法重写”(override)。当你对VIP对象调用format时,执行的是子类的版本;对普通联系人调用,执行的是父类的版本。这种“同一个方法名,不同对象执行不同逻辑”的行为,正好引出了第三个特性——多态。
继承的实际价值在于“分层管理公共逻辑”。如果多个类共享一套基础字段和基础方法,你应该把这些公共部分抽到父类中,子类只写自己独特的部分。这样修改公共逻辑时只需要改父类一处,所有子类自动同步。
4.3 多态:同一方法名,不同表现
严格来说,Python本身就是动态类型语言,多态几乎无处不在。比如len()既可以统计字符串长度,也可以统计列表元素个数,还可以统计字典的键数量,这就是多态在起作用。
在面向对象代码里,多态常搭配继承出现:
contacts = [ Contact("张三", "13800138001", "zhangsan@example.com"), VIPContact("李四", "13800138002", "lisi@example.com", level=2), ] for contact in contacts: print(contact.format())这里contacts列表混装了Contact和VIPContact两种对象,但在循环里统一调用format(),Python会根据每个对象实际类型自动调用对应的format方法。输出结果是:
张三,13800138001,zhangsan@example.com VIP[2],李四,13800138002,lisi@example.com这一行代码的价值在于:调用方不需要关心对象具体是什么类型,只需要知道它一定有format方法。如果后续再加一个CompanyContact,调用方的代码完全不用改。这就是多态带来的“面向接口编程”的优势——你依赖的是对象契约,而不是具体类。
4.4 组合优于继承?我也说两句
现在业内有个流行说法叫“组合优于继承”,意思是不要一上来就建继承层级,更倾向于把各种能力作为组件组合到对象中。这种观点有道理,尤其是当继承层级过深(比如父类继承祖父类、曾祖父类)时,代码会变得极其难以理解。
我的建议是,对主题为通信录这种中小规模的程序来说,不需要刻意引入组合模式。先用继承解决明显的公共逻辑复用问题,把代码写清晰。当你发现继承关系变得错综复杂、某一个子类只需要父类的部分方法时,再考虑组合重构。面向对象不是目的,代码的可维护性才是。
5. Python面向对象的一些特殊玩法与使用边界
到这里,核心概念已经讲完。这一章我想补充一些Python面向对象特有的做法,以及一些实用边界判断。这些内容是我在实际项目中反复用到的,但很多入门教材不会讲。
5.1 类变量与实例变量的区别
在__init__里通过self.xxx = xxx定义的是实例变量,每个对象各存一份。但类本身也可以拥有自己的变量,称为类变量:
class Contact: category = "联系人" # 类变量,所有实例共享 def __init__(self, name, phone, email): self.name = name # 实例变量 self.phone = phone self.email = email c1 = Contact("张三", "13800138001", "zhangsan@example.com") c2 = Contact("李四", "13800138002", "lisi@example.com") print(c1.category) # 联系人 print(c2.category) # 联系人类变量存储在类本身,所有实例共享同一份。而实例变量在创建对象时才分配,每个实例各存一份。什么时候用到类变量?比如全项目统一的数量上限、统一的分类名称,或者用来统计这个类被实例化了几次:
class Contact: count = 0 def __init__(self, name, phone, email): self.name = name self.phone = phone self.email = email Contact.count += 1有一个容易踩的坑:类变量虽然可以通过实例读取,但如果你通过实例给这个变量赋值,Python会创建一个新的实例变量,把类变量“遮住”。比如执行c1.category = "VIP联系人"后,c1.category变成了新的实例变量,c2.category仍然从类里读到原来的值,而类变量本身并没有被修改。很多人在这里栽过跟头,我说出来希望你避开。
5.2 属性装饰器:用方法伪装属性
有时候你想在获取属性时额外加一点逻辑,比如手机号脱敏显示。用get_phone这种传统方法调用也可以,但Python提供了更优雅的写法——@property装饰器:
class Contact: def __init__(self, name, phone, email): self.name = name self.phone = phone self.email = email @property def masked_phone(self): return self.phone[:3] + "****" + self.phone[-4:] contact = Contact("张三", "13800138001", "zhangsan@example.com") print(contact.masked_phone) # 138****8001注意masked_phone是方法,但加了@property之后,你在外部用contact.masked_phone这种属性访问的方式来调用,非常自然。这等于你提供了一种“计算型属性”——它看起来像一个属性,实际上背后有逻辑在运行。
与之配套的还有@xxx.setter,它可以替代前面set_phone这种setter方法的写法,让外部代码的写法更自然。不过这是进阶内容,你先掌握@property的读取用法就够用了。
5.3 什么时候该用类,什么时候不该用
面向对象是个好工具,但凡是工具就该有适用边界。我见过不少初学者学着学着就走向了另一个极端——什么东西都套个类,明明三行函数能解决的问题,非要定义三个类再加一层继承,整个项目变得臃肿不堪。
我的判断标准非常简单:当你的代码里反复出现“一组数据 + 针对这组数据的多个函数”时,就该考虑把它们打包成类了。比如你写了一系列处理学生信息、课程信息、成绩信息的函数,每个信息实体都有多个字段、多个操作,类就是天然的组织单元。反之,如果你只是在写零散的工具函数,比如一个处理字符串、一个处理日期、一个算数学公式,这些彼此之间没有关联数据,你完全没有必要强行建类。
还有一个判断方向是:当你发现自己给函数传参时,总是把同一个字典或同一个列表传来传去,并且多个函数都依赖这个字典里的同一组键时,这就是一个强烈的信号——该用类了。因为这个字典就是“数据”,围绕它的那些函数就是“操作”,类正是把它们捆绑在一起的工具。
5.4 不要过度设计:迭代是主线
我特别想说的另外一点是:面向对象不是预先设计出来的,而往往是代码演进迭代出来的。你不需要在一个项目开工第一天就画一个巨大的类图,把所有继承关系都规划好。更合理的路径是:第一版先用函数把它跑通,等到确实感到函数代码难以维护、重复逻辑越堆越多时,再顺手把那些数据+操作重构成类。
我自己的经验里,很多类的设计都是第二次、第三次重构时才逐渐清晰的。一开始我常常高估自己对业务的理解,预设了很多层继承,结果最后大部分子类根本用不上,反而把代码搞复杂了。后来我变得更务实——先用最简单的方式实现,等痛点明确,重构就有了方向。面向对象不是一场表演,不是为了看起来高大上,而是为了让明天的你改代码时不用骂今天的自己。
6. 一个综合案例:把通信录管理器用面向对象重构
前面讲了很多概念和碎片代码,这一章我把它们整合到一起,做一个可直接运行的综合示例。这个示例会体现:类的组织、实例化、继承、多态、魔术方法、属性装饰器,以及文件读写。你可以直接复制运行,然后基于它继续扩展。
import csv class Contact: category = "普通联系人" def __init__(self, name, phone, email): self.name = name self.phone = phone self.email = email @property def masked_phone(self): return self.phone[:3] + "****" + self.phone[-4:] def validate(self): return self.phone.startswith("1") and len(self.phone) == 11 def format_line(self): return f"{self.category}|{self.name}|{self.phone}|{self.email}" def __str__(self): return f"{self.category}:{self.name},电话:{self.masked_phone}" class VIPContact(Contact): category = "VIP联系人" def __init__(self, name, phone, email, level): super().__init__(name, phone, email) self.level = level def format_line(self): return f"{self.category}|{self.name}|{self.phone}|{self.email}|VIP{self.level}" def __str__(self): return f"VIP{self.level} {self.name},电话:{self.masked_phone}" class AddressBook: def __init__(self): self.contacts = [] def add_contact(self, contact): if not contact.validate(): raise ValueError("手机号格式不正确") self.contacts.append(contact) def find(self, name): for contact in self.contacts: if contact.name == name: return contact return None def display_all(self): for contact in self.contacts: print(contact) def save_to_csv(self, filename): with open(filename, "w", encoding="utf-8", newline="") as f: writer = csv.writer(f) for contact in self.contacts: writer.writerow(contact.format_line().split("|")) def load_from_csv(self, filename): self.contacts = [] with open(filename, "r", encoding="utf-8") as f: reader = csv.reader(f) for row in reader: if row[0] == "VIP联系人": self.contacts.append(VIPContact(row[1], row[2], row[3], int(row[4].replace("VIP", "")))) else: self.contacts.append(Contact(row[1], row[2], row[3])) if __name__ == "__main__": book = AddressBook() book.add_contact(Contact("张三", "13800138001", "zhangsan@example.com")) book.add_contact(VIPContact("李四", "13800138002", "lisi@example.com", level=2)) book.display_all() book.save_to_csv("contacts.csv") new_book = AddressBook() new_book.load_from_csv("contacts.csv") new_book.display_all()这个例子完整展示了类的各种用法。你可以注意到,AddressBook本身也是一个类,它管理着一组Contact对象。面向对象并不排斥“对象包含对象”这种关系,管理联系人列表的通讯录,与联系人本身,天然是两个层级的概念。
在实际运行中,你会发现load_from_csv那段代码有点繁琐:要判断行首字段是普通联系人还是VIP,再进行不同类型对象的创建。这是文件读写和类型恢复中常见的成本,没有银弹可以完全规避,只能尽量把逻辑封装好。你也可以用json模块替代CSV来存储,json天生支持嵌套结构,恢复对象时会稍微轻松一点。
另外要提醒一点,csv.writer在保存包含中文时,文件需要用utf-8编码打开,并在写入时指定newline="",否则在Windows平台上会出现空行问题。这些都是我在实际使用中踩过的坑,放在这里帮你避开。
7. 学完面向对象后的几个练习方向与学习建议
纸上得来终觉浅。学面向对象最忌讳的就是只看不写。我给你列几个循序渐进的练习方向,每个方向都指向真实项目里会遇到的问题。你可以按顺序逐个完成,每个练习都会让你的类设计能力上升一截。
7.1 练习一:图书管理系统
设计Book类和Library类。Book包含书名、作者、ISBN、是否借出等属性,方法包括借出、归还、显示信息。Library管理一组Book对象,提供添加、删除、搜索(按书名或ISBN)功能。这个练习重点在于:两个类之间的协作关系,Library持有Book对象列表,操作通过Book的方法完成还是通过Library直接修改Book的属性?多想一下这类问题,你能体会到设计选择的权衡。
7.2 练习二:简单银行账户
设计BankAccount类,属性包括账户名、余额,方法包括存款、取款、查询余额、打印对账单。取款时要判断余额是否足够,不足则需要抛异常或返回明确提示。这个练习的重点是“封装”——余额不应该被外部直接修改,只能通过deposit和withdraw方法操作。你可以在withdraw里加入余额校验逻辑,体会“所有修改都经过同一入口”的好处。
7.3 练习三:形状面积计算
设计一个Shape基类,有area()方法但抛出NotImplementedError。然后实现Rectangle和Circle子类,各自重写area()方法。最后写一个函数,接收一个Shape对象列表,循环调用area()并汇总总面积。这个练习的核心是多态——你不需要在函数里判断对象是矩形还是圆形,只需调用area()即可。
我个人建议,如果你已经能独立完成前两个练习,面向对象的基础就算打牢了。第三个练习更偏多态和设计模式,可以作为进阶训练。把这些练习做完,你再去读开源项目的源码,会发现很多代码结构已经能看懂了,那种感觉比看完十篇教程都实在。
7.4 关于Python版本和开发环境的一些碎碎念
既然你在学Python,我顺便提一嘴环境的事。建议你安装Python 3.10以上的版本,因为新版本的语法特性、异常处理提示都更友好。写代码时用VS Code配Python扩展,或者直接用PyCharm都可以。我自己早期是用PyCharm入门,后期更多用VS Code配合命令行跑脚本,两个工具没有绝对的好坏,顺手就行。如果你在安装Python或配置调试环境时碰到过python was not found这类问题,多数情况是环境变量没有配置好,或者是Windows商店的Python占位符在捣乱,去Python官网下载安装包时务必勾选“Add Python to PATH”,可以省掉很多麻烦。
还有一个细节是模块和包的组织。面向对象项目里,一个类通常放在一个Python文件中,相关的一组类放到同一个包目录下。比如contact.py放Contact和VIPContact,address_book.py放AddressBook,然后你可以在主脚本里用from contact import Contact导入。这种组织方式既是面向对象思维的自然延伸,也是Python社区约定俗成的项目结构规范。