python面向对象编程(上)
前言
python语言在设计之初便遵循“一切皆对象”的理念,所有类都隐式继承自object类,因此任何已定义的对象(包括None、内置类型实例、自定义类的实例、类本身、函数、模块等)都是对象。这一点使python的语法极为优雅,并且天然地支持OOP范式(Object-Oriented Paradigm),在python中学习“面向对象编程”比在任何其他语言中学习都更为合适。
>>>isinstance(67,object)# 数字是对象True>>>isinstance(int,object)# 类也是对象True>>>isinstance(object,object)# object本身也是object的实例True本文将以笔者最近在开发的一个项目Vatrix-vbe-sm为例,为python入门者讲解类与对象的使用方法。笔者仍是准大一新生,如有错漏不当之处,还请指出。
目标
本系列“python面向对象编程”教程预计分为上下两篇,在本篇(上篇)中,我们将完成游戏植物大战僵尸(一代原版)中僵尸的行走逻辑,由此一窥python语言中OOP范式的应用。
文章目录
- 前言
- 目标
- 类与对象
- 什么是类?什么是对象?
- 如何让我创建的僵尸对象带有具体的属性?
- 如何在实例化过程中传入特定的参数?
- 如何让我创建的僵尸对象能做一些事情?比如移动?
- 更多的类
- 用管理类封装游戏整体逻辑
- 职责划分的设计
- 结语
- 参考答案
类与对象
什么是类?什么是对象?
类可以视作一张蓝图,而对象是根据这张蓝图制作出的产品,根据类制作出对象的过程就是实例化。
classZombie:# 定义类pass# 具体实现if__name__=="__main__":# 标注程序入口zom=Zombie()# 实例化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):zom=Zombie()zombies.append(zom)一个类可以可以创建多个实例,就像拿着一份蓝图可以一直造东西一样。上例中,我们生成了50个僵尸对象,并存放在了zombies列表中。
if__name__=="__main__":numbers=[]foriinrange(50):num=int(i)numbers.append(num)类比上述两例,zombies列表中有50个僵尸对象,而numbers列表中有50个数字对象。
如何让我创建的僵尸对象带有具体的属性?
刚刚造的僵尸是个空壳,它没有血量、没有位置、没有速度。为了让每个僵尸对象自带“出生信息”,我们需要利用__init__方法,在每个对象被实例化出来之后,对其进行初始化。
classZombie:def__init__(self):self.row=0# 所在行self.x=11.12# x坐标self.vel=random.uniform(0.23,0.37)# 速度if__name__=="__main__":zom=Zombie()print(zom.row,zom.x,zom.vel)在类的绘制过程中,self用于代指其创建出的实例(比如zom),它能够确保这些属性数据关联到正确的实例上。因此以上代码等价于:
classZombie:passif__name__=="__main__":zom=Zombie()zom.row=0zom.x=11.12zom.vel=random.uniform(0.23,0.37)print(zom.row,zom.x,zom.vel)纵然以上两例等效,但非常不推荐使用第二种写法。代码书写时,对象的任何属性都应尽可能在类的__init__方法中声明,以免引发AttributeError。
__init__是在对象被创建之后、返回给调用者之前python自动调用的初始化方法。它的第一个参数必须写self,self代表当前正在被初始化的实例对象,通过self.属性名 = 值可以给这个实例添加属性。
如何在实例化过程中传入特定的参数?
如果所有僵尸的属性都相同,那游戏就太死板了。我们希望在造僵尸的时候,能指定它出现在第几行、哪个位置。
classZombie:def__init__(self,row,x,vel=None):# 传参进入self.row=row# 直接传入用户所给的值self.y=100*row+80# 可以根据传入的参数计算属性值self.x=x# 直接传入用户所给的值self.vel=velifvelisnotNoneelserandom.uniform(0.23,0.37)# 可选参数的初始化if__name__=="__main__":zom=Zombie(row=2,x=11.12)# 传参进入print(zom.row,zom.y,zom.x,zom.vel)类初始化方法__init__在收到用户提供的参数后,会依照预设逻辑计算初始属性值:
row,x:将用户传入值直接记录到对象属性y:依照用户输入的row值进行计算,再存为对象属性vel:如果用户没有输入,那么随机一个0.23到0.37之间的小数,否则直接使用用户输入的值
vel=None表示这个参数是可选的,调用时可以不传。如果不传,vel会被赋值为None,然后我们用条件表达式(vel) if (vel is not None) else (...)来给它一个随机默认值。这是python中处理可选参数的常见写法。
如何让我创建的僵尸对象能做一些事情?比如移动?
deflet_zombie_move_a_tick(zom,backward=False):ifbackward:# 从左向右移动zom.x+=zom.velelse:# 默认从右向左移动zom.x-=zom.velif__name__=="__main__":zombies=[Zombie(1,100),Zombie(2,120),Zombie(3,150)]# 所有僵尸移动一游戏刻forzominzombies:let_zombie_move_a_tick(zom=zom,backward=False)上面的代码定义了一个普通函数let_zombie_move_a_tick,它接受一个僵尸对象作为参数,并根据backward标志更新其 x 坐标。这种方式虽然可行,但存在一个明显的缺点:函数与对象是分离的。如果我们有多个不同类型的对象(如僵尸、植物、子弹),都需要移动,就得为每种对象编写类似的函数,代码会变得冗长且不易维护。
classZombie:def__init__(self,row,x,vel=0.37):...# 同上defmove_a_tick(self,backward=False):ifbackward:self.x+=self.velelse:self.x-=self.velif__name__=="__main__":zombies=[Zombie(1,100),Zombie(2,120),Zombie(3,150)]forzominzombies:zom.move_a_tick(backward=False)将移动逻辑封装为类的方法move_a_tick后,每个僵尸对象都“知道”如何移动自己。调用时只需zom.move_a_tick(backward=False),代码更简洁、更符合面向对象的设计思想。此外,方法内部的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.cycle=0self.zombies=[]self.win=Falseself.lose=Falsedefupdate(self):self.update_zombies()self.cycle+=1defupdate_zombies(self):...# TODOdefcheck_gameover(self)->bool:# 这里的 -> bool 是个类型注解,可以不写,显式地写出来可以注明这个方法的返回值类型self.win|=len(self.zombies)==0self.lose|=any(z.x<-100forzinself.zombies)returnself.winorself.loseif__name__=="__main__":g=Game()g.zombies.extend([Zombie(1,100),Zombie(2,120),Zombie(3,150)])print(g.zombies,g.cycle)whilenotg.check_gameover()andg.cycle<1000: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类本身可以始终保持稳定、无需改动,真正实现了各功能的解耦。
