函数让代码复用,模块和包以物理方式拆分文件让结构更清晰,但这还不够,我们的Python工具箱中还有一个重要的工具来展示给大家,那就是面向对象编程。
我们先看一个简单的例子,再慢慢细说面向对象编程相关的内容。比如我现在想实现一个计算学生总分、平均分、等级评定的功能,每个学生有语文、数学、英语三门课程成绩,代码是这么写的:
def calc_total(student: dict) -> int: """ 计算总分 :param student: 学生信息 :type student: dict :return: 总分 :rtype: int """ scores = student["scores"] return sum(scores)def calc_average(student: dict) -> float: """ 计算平均分 :param student: 学生信息 :type student: dict :return: 平均分 :rtype: int """ total = calc_total(student) return total / len(student["scores"])def get_grade(student: dict) -> str: """ 判断等级 :param student: 学生信息 :type student: dict :return: 等级 :rtype: str """ total = calc_total(student) if total >= 270: return "优秀" elif total >= 240: return "良好" elif total >= 180: return "及格" else: return "不及格"if __name__ == "__main__": stu1 = { "name": "张三", "scores": [85,70,91] } total_score = calc_total(stu1) avg_score = calc_average(stu1) grade = get_grade(stu1) print(f'总分:{total_score},平均分:{avg_score},等级:{grade}')上面的代码虽然实现了我想要的功能,但是你可能会发现里面的学生信息(stu1)需要在函数间反复传参,你可能想到了使用全局变量,但全局变量容易管理失控,因为任何函数都可以随意的修改它,或者你想到了闭包,这种简单的场景固然可以得到处理,但是如果后续再扩展功能也将变得不那么得心应手。
其实我们还有更好的方式来解决这个问题,那就是本章我们要学习的面向对象编程。对于这个例子,按照面向对象编程的方式可以这么实现:
class Student: """ 学生类,封装学生信息和成绩计算方法 """ def __init__(self, name: str, scores: list[int]) -> None: """ 初始化学生对象 :param name: 姓名 :type name: str :param scores: 分数 :type scores: list[int] """ self.name = name self.scores = scores def calc_total(self) -> int: """ 计算总分 :return: 总分 :rtype: int """ return sum(self.scores) def calc_average(self) -> float: """ 计算平均分 :return: 平均分 :rtype: float """ total = self.calc_total() return total / len(self.scores) def get_grade(self) -> str: """ 判断等级 :return: 等级 :rtype: str """ total = self.calc_total() if total >= 270: return "优秀" elif total >= 240: return "良好" elif total >= 180: return "及格" else: return "不及格"if __name__ == "__main__": stu1 = Student("张三", [85, 70, 91]) total_score = stu1.calc_total() avg_score = stu1.calc_average() grade = stu1.get_grade() print(f'总分:{total_score},平均分:{avg_score},等级:{grade}')上述的代码中有我们熟悉的部分,比如函数,也有我们在前几章没有聊到过的内容,比如class,self等等,这些都是面向对象相关的知识,也是接下来我们要聊的内容。
类和对象是面向对象编程中重要的概念。类是一个抽象的模板,它定义了一类事物相同的特征(属性)和行为(方法)。对象则是类的实例,通过类来创建具体的实体。类是抽象的,对象则是具体的;类是静态的,对象则是动态的。如果将类比作是建筑的图纸,那么对象就是建造出来的房子。亦或者将类比作汽车设计图,那么对象就是具体的汽车。
我们用class关键字来定义类,在上面的例子中我们定义了Student类,通过stu1 = Student("张三", [85, 70, 91])这行代码我们创建了一个对象,这个过程也叫实例化,创建的对象也叫实例。
一个类可以创建多个对象(实例),比如我们可以再创建一个新的对象stu2,stu2 = Student("李四", [75, 82, 89])。
我们在Student类中定义了一些函数,比如calc_total、calc_average,这些函数称之为方法。都是函数,为什么到这里又叫方法了?
其实方法和函数在Python中还是有些区别的,定义在类中的函数,它本质上是绑定了类/对象,称之为方法,而函数我们在前面的章节中就介绍过,它是不依赖类的,它在模块中是独立的、可复用的代码块,可以直接调用。简单来说,方法是特殊的函数,但函数不一定是方法。
__init__方法你可能已经注意到了我们在上面写的Student类中有一个__init__方法,这里的init是initialization的缩写,也就是初始化的意思。需要注意的是它并不是创建对象的函数,而是在创建对象后初始化(赋值)属性。而且它是创建对象时自动执行的,我们在执行stu1 = Student("张三", [85, 70, 91])这行代码时它就会被自动执行。这个方法也可以省略,如果我们将Student类中的这个方法省略,那么我们在调用方法时就要提前赋值,需要这么写:
stu1 = Student()stu1.name = "张三"stu1.scores = [85, 70, 91]然后再去调用其他的函数就可以了,如果你在调用之前忘记了赋值,执行程序时就会出现AttributeError的异常提示。所以还是建议你在编写类的时候就提供__init__方法,这样做是有好处的,首先可以强制你初始化,这就保证了对象一旦被创建就有了完整的数据,也可以进行数据校验。其次还可以避免你手动为每个属性赋值,这就要多写好几行代码。
__init__方法是函数,自然也具备函数的特性,比如可以为参数设置默认值。
在__init__方法中还有一个参数是self,这也是面向对象中的方法的特殊之处,它是第一个参数,在其他实例方法中也是第一个参数,它代表的是当前对象本身,简单来说,谁调用,self就是谁。
在方法调用时,self是不需要传递的,因为Python会自动把当前对象传递给self。self这个命名并不是强制的,你可以写成其他的,比如this,但不建议这么做,因为一般都这么写,也算是约定了。
我们写的Student类中的方法都叫实例方法,它有一个典型的特点,就是方法中的第一个参数都是self,它通过实例.方法名(参数)的方式来调用,实例方法是面向对象编程中最常见和最基本的方法,除此之外,还有类方法和静态方法。
类方法是用装饰器@classmethod修饰的方法,它的第一个参数必须是cls(也是属于约定命名,可以修改但不建议),cls代表的是类本身。
如果我们用类方法的方式来为Student增加一个创建学生对象方法,可以这么写:
@classmethoddef create(cls, name: str, scores: list[int]) -> Self: return cls(name, scores)上面这段代码中有一个返回类型标注为Self,这个是Python 3.11版本引入的类型注解,用于表示当前类的实例类型,它需要通过from typing import Self导入。
类方法的使用与实例方法使用不同,它不需要通过实例来调用,直接通过类名.类方法名(参数)就可以调用,是这样使用的:
stu2 = Student.create("李四", [92, 92, 86])total_score = stu2.calc_total()还有一种方法是静态方法,它用装饰器@staticmethod修饰,没有默认的第一个参数(self或cls),它和普通函数看起来没什么区别,只是它归属在类的命名空间下。
如果我们在Student类中实现一个静态方法可以这么写:
@staticmethoddef is_adult(age: int) -> bool: """判断是否为成年人""" return age >= 18静态方法调用比较自由,既可以用实例.静态方法名(参数)调用,也可以用类名.静态方法名(参数)来直接调用,推荐使用类名的方式调用,使用方法如下:
stu3 = Student("王五", [80, 80, 80])print(Student.is_adult(17)) # Falseprint(stu3.is_adult(19)) # True不同的方法一般要应对不同的问题,那么这三类方法各自的应用场景是什么呢?
如果你需要操作实例的属性,而且属性的修改的只影响一个实例,或者基于实例的具体状态做逻辑处理,则选用实例方法,这也是多数情况下使用的方法。
如果你需要操作类的共享状态或者创建实例,则选用类方法。这里解释一下类的共享状态,更直观点理解是指类的属性,它与对象的属性的不同之处在于,它直接定义在类中,不需要绑定在实例上,比如Student对象中的name就是属于实例属性,类属性是所有实例都会共享的属性,所以可以用在数据库连接池的管理、配置加载等,而创建实例就像上面Student的create方法,它可以提供不同参数要求的多个方法来创建实例,这是一种典型的工厂方法。在标准库中,datetime类就用了很多的类方法来创建不同格式的datetime实例,还有pathlib.Path类等等。
如果你既不需要操作实例的属性,也不需要操作类的属性,无状态依赖,则可以选用静态方法,它典型的应用就是工具类方法、数据校验等。
我们在前面已经多次提到过属性,属性就是类或对象中的变量。它主要分为两类,一类是类属性,属于类本身,类属性会被这个类的所有实例共享。一类是实例属性,实例属性属于具体的对象,独立拥有。
我们来看一个例子:
class Student: # 类属性,所有Student实例共享 school = "家里蹲大学" def __init__(self, name: str, scores: list[int]) -> None: self.name = name # 实例属性 self.scores = scores # 实例属性在这个例子中,school是类属性,name和scores是实例属性。
类属性访问通过类名.属性访问,当然也可以通过实例来访问但不推荐,实例属性通过实例.属性访问,示例如下:
print(Student.school)stu1 = Student("张三", [80, 80, 80])print(stu1.name)print(stu1.school) # 能访问,但不推荐stu2 = Student("李四", [80, 80, 80])print(stu2.name)print(stu2.school) # 能访问,但不推荐执行上面的代码你会发现两个实例输出的school的值是一样的,也就验证了类属性是共享的。类属性是不能通过实例来修改的,比如我现在想通过实例俩修改school这个类属性:
stu1 = Student("张三", [80, 80, 80])stu1.school = "社会学院"print(Student.school)print(stu1.school)你会发现类属性school还是原来的值,而通过stu1实例来修改school属性实际上是为这个实例添加了一个同名的属性,并没有改变类属性的值。
Python中没有像其他编程语言中有public、protected、private之类的关键字来控制属性的访问,它默认的属性(如Student中的self.name)就是公开属性(public),是可以直接被外界访问和修改的,如下示例:
stu1 = Student("张三", [80, 80, 80])print(stu1.name) # 输出:张三stu1.name = "李四"print(stu1.name) # 输出:李四如果我们想将某个属性设置为被保护的属性,也就是不建议外部直接访问,有一个约定的方式,是以单下划线开头来命名属性,如:_age,但这只是一种约定,实际上还可以被访问,示例如下:
class Student: def __init__(self, name: str, age: int) -> None: self.name = name self._age = agestu1 = Student("张三", 16)print(stu1._age) # 输出:16如果你在VSCode中使用了代码分析插件,会提示你_age是一个受保护的成员变量,但是并不影响程序的执行。
在私有属性方面,Python是有一定控制的,它通过双下划线来定义,如__age,这样的属性不可以被外部访问,如果访问则会出现AttributeError错误提示。
我们来看一个例子:
class Student: def __init__(self, name: str, age: int) -> None: self.name = name self.__age = agestu1 = Student("张三", 16)print(stu1.__age) # 会出现AttributeError执行上面这段代码就会出现AttributeError提示。
我们在聊属性访问控制的时候,说到了被保护属性,这种属性虽然约定不可直接被外部访问,但有时候需要提供访问的方式,我们就可以用Python中@property属性装饰器的方法来处理这个问题,可以这么来实现:
class Student: def __init__(self, name: str, age: int) -> None: self.name = name self._age = age @property def age(self) -> int: """获取age""" return self._agestu1 = Student("张三", 16)print(stu1.age) # 输出:16我们提供了一个age方法用来访问属性,在这个方法上添加了@property装饰器,你会发现在访问时其实是直接当做属性调用了而不是方法,这也正是属性装饰器的能力,它可以将方法伪装成属性,而且它限制了属性的修改,此时如果你想修改age属性,比如执行这样一行代码stu1.age = 18,则会出AttributeError提示。如果需要修改age属性则需要提供一个修改方法,完整的代码是这样的:
class Student: def __init__(self, name: str, age: int) -> None: self.name = name self._age = age @property def age(self) -> int: """获取age""" return self._age @age.setter def age(self, value: int) -> None: """设置age""" if not isinstance(value ,int) or value < 0 or value > 150: raise ValueError("年龄必须是0-150之间的整数") self._age = valuestu1 = Student("张三", 16)print(stu1.age) # 输出:16stu1.age = 18print(stu1.age) # 输出:18我们增加了一个新的age方法用于设置age属性,而且在这个方法上还需要增加一个装饰器,它的写法是属性.setter,在这里我们的属性就是age,它和方法名一致。这样我们就可以修改age属性了,一个获取属性的方法,一个修改属性的方法,这是替代了传统的属性的getter和setter方法。但这种方式并不能避免你直接访问和修改_age属性,因为这里依然是约定外部不可以直接访问被保护的属性,@property是提供了一种统一、可控、易维护的属性访问入口,它是引导开发者遵循规范。
除了setter还有一个deleter装饰器,用法为属性名.deleter,和setter用法相似。比如在Student类中在增加一个方法:
@age.deleterdef age(self): del self._age# 调用方法del stu1.age # 之后再访问age就会出现AttributeErrordeleter装饰器日常使用较少,但也有一些特定的使用场景,比如资源清理与管理。
通过以上的一些内容,我们来总结一下属性装饰器的功能,一个是可以将方法伪装成属性,二是可以控制属性的赋值和删除,三是可以用于数据校验,在设置age属性的方法中已经有所体现了,它还有动态计算属性(如果用到了其他属性,它会随着其他属性的变化而变化,而不是固定的),隐藏内部实现的作用。
我觉得在学习面向对象编程的过程中有几个容易混淆的概念需要了解。一个是属性,指的是类或对象中的成员变量。一个是数据,指的是属性的值,宽泛点讲,可以指对象中存储所有的信息。一个是状态,状态是一个动态的概念,它指的是某一时刻属性的值。
说到面向对象编程就不得不提它的三个特性:封装、继承和多态。
封装是将数据和操作数据的方法绑定,隐藏内部细节,只暴露必要的接口。这样做可以防止外部代码随意修改对象的内部状态,提高代码的安全性和可维护性。
继承是一个类(子类)可以继承另一个类(父类)的属性和方法,它建立了类之间的层次关系,可以减少重复代码,实现代码复用。
多态是同一个方法名在不同的对象上调用会产生不同的行为,也就是同一个接口,不同的实现,多态的前提是继承和方法重写。
聊了基本的概念,下面我们来通过一些例子来说明,这不就不得掏出面向对象中经典的Animal对象了,代码如下:
class Animal: def __init__(self, name: str, age: int) -> None: self.name = name self.__age = age @property def age(self) -> int: """获取年龄""" return self.__age def make_sound(self) -> None: """动物叫声""" print(f'{self.name}发出了叫声')上面例子中的__age属性就体现了封装的特性,它作为私有属性,它只有在初始化的时候可以设置值,外部不能直接访问,我们提供了一个age方法可以用来访问__age属性。
Animal是一个比较宽泛的分类,现实生活中有很多的动物,比如有狗(Dog),有猫(Cat)等,它们有动物一些共有的特性,我们在实现Dog、Cat类时就可以继承Animal类从而减少冗余的代码,而且可以重写父类Animal中的方法实现自己特有的行为,我们在上面的代码中增加一些新的代码,如下:
class Dog(Animal): def make_sound(self) -> None: print(f'{self.name}汪汪叫')class Cat(Animal): def make_sound(self) -> None: print(f'{self.name}喵喵叫')if __name__ == "__main__": dog = Dog("旺财", 2) dog.make_sound() print(dog.age) cat = Cat("胖橘", 3) cat.make_sound() print(cat.age)通过继承我们可以直接使用父类的属性和方法,也可以重写父类中的方法(make_sound)实现自己特有的行为。
继承类的语法是class 子类(父类1,父类2,...),这也意味着Python是支持多继承的,但在实际开发中通常我们应该谨慎使用多继承,虽然强大,但也容易导致代码混乱,不易维护。
说到多继承,有一个经典菱形继承问题我们可以简单聊聊,如果有两个类B和C,同时继承了类A,而B和C又被类D继承,就形成了一个菱形,类似这样:
A / \/ \B C\ / \ / D我们来写一个简单的例子,你就会发现其中的问题:
class A: def __init__(self) -> None: print("A init")class B(A): def __init__(self) -> None: A.__init__(self) print("B init")class C(A): def __init__(self) -> None: A.__init__(self) print("C init")class D(B, C): def __init__(self) -> None: B.__init__(self) C.__init__(self) print("D init")if __name__ == "__main__": d = D()在实现一些功能的时候,我们可能需要先调用父类的初始化方法,然后再执行自己的初始化内容,上面的代码运行之后,会有如下的结果:
A initB initA init # 重复执行了C initD init从执行结果中你会发现类A的初始化被执行了两次,你可能觉得初始化执行两次好像也没什么,这是因为我们的例子简化了问题的场景,重复打印两次倒也没什么问题,但在实际的开发场景中,重复初始化可能会导致数据错误,问题还是比较严重的。对于这个问题,Python的解决方案是使用super(),我们可以这么写:
class A: def __init__(self) -> None: print("A init")class B(A): def __init__(self) -> None: super().__init__() # 使用super() print("B init")class C(A): def __init__(self) -> None: super().__init__() # 使用super() print("C init")class D(B, C): def __init__(self) -> None: super().__init__() # 使用super() print("D init")if __name__ == "__main__": d = D()运行上面的代码你会发现没有重复的初始化内容了,super()方法不仅解决了菱形继承的问题,还避免了初始化时的硬编码,因为之前的初始化代码都是直接用类名调用的,都写死了。
super()方法的核心作用是调用父类方法,在单继承和多继承中使用方法是相同的,只是在多继承中,它不是简单的调用父类中的方法,而是遵循方法解析顺序(Method Resolution Order,简称MRO)调用下一个类的方法。简单来说,MRO给这些有继承关系的类进行了排队,在这个队列中规定了先访问谁再访问谁,而且确保只访问一次,我们可以通过类的__mro__属性来查看这个顺序,比如打印上面类D的MRO可以执行这样的代码print(D.__mro__)。
虽然super()方法可以帮助我们解决菱形继承问题,但依然需要谨慎使用多继承,因为使用多继承本身就意味着复杂度提升了,而且目前也有更好的方式来替代多继承,比如组合,我们在后面会聊到这个方案。
我们继续回到Animal这个类来聊多态这个特性,来看一段代码:
class Animal: def __init__(self, name: str, age: int) -> None: self.name = name self.__age = age @property def age(self) -> int: """获取年龄""" return self.__age def make_sound(self) -> None: """动物叫声""" print(f'{self.name}发出了叫声')class Dog(Animal): def make_sound(self) -> None: print(f'{self.name}汪汪叫')class Cat(Animal): def make_sound(self) -> None: print(f'{self.name}喵喵叫')def animal_sound(animal: Animal) -> None: """动物的叫声,统一的执行方法""" animal.make_sound()if __name__ == "__main__": dog = Dog("旺财", 2) animal_sound(dog) cat = Cat("胖橘", 3) animal_sound(cat)这段代码比之前增加了一个animal_sound函数,这个函数不属于任何一个类,是模块中的函数。在__main__中调用时,我们直接将初始化的Dog和Cat实例传入即可,就会执行它们自己的叫声,这就是面向对象编程中的多态特性,同一个方法调用,不同的对象表现出的行为就是不同的。但Python中的多态表现还不止于此,我们再来增加点代码:
class Car: def make_sound(self) -> None: print("滴滴滴")class Phone: def make_sound(self) -> None: print("嗡嗡嗡")我们又写了两个类,Car和Phone,这两个类中也都有make_sound()方法,但是并没有继承Animal类,那如果我们也是用animal_sound()方法来调用会发生什么?
if __name__ == "__main__": car = Car() animal_sound(car) phone = Phone() animal_sound(phone)答案是animal_sound()方法依然会正常执行,是不是很神奇?
这就是Python中的“鸭子类型(Duck Typing)”:如果它走起来像鸭子,叫起来像鸭子,那么它就是鸭子。鸭子类型的优缺点都非常明显,优点是灵活性高、代码简洁、支持多态;最大的缺点是如果传入的对象没有所需的方法会出现运行时错误,还有就是可读性也比较差,不容易理解。在实践过程中,我们可以通过类型提示(给变量标注类型)和异常捕获(try...except)方式来帮助我们处理潜在的问题。
聊完面向对象编程的三大特性,有一个与之前相关的内容,我觉得也有必要说一下,那就是抽象类。在父类Animal中定义的make_sound()方法,我们的实现似乎没什么意义,但我又想让所有的子类都实现,因为这是一个都需要具备的方法,而且Animal作为一个父类,并没有被实例化的必要,它作为子类的一个实现的模板或者说骨架可能更合适,那么我们就可以用抽象类来帮助我们定义这个类。代码如下:
from abc import ABC,abstractmethod# 继承ABC类class Animal(ABC): def __init__(self, name: str, age: int) -> None: self.name = name self.__age = age @property def age(self) -> int: """获取年龄""" return self.__age # 使用装饰器 @abstractmethod def make_sound(self) -> None: """动物的叫声""" passclass Dog(Animal): def make_sound(self) -> None: print(f'{self.name}汪汪叫')class Cat(Animal): def make_sound(self) -> None: print(f'{self.name}喵喵叫')def animal_sound(animal: Animal) -> None: """动物的叫声,统一的执行方法""" animal.make_sound()if __name__ == "__main__": dog = Dog("旺财", 2) animal_sound(dog) a = Animal("动物", 1) # 会出现TypeError我们导入了abc模块中的ABC,abstractmethod,让Animal类继承ABC类,在make_sound()方法上使用@abstractmethod装饰器,这种方法被称为抽象方法,这样make_sound()就会强制子类必须实现这个方法,Animal类也不能再进行实例化了。
抽象类通过显示定义接口和强制实现的机制,解决了Python作为动态语言接口约束不足的问题。它的核心作用有两点,一是通过@abstractmethod装饰器声明子类必须实现的方法,统一了接口;二是实现代码复用,因为抽象类中不仅可以定义抽象方法,也可以定义普通的方法。当然需要注意的一点是抽象类不能进行实例化。
继承虽然是面向对象编程的三大特性之一,但在面向对象编程中流行着组合优于继承的设计原则。继承表达的是“is-a”(是一个)的关系,比如Cat is an Animal。组合表达的是“has-a”(拥有)的关系,比如Car has an Engine。我们来看一个经典的例子,代码如下:
class Bird: def __init__(self, name: str) -> None: self.name = name def fly(self) -> None: print(f"{self.name}正在飞") def eat(self) -> None: print(f"{self.name}正在吃食")class Swallow(Bird): passclass Mapie(Bird): passif __name__ == "__main__": s = Swallow("燕子") s.fly() m = Mapie("喜鹊") m.fly()我定义了一个Bird(鸟)类,然后有定义了两个子类Swallow(燕子)和Mapie(喜鹊),其中的pass是一个语法占位符,不执行代码逻辑,这是为了简化我们的场景,燕子和喜鹊都可以飞,这一切看起来都运行良好,随着需求的增加,有一天又来了一个鸟类——Penguin(企鹅),于是写下了这样的代码:
class Penguin(Bird): pass从这里开始就出现问题了,因为企鹅虽然也属于鸟类,具备鸟类共有的特性,但是企鹅不会飞啊!但它依然继承了会飞的能力,这显然是与我们的预期不符的,那我就不得不考虑在企鹅这个类中禁用这个方法,但是因为存在这个方法,如果有人调用就会出现错误,这样做还违背了里氏替换原则(在后面的部分会聊到),其实还有更好的方式可以实现,那就是使用组合,我们可以这么做:
class Bird: def __init__(self, name: str) -> None: self.name = name def eat(self) -> None: print(f"{self.name}正在吃食")class Flyable: def __init__(self, name: str) -> None: self.name = name def fly(self) -> None: print(f"{self.name}正在飞")class Swallow(Bird): def __init__(self, name: str) -> None: super().__init__(name) self.flyable = Flyable(name) def fly(self): self.flyable.fly()class Mapie(Bird): def __init__(self, name: str) -> None: super().__init__(name) self.flyable = Flyable(name) def fly(self): self.flyable.fly()class Penguin(Bird): passif __name__ == "__main__": s = Swallow("燕子") s.fly() s.eat() m = Mapie("喜鹊") m.fly() m.eat() p = Penguin("企鹅") p.eat()这与上面使用继承最大的不同就是我们将飞行抽象出一个单独的类,让鸟“拥有”飞行能力,将飞行能力解耦,让会飞的鸟拥有这种能力即可,不会飞的鸟就不需要拥有这种能力,就像搭建积木一样,将一个个独立的模块组合起来就能达到我们想要的结果。
继承属于强绑定,而组合是一种弱关联,更符合面向对象编程中高内聚、低耦合的思想,我们优先使用组合,但继承也不是一无是处,在类之间有稳定的“is-a”关系时还是应该使用继承。
我们在定义每个类的时候都会写一个__init__方法用于初始化数据,像这种以双下划线开头和结尾的方法被称为魔术方法,也叫特殊方法或dunder方法( Double Underscore Methods),魔术方法是Python解释器在特定时刻自动触发的,而非显示调用。
它的功能非常强大,让我们自定义的类表现的像Python的内置类型一样。魔术方法的覆盖范围也非常广,从对象的创建到销毁以及各类的操作(如运算符、属性访问、容器行为等)。像__init__这样的魔术方法在Python中有很多,下面我们列一下常用的几类魔术方法,如下表:
这里我只列出了一部分,如果想了解更多和更详细的内容可以查看官方文档(https://docs.python.org/3/reference/datamodel.html#special-method-names)。
魔术方法挺多的,只是列出来也是挺枯燥的,我们来看几个简单的例子,先以__str__为例,看一下代码,如下:
class Student: def __init__(self, name: str, age: int) -> None: self.name = name self.age = ageif __name__ == "__main__": stu1 = Student("张三", 18) print(stu1)执行上面这段代码会输出类似<__main__.Student object at 0x00000...>这样一段内容,这是默认的字符串表示,阅读很不友好,此时我们可以自定义__str__魔术方法,让输出更友好,在上面的Student类中增加下面一个方法:
def __str__(self) -> str: return f'Student(name={self.name},age={self.age})'再次执行代码就会发现输出这样的内容Student(name=张三,age=18),这就容易识别多了。
我们再来看一个__add__的例子。我现在想实现一个简单的向量相加计算的类,先来定义一个类:
class Vector: def __init__(self, x: float, y: float) -> None: self.x = x self.y = y目前有有两种方式可以实现,一个是在Vector这个类中实现一个add的方法,用来实现加法运算,可以增加如下方法:
def add(self, other: Self) -> Self: return Vector(self.x + other.x, self.y + other.y)def __str__(self) -> str: return f'Vector({self.x}, {self.y})'为了打印结果时便于阅读,我还重写了__str__方法,然后我们在Vector类中再写一些测试代码:
if __name__ == "__main__": v1 = Vector(1.0, 2.0) v2 = Vector(3.0, 4.0) v = v1.add(v2) print(v) # 输出:Vector(4.0, 6.0)直接定义一个add方法自然是可以实现向量的加法的,还有另一种方式就是增加__add__魔术方法,如果没有这个方法,我们直接使用+运算,是会出现TypeError提示的。所以我们需要增加__add__魔术方法以重载加法(+)运算,代码如下:
def __add__(self, other: Self) -> Self: return Vector(self.x + other.x, self.y + other.y) 这样我们就可以像做两个数相加一样来做向量的加法了:
if __name__ == "__main__": v1 = Vector(1.0, 2.0) v2 = Vector(3.0, 4.0) v = v1 + v2 print(v)增加了__add__魔术方法,就可以正常执行两个Vector实例+运算了,这样做我觉得比使用add方法更加的简洁、直观、符合直觉,而且它还支持运算符的链式调用,比如:v1 + v2 + v3。
由魔术方法,我想到了一个与之关联的特性,那就是数据类,这是Python 3.7引入的一特性,它可以帮助我们生成一些魔术方法,我们仍以上面的Vector类为例,使用数据类的方式可以这么写:
from dataclasses import dataclassfrom typing import Self@dataclassclass Vector: x: float y: float def __add__(self, other: Self) -> Self: return Vector(self.x + other.x, self.y + other.y)if __name__ == "__main__": v1 = Vector(1.0, 2.0) v2 = Vector(3.0, 4.0) v = v1 + v2 print(v)我们在Vector类上增加一个@dataclass装饰器,这个装饰器来自于dataclasses模块。之后我们就可以不用再手动写__init__方法就可以在直接传入参数进行对象实例化,其实就是因为我们将Vector定义为了数据类,它自动帮我们生成了__init__方法,而且还不仅仅只生成了__init__方法,还帮助我们生成了__repr__,__eq__方法。
如果你执行了上面的代码会发现虽然我们没有写__str__魔术方法,但在使用print方法时依然可以输出可读性比较好的字符串,这是因为Python解释器找不到__str__方法时会自动回退去调用__repr__方法,而__repr__方法数据类帮我们自动生成了。
还有一些魔术方法我们可以通过属性控制,比如增加order属性(即@dataclass(order=True))就可以自动帮我们生成__lt__,__le__, __gt__,__ge__方法,这就可以实现实例间的比较了。还可以设置frozen=True属性将类的实例变为不可变对象,设置slots=True属性来节省内存提高属性的访问速度。
如果你还想更加详细的了解数据类的相关内容,可以参看Data Classes(https://docs.python.org/3/library/dataclasses.html#)和PEP 557(https://peps.python.org/pep-0557/)两篇文档。
数据类虽然像普通类一样可以在其中定义方法,但不太建议写复杂的逻辑,类中有复杂逻辑的方法时,普通类反而更合适。数据类非常适合于用来存储数据,它的核心在于存数据,简单来说,只写属性,不写逻辑复杂的方法,比如说WEB中定义API响应的结果对象,配置参数,数据传输对象等场景就非常适合。
在Python中一切皆对象,每个对象都包含三个属性:
我们可以通过一些简单的例子来验证,代码如下:
# 基本类型num = 1print(f'id:{id(num)}, type:{type(num)}')# 字符串s = 'Python'print(f'id:{id(s)}, type:{type(s)}')# 函数def add(a, b): return a + bprint(f'id:{id(add)}, type:{type(add)}')# 类class Student: def __init__(self) -> None: passstu = Student()print(f'id:{id(stu)}, type:{type(stu)}')print(f'id:{id(Student)}, type:{type(Student)}')执行上面的代码你会发现以上这些不同的类型数据都有对应的id和type。
理解了这一点,不仅能够让我们在实践中写出更加优雅和灵活的Pythonic代码,还有助于我们理解Python中高阶特性的本质。
我们在实际的开发中可能会遇到各种各样的问题,但有些问题实际上是反复出现过的,不仅自己现在遇到过,别人在过去也遇到过,对于这样反复出现的经典场景问题,有人就总结出了通用的解决方案,我们称之为设计模式。
设计模式分为三类,分别是创建型模式、结构型模式、行为型模式。
常用的创建型模式有:单例模式、工厂模式、建造者模式、原型模式。
常用的结构型模式有:装饰器模式、适配器模式、代理模式、组合模式、桥接模式。
常用的行为型模式有:观察者模式、策略模式、迭代器模式、模板方法模式、状态模式。
我们这里只是列出了一部分,经典的GoF设计模式有23种。总的来说,创建型模式聚焦怎么灵活创建对象,结构型模式聚焦怎么灵活组合对象,行为型模式聚焦对象怎么灵活交互。
下面我们以单例模式的实现为例来具体了解设计模式。
单例模式是一种比较经典和常见的设计模式,它的作用是确保一个类只有一个实例,并提供全局的访问点。它在配置管理(应用程序加载配置文件)、日志记录、数据库连接池、状态管理等场景中应用广泛,单例模式的实现方式也有很多种,我们来看几种比较经典的实现方式。
__new__方法class Singleton: _instance = None _initialized = False def __new__(cls): if not cls._instance: cls._instance = super().__new__(cls) return cls._instance def __init__(self) -> None: if not Singleton._initialized: Singleton._initialized = Trueif __name__ == "__main__": s1 = Singleton() s2 = Singleton() print(s1 == s2)上面的代码虽然调用了两次创建Singleton实例,但比较它们两个发现是一样的,这就是单例的特点。
这种实现单例的方式是用__new__方法,我们在魔术方法中有提到这个方法,它的作用是创建对象本身,返回的结果是一个创建好的空对象,而__init__方法则是为创建好的对象添加属性、赋值等初始化操作。而且我们也会发现__new__方法的参数是cls,它表明这是一个类方法,但无需加@classmethod装饰器,__init__方法的参数是self,表明是一个实例方法。
def singleton(cls): instances = {} def wrapper(*args, **kwargs): if cls not in instances: instances[cls] = cls(*args, **kwargs) return instances[cls] return wrapper@singletonclass SingletonCalss: def __init__(self, value) -> None: self.value = valueif __name__ == "__main__": s1 = SingletonCalss(1) s2 = SingletonCalss(2) print(f's1 value:{s1.value}, s2 value:{s2.value}') print(s1 == s2)装饰器实现单例模式主要通过维护一个字典,在类被实例化时检查是否已经存在实例。我们在这种实现方式中还特意支持了可以传入参数的形式,即便SingletonCalss有一个初始化参数,两次传入的参数不一样,第二次实例化时instances字典中已经有了实例就不会再进行第二次初始化了,也就保证了多个实例实际上是同一个。
class SingletonMeta(type): _instances = {} def __call__(cls, *args, **kwargs): if cls not in cls._instances: cls._instances[cls] = super().__call__(*args, **kwargs) return cls._instances[cls]class Singleton(metaclass=SingletonMeta): def __init__(self, value) -> None: self.value = valueif __name__ == "__main__": s1 = Singleton(1) s2 = Singleton(2) print(f's1 value:{s1.value}, s2 value:{s2.value}') print(s1 is s2)在解释上面这段代码之前,我们需要先解释一个概念:元类,元类是Python中的一个高级概念,我们已经讲过对象(实例)是由类创建的,类其实也是一个对象,它也需要有一个东西来创建它,这个创建类的东西,称之为元类,可以说元类是类的类,有点绕,你或许听到过元数据这个词语,我觉得他们在概念理解上是由类似之处的,所谓元可以理解为关于...的信息,元数据就是关于数据的数据。
我们在聊一切皆对象时说过对象都有三个属性,其中一个就是type,它可以通过type()来获取,这里的type其实是一个类,只是它被设计为可以像函数一样被调用,type类就是一个元类,而且它还是Python中所有类默认的元类,当你用class定义一个类时,Python解释器实际上回调用type来创建这个类,可以说它是所有类的“造物主”。
元类非常强大,它的核心作用是控制类的创建过程,但也很复杂,应该说99%的情况下我们是不需要自己写元类的,它的主要应用场景是框架和库,帮助实现一些魔术功能,但刚好在单例模式中也有这种实现方式,所以我们在此处简单了解一下。
说完基本的概念,我来解释一下上面使用元类方式实现的单例模式的代码。我们自定义了一个元类SingletonMeta,它需要继承元类type,在SingletonMeta中我们定义了一个类变量_instances用于存储每个类的唯一实例,需要实现的核心方法是__call__魔术方法,cls变量就是当前需要实例化的类(Singleton),if cls not in cls._instances这行代码是为了查找当前实例是否在_instances字典中已经存储,cls._instances实际上相当于SingletonMeta._instances,这是为了避免硬编码,因为一旦SingletonMeta有了子类,这个地方就不适用了,而写cls._instances不仅更灵活还直接利用了Python的属性查找规则,在自己类中查找不到的属性会到父类中去查找,父类中没有则去它的元类中查找,元类中如果也没有就会报错了,所以这里实际上是自动到SingletonMeta元类中去查找_instances属性了。
super().__call__(*args, **kwargs)这行代码是在没有实例的情况下则利用父类(也就是type)的__call__方法去创建,__call__方法会自动取调用__new__和__init__方法完成对象的创建和初始化,创建完成之后我们就可以将其放入到_instances字典中避免下次调用重新创建对象。
class Singleton(metaclass=SingletonMeta)这行代码是我们在编写Singleton类的时候指定了元类,这里并不是继承,而是通过metaclass来指定元类,将元类指定为我们自定义的元类SingletonMeta,因为默认使用的是type元类。
其余的代码就比较容易理解了,这里就不再过多解释。
class Singleton: def __init__(self) -> None: self.value = "初始化数据"singleton_instance = Singleton()这种方式是Python特有的,也是最简单的实现单例模式的方式,我们创建一个.py文件,也就是一个模块,在这个模块中编写上面的代码,利用Python模块的一次导入,全局唯一的特性,这种实现方式非常适合资源加载,当然也支持外部传入参数,这里就不再演示了,有兴趣可以自己编写。
通过这种方式实现的单例模式使用也比较简单,在其他.py文件中导入这个模块即可使用,即便你在多个.py文件中导入,也不会被重复初始化。
我们需要明确的是,设计模式的核心目的在于解耦、复用和扩展。但设计模式并非“银弹”,我们应该按需使用,避免过度设计(简单的功能无需套用设计模式)。
在面向对象编程中,设计模式是一套可复用、经过验证的解决方案,可以说是一种具体的套路,属于设计的“术”,如果我们将这些“术”总结、抽象到更为普遍的指导思想、设计哲学的“道”,我认为可以是SOLID原则。SOLID原则是由罗伯特·C·马丁(Robert C. Martin)整理提出,它是五个原则的字母缩写:
一个类应该只负责一件事,只有一个原因引起它变化。
对扩展开放,对修改关闭。也就是可以通过新增代码来增加新功能,但不应该对已有的、稳定的代码随意修改,从而引入新的Bug。
子类必须能完全替换其父类,且不破坏程序的正确性。
客户端(使用某个接口的代码)不应该依赖它不需要的接口,应该将胖接口拆分为职责单一、粒度更小的接口。
依赖接口,不依赖具体实现,可通过依赖注入降低耦合。也就是说高层模块不应该依赖低层模块,两者都应该依赖于抽象(接口),抽象不应该依赖细节,细节应该依赖抽象。
SOLID是五个原则的缩写,合起来也是一个有意义的单词,有固体的、坚实的之意,对代码的寓意也非常的好,希望我们的代码足够稳固。总而言之,我觉得我们写代码时朝着好理解、好复用、好扩展、好测试、好维护的方向努力,自然就能写出好代码。
面向对象编程相关的内容还是很多的,除了基础的类和对象的概念,还有三大特性、抽象类、属性装饰器、魔术方法、设计模式和一些经典的指导原则。
但只了解这些零碎的知识点还是远远不够的,实战才是我们积累经验的阶梯。我想到了实现一个简易的图书管理系统,这是我们多数人都比较熟悉的东西,也是比较经典的练习题,这个简易的图书管理系统,我想实现图书的入库、借阅、归还、查询、用户注册、查看借阅记录等功能,这个过程中我们可能还需要处理用户不存在、借阅时长不符等异常问题,这里面应该主要涉及三个主要的类:图书、图书馆、用户。
好了,开始你的练习吧,尽可能多的用到我们聊到的知识点,当然,如果你有自己想实现的内容,那自然是更好。