三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Python面向对象编程:从类与对象到封装继承多态

Python面向对象编程:从类与对象到封装继承多态

1. 从“写脚本”到“搭积木”:为什么你需要面向对象

如果你刚开始学Python,或者已经用Python写过一些自动化脚本、处理过一些数据,那你大概率已经习惯了“面向过程”的编程方式。简单来说,就是“第一步做什么,第二步做什么”,把解决问题的步骤一条条写下来。这很直观,处理简单任务时效率很高。但当你开始接触稍微复杂点的项目,比如要写一个图形界面应用、一个网络爬虫框架,或者一个游戏时,你会发现代码越来越长,变量越来越多,函数之间的关系越来越乱。改一个地方,可能莫名其妙地影响了另一个毫不相干的功能。这时候,你就需要换一种思维方式了——面向对象编程。

面向对象编程不是Python独有的,但它绝对是Python成为主流编程语言的核心支柱之一。你可以把它理解为一种更高级的“搭积木”方式。在面向过程里,你关注的是“步骤”;在面向对象里,你关注的是“事物”。你要做的,是先定义好各种“积木”(也就是“类”),每个积木有自己的形状(属性)和功能(方法),然后把这些积木组合起来,构建出复杂的程序世界。比如,你要开发一个“人狗大作战”的游戏,面向过程的思路可能是:先初始化人的位置和血量,再初始化狗的位置和血量,然后写一个战斗循环,在循环里判断距离、计算伤害……代码会揉成一团。而面向对象的思路是:我先定义一个“生物”类,它有“位置”、“血量”这些属性,有“移动”、“攻击”这些方法。然后,我让“人类”和“狗类”都继承“生物”类,人类可能再多一个“使用武器”的方法,狗类多一个“扑咬”的方法。最后,我创建几个人类对象和几个狗类对象,让他们自己去交互。代码结构清晰,职责分明,扩展新角色(比如加个“猫类”)也异常简单。

这就是面向对象的核心价值:管理复杂性。它通过封装、继承和多态这三个核心特性,让代码更容易理解、更容易维护、更容易复用。很多新手觉得面向对象“抽象”、“难懂”,其实是因为一开始就用错了学习材料,直接去啃“类”、“对象”、“实例化”、“多态”这些干巴巴的概念。我的建议是,忘掉这些术语,先从一个你迫切想解决的具体问题入手,比如你想优化一个爬虫,或者想给自己写一个管理个人财务的小工具,在解决实际问题的过程中,你自然会体会到面向对象的好处。接下来,我会带你从零开始,用最接地气的方式,把面向对象这套“积木”的玩法彻底讲透。

2. 类与对象:从蓝图到实体的跨越

理解面向对象,第一步必须彻底分清“类”和“对象”。这是很多人的第一个坎,跨过去就海阔天空。

2.1 “类”是蓝图,“对象”是产品

你可以把“类”想象成一张建筑设计蓝图。这张蓝图上详细规定了房子的结构:要有几间卧室、客厅多大、门窗怎么设计。但蓝图本身不是房子,你不能住进去。根据这张蓝图,你可以建造出无数栋实实在在的房子,这些房子就是“对象”。每一栋房子(对象)都符合蓝图(类)的规范,但它们彼此独立:你家在北京朝阳区,我家在上海浦东,门牌号、里面的家具、住的人都不一样。

在Python里,我们用class关键字来定义这张“蓝图”。举个例子,我们来定义一个最简单的“狗”类:

class Dog: # 初始化方法,相当于蓝图中对房子基本结构的定义 def __init__(self, name, breed): self.name = name # 属性:名字 self.breed = breed # 属性:品种 self.energy = 100 # 属性:精力值,给个默认值 # 方法:狗的行为之一,叫 def bark(self): print(f"{self.name} 在汪汪叫!") # 方法:狗的行为之二,跑 def run(self, distance): if self.energy >= distance * 10: self.energy -= distance * 10 print(f"{self.name} 跑了{distance}公里,还剩{self.energy}点精力。") else: print(f"{self.name} 太累了,跑不动了!")

看这段代码,Dog就是一个类,是我们的蓝图。__init__是一个特殊的方法,叫做构造方法或初始化方法。当你根据蓝图“建造”一只具体的狗时,就会自动调用这个方法。self是一个关键参数,它代表将来被创建出来的那个“具体的对象”。self.name = name这行代码的意思就是:“给这只具体的狗对象,赋予一个叫name的属性,其值等于传入的name参数。”

现在,我们来根据蓝图生产产品(创建对象):

# 创建第一只狗对象,名叫“阿黄”,品种是“中华田园犬” my_dog = Dog("阿黄", "中华田园犬") # 创建第二只狗对象,名叫“Lucky”,品种是“金毛” friends_dog = Dog("Lucky", "金毛寻回犬") print(my_dog.name) # 输出:阿黄 print(friends_dog.breed) # 输出:金毛寻回犬 my_dog.bark() # 输出:阿黄 在汪汪叫! friends_dog.run(5) # 输出:Lucky 跑了5公里,还剩50点精力。 my_dog.run(8) # 输出:阿黄 跑了8公里,还剩20点精力。 friends_dog.run(6) # 输出:Lucky 太累了,跑不动了!

看到了吗?my_dogfriends_dog是两个独立的对象,它们都源自Dog类这个蓝图,所以都有namebreedenergy属性和bark()run()方法。但它们各自的状态是独立的:阿黄跑完8公里后精力只剩20,这完全不影响Lucky的精力值。这就是“对象”的意义:它是拥有独立状态和行为的实体。

实操心得:初学时,最容易混淆的就是在类的方法内部访问属性。一定要记住,只要是这个对象自己的属性,在方法内部都必须通过self.属性名来访问和修改。比如在run方法里修改self.energy,修改的就是调用这个方法的那个具体对象的精力值。如果你写成了energy = distance * 10,那只是在方法内部创建了一个局部变量,完全没影响到对象的属性,bug就这么产生了。

2.2__init__self的深度解析

__init__self是理解类与对象关系的钥匙,值得多花点篇幅。

self是什么?self不是Python的关键字,你甚至可以用thisme或者其他任何名字代替它(但强烈不建议,这是约定俗成的规矩)。它本质上是一个指向对象自身的引用。当你在类的外部调用my_dog.bark()时,Python会自动把my_dog这个对象作为第一个参数传给bark方法,这个参数就是self。所以,def bark(self):里的self,在调用时就变成了my_dog。这样,在方法内部通过self.name就能知道是“哪只狗”在叫了。

__init__不是构造函数这是一个常见的误解。严格来说,Python中真正的“构造”对象的方法是__new__,它负责创建并返回一个新的对象实例。而__init__是在__new__之后被调用的,它的工作是初始化这个刚刚被创建出来的对象,给它的属性赋上初始值。对于绝大多数日常应用,你只需要理解和定义__init__就够了。

__init__里设置默认值__init__是统一设置对象初始状态的最佳位置。像上面例子中的self.energy = 100,我们给所有新创建的狗一个统一的初始精力值。你也可以在这里进行一些简单的逻辑判断。比如,如果我们不希望狗的名字是空字符串,可以这样做:

def __init__(self, name, breed): if not name: raise ValueError("狗必须有一个名字!") self.name = name self.breed = breed self.energy = 100

这样,如果有人试图创建Dog("", "哈士奇"),程序就会立刻报错,避免了后续出现“无名狗”的诡异情况。这是一种很好的数据校验和保证对象一致性的实践。

3. 面向对象的三大支柱:封装、继承与多态

理解了类和对象,我们就有了搭建程序世界的基本材料。但要让这个世界井然有序、易于扩展,还需要遵循三大设计原则:封装、继承和多态。这是面向对象编程的精髓。

3.1 封装:隐藏复杂,暴露简洁

封装的核心思想是“黑箱化”。你把一个东西的内部复杂实现细节包装起来,只对外提供一个清晰、简单的使用接口。用户不需要知道里面是怎么运作的,只需要知道怎么用就行了。在Python中,封装主要通过“访问控制”来实现。

没有真正的“私有”与Java、C++等语言不同,Python没有严格的“私有”(private)成员概念。它采用了一种叫做“名称改写”的约定俗成机制。如果你在一个属性或方法名前加上双下划线__,Python会在内部将其名称改写成_类名__属性名的形式,这在一定程度上起到了防止意外覆盖的作用,但并非无法访问。

class BankAccount: def __init__(self, owner, balance): self.owner = owner # 公开属性 self.__balance = balance # “私有”属性 def deposit(self, amount): if amount > 0: self.__balance += amount print(f"存入{amount},当前余额:{self.__balance}") else: print("存款金额必须为正数!") def get_balance(self): # 提供一个公开的方法来查询余额,而不是直接暴露 __balance return self.__balance # 使用 account = BankAccount("张三", 1000) print(account.owner) # 正常:张三 # print(account.__balance) # 报错:AttributeError print(account.get_balance()) # 正确:通过方法访问,输出 1000 account.deposit(500) # 输出:存入500,当前余额:1500 # Python的名称改写 print(account._BankAccount__balance) # 输出:1500 (依然可以访问,但不应该这么做)

在上面的例子中,我们把余额__balance“封装”了起来。外部代码不能直接account.balance = -1000这样随意修改,必须通过我们提供的depositget_balance方法来操作。这样,我们就在方法内部加入了校验逻辑(如存款必须为正数),保证了数据的安全性和一致性。

注意事项:Python社区更常见的做法是使用单下划线_来表示“这是内部属性,请勿直接访问”(这是一种弱提示)。双下划线__主要用于避免在子类中发生属性名冲突。对于绝大多数情况,使用单下划线_balance并配合清晰的文档说明,是更Pythonic(符合Python风格)的做法。封装的目的不是为了制造访问障碍,而是为了建立清晰的契约,告诉协作者:“请通过这些方法来操作,我来保证逻辑正确。”

3.2 继承:站在巨人的肩膀上

继承解决的是代码复用和层次关系问题。当你要定义一个新类,而这个新类与某个现有类大部分特性都相同时,就没必要从头重写。你可以让新类“继承”现有类,自动获得父类的所有属性和方法,然后只专注于定义自己独特的部分。

比如,我们有一个通用的“车辆”类,现在要定义“电动汽车”类。

class Vehicle: def __init__(self, brand, model): self.brand = brand self.model = model self.speed = 0 def accelerate(self, increment): self.speed += increment print(f"{self.brand} {self.model} 加速至 {self.speed} km/h") def brake(self, decrement): self.speed = max(0, self.speed - decrement) print(f"{self.brand} {self.model} 减速至 {self.speed} km/h") class ElectricCar(Vehicle): # 括号里写上父类名,表示继承 def __init__(self, brand, model, battery_capacity): # 1. 首先调用父类的初始化方法,完成基础属性的设置 super().__init__(brand, model) # 2. 然后初始化子类特有的属性 self.battery_capacity = battery_capacity self.battery_level = 100 # 子类可以添加父类没有的新方法 def charge(self, percentage): self.battery_level = min(100, self.battery_level + percentage) print(f"已充电至 {self.battery_level}%") # 子类也可以“重写”父类的方法,提供特殊实现 def accelerate(self, increment): # 电动车加速可能更耗电 if self.battery_level > 5: super().accelerate(increment) # 调用父类的加速逻辑 self.battery_level -= increment * 0.5 print(f"电量剩余 {self.battery_level}%") else: print("电量不足,无法加速!") # 使用 my_tesla = ElectricCar("Tesla", "Model 3", 75) print(my_tesla.brand, my_tesla.model) # 继承自Vehicle: Tesla Model 3 my_tesla.accelerate(30) # 重写后的方法: Tesla Model 3 加速至 30 km/h \n 电量剩余 85.0% my_tesla.brake(10) # 继承自Vehicle: Tesla Model 3 减速至 20 km/h my_tesla.charge(30) # 子类特有方法: 已充电至 100%

关键点解析:

  1. super().__init__():在子类的__init__中,必须首先调用super().__init__(...)来初始化从父类继承来的属性。这是黄金法则,忘记它会导致父类属性未初始化,引发各种奇怪错误。
  2. 方法重写:子类可以重新定义父类已有的方法(如accelerate)。在重写的方法中,你既可以用全新的逻辑完全覆盖,也可以像上面那样,先调用super().accelerate(...)执行父类的通用逻辑,再补充子类的特殊逻辑(如耗电计算)。
  3. is-a关系:继承应该用于表达“是一个”的关系。ElectricCar是一种Vehicle,这逻辑上是通的。不要为了单纯复用代码而滥用继承,比如让Dog继承Car,这会造成概念混乱。

继承让代码形成了清晰的层次结构,极大地提高了复用性。Python还支持多重继承(一个类有多个父类),但这把双刃剑非常锋利,设计不当会导致著名的“菱形继承”问题,对于初学者,建议先掌握单继承,在充分理解MRO(方法解析顺序)后再谨慎使用多重继承。

3.3 多态:同一接口,不同表现

多态是继承的孪生兄弟,它建立在继承之上。多态指的是:不同类型的对象,对同一消息(方法调用)做出不同的响应。这让我们可以用统一的接口处理不同类型的对象,而无需关心它们具体的类型。

让我们延续上面的例子,并增加一个GasolineCar类:

class GasolineCar(Vehicle): def __init__(self, brand, model, fuel_capacity): super().__init__(brand, model) self.fuel_capacity = fuel_capacity self.fuel_level = 100 def accelerate(self, increment): if self.fuel_level > 10: super().accelerate(increment) self.fuel_level -= increment * 0.8 print(f"燃油剩余 {self.fuel_level}%") else: print("燃油不足,无法加速!") def refuel(self): self.fuel_level = 100 print("油箱已加满!") # 多态的魔力 def test_drive(vehicle): """一个通用的试驾函数,它只关心传入的对象是个‘车’""" vehicle.accelerate(50) vehicle.brake(20) # 准备两辆不同类型的车 my_electric = ElectricCar("NIO", "ET7", 100) my_gasoline = GasolineCar("Toyota", "Camry", 60) # 用同一个函数去测试它们 print("--- 测试电动车 ---") test_drive(my_electric) # 调用的是 ElectricCar.accelerate print("\n--- 测试汽油车 ---") test_drive(my_gasoline) # 调用的是 GasolineCar.accelerate

运行这段代码,你会发现test_drive函数根本不知道它接收的vehicle具体是电动车还是汽油车。它只是调用了acceleratebrake方法。而具体执行电动车版本的加速(会耗电)还是汽油车版本的加速(会耗油),则由对象自己的类型决定。这就是多态:接口统一,实现各异

多态极大地提高了程序的灵活性和可扩展性。未来如果我们新增一个HybridCar(混合动力车),只要它继承了Vehicle并实现了acceleratebrake方法,test_drive函数就能直接用它来试驾,完全不需要修改。这使得我们的代码对“变化”关闭,对“扩展”开放,是设计高质量、可维护代码的关键。

4. 类属性、类方法与静态方法:超越实例的维度

到目前为止,我们讨论的属性和方法都是属于“对象”的。每个对象都有自己独立的一份。但有些时候,我们需要一些属于“类”本身的东西,或者一些与类和实例都无关,只是逻辑上放在类里的工具函数。这就是类属性、类方法和静态方法的用武之地。

4.1 类属性:所有对象共享的“全局”变量

类属性是定义在类内部,但在所有方法(包括__init__)之外定义的变量。它不属于任何一个实例,而是属于类本身,被所有该类的实例共享。

一个经典的例子是给某个类所有的实例进行编号:

class Player: # 类属性 player_count = 0 def __init__(self, name): self.name = name # 每创建一个玩家,类属性 player_count 就加1 Player.player_count += 1 self.id = Player.player_count # 用当前的计数作为该玩家的ID def display(self): print(f"玩家ID: {self.id}, 玩家名: {self.name}") # 创建玩家 p1 = Player("Alice") p2 = Player("Bob") p3 = Player("Charlie") p1.display() # 输出:玩家ID: 1, 玩家名: Alice p2.display() # 输出:玩家ID: 2, 玩家名: Bob p3.display() # 输出:玩家ID: 3, 玩家名: Charlie print(f"总共创建了 {Player.player_count} 名玩家。") # 输出:总共创建了 3 名玩家。 # 也可以通过实例访问(但不推荐,容易混淆) print(p1.player_count) # 输出:3

注意陷阱:可变类属性如果类属性是一个可变对象(如列表、字典),那么通过任何一个实例去修改它,都会影响到所有实例和类本身。

class Warehouse: inventory = [] # 类属性,一个空列表 def __init__(self, location): self.location = location def add_item(self, item): Warehouse.inventory.append(item) # 修改类属性 wh1 = Warehouse("北京") wh2 = Warehouse("上海") wh1.add_item("电脑") wh2.add_item("手机") print(Warehouse.inventory) # 输出:['电脑', '手机'] print(wh1.inventory) # 输出:['电脑', '手机'] print(wh2.inventory) # 输出:['电脑', '手机'] # 所有“仓库”共享同一个库存列表!

这通常不是你想要的。对于这种情况,更常见的做法是在__init__中初始化一个实例属性self.inventory = [],让每个仓库有自己的库存列表。类属性更适合用于存储一些真正的、全局的、不可变的配置或常量。

4.2 类方法与静态方法:不依赖于实例的操作

类方法:第一个参数是cls,代表类本身。类方法可以访问和修改类属性,但不能访问实例属性(因为它没有self)。使用@classmethod装饰器定义。静态方法:既不需要self也不需要cls。它就像一个普通的函数,只是由于逻辑上的关联性而被放在类里面。使用@staticmethod装饰器定义。

class DateUtils: # 一个假设的日期格式 DATE_FORMAT = "%Y-%m-%d" def __init__(self, date_str): self.date = datetime.strptime(date_str, self.DATE_FORMAT) @classmethod def from_timestamp(cls, timestamp): """类方法:从一个时间戳创建 DateUtils 对象。 这是一个‘替代构造函数’的经典用法。""" date_obj = datetime.fromtimestamp(timestamp) date_str = date_obj.strftime(cls.DATE_FORMAT) # 使用 cls 访问类属性 return cls(date_str) # 等价于 DateUtils(date_str) @staticmethod def is_leap_year(year): """静态方法:判断是否为闰年。 这个逻辑与具体的 DateUtils 实例无关,只是一个工具函数。""" return (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0) def display(self): print(self.date.strftime(self.DATE_FORMAT)) # 使用 # 1. 普通实例化 du1 = DateUtils("2023-10-27") du1.display() # 输出:2023-10-27 # 2. 使用类方法作为替代构造函数 du2 = DateUtils.from_timestamp(1698336000) # 时间戳对应 2023-10-27 du2.display() # 输出:2023-10-27 # 3. 使用静态方法,无需创建实例 print(DateUtils.is_leap_year(2024)) # 输出:True print(DateUtils.is_leap_year(2023)) # 输出:False # 也可以通过实例调用,但不推荐,容易引起误解 print(du1.is_leap_year(2020)) # 输出:True

如何选择?

  • 需要访问或修改类状态(类属性)时,用类方法from_timestamp需要知道DATE_FORMAT这个类属性,并且最终要调用cls(...)来创建实例,所以它是类方法。
  • 逻辑上与类相关,但既不访问实例属性也不访问类属性时,用静态方法is_leap_year只对输入参数做计算,和DateUtils的任何属性都无关,所以它是静态方法。把它放在类里,只是为了代码组织更清晰,表明它是日期相关的工具函数。
  • 普通实例方法:需要访问或修改实例属性(self.xxx)时使用。

正确使用类方法和静态方法,能让你的类设计更加清晰和灵活。特别是类方法作为“工厂方法”来提供不同的对象创建方式,是非常实用的设计模式。

5. 特殊方法(魔术方法):让对象更“Pythonic”

Python 的类有一大批以双下划线开头和结尾的特殊方法,如__init____str____len__等。它们被称为“魔术方法”或“双下方法”。Python 解释器会在特定的时机自动调用它们。实现这些方法,可以让你的自定义对象表现得像 Python 的内置类型一样自然、优雅。

5.1 对象表示:__str____repr__

这是最常用的一对魔术方法,用于定义对象的字符串表示形式。

  • __str__:面向用户,追求可读性。当你使用print(obj)str(obj)时被调用。
  • __repr__:面向开发者,追求明确性。理想情况下,eval(repr(obj))应该能重新创建该对象。在交互式环境直接输入对象名时,以及作为容器(如列表)的元素被打印时,会调用__repr__
class Point: def __init__(self, x, y): self.x = x self.y = y def __str__(self): return f"Point({self.x}, {self.y})" def __repr__(self): return f"Point(x={self.x}, y={self.y})" p = Point(3, 4) print(p) # 调用 __str__: 输出 Point(3, 4) print(str(p)) # 调用 __str__: 输出 Point(3, 4) print(repr(p)) # 调用 __repr__: 输出 Point(x=3, y=4) # 在列表中,打印的是 __repr__ points = [Point(1, 2), Point(3, 4)] print(points) # 输出:[Point(x=1, y=2), Point(x=3, y=4)]

一个好的实践是:至少实现__repr__。如果__str__未定义,Python 会使用__repr__作为备选。

5.2 让对象可调用:__call__

实现了__call__方法的对象,可以像函数一样被“调用”。这常用于创建有状态的函数,或者实现装饰器类。

class Counter: def __init__(self): self.count = 0 def __call__(self): self.count += 1 print(f"已被调用 {self.count} 次") c = Counter() c() # 输出:已被调用 1 次 c() # 输出:已被调用 2 次 c() # 输出:已被调用 3 次 # 对象 c 记住了状态(count)

5.3 让对象支持算术运算

通过实现__add__,__sub__,__mul__等方法,可以让你的对象支持+,-,*等运算符。

class Vector: def __init__(self, x, y): self.x = x self.y = y def __add__(self, other): """定义 + 运算""" if isinstance(other, Vector): return Vector(self.x + other.x, self.y + other.y) else: raise TypeError("只能与 Vector 类型相加") def __mul__(self, scalar): """定义 * 运算(数乘)""" if isinstance(scalar, (int, float)): return Vector(self.x * scalar, self.y * scalar) else: raise TypeError("只能与数字相乘") def __str__(self): return f"Vector({self.x}, {self.y})" v1 = Vector(2, 3) v2 = Vector(4, 5) v3 = v1 + v2 # 调用 v1.__add__(v2) print(v3) # 输出:Vector(6, 8) v4 = v1 * 3 # 调用 v1.__mul__(3) print(v4) # 输出:Vector(6, 9)

5.4 让对象可迭代、可作为容器

实现__len____getitem__可以让你的对象表现得像列表或字典。

class Playlist: def __init__(self): self.songs = [] def add_song(self, song): self.songs.append(song) def __len__(self): """当调用 len(obj) 时触发""" return len(self.songs) def __getitem__(self, index): """当使用 obj[index] 或 for song in obj 时触发""" return self.songs[index] def __str__(self): return "\n".join(f"{i+1}. {song}" for i, song in enumerate(self.songs)) my_playlist = Playlist() my_playlist.add_song("晴天") my_playlist.add_song("七里香") my_playlist.add_song("夜曲") print(len(my_playlist)) # 输出:3,调用 __len__ print(my_playlist[1]) # 输出:七里香,调用 __getitem__ print("---") for song in my_playlist: # 支持迭代,因为实现了 __getitem__ print(song)

魔术方法还有很多,比如__eq__用于定义==__lt__用于定义<__enter__/__exit__用于支持with语句(上下文管理器)。合理使用它们,能让你自定义的类与 Python 语言本身无缝融合,写出非常优雅和高效的代码。当你发现你的对象需要某种 Python 内置类型才有的行为时,第一反应就应该是去查查有没有对应的魔术方法可以实现。

← 返回列表