python面向对象编程(上)

python面向对象编程(上) 前言python语言在设计之初便遵循“一切皆对象”的理念所有类都隐式继承自object类因此任何已定义的对象包括None、内置类型实例、自定义类的实例、类本身、函数、模块等都是对象。这一点使python的语法极为优雅并且天然地支持OOP范式Object-Oriented Paradigm在python中学习“面向对象编程”比在任何其他语言中学习都更为合适。isinstance(67,object)# 数字是对象Trueisinstance(int,object)# 类也是对象Trueisinstance(object,object)# object本身也是object的实例True本文将以笔者最近在开发的一个项目Vatrix-vbe-sm为例为python入门者讲解类与对象的使用方法。笔者仍是准大一新生如有错漏不当之处还请指出。目标本系列“python面向对象编程”教程预计分为上下两篇在本篇上篇中我们将完成游戏植物大战僵尸一代原版中僵尸的行走逻辑由此一窥python语言中OOP范式的应用。文章目录前言目标类与对象什么是类什么是对象如何让我创建的僵尸对象带有具体的属性如何在实例化过程中传入特定的参数如何让我创建的僵尸对象能做一些事情比如移动更多的类用管理类封装游戏整体逻辑职责划分的设计结语参考答案类与对象什么是类什么是对象类可以视作一张蓝图而对象是根据这张蓝图制作出的产品根据类制作出对象的过程就是实例化。classZombie:# 定义类pass# 具体实现if__name____main__:# 标注程序入口zomZombie()# 实例化print(zom)# __main__.Zombie object at 0x000001CC19xxxxxx上例中我们通过class关键字定义了Zombie类Class然后通过Zombie()生成了一只僵尸对象Object或者说创建了一个僵尸类的实例Instance并把这只僵尸的控制权交给了zom变量。__main__表示这个文件是主程序入口。if __name__ __main__的作用是只有当你直接运行这个 .py 文件时里面的代码才会执行如果别人把这个文件当作模块导入里面的测试代码不会运行。if__name____main__:zombies[]foriinrange(50):zomZombie()zombies.append(zom)一个类可以可以创建多个实例就像拿着一份蓝图可以一直造东西一样。上例中我们生成了50个僵尸对象并存放在了zombies列表中。if__name____main__:numbers[]foriinrange(50):numint(i)numbers.append(num)类比上述两例zombies列表中有50个僵尸对象而numbers列表中有50个数字对象。如何让我创建的僵尸对象带有具体的属性刚刚造的僵尸是个空壳它没有血量、没有位置、没有速度。为了让每个僵尸对象自带“出生信息”我们需要利用__init__方法在每个对象被实例化出来之后对其进行初始化。classZombie:def__init__(self):self.row0# 所在行self.x11.12# x坐标self.velrandom.uniform(0.23,0.37)# 速度if__name____main__:zomZombie()print(zom.row,zom.x,zom.vel)在类的绘制过程中self用于代指其创建出的实例比如zom它能够确保这些属性数据关联到正确的实例上。因此以上代码等价于classZombie:passif__name____main__:zomZombie()zom.row0zom.x11.12zom.velrandom.uniform(0.23,0.37)print(zom.row,zom.x,zom.vel)纵然以上两例等效但非常不推荐使用第二种写法。代码书写时对象的任何属性都应尽可能在类的__init__方法中声明以免引发AttributeError。__init__是在对象被创建之后、返回给调用者之前python自动调用的初始化方法。它的第一个参数必须写selfself代表当前正在被初始化的实例对象通过self.属性名 值可以给这个实例添加属性。如何在实例化过程中传入特定的参数如果所有僵尸的属性都相同那游戏就太死板了。我们希望在造僵尸的时候能指定它出现在第几行、哪个位置。classZombie:def__init__(self,row,x,velNone):# 传参进入self.rowrow# 直接传入用户所给的值self.y100*row80# 可以根据传入的参数计算属性值self.xx# 直接传入用户所给的值self.velvelifvelisnotNoneelserandom.uniform(0.23,0.37)# 可选参数的初始化if__name____main__:zomZombie(row2,x11.12)# 传参进入print(zom.row,zom.y,zom.x,zom.vel)类初始化方法__init__在收到用户提供的参数后会依照预设逻辑计算初始属性值row,x将用户传入值直接记录到对象属性y依照用户输入的row值进行计算再存为对象属性vel如果用户没有输入那么随机一个0.23到0.37之间的小数否则直接使用用户输入的值velNone表示这个参数是可选的调用时可以不传。如果不传vel会被赋值为None然后我们用条件表达式(vel) if (vel is not None) else (...)来给它一个随机默认值。这是python中处理可选参数的常见写法。如何让我创建的僵尸对象能做一些事情比如移动deflet_zombie_move_a_tick(zom,backwardFalse):ifbackward:# 从左向右移动zom.xzom.velelse:# 默认从右向左移动zom.x-zom.velif__name____main__:zombies[Zombie(1,100),Zombie(2,120),Zombie(3,150)]# 所有僵尸移动一游戏刻forzominzombies:let_zombie_move_a_tick(zomzom,backwardFalse)上面的代码定义了一个普通函数let_zombie_move_a_tick它接受一个僵尸对象作为参数并根据backward标志更新其 x 坐标。这种方式虽然可行但存在一个明显的缺点函数与对象是分离的。如果我们有多个不同类型的对象如僵尸、植物、子弹都需要移动就得为每种对象编写类似的函数代码会变得冗长且不易维护。classZombie:def__init__(self,row,x,vel0.37):...# 同上defmove_a_tick(self,backwardFalse):ifbackward:self.xself.velelse:self.x-self.velif__name____main__:zombies[Zombie(1,100),Zombie(2,120),Zombie(3,150)]forzominzombies:zom.move_a_tick(backwardFalse)将移动逻辑封装为类的方法move_a_tick后每个僵尸对象都“知道”如何移动自己。调用时只需zom.move_a_tick(backwardFalse)代码更简洁、更符合面向对象的设计思想。此外方法内部的self会自动绑定到调用该方法的实例因此我们直接使用self.x和self.vel即可访问当前僵尸的属性。这种“数据与操作绑定在一起”的方式正是面向对象编程的核心优势之一。类中定义的方法其第一个参数固定为self调用时不需要手动传入Python 会自动将调用该方法的实例对象传进去。所以zom.move_a_tick()就相当于Zombie.move_a_tick(zom)self就是zom本身。更多的类用管理类封装游戏整体逻辑在编写稍复杂的程序时我们通常不只有一种类。对于“植物大战僵尸”这个例子除了表示单个僵尸的Zombie类我们还需要一个管理全局状态的类比如Game。它负责维护当前所有僵尸的列表记录游戏循环的刻数cycle每帧更新所有僵尸的状态检测游戏胜负条件。下面我们定义一个 Game 类将以上职责封装在一起classZombie:...# 同上classGame:def__init__(self):self.cycle0self.zombies[]self.winFalseself.loseFalsedefupdate(self):self.update_zombies()self.cycle1defupdate_zombies(self):...# TODOdefcheck_gameover(self)-bool:# 这里的 - bool 是个类型注解可以不写显式地写出来可以注明这个方法的返回值类型self.win|len(self.zombies)0self.lose|any(z.x-100forzinself.zombies)returnself.winorself.loseif__name____main__:gGame()g.zombies.extend([Zombie(1,100),Zombie(2,120),Zombie(3,150)])print(g.zombies,g.cycle)whilenotg.check_gameover()andg.cycle1000:g.update()print(g.zombies,g.cycle)此处的Game是一个抽象的管理器他不是具体的某一样东西的图纸而是整个游戏的总管。为了在开发时更好地适应这种思路我们可以把他看作一个单例即只会经历一次实例化。Game是一个容器、调度器而Zombie是实体、被调度者两者都是程序开发过程中需要用到类的常见场景。|是“或等于”运算符self.win | ...等价于self.win self.win or ...。any(条件 for z in 列表)用于判断列表中是否存在至少一个元素满足条件这里用来检查是否存在一个僵尸走到了屏幕左边x -100。职责划分的设计对比以下两种设计设计一将更新逻辑封装在Zombie内部Game只负责遍历调用。classZombie:defupdate(self):self.move_a_tick()...classGame:defupdate_zombies(self):forzinzombies:z.update()...设计二Zombie只提供原始操作如move_a_tick由Game决定何时、如何调用甚至组合多个操作。classZombie:...classGame:defupdate_zombies(self):forzinzombies:z.move_a_tick()...请读者充分思考在游戏设计语境下哪一种写法更优、更有利于未来的功能拓展。答案将在[文章结尾](# 参考答案)揭晓。结语到此我们已经通过一只僵尸的移动逻辑初步了解了 Python 中类与对象的基本用法。不过在结束之前我想聊一个比语法更值得关注的问题。在学习过程中你可能会经常听到“面向对象优于一切”“函数式编程更高级”“设计模式是万能解药”这类说法。于是你可能会下意识地把所有东西都塞进类里为一个根本用不上的“未来扩展”预先留下接口或者为了套用某个模式而把简单的逻辑拆得七零八碎。这其实就是过度设计。没有哪种范式天生就“正确”关键看两点当前的实际需求、你对未来变化的预判。如果某个部分将来大概率会频繁变动并且依赖外部信息那就把控制权放到更高层如果某个部分职责单一、变化稳定就让它自己管好自己。你甚至可以把两者结合起来——用 OOP 组织架构用函数式的方式处理数据列表这完全可行而且往往能让代码更清晰。设计模式和编程范式都是工具它们帮助我们思考和组织但不应该成为束缚。写代码的最终目的是解决问题而不是为了完美遵守某种教条。每当你准备新增一个类或引入一个模式时不妨先问自己一句这里真的需要它吗保持这种审慎的态度比掌握任何具体语法都更重要。下一篇我们会继续学习类的多态及魔法方法到那时你会发现这些强大的特性同样需要“用对地方”。期待和你一起继续探索。参考答案在实际的游戏开发中我更推荐设计二。原因在于Zombie是否需要移动、能不能前进往往取决于Game中的其他元素比如前方是否有Plant挡路或者是否被寒冰子弹减速。这些判断需要全局视野而Game恰好拥有所有对象的信息植物列表、子弹列表。如果采用设计一把update写在Zombie内部为了让僵尸能“感知”外部环境就不得不将Game的引用传入Zombie这会导致类与类之间产生双向耦合而这往往是屎山代码的开端属于过度设计。相反设计二将Zombie定位为一个纯粹的“dataclass”只负责保存属性并执行类似move_a_tick这种原子性的动作而把所有的决策与交互逻辑上移到Game中。这样即使未来添加再多种类的植物或子弹我们只需修改Game.update_zombies中的条件判断Zombie类本身可以始终保持稳定、无需改动真正实现了各功能的解耦。