- 教程
- 文档
【免费下载链接】Python-100-Days
Python - 100天从新手到大师
本文基于 Python-100-Days 项目中 19.面向对象编程进阶.md 的核心脉络展开。在 18.面向对象编程入门 掌握了类、对象、封装、继承、多态的基础概念之后,本节深入讨论 Python 面向对象编程的进阶主题:属性的访问可见性、动态语言特有的动态属性与__slots__机制、静态方法与类方法、property属性装饰器,以及继承体系中的super()、里氏替换原则与多态行为。读完本文,你将理解 Python 中"开放比封闭好"的设计哲学,掌握用__slots__精确控制实例属性、用@property把方法伪装成属性、用@staticmethod/@classmethod向类发送消息,并能借助继承与多态写出符合里氏替换原则的可复用代码,同时结合仓库中 Day31-35/code 的源码实例(工资结算系统、扑克牌、MRO 演示)印证每个知识点在生产代码中的真实用法。
1. 可见性:下划线背后的访问约定
1.1 私有属性与受保护属性
在很多面向对象编程语言中,对象的属性通常会被设置为私有(private)或受保护(protected)成员,不允许外部直接访问;对象的方法通常都是公开(public)的,因为公开方法是对象能够接受的消息,也是对象暴露给外界的调用接口,这就是所谓的访问可见性。
Python 没有private/protected/public这样的访问修饰符,而是通过给属性名添加前缀下划线的方式来说明访问可见性:
- 以
__name(双下划线开头)表示私有属性; - 以
_name(单下划线开头)表示受保护属性。
class Student: def __init__(self, name, age): self.__name = name self.__age = age def study(self, course_name): print(f'{self.__name}正在学习{course_name}.') stu = Student('王大锤', 20) stu.study('Python程序设计') print(stu.__name) # AttributeError: 'Student' object has no attribute '__name'上面代码的最后一行会引发AttributeError(属性错误)异常,异常消息为:'Student' object has no attribute '__name'。由此可见,以__开头的属性__name相当于是私有的,在类的外面无法直接访问,但类里面的study方法中可以通过self.__name访问该属性。
这里需要说明一个 Python 独有的机制——名称改写(name mangling):当类定义中出现__name这样的双下划线属性时,解释器会在编译阶段把它改写成_Student__name(即"单下划线 + 类名 + 属性名")。这也就是为什么外部访问stu.__name会失败,而stu._Student__name依然能够读到的原因。因此 Python 的"私有"更多是一种约定而非强制隔离。
1.2 开放比封闭好:"大家都是成年人"
需要说明的是,大多数使用 Python 语言的人在定义类时,通常不会选择让对象的属性私有或受保护,正如一句名言所说:"We are all consenting adults here"(大家都是成年人)。成年人可以为自己的行为负责,而不需要通过 Python 语言本身来限制访问可见性。事实上,大多数程序员都认为开放比封闭要好,把对象的属性私有化并非必不可少的东西,所以 Python 语言并没有从语义上做出最严格的限定——上面的代码如果愿意,用stu._Student__name的方式仍然可以访问到私有属性__name,有兴趣的读者可以自己试一试。
单下划线开头的_name同样是约定大于强制:它向其他开发者传递"这是内部实现细节,请不要在外部直接使用"的信号,Python 不会对它做任何改写或拦截。
2. 动态属性与__slots__魔法
2.1 动态语言:运行时改变对象结构
Python 语言属于动态语言。维基百科对动态语言的解释是:"在运行时可以改变其结构的语言,例如新的函数、对象、甚至代码可以被引进,已有的函数可以被删除或是其他结构上的变化"。动态语言非常灵活,目前流行的 Python 和 JavaScript 都是动态语言,除此之外,诸如 PHP、Ruby 等也都属于动态语言,而 C、C++ 等语言则不属于动态语言。
在 Python 中,我们可以动态为对象添加属性,这是 Python 作为动态类型语言的一项特权:
class Student: def __init__(self, name, age): self.name = name self.age = age stu = Student('王大锤', 20) stu.sex = '男' # 给学生对象动态添加sex属性需要提醒的是,对象的方法本质上也是对象的属性,只不过与该属性对应的是一个可以调用的函数。如果给对象发送一个无法接收的消息,引发的异常仍然是AttributeError。
2.2 用__slots__收紧实例属性
如果不希望在使用对象时动态地给对象添加属性,可以使用 Python 语言中的__slots__魔法。对于Student类来说,可以在类中指定__slots__ = ('name', 'age'),这样Student类的对象只能有name和age属性,如果想动态添加其他属性将会引发异常:
class Student: __slots__ = ('name', 'age') def __init__(self, name, age): self.name = name self.age = age stu = Student('王大锤', 20) # AttributeError: 'Student' object has no attribute 'sex' stu.sex = '男'从实现原理上看,__slots__通过描述符协议(descriptor protocol)以固定、紧凑的方式存储实例属性,替代了每个实例默认携带的__dict__字典。它带来两个直接收益:一是精确约束,让实例只能拥有声明过的属性;二是节省内存,在需要创建大量对象的场景下,省去每个实例维护一个字典的开销。
这一机制在仓库中有真实的工程化用法。在 Day31-35/code/example16.py 中,Student类声明了__slots__ = ('stuid', 'name', 'gender'),主函数中stu.gender = 'Male'可以正常执行,而被注释掉的stu.birth = '1980-11-28'一旦放开就会抛出AttributeError,这正是__slots__约束生效的体现;同时该文件还展示了配合__slots__重写__hash__与__eq__魔术方法,让对象可以放进set或作为dict的键。更彻底的做法见 Day31-35/code/example17.py 中的混入类SetOnceMappingMixin,它通过__slots__ = ()声明"不占任何实例属性存储",以规避与dict父类__dict__的冲突。
3. 静态方法和类方法
3.1 发给类的消息
之前我们在类中定义的方法都是对象方法,换句话说这些方法是对象可以接收的消息。除了对象方法之外,类中还可以有静态方法和类方法,这两类方法是发给类的消息,二者并没有实质性的区别。在面向对象的世界里,一切皆为对象,我们定义的每一个类其实也是一个对象,而静态方法和类方法就是发送给类对象的消息。
那么,什么样的消息会直接发送给类对象呢?举一个经典的例子:定义一个三角形类,通过传入三条边的长度来构造三角形,并提供计算周长和面积的方法。计算周长和面积肯定是三角形对象的方法,这一点毫无疑问。但是在创建三角形对象时,传入的三条边长未必能构造出三角形,为此我们可以先写一个方法来验证给定的三条边长是否可以构成三角形。这种方法很显然不是对象方法,因为在调用它时三角形对象还没有创建出来。我们可以把这类方法设计为静态方法或类方法,也就是说这类方法不是发送给三角形对象的消息,而是发送给三角形类的消息:
class Triangle(object): """三角形""" def __init__(self, a, b, c): """初始化方法""" self.a = a self.b = b self.c = c @staticmethod def is_valid(a, b, c): """判断三条边长能否构成三角形(静态方法)""" return a + b > c and b + c > a and a + c > b # @classmethod # def is_valid(cls, a, b, c): # """判断三条边长能否构成三角形(类方法)""" # return a + b > c and b + c > a and a + c > b def perimeter(self): """计算周长""" return self.a + self.b + self.c def area(self): """计算面积""" p = self.perimeter() / 2 return (p * (p - self.a) * (p - self.b) * (p - self.c)) ** 0.5 if Triangle.is_valid(3, 4, 5): t = Triangle(3, 4, 5) print(f'周长: {t.perimeter()}') print(f'面积: {t.area()}') else: print('无效的边长!!!')3.2 三者调用方式的区别
上面的代码使用staticmethod装饰器声明了is_valid方法是Triangle类的静态方法;如果要声明类方法,可以使用classmethod装饰器(如注释掉的代码所示)。可以直接使用类名.方法名的方式来调用静态方法和类方法,二者的区别在于:
- 类方法的第一个参数是类对象本身(约定命名为
cls); - 静态方法没有接收消息的对象参数;
- 对象方法的第一个参数是普通对象(约定命名为
self)。
总结一下:对象方法、类方法、静态方法都可以通过"类名.方法名"的方式来调用,区别在于方法的第一个参数到底是普通对象、类对象,还是没有接收消息的对象。静态方法通常也可以直接写成一个独立的函数,因为它并没有跟特定的对象绑定。
3.3 工厂模式中的静态方法
在仓库源码中,静态方法的典型应用是工厂模式。在 Day31-35/code/example12.py 中,EmployeeFactory类通过@staticmethod声明了create静态方法,根据传入的员工类型参数'M'、'P'、'S'分别创建部门经理、程序员和销售员对象:
class EmployeeFactory(): """创建员工的工厂(工厂模式 - 通过工厂实现对象使用者和对象之间的解耦合)""" @staticmethod def create(emp_type, *args, **kwargs): """创建员工""" emp_type = emp_type.upper() emp = None if emp_type == 'M': emp = Manager(*args, **kwargs) elif emp_type == 'P': emp = Programmer(*args, **kwargs) elif emp_type == 'S': emp = Salesman(*args, **kwargs) return emp注意这里的create方法与具体员工对象无关,它在创建对象之前就必须被调用,正符合"静态方法是发给类的消息"这一语义,并且实现了对象使用者与对象之间的解耦。
3.4property装饰器:把方法变成属性
这里做一个补充说明:可以给上面计算三角形周长和面积的方法添加一个property装饰器(Python 内置类型),这样三角形类的perimeter和area就变成了两个属性,不再通过调用方法的方式来访问,而是用对象访问属性的方式直接获得,修改后的代码如下所示:
class Triangle(object): """三角形""" def __init__(self, a, b, c): """初始化方法""" self.a = a self.b = b self.c = c @staticmethod def is_valid(a, b, c): """判断三条边长能否构成三角形(静态方法)""" return a + b > c and b + c > a and a + c > b @property def perimeter(self): """计算周长""" return self.a + self.b + self.c @property def area(self): """计算面积""" p = self.perimeter / 2 return (p * (p - self.a) * (p - self.b) * (p - self.c)) ** 0.5 if Triangle.is_valid(3, 4, 5): t = Triangle(3, 4, 5) print(f'周长: {t.perimeter}') print(f'面积: {t.area}') else: print('无效的边长!!!')@property的价值在于:它把"计算得到的派生值"包装成与普通属性一致的读取接口,调用方不需要关心背后是存储字段还是即时计算,后续即使内部实现改变(例如改为缓存计算结果),对外接口也无需变动。
仓库中有多处@property的实践:
- Day31-35/code/example04.py 的
Thing类把"价格重量比"定义为只读属性value,供贪心算法按性价比排序时直接读取; - Day31-35/code/example14.py 的
Poker类把"是否还有牌可发"定义为has_more属性,while poker.has_more:的写法让发牌循环读起来像在读取状态而非调用方法; - 公开课/年薪50W+的Python程序员如何写代码/code/Python/opencourse/part03/example.py 中的
Poker类同样以@property暴露has_next。
4. 继承和多态
4.1 从已有类创建新类
面向对象的编程语言支持在已有类的基础上创建新类,从而减少重复代码的编写。提供继承信息的类叫做父类(超类、基类),得到继承信息的类叫做子类(派生类、衍生类)。例如,定义一个学生类和一个老师类,会发现它们有大量的重复代码,而这些重复代码都是老师和学生作为人的公共属性和行为,所以在这种情况下,应该先定义人类,再通过继承,从人类派生出老师类和学生类:
class Person: """人""" def __init__(self, name, age): self.name = name self.age = age def eat(self): print(f'{self.name}正在吃饭.') def sleep(self): print(f'{self.name}正在睡觉.') class Student(Person): """学生""" def __init__(self, name, age): super().__init__(name, age) def study(self, course_name): print(f'{self.name}正在学习{course_name}.') class Teacher(Person): """老师""" def __init__(self, name, age, title): super().__init__(name, age) self.title = title def teach(self, course_name): print(f'{self.name}{self.title}正在讲授{course_name}.') stu1 = Student('白元芳', 21) stu2 = Student('狄仁杰', 22) tea1 = Teacher('武则天', 35, '副教授') stu1.eat() stu2.sleep() tea1.eat() stu1.study('Python程序设计') tea1.teach('Python程序设计') stu2.study('数据科学导论')继承的语法是在定义类的时候,在类名后的圆括号中指定当前类的父类。如果定义一个类的时候没有指定父类,那么默认的父类是object类。object类是 Python 中的顶级类,这也就意味着所有的类都是它的子类,要么直接继承它,要么间接继承它。
4.2super()与里氏替换原则
在子类的初始化方法中,可以通过super().__init__()来调用父类的初始化方法,super函数是 Python 内置函数中专门为获取当前对象的父类对象而设计的。从上面的代码可以看出,子类除了可以通过继承得到父类提供的属性和方法外,还可以定义自己特有的属性和方法,所以子类比父类拥有更多的能力。
在实际开发中,我们经常会用子类对象去替换掉一个父类对象,这是面向对象编程中一个常见的行为,也叫做**"里氏替换原则"**(Liskov Substitution Principle)。在 Day31-35/code/example12.py 的工资结算系统中,Manager、Programmer、Salesman三个子类都在自己的__init__里通过super().__init__(name)复用了父类Employee的属性初始化逻辑,随后统一作为Employee类型被放进列表处理,正是里氏替换原则的直接应用。
4.3 方法重写与多态
子类继承父类的方法后,还可以对方法进行重写(重新实现该方法)。不同的子类可以对父类的同一个方法给出不同的实现版本,这样的方法在程序运行时就会表现出多态行为——调用相同的方法,做了不同的事情。多态是面向对象编程中最精髓的部分,当然也是对初学者来说最难以理解和灵活运用的部分,下一个章节(20.面向对象编程应用)会用专门的例子深入讲解。
多态在生产代码中的典型范式见 Day31-35/code/example12.py:父类Employee用ABCMeta元类声明为抽象类,get_salary用abstractmethod装饰器声明为抽象方法(只有声明没有实现,声明它是为了让子类去重写它);三个子类分别重写get_salary返回不同规则计算的月薪——经理固定 15000 元、程序员按 200 元/小时结算、销售员为 1800 元底薪加 5% 销售额提成。当调用方向同一个父类引用发送相同的get_salary()消息时,程序会根据对象实际类型动态分派,各自执行自己的实现,这就是多态。
4.4 多重继承与方法解析顺序(MRO)
Python 语言允许多重继承,也就是说一个类可以有一个或多个父类。当出现菱形继承(钻石继承)时,子类到底继承哪个父类的方法,由方法解析顺序(Method Resolution Order,MRO)决定:Python 2.x 采用深度优先搜索,Python 3.x 采用 C3 算法(类似于广度优先搜索)。这一点在 Day31-35/code/example17.py 中有完整的演示——类D(B, C)多重继承自B和C,而B、C又都继承自A,程序通过print(D.mro())输出方法解析顺序,并调用D().say_hello()观察最终执行的是哪个父类的方法:
class A(): def say_hello(self): print('Hello, A') class B(A): pass class C(A): def say_hello(self): print('Hello, C') class D(B, C): pass同一文件中的SetOnceDict(SetOnceMappingMixin, dict)还展示了**混入类(Mixin)**的典型写法:通过多重继承把"键已存在则拒绝再次写入"的行为混入到标准dict中,配合__slots__ = ()避免与父类的实例存储冲突。这也是多重继承在实际代码中最常见的两种用途之一。
5. 总结
Python 是动态类型语言,Python 中的对象可以动态地添加属性,对象的方法其实也是属性,只不过和该属性对应的是一个可以调用的函数。在面向对象的世界中,一切皆为对象,我们定义的类也是对象,所以类也可以接收消息,对应的方法是类方法或静态方法。通过继承,我们可以从已有的类创建新类,实现对已有类代码的复用。
综合本节与仓库源码,可以归纳出几条可直接落地的实践准则:
- 可见性:用
__name、_name传递访问约定,但记住 Python 不做强制隔离,__name只是被改写为_ClassName__name,"大家都是成年人"; - 动态属性:动态语言允许随时为对象添加属性;若需要精确控制实例属性或节省大量对象的内存开销,用
__slots__ = ('attr1', 'attr2')收紧,参考 Day31-35/code/example16.py; - 类级消息:与对象无关的校验、构造逻辑用
@staticmethod,需要访问类本身的用@classmethod;工厂模式中的创建入口适合静态方法,参考 Day31-35/code/example12.py; - 只读派生值:用
@property把计算方法包装成属性接口,参考 Day31-35/code/example14.py; - 继承与多态:用
super().__init__()复用父类初始化逻辑,遵循里氏替换原则让子类替换父类,通过重写方法实现多态分派;多重继承时理解 MRO(C3 算法)与 Mixin 模式,参考 Day31-35/code/example17.py。
掌握了这些进阶机制,下一步就可以在 20.面向对象编程应用 中通过扑克游戏和工资结算系统两个综合案例,把本节的知识点串联起来进行实战。
- 教程
- 文档
【免费下载链接】Python-100-Days
Python - 100天从新手到大师
相关推荐
如何为视频智能匹配合适背景音乐:LuoGen-agent BGM功能使用指南
如何为视频智能匹配合适背景音乐:LuoGen agent BGM功能使用指南 在短视频创作中, 背景音乐 (BGM)往往是决定作品能否"爆"的关键一环。一段合适
继承与多态:面向对象高级特性
继承与多态:面向对象高级特性 本文深入探讨了Java面向对象编程中的四个核心高级特性:继承关系的实现与super关键字、方法重写与运行时多态机制、抽象类与接口的
InternVL2-4B如何看懂图片?单图问答、多轮对话与OCR文字识别完整实战指南
InternVL2 4B如何看懂图片?单图问答、多轮对话与OCR文字识别完整实战指南 InternVL2 4B 是 OpenGVLab 发布的开源多模态大模型,
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考