目录
- 一、面向对象概述
- 1.1 面向对象简介
- 1.2 对象和类
- 1.3 定义属性和行为
- 1.3.1 用数据描述对象的状态
- 1.3.2 行为就是动作
- 1.4 隐藏细节并创建公共接口
- 1.5 组合
- 1.6 继承
- 1.6.1 继承提供抽象
- 1.6.2 多重继承
- 二、封装
- 2.1 Python类定义
- 2.2 创建类的成员
- 2.2.1 创建实例方法并访问
- 2.2.2 创建数据成员并访问
- 2.3 实例化
- 2.3.1 构造的2个阶段
- 2.3.2 创建__init__()方法
- 2.3.3 实例对象instance
- 2.3.4 self
- 2.4 实例变量和类变量
- 2.5 特殊属性
- 2.6 属性本质
- 2.7 类方法和静态方法
- 2.7.1 普通函数
- 2.7.2 普通方法
- 2.7.3 类方法
- 2.7.4 静态方法
- 2.7.5 方法的调用
- 2.8 访问控制
- 2.8.1 私有(Private)成员
- 2.8.2 私有成员本质
- 2.8.3 保护(protected)成员
- 2.9 属性装饰器
- 2.9.1 创建用于计算的属性
- 2.9.2 为属性添加安全保护机制
- 2.10 封装总结
- 三、案例
- 3.1 跑步案例
- 3.2 家具案例
一、面向对象概述
1.1 面向对象简介
每个人都知道什么是对象(物体):我们可以感知、感觉和摆弄的具体东西。我们最早接触的对象通常是婴儿玩具。积木、塑料形状、各式拼图等通常是我们接触的第一批对象。婴儿很快就会知道不同对象可以做不同事情:闹铃会响,按钮可以被按下,杠杆可以被拉动等。在软件开发中,对对象的定义并没有太大的不同。软件对象可能不是你可以拿起、感知或感觉的有形物件,但它们都是某些物件的模型。它们可以做某些事情,可以接受特定的操作方式。正规来说,对象是数据和相关行为的集合。想想什么是对象,面向对象又是什么意思。在字典中,面向是正对着的意思。面向对象编程是指通过构建对象模型的方式来写代码,这是用于描述复杂系统行为的众多技术中的一个。面向对象就是指一系列对象通过数据和行为相互交互。如果你看过相关的宣传材料,那么你可能看到过面向对象分析、面向对象设计、面向对象分析和设计、面向对象编程等术语,这些都是与面向对象相关的概念。实际上,分析、设计和编程都是软件开发过程中的某个阶段。称它们为面向对象仅仅是为了表明软件开发的方法。
面向对象分析(Object-Oriented Analysis,OOA) 是分析软件要解决的问题、系统或任务,找出其中的对象以及对象之间的各种交互的过程。分析阶段都是关于要做什么的。分析阶段的产出是对系统的描述,也就是需求。如果我们把分析阶段一步做完,就会把一个用户任务分解成一套必需的功能,比如作为一个植物学家,我需要一个网站来帮助用户给植物分类,我可以帮助用户正确识别植物。比如,下面是一些网站访问者可能需要的功能。每个功能都是一个对象和一个关联的操作;操作用楷体,对象用粗体。
- 浏览之前上传的东西
- 上传新的已知样例
- 测试质量
- 浏览产品
- 查看推荐
在某种意义上,分析这个词有点儿用词不当。我们之前讨论过的婴儿并不会分析积木或拼图。相反,婴儿会探索环境,摆弄各种形状,查看这些东西可能适合的位置。更好的术语可能是面向对象探索。在软件开发中,分析的初始阶段包括采访用户、研究他们的流程、消除不可能的情况等。面向对象设计(Object-Oriented Design,OOD) 是把需求转变成实现说明书的过程。设计师要命名对象、定义对象的行为,并明确定义哪些对象可以触发其他对象的特定行为。设计阶段都是关于把要做什么转变成如何做的。设计阶段的产物是实现说明书。如果我们把设计阶段一步完成,就会把面向对象分析时定义的需求转变成类和接口的集合。如果设计得比较理想,就可以用任何一个面向对象的编程语言实现这些类和接口。面向对象编程(Object-Oriented Programming,OOP) 是把设计变成满足用户需求的可执行程序的过程。如果我们处在一个理想的世界中,像很多教科书上教我们的那样,完美地按照这些阶段一步步执行,那就太好了!但通常现实世界要模糊得多,无论我们多么努力地阶段执行,我们总会在设计时发现有些需要进一步分析的内容,在编程时发现有些需要在设计中进一步澄清的功能。越来越多的团队都认识到,这种瀑布式的阶段划分效果并不好。迭代式的开发模型似乎更好。在迭代开发过程中,先对任务的一小部分进行建模、设计和编程,然后审查开发出的产品,并在一系列较短的开发周期中不断改进功能和引入新功能。本文的其余部分是关于面向对象编程的,但本小节将涵盖基本的面向对象设计原则,这让我们可以聚焦于理解概念,而无须关注 Python 的语法或调用栈。
1.2 对象和类
对象是数据和相关行为的集合。 我们如何区分不同类型的对象呢?苹果(Apple)和橘子(Orange)都是对象,但很显然苹果和橘子不是同一类对象。苹果和橘子在计算机编程中并不常用,但让我们假设现在在为一个农场开发库存软件。为了方便描述下面的示例,我们可以假设用 桶(Barrel) 装苹果,而用 篮子(Basket) 装橘子。我们要解决的问题到目前为止涉及4种对象:苹果、橘子、篮子和桶。在面向对象建模中,用 类(class) 来描述对象的种类。所以,从技术上讲,我们现在有4个类。理解类和对象的区别是很重要的。类描述了相关的对象。它们就像创建对象的蓝图。你面前的桌子上可能放着3个橘子,每个橘子都是不同的对象,但这3个橘子都是橘子类,都拥有橘子类的属性和相关行为。对于库存软件中4个类的关系,可以用 统一建模语言(简称 UML)的类图表示。这是我们的第一个类图,如下图所示:
上图表明 Orange 类的实例(通常被称为 orange)和 Basket 有某种关联,Apple 类的实例(通常被称为 apples)和 Barrel 有某种关联。关联是类的实例之间最基本的关系。UML 图的语法浅显易懂。你在看到一张图的时候,通常不需要阅读说明文档就能理解它的意思。UML 图也很容易绘制,很自然。毕竟,很多人在描述类和类的关系时,会自然地画出矩形,并用线条把它们连起来。在这种自然的图表基础上制定一个标准,有利于程序员与设计师、管理人员互相交流,也有利于程序员之间互相交流。值得注意的是,UML 图通常描述的是类定义,但类定义也描述了对象的属性。上图中包含了 Apple 类和 Barrel 类,表明一个特定的 apple 被放在某一个 barrel 中。我们也可以用 UML 图描述单个对象,但这基本没必要。通过类图足以说明对象之间的关系,因为对象是类的成员。有些程序员认为使用 UML 是浪费时间。他们认为在迭代开发中,包含 UML 图的正规设计说明书是多余的,直接实现就行了。而且,维护这些 UML 图只会浪费时间,没什么用处。任何多于一个人的开发团队都偶尔需要坐下来讨论一下正在开发的组件的细节。UML 图对于确保快速、简单和一致的沟通是极其有用的。就算那些嘲笑正规类图的团队也会在其设计会议和团队讨论中使用一些草图,这些草图相当于 "山寨版"
的 UML 图。
此外,你必须要与之沟通的最重要的人是将来的自己。我们都认为自己会记住当初的设计决定,但将来几乎总会出现这样的时刻:我当时为什么这么干?如果我们保留了最初设计时画的图表,最终会发现这些是很有用的参考。然而,本文并不是关于 UML 的教程。网上有很多关于 UML 的教程,也有很多这个主题的书。UML 包含的不仅是类图和对象图,还包含用例图、部署图、状态图和活动图。在讨论面向对象设计时,我们会用到一些常见的类图的语法。你可以先从示例中学习它们的结构,然后自然而然地将其用到你的团队和个人的设计中。我们最初的类图是正确的,但没有告诉我们 apple 要放在 barrel 中,也没有明确一个 apple 是否可以放在多个 barrel 中。它只是告诉我们 apple 和 barrel 有某种关联。类之间的关联通常是显而易见的,不需要额外的解释,但在必要时,我们可以添加额外的解释来进一步澄清。UML 的美在于大部分的东西都是非必需的。我们只需要在图中画出对当前设计有价值的信息。在一个快速会议上,我们可能只需要在白板上画出一个个用线连起来的矩形。而在一个正规的文档中,我们可能需要更多的细节。在 apple 和 barrel 的示例中,它们之前的关系很明显是多个 apple 可被放在一个 barrel 中。但为了防止有人认为一个 barrel 只能放一个 apple,我们可以完善一下类图,如下图所示:
上图通过一个小箭头告诉我们 Orange 被放在 Basket 中,它还告诉我们这个关系两边的对象各自的数量。一个 Basket 可以包含多个(用 *
表示)Orange 对象。任何一个 Orange 只能被放在一个 Basket 中。这个数字被称为对象的多重性,你也可能听到它被称为基数。我们可以将基数理解成一个具体的数字或范围。在这里用多重性 *
表示多于一个的实例。你有时候可能会忘记一个关系中的哪个边应该用哪个多重性数字。与类距离最近的多重性表示那个类的对象的数量,它们可以与关系另一端的任意一个对象关联。对于 apple 被放入 barrel 的关联,从左往右读,很多 Apple 类的实例(就是很多 Apple 对象)可被放入任意一个 Barrel。从右往左读,一个 Barrel 可以与任意一个 Apple 关联。我们已经掌握了类的基础知识,以及它们如何定义对象之间的关系。现在我们需要讨论定义对象状态的属性,以及对象的行为,对象的行为可能导致状态改变或与其他对象交互。
1.3 定义属性和行为
现在我们已经了解了一些基本的面向对象的术语。对象是一些可以互相关联的类的实例。类的实例是一个具有自己的数据和行为的特定对象;比如我们面前桌子上的一个橘子就是广义的橘子类的一个实例。这个橘子有自己的状态,比如生的还是熟的;我们通过特定的属性值来实现对象的状态。橘子也有行为。橘子自身一般是被动的,但其他对象会触发它们的行为,进而引起状态变化。下面我们来深入学习这两个词的含义:状态和行为。
1.3.1 用数据描述对象的状态
我们先从数据开始。数据代表一个特定对象的个体特征,也就是现在的状态。类可以定义一系列属于这个类的对象的共有特征。对于这些特征,任何特定对象都可以拥有不同的数据值。比如,在我们的桌子上放着的3个橘子重量可能各不相同。橘子类可以有一个 weight 属性表示这个特征,所有橘子类实例都有 weight 属性,但每个橘子都可以有不同的 weight 值。不过属性的值并不需要是唯一的,两个橘子也可能重量一样。
属性(attribute)也经常被称为 成员(member) 或 特性(property)。有些作者认为这些术语有不同的含义,通常来说属性(attribute)是可以修改的,而特性(property)是只读的。在 Python 中,特性可以被定义为只读,但它在本质上还是可以修改的,所以只读的概念在 Python 中没有太大意义。此处,我们将这两个词视为同义词。另外,我们在后续中会讨论 property 关键字的特殊作用。在 Python 中,我们也可以把属性称为实例变量。这可以帮助理解属性的原理。属性是属于每个类实例的变量,这些变量可以有不同的值。Python 也有其他类型的属性,但我们现在只讨论最常见的这种实例变量。在我们的水果库存应用中,果农可能希望知道橘子来自哪个果园(orchard),是何时采摘(date_picked)的,以及重量(weight)是多少。他们也许希望跟踪每一个 Basket 中的橘子被存储在哪里(location)。苹果可能有颜色(color)属性,桶可能有不同的尺寸(size)。有些属性可能是多个类共有的,比如我们可能也想知道是何时采摘的苹果。在这个示例中,我们就随意给类图设置了几个不同的属性,如下图所示:
根据我们设计的具体程序,我们也可以指定每个属性值的类型。在 UML 中,属性类型通常使用编程语言中通用的名称,比如整数(integer)、浮点数(float)、字符串(string)、字节(byte)或布尔值(Boolean)。然后,它们也可以是列表、树、图等常见的集合类型,甚至是与具体应用相关的其他非通用类型。这是一个设计阶段可能与编程阶段有所重叠的地方。这些基本数据类型和自带的集合类型,在不同的编程语言中可能有所不同。下面是一个 Python 版本的带属性类型的类图,如下图所示:
通常,在设计阶段,我们不需要过度担心数据类型的问题,因为具体的实现细节是在编程阶段确定的。通用的类型名称在设计阶段就够用了,这就是为什么在上图中用 date 表示采摘日期,在实际的 Python 编程中使用 datetime.datetime。如果在设计中需要用到列表类型,Java 程序员可以选择 LinkedList 或 ArrayList 来实现,而 Python 程序员(也就是我们)可以选择 list 类型来实现,通过 List[Apple] 做类型提示。到目前为止,在水果库存的示例中,所有的属性都是基本类型的。然而,有一些隐含的属性,我们可以通过关联关系显式说明。对于一个给定的橘子,我们可以用一个 basket 属性来表示这个橘子所在的篮子,这个属性的类型提示是 Basket。
1.3.2 行为就是动作
现在我们知道了如何用数据描述对象的状态,最后一个要学习的术语是行为(behavior)。行为是一个对象可以发生的动作。在某一类对象上可以发生的动作通过这个类的 方法(method) 来表达。在编程层面上,方法就像结构化编程中的函数,但它们可以访问对象的属性,也就是当前对象数据的实例变量。像函数一样,方法也可以接收 参数 并返回表示结果的 值。
参数代表在调用方法时需要传递给方法的一系列对象。在实际调用时传给方法的对象案例通常被称为实参(argument)。这些对象被绑定到参数变量中,然后在方法体中使用,用于执行方法需要完成的任何行为或任务。返回值是任务的结果。在执行方法时可能会造成对象内部状态的变化。我们已经给我们的 "橘子苹果库存管理系统"
画了基本的草图,现在继续扩展一下。对于橘子(orange)来说,一个可能的动作是 pick(采摘)。想一下实现细节,pick 需要做两件事:
- 更新 orange 的 Basket 属性,记录这个橘子属于某个特定的篮子
- 更新 Basket 的 Orange 列表属性,记录在这个篮子中有这个橘子
所以,pick 需要知道它要处理哪个篮子。我们通过将 Basket 作为参数传递给 pick 方法来实现这一点。由于我们的果农同时也卖果汁,所以我们也可以为 Orange 类添加一个 squeeze(榨汁)方法。当榨汁的时候,squeeze 方法可能会返回获得果汁的数量,同时也需要将橘子从它所在的篮子中移除。Basket 类可以有一个 sell(售卖) 动作。当一篮水果被卖掉时,我们的库存系统需要更新一些我们现在还没涉及的对象的数据来记账或者计算利润。或者,我们篮子里的橘子可能还没卖掉就已经坏掉了,因此我们需要添加一个 discard(丢弃) 方法。现在我们将这些方法添加到类图中,如下图所示:
通过给各个对象添加属性和方法,我们能够创建一个由交互的对象所组成的系统。系统中的每个对象都属于某一个类。这些类指定了对象可以拥有哪些类型的数据以及有哪些可以被调用的方法。每个对象的数据都可能与同一个类中其他对象的状态是不同的。因为对象的状态不同,所以在调用不同对象的方法时可能产生不同的反应。面向对象的分析和设计就是为了弄清楚有哪些对象以及它们之间应该如何交互。每个类都有责任和要协作的事情。后文描述的原则,就是用来使这些交互过程尽可能简单、直观的。请注意,销售一篮水果的动作不一定要被放在 Basket 类中。它也可以被放在其他某个负责多个篮子以及篮子位置的类中(图中没有画出)。我们的设计通常都有边界。我们也需要考虑如何把职责分配给不同类的问题。职责分配问题并不总是一下就分得很清楚,这使得我们不得不画多个 UML 图来比较不同的方案。
1.4 隐藏细节并创建公共接口
在面向对象设计中给对象建模的关键目的在于,决定该对象的公共接口是什么。接口是对象允许其他对象访问的属性和方法的集合,其他对象可以通过接口与这个对象进行交互,而不需要(在某些编程语言中也不允许)访问对象的内部工作。一个真实世界中的示例就是电视机。对我们来说,电视机的接口就是遥控器。遥控器上的每个按钮都代表可以调用的电视机对象的方法。当我们作为调用对象访问这些方法时,我们不需要关心电视机到底是从天线、电缆还是卫星那里获取信号的,也不需要关心传递什么样的电子信号来调节音量,或者声音到底是发往音箱还是耳机的。如果我们打开电视机查看内部构造,例如将音箱和耳机的输出线拆开,那么我们只会失去保修资格。
这个隐藏对象实现细节的过程,被称为 信息隐藏, 有时候也被称为 封装(Encapsulation), 但是封装是一个更加宽泛的术语,被封装的数据并不一定是隐藏的。从字面上看,封装就是把属性用胶囊或者封装纸包起来。电视机的外壳封装了电视机的内部状态和行为。我们可以访问它外部的显示器、扬声器和遥控器。我们不能直接访问外壳内部的信号接收器或放大器的排线。如果我们自己组装一套娱乐系统,那么我们要改变组件的封装程度,组件需要暴露更多的接口,方便我们自己组装。如果我们是物联网设备的制造商,那么我们可能会进一步分解组件,打开外壳,拆开厂家封装起来的内部元器件。封装和信息隐藏的区别通常是无关紧要的,尤其是在设计层面。很多参考文献会把它们当作同义词。作为 Python 程序员,我们往往没有也不需要真正的信息隐藏(我们将在后续中讨论其原因),因此使用含义更广泛的封装也是合适的。
然而,公共接口还是非常重要的,需要仔细设计,因为在未来很多其他类依赖于它的时候就会很难修改。更改接口可能会导致任何调用它的客户端对象(指调用当前对象的其他对象)出错。我们可以随意改变内部构造,例如,让它变得更高效,或者除了从本地还可以从网络上获取数据,而客户端对象仍然可以不加修改地使用公共接口与我们的对象正常交流。另外,如果我们改变了接口中的公共属性名,或者更改了方法参数的顺序或类型,那么对所有的客户端类都需要进行更改。在设计公共接口的时候,应尽量保持简单,永远优先考虑易用性而非编码的难度(这一建议同样适用于用户接口)。因此,有时会看到某些 Python 的变量名以下画线 _
开头(比如 _name
)作为警示,表示它们不是公共接口的一部分。
记住,程序中的对象虽然可能代表真实的物体,但这并不意味着它们是真实的物体,它们只是模型。建模带来的最大好处之一是,可以忽略无关的细节。我小时候做的汽车模型看着很像1956年的雷鸟(一种汽车),但它显然不能跑。这些细节对于年幼还不会开车的我来说太过复杂,也是无关紧要的。模型是对真实概念的一种 抽象(Abstraction)。 抽象 是另一个与封装和信息隐藏相关的面向对象的术语。抽象意味着只处理与给定任务相关的最必要的一层细节,是从内部细节中提取公共接口的过程。汽车司机(Driver)需要与方向盘、油门和刹车装置交互,而不需要考虑发动机、传动系统及刹车系统的工作原理。而如果是机械师(Mechanic),则需要处理完全不同层面的抽象,可能需要优化引擎和调节刹车系统等。以下是汽车两个抽象层面的类图,如下图所示:
现在,我们又学习了几个概念上有点儿类似的新术语。我们用一句话来总结这些术语:抽象是用独立的公共接口封装信息的过程。私有属性或者方法应该对外隐藏,也就是信息隐藏。在 UML 图中,我们可以用减号 -
开头表示一个属性或方法不是公共接口。如上图所示,公共接口用加号 +
开头。所有这些概念都告诉我们一个重要的设计目标,让我们的模型易于被其他对象理解。这意味着注意细节。尽量确保方法和属性的名称可以 "望文生义"
(虽然这很难)。在系统分析过程中,对象通常代表原始问题中的名词,而方法通常是动词,属性可能是形容词或名词。按照这个规律给类、属性和方法命名。在设计接口时,想象你就是对象,你想要定义清晰的对外责任,但你对如何履行这些责任要保持强烈的隐私偏好。不要让其他对象访问你的内部数据,除非你觉得这对于履行你的责任是有必要的。不要给它们任何可以调用你的执行任务的接口,除非确定这是你的责任的一部分。
1.5 组合
到目前为止,我们学习了如何设计由一组彼此交互的对象所构成的系统,在对象的设计和交互上,要根据要解决的问题做适当的抽象。但我们还不知道如何创建这些抽象层。有很多不同的方法可以做到,我们将在后续文章中讨论一些高级的设计模式。大部分设计模式都依赖于两个基本的面向对象原则:组合与继承。组合的概念简单一些,所以我们从它下手。组合是通过把几个对象收集在一起来生成一个新对象的行为。当一个对象是另一个对象的一部分时,组合通常是比较合适的选择。实际上,我们已经在上面机械师的示例中见识了组合过程。汽车是由发动机、传动装置、启动装置、车前灯、挡风玻璃及其他部件组成的,而发动机又是由活塞、曲柄轴和阀门等组成的。在这个示例中,组合是提供抽象的好办法。Car 对象可以提供司机所需的接口,同时也能够访问内部的组件,从而为机械师提供适合他们操作的深层抽象。当然,如果机械师需要更多的信息来诊断问题或调节发动机,那么这些组成部分也可以进一步被细分。这是一个常用的介绍组合概念的示例,但在设计计算机系统时它并不是特别有用。物理对象通常很容易被分解为零件对象。人们至少从古希腊时就开始这么做,提出了原子是物质最小的组成单位的假设(当然他们那时还没有粒子加速器)。因为计算机系统涉及很多特有的概念,把计算机系统分解成组件对象不像分解阀门和活塞那么自然。
面向对象系统中的对象偶尔也会代表物理对象,例如人、书或手机。但更多时候代表的是抽象的概念。人有名字,书有标题,手机用于打电话等。在物理世界中,我们通常不会把打电话、书的标题、人的名字、约会,以及支付等看作对象,但是在计算机系统中,它们通常会被建模为对象。让我们试着模拟一个更加面向计算机的示例,从实践中学习组合的概念。我们将设计一个基于计算机的象棋游戏。这是20世纪80年代与20世纪90年代校园里非常流行的一个消遣活动。人们曾经预测在未来某一天计算机能够打败人类象棋大师。当这件事在1997年真的发生时(IBM的深蓝机器人打败了世界象棋冠军Gary Kasparov),人们对这个问题的兴趣渐渐淡去。现在,深蓝机器人的新版本总能打败人类。
象棋游戏(game)需要两个玩家(player)参与(play),使用一个由8×8网格组成的64格(position)棋盘(board),棋盘上包含两队各16枚(piece)可以移动(move)的棋子,两个玩家各自以不同的方式轮流(take turn)移动棋子。每一枚棋子都可以吃掉(take)另一枚棋子。玩家每走一步,棋盘必须在计算机显示器上重新绘制(draw)自己。在上面的描述中,我已经用楷体标记了一些可能的对象,用粗体标记了几个关键方法。通常这是从面向对象分析到设计的第一步。现在,我们把重点放在组合的概念上,先关注棋盘,不用太在意不同玩家和不同类型的棋子。我们先从最高的抽象开始。我们有两个玩家(Player),他们和 Chess Set 交互,轮流下棋,如下图所示:
这看起来不太像之前的类图,因为它确实不是类图。这是对象图,又叫实例图。它描绘的是系统在某个特定状态下,对象实例之间的关系,而不是类的交互。上图中的两个玩家是同一个类的不同实例。相应的类图如下图所示:
这个类图表明,一盘象棋只能由2个玩家(Player)一起玩,而且任何一个玩家在某一个时间点上只能玩一盘 Chess Set。我们现在的重点是组合概念,不是 UML,所以我们考虑一下 Chess Set 是由什么组成的。我们暂时不关心 Player 是由什么组成的。我们可以假设 Player 有心脏、大脑以及其他器官,但这些与我们的模型无关。实际上,玩家可能是既没有心脏也没有生理上的大脑的深蓝机器人。
Chess Set 由一个棋盘(board)和32枚棋子组成。棋盘又包含64个网格位置。你可能会说棋子不是 Chess Set 的组成部分之一,因为你可以用另一副棋的棋子替换这副棋的棋子。虽然这在计算机游戏中不大可能发生,但这个问题引出了一个概念:聚合(aggregation)。
聚合和组合的概念非常相似,区别在于聚合对象可以独立存在。棋盘中的格子无法独立于棋盘存在,因为我们说棋盘和格子是组合关系。但是,棋子可以独立于棋盘存在(棋盘丢了,棋子还可以独立存在),我们说棋子和棋盘是聚合关系。我们也可以从对象生命周期的角度区分聚合和组合:
- 如果外围对象控制相关(内部)对象的创建和销毁,那么组合更适合
- 如果相关对象可以独立于外围对象创建,或者它的生命周期可以更长,那么聚合关系更适合
同时,别忘了组合关系也是聚合关系,因为聚合是一种更广义的组合。任何组合关系一定也是聚合关系,但聚合关系不一定是组合关系。现在,我们画出 Chess Set 组合类图,并给各个类添加表达组合关系的属性,如下图所示:
组合关系在 UML 中用实心菱形表示,而空心菱形表示聚合关系。你会发现,棋盘(Board)和棋子(Piece)都是象棋(Chess Set)的一部分,它们都是 Chess Set 类的属性。这再次说明,在实践中,聚合与组合的区别一旦过了设计阶段就变得无关紧要了。在实现阶段,它们的用法基本相同。然而,在与团队讨论不同对象之间如何交互时,两者的区别还是很有帮助的。尤其是当讨论相关对象在内存中存活多久时,你将需要区分是组合还是聚合。在很多情况下,删除一个组合对象会同时删除关系中的相关对象,比如删除棋盘(Board)会同时删除棋盘上的所有格子。然而,删除一个聚合对象,不会自动删除关系中的相关对象。
1.6 继承
我们讨论了对象之间的3种关系:关联、组合与聚合。然而,我们还没完全设计好象棋游戏,并且这几种关系似乎仍不够用。我们讨论的玩家可能是人类,也可能是一段人工智能代码。如果我们说 "玩家(Player)和人类是关联关系"
, 或者说 "人工智能实现是玩家对象的组成部分之一"
, 好像都不大对。我们真正需要描述的是 "深蓝机器人是一个玩家"
, 或者 "Gary Kasparov是一个玩家"
。 "是一个"
这种关系是由 继承(Inheritance) 产生的。继承是面向对象编程中最有名、最广为人知,也最被过度使用的一种关系。继承有点儿像族谱树。我姓向,我的爷爷姓向,而我爸爸继承了这一姓氏,我又从我爸爸那里继承了这一姓氏。与人类继承特征和姓氏不同,在面向对象编程中,一个类可以从另一个类那里继承属性和方法。例如,在一副国际象棋中有32枚棋子,但只有6种不同的类型(卒、车、象、马、国王和王后),每种类型的棋子在移动时的行为各不相同。所有这些棋子的类有许多共同的属性,如颜色、所属象棋等,但它们同时拥有唯一的形状,以及不同的移动规则。我们来看一下,这6种类型的棋子是如何继承自 Piece 类的,如下图所示:
空心箭头形状代表每种棋子类都继承自 Piece 类。所有的子类都自动从父类中继承了 chess_set 和 color 属性。每种棋子都有不同的 shape 属性(当渲染棋盘时被绘制在屏幕上)以及不同的 move 方法,用于移动到新的位置上。我们知道所有的 Piece 类的子类都需要有一个 move 方法,否则当棋盘需要移动一枚棋子时不知道该怎么办。假如我们想要创建一个新版的象棋游戏,那么可以在里面加入一种新的棋子(巫师,Wizard)。如果这个新类没有 move 方法,棋盘在要移动这种棋子时就会被卡住,而我们现在的设计无法阻止这种事情的发生。我们可以通过给 Piece 类创建一个假的 move 方法来解决这个问题。这个方法可能只会抛出一个错误提示信息:这枚棋子无法被移动,而子类用具体的实现来 重写(Override) 这个方法。
如果子类有具体的move方法,则棋盘会调用子类的move方法,否则会调用父类的move方法,而这个方法会提示:棋子无法移动。
在子类中重写方法能够让我们开发出非常强大的面向对象系统。例如,我们要实现一个具有人工智能的 Player 类,假设名为 DeepBluePlayer,我们可以在父类的 Player 类中提供一个 calculate_move 方法,决定移动哪一枚棋子和移动到什么位置上。父类可能只是随机选择一枚棋子和方向进行移动,我们可以在 DeepBluePlayer 子类中用更智能的逻辑重写这个方法。前者可能只适合与新手对抗,而后者可以挑战大师级的选手。重要的是,这个类的其他方法(比如通知棋盘选中了哪枚棋子等)完全不用改动,它们的实现可以在两个类中共享。在 Piece 的示例中,为 move 方法提供一个默认的实现并没有什么意义。我们要做的是要求在子类中必须有 move 方法。要做到这一点,可以将 Piece 创建为 抽象类(Abstract Class),并将 move 方法声明为 抽象方法(Abstract Method)。 抽象方法基本上就是说:“在当前类中不提供方法的具体实现,但我们要求所有非抽象的子类必须实现这一方法。” 实际上,我们可以创建一个不实现任何方法的抽象类。这个类只告诉我们它应该做什么,但是完全不告诉我们要如何去做。在某些编程语言中,这种完全抽象的类也被叫作 接口(Interface)。 在 Python 中可以定义只包含抽象方法的类,但这极少见。
在抽象方法中虽然没有实现,但包含方法名、参数列表和返回值。通过这些可以清晰地定义这个方法要完成什么任务,而具体如何实现任务就留给子类根据自己的特点完成,比如在象棋中,棋子马是按照日字形移动的,而棋子象是按照田字形移动的,它们虽然都实现了move方法,但是move方法中的具体实现很不一样。
1.6.1 继承提供抽象
让我们探索一下面向对象术语中最长的一个单词 多态(Polymorphism), 它是调用同一个方法时根据子类的不同实现而有不同表现的能力。我们已经在前面描述的棋子系统中见过了。如果我们的设计继续深入,可能会发现 Board 对象可以从玩家那里接收移动指令并调用棋子的 move 方法。Board 不需要知道棋子的类型,只需调用其 move 方法,相应的棋子子类将会知道如何移动,比如使用马(Knight)的移动方法或者兵(Pawn)的移动方法。多态是一个非常酷的概念,但在 Python 编程中是一个很少出现的单词。Python 使用另一种方法让子类看起来像父类。用 Python 实现的棋盘对象可以接收任何拥有 move 方法的对象,不管它是棋子象、汽车还是鸭子。当 move 方法被调用时,棋子象(Bishop)会在棋盘上移动,汽车会驶向某处,鸭子则会看心情游走或飞走。
Python 中的这种多态通常被称为 鸭子类型: 如果它走路像鸭子,游泳像鸭子,那么它就是鸭子。我们不关心它是否真的是一只鸭子(继承自鸭子类),只要它可以像鸭子一样会游泳或走路即可。鹅和天鹅就很容易提供这种像鸭子的行为。以鸟类设计为例,鸭子类型允许将来的设计者方便地创建新的鸟类,而不用为所有可能种类的水鸟指定正式的继承层级。在上面的棋子示例中,我们用正规的继承关系涵盖了所有可能的棋子类型。鸭子类型也允许程序员扩展原有的设计,加入一些原来的设计者完全没有考虑的行为。比如,将来的设计者可以创建一个会游泳、会走路的企鹅,它可以使用同样的鸭子接口但不需要继承自鸭子父类。
鸭子类型可以被理解为只要你提供了所需的方法就可以,不管你的父类是什么。在棋子系统中,move方法是棋子必需的,任何有move方法的类都可以被当作棋子用,而不管它是否是Piece的子类。而在Java等强类型语言中,会同时要求你的类型正确和方法正确,也就是说棋子必须继承自Piece或者Piece的子类,否则就算有move方法也不能当作棋子。-----后续会使用例子进行说明
1.6.2 多重继承
当我们把继承理解为族谱树时,会发现我们可能不单从父母之一那里继承了特征(我们通常同时继承了父亲和母亲的特征)。当陌生人对一位自豪的妈妈说她的儿子眼睛很像爸爸时,妈妈的回答可能是 "对,但他的鼻子像我"
。 面向对象设计同样可以实现这样的多重继承,允许子类从多个父类那里继承特征。在实践中,多重继承可能是一件棘手的事情,有些编程语言(尤其是Java)甚至严格禁止这样做。然而,多重继承也有它的用处。最常见的是,用于创建包含两组完全不同行为的对象。例如,设计一个对象用于连接扫描仪并将扫描的文件通过传真发送出去,这一对象可能继承自两个完全独立的 scanner 和 faxer 对象。只要两个父类拥有完全不同的接口,子类同时继承这两个类就并没有什么坏处。但是如果两个类的接口有重叠,多重继承就可能造成混乱。扫描仪和传真并没有相同的接口,同时继承它们的功能并没有什么问题。举个相反的示例,有一个摩托车类拥有 move 方法,还有一个船类也拥有 move 方法。我们先想要将它们合并为一个终极水陆两用车,当调用 move 方法时,生成的类如何知道要执行的操作呢?这需要在设计时详细解释。Python 有一个 方法解析顺序(Method Resolution Order,MRO), 可以帮我们确定优先调用哪个方法。使用 MRO 规则是简单的,但最好的办法是避免多重继承。虽然多重继承作为一种可以把不相关的特征整合在一起的 混入(mixin) 技术有一定的帮助,但在很多情况下使用对象组合是更简单的选择。
mixin的中文是混合或混入,在不同的编程语言中具体含义不同。它通常指通过引入其他类或模块来增强当前类功能的一种编程模式,与通过继承来增加当前类的功能不同。
继承是一个非常有力的扩展行为和功能的工具,也是与面向对象设计相比更早的编程方法最具进步性的地方。因此,它通常是面向对象程序员最早学会的工具。但是要注意不要手里拿着锤子就把螺丝钉也看作普通钉子。继承对严格的 "是一个"
关系是最优的解决方案,但是可能被滥用。程序员经常用继承来共享代码,即使两种对象之间可能只有很少的关联,而不是严格的 "是一个"
关系。虽然这不一定是坏的设计,但却是一个极好的机会去考虑为何要采用这样的设计,用别的关系或者设计模式是否会更合适。
二、封装
2.1 Python类定义
在 Python 中,类的定义使用 class 关键字来实现,语法如下:
class ClassName:
'''类的帮助信息''' # 类文档字符串
statement # 类体
参数说明:
- 必须使用 class 关键字
- ClassName:用于指定类名,一般使用大写字母开头,如果类名中包括两个单词,第二个单词的首字母也大写,这种命名方法也称为
"大驼峰式命名法"
, 这是惯例。当然,也可根据自己的习惯命名,但是一般推荐按照惯例来命名,其本质是一个标识符 - 类定义完成后,就产生了一个类对象,绑定到了标识符 ClassName 上
'''类的帮助信息'''
: 用于指定类的文档字符串,定义该字符串后,在创建类的对象时,输入类名和左侧的括号'('
后,将显示该信息- statement:类体,主要由类变量(或类成员)、方法和属性等定义语句组成。如果在定义类时,没想好类的具体功能,也可以在类体中直接使用 pass 语句代替
例如,下面以大雁为例声明一个类,代码如下:
class Geese:
'''大雁类'''
pass
2.2 创建类的成员
类的成员主要由实例方法和数据成员组成。在类中创建了类的成员后,可以通过类的实例进行访问。
2.2.1 创建实例方法并访问
所谓实例方法是指在类中定义的函数。该函数是一种在类的实例上操作的函数。实例方法的第一个参数必须是 self,并且必须包含一个 self 参数。创建实例方法的语法格式如下:
def functionName(self,parameterlist):
block
参数说明:
1.functionName: 用于指定方法名,一般使用小写字母开头
2.self: 必要参数,表示类的实例,其名称可以是self以外的单词,使用self只是一个惯例而已
3.parameterlist: 用于指定除self参数以外的参数,各参数间使用逗号","进行分隔
4.block: 方法体,实现的具体功能
实例方法创建完成后,可以通过类的实例名称和点 (.) 操作符进行访问,语法格式如下:
instanceName.functionName(parametervalue)
参数说明:
1.instanceName: 为类的实例名称
2.functionName: 为要调用的方法名称
3.parametervalue: 表示为方法指定对应的实际参数,其值的个数与创建实例方法中parameterlist的个数相同
2.2.2 创建数据成员并访问
数据成员是指在类中定义的变量,即属性,根据定义位置,又可以分为类属性和实例属性。
类属性: 类属性是指定义在类中,并且在函数体外的属性。类属性可以在类的所有实例之间共享值,也就是在所有实例化的对象中公用。说明: 类属性可以通过类名称或者实例名访问。
实例属性: 实例属性是指定义在类的方法中的属性,只作用于当前实例中。
举例:
class Person:
"""A Example Class"""
x = 'abc' # 类属性
def showme(self): # 方法,也是类属性
return __class__.__name__ # 返回当前类的名称
print(Person) # <class '__main__.Person'>
print(Person.__name__) # 类名字 Person
print(Person.__doc__) # 类文档 # A Example Class
print(Person.showme) # 类属性 <function Person.showme at 0x000001B9E643D630>
类对象: 类也是对象,类的定义执行后会生成一个类对象
类属性: 类定义中的变量和类中定义的方法都是类的属性。上例中类 Person 的 x 和 showme
类变量: 属性也是标识符,也是变量。上例中类 Person 的 x 和 showme
Person 中,x、showme 都是类的属性, __name__
、__doc__
是类的特殊属性。showme 方法是类的属性,如同吃是人类的方法,但是每一个具体的人才能吃东西,也就是说吃是人的实例能调用的方法。showme 是方法 method,本质上就是普通的函数对象 function,在 2.2.1 创建实例方法并访问 小节中已经提到过它一般要求至少有一个参数。第一个形式参数可以叫做 self(self 只是个惯用标识符,可以换名字),这个参数位置就留给了 self。self 指代当前实例本身。问题:上例中,类是谁?实例是谁?
2.3 实例化
定义完类后,并不会真正创建一个实例。这有点像一个汽车的设计图。设计图可以告诉你汽车看上去怎么样,但设计图本身不是一个汽车。你不能开走它,它只能用来建造真正的汽车,而且可以使用它制造很多汽车。那么如何创建实例呢?class 语句本身并不创建该类的任何实例。所以在类定义完成以后,可以创建类的实例,即实例化该类的对象。创建类的实例的语法如下:
ClassName(parameterlist)
其中,ClassName 是必选参数,用于指定具体的类;parameterlist 是可选参数,当创建一个类时,没有创建 __init__()
方法(该方法将在后续中进行详细介绍),或者 __init__()
方法只有一个 self 参数时,parameterlist 可以省略。
创建 2.2.2 创建数据成员并访问 小节定义的 Person 类的实例,可以使用下面的代码:
a = Person() # 实例化
# 0x000001B2A6CFA680是实例的内存地址的十六进制表达
print(a) # <__main__.Person object at 0x000001B2A6CFA680>
使用上面的语法,在类对象名称后面加上一个括号,就调用类的实例化方法,完成实例化。实例化就真正创建一个该类的对象(实例 instance)。例如:
tom = Person() # 不同的实例
jerry = Person() # 不同的实例
上面的 tom、jerry 都是 Person 类的实例,通过实例化生成了2个不同的实例。通常,每次实例化后获得的实例,是不同的实例,即使是使用同样的参数实例化,也得到不一样的对象。Python 类实例化后,会自动调用 __init__
方法。这个方法第一个形式参数必须留给 self,其它形式参数随意。
2.3.1 构造的2个阶段
确切地讲, tom = Person() 过程分为2个阶段:实例化和初始化。如同,流水线上生成一辆汽车,首先得先造一个车的实例,即造一辆实实在在的一个真实的车。但是这个车不能直接交付给消费者。而 __init__
方法称为初始化方法,要对生成出的每一辆车做出厂配置。这才能得到一个能使用的汽车。但是需要注意的是,很多人习惯上把这两个阶段不加区分含糊的叫做实例化、初始化,说的就是这两个阶段的总称。
2.3.2 创建__init__()方法
在创建类后,可以手动创建一个 __init__
方法。该方法是一个特殊的方法,类似 Java 语言中的构造方法。每当创建一个类的新实例时,Python 都会自动执行它,可以不定义,如果没有定义会在实例化后隐式调用其父类的。__init__
方法必须包含一个 self 参数,并且必须是第一个参数。self 参数是一个指向实例本身的引用,用于访问类中的属性和方法。在方法调用时会自动传递实际参数 self,因此当 __init__
方法只有一个参数时,在创建类的实例时,就不需要指定实际参数了。示例:
class Person:
def __init__(self):
print('init~~~~~~')
print(Person) # 不会调用__init__ <class '__main__.Person'> __main__是模块
print(Person()) # 会调用__init__ <__main__.Person object at 0x00000199E0EB3BB0>
tom = Person() # 会调用__init__
初始化函数可以多个参数,但注意第一个位置必须是 self,例如 __init__(self, name, age)
,代码如下:
class Person:
def __init__(self, name, age):
print('init~~~~~~')
self.name = name
self.age = age
def showage(self):
print("{} is {}.".format(self.name, self.age))
tom = Person('Tom', 20) # 实例化,会调用__init__方法并为实例进行属性的初始化
# init~~~~~~
print(tom.name, tom.age) # Tom 20
tom.showage() # Tom is 20.
jerry = Person('Jerry', 18)
# init~~~~~~
print(jerry.name, jerry.age) # Jerry 18
jerry.age += 1
print(jerry.name, jerry.age) # Jerry 19
jerry.showage() # Jerry is 19.
注意: __init__()
方法不能有返回值,也就是只能是 return None。
2.3.3 实例对象instance
上例中,类 Person 实例化后获得一个该类的实例,就是实例对象。上例中的 tom、jerry 就是 Person 类的实例。__init__
方法的第一参数 self 就是指代某一个实例自身。执行 Person('Tom', 20)
时,调用 __init__
方法。self.name 就是 tom 对象的 name,name 是保存在了 tom 对象上,而不是 Person 类上。所以,称为实例变量。类实例化后,得到一个实例对象,调用方法时采用 tom.showage() 的方式,但是 showage 方法的形参需要一个形参 self,我们并没有提供,并没有报错,为什么?
方法绑定: 采用 tom.showage() 的方式调用,实例对象会绑定到方法上。这个 self 就是 tom,指向当前调用该方法的实例本身。tom.showage() 调用时,会把方法的调用者 tom 实例作为第一参数 self 的实参传入
__init__()
方法。
2.3.4 self
class Person:
def __init__(self):
print(1, 'self in init = {}'.format(id(self)))
def showme(self):
print(2, 'self in showme = {}'.format(id(self)))
tom = Person()
'''
1 self in init = 1560779962832
3 tom = 1560779962832
------------------------------
2 self in showme = 1560779962832
'''
print(3, 'tom = {}'.format(id(tom)))
print('-' * 30)
tom.showme()
上例说明,self 就是调用者,就是 tom 对应的实例对象。再次强调 self 这个形参标识符的名字只是一个惯例,它可以修改,但是请不要修改,否则影响代码的可读性。看打印的结果,思考一下执行的顺序,为什么?
2.4 实例变量和类变量
# -*- coding: utf-8 -*-
# @Time : 2024-10-20 9:38
# @Author : AmoXiang
# @File: 2.类变量与实例变量.py
# @Software: PyCharm
# @Blog: https://blog.csdn.net/xw1680
class Person:
age = 3
def __init__(self, name):
self.name = name
# TypeError: Person.__init__() takes 2 positional arguments but 3 were given
# tom = Person('Tom', 20) # 错误,只能传一个实参
tom = Person('Tom')
jerry = Person('Jerry')
print(tom.name, tom.age) # Tom 3
print(jerry.name, tom.age) # Jerry 3
# print(Person.name) # 能访问吗? 不能
# print(Person.name) AttributeError: type object 'Person' has no attribute 'name'
print(Person.age) # 3
Person.age = 30
print(Person.age, tom.age, jerry.age) # age分别是多少?30 30 30
实例变量是每一个实例自己的变量,是自己独有的。类变量是类的变量,是类的所有实例共享的属性或方法。
2.5 特殊属性
特殊属性含义:
__name__ 对象名
__class__ 对象的类型
__dict__ 对象的属性的字典
__qualname__ 类的限定名
注意: Python 中每一种对象都拥有不同的属性。函数是对象,类是对象,类的实例也是对象。
2.6 属性本质
示例代码:
# -*- coding: utf-8 -*-
# @Time : 2024-10-20 10:12
# @Author : AmoXiang
# @File: 3.属性本质.py
# @Software: PyCharm
# @Blog: https://blog.csdn.net/xw1680
class Person:
age = 3
def __init__(self, name):
self.name = name
print('----类----')
# <class 'type'> <class 'type'> True
print(Person.__class__, type(Person), Person.__class__ is type(Person)) # 类型
'''[('__dict__', <attribute '__dict__' of 'Person' objects>), ('__doc__', None),
('__init__', <function Person.__init__ at 0x000001CACCDFD630>), ('__module__', '__main__'),
('__weakref__', <attribute '__weakref__' of 'Person' objects>), ('age', 3)]'''
print(sorted(Person.__dict__.items()), end='\n\n') # 类字典
tom = Person('Tom')
print('----通过实例访问类----')
# <class '__main__.Person'> <class '__main__.Person'> True
print(tom.__class__, type(tom), tom.__class__ is type(tom))
# Person Person
print(tom.__class__.__name__, type(tom).__name__)
'''[('__dict__', <attribute '__dict__' of 'Person' objects>), ('__doc__', None),
('__init__', <function Person.__init__ at 0x000001CACCDFD630>), ('__module__', '__main__'),
('__weakref__', <attribute '__weakref__' of 'Person' objects>), ('age', 3)]'''
print(sorted(tom.__class__.__dict__.items()))
print('----实例自己的属性----')
# [('name', 'Tom')]
print(sorted(tom.__dict__.items())) # 实例的字典
上例中,可以看到类属性保存在类的 __dict__
中,实例属性保存在实例的 __dict__
中,如果从实例访问类的属性,也可以借助 __class__
找到所属的类,再通过类来访问类属性,例如 tom.__class__.age
。 有了上面知识,再看下面的代码:
class Person:
age = 3
height = 170 # age,height 两个类属性
def __init__(self, name, age=18):
self.name = name
self.age = age # name,age实例属性
tom = Person('Tom') # 实例化、初始化
jerry = Person('Jerry', 20)
Person.age = 30
# 1 30 18 20
print(1, Person.age, tom.age, jerry.age) # 输出什么结果
# 2 170 170 170
print(2, Person.height, tom.height, jerry.height) # 输出什么结果
jerry.height = 175
# 3 170 170 175
print(3, Person.height, tom.height, jerry.height) # 输出什么结果
tom.height += 10
# 4 170 180 175
print(4, Person.height, tom.height, jerry.height) # 输出什么结果
# 5 185 180 175
Person.height += 15
print(5, Person.height, tom.height, jerry.height) # 输出什么结果
Person.weight = 70
# 6 70 70 70
print(6, Person.weight, tom.weight, jerry.weight) # 输出什么结果
print(7, tom.__dict__['height']) # 可以吗 可以
# print(8, tom.__dict__['weight']) # 可以吗 不可以 KeyError: 'weight'
总结: 是类的,也是这个类所有实例的,其实例都可以访问到;是实例的,就是这个实例自己的,通过类访问不到。类变量是属于类的变量,这个类的所有实例可以共享这个变量。对象(实例或类)可以动态的给自己增加一个属性(赋值即定义一个新属性)。这也是动态语言的特性。实例.__dict__[变量名]
和 实例.变量名
都可以访问到实例自己的属性(注意这两种访问是有本质区别的)。对实例访问来说,实例的同名变量会隐藏掉类变量,或者说是覆盖了这个类变量。但是注意类变量还在那里,并没有真正被覆盖。实例属性的查找顺序,指的是实例使用 .点号 来访问属性,会先找自己的 __dict__
,如果没有,然后通过属性 __class__
找到自己的类,再去类的 __dict__
中找。注意:如果实例使用 __dict__[变量名]
访问变量,将不会按照上面的查找顺序找变量了,这是指明使用字典的 key 查找,不是属性查找。一般来说,类变量可使用全大写来命名。
2.7 类方法和静态方法
前面的例子中定义的 __init__
等方法,这些方法本身都是类的属性,第一个参数必须是 self,而 self 必须指向一个对象,也就是类实例化之后,由实例来调用这个方法。
2.7.1 普通函数
class Person:
def general_function():
print('普通的函数')
# <function Person.general_function at 0x000001CDC6BCD630>
print(Person.general_function) # 类中定义的标识符,依然是类属性
# 普通的函数
Person.general_function() # 可以吗?可以
# 方法绑定,TypeError: Person.general_function() takes 0 positional arguments but 1 was given
# 会把Person()作为引用传入函数general_function中,但是函数中没有形参接受参数
# Person().general_function() # 可以吗?不可以
print(Person.__dict__) # 是类属性吗?是
使用类直接调用该方法,没有任何实例的绑定,直接调用即可。实例调用类属性定义的方法时,会绑定实例到该方法上,general_function 定义时,没有指定一个形参,导致绑定对象注入时没有对应的形参。上例只是为了学习原理,在 Python 类定义的时候,禁用这种定义方式。
2.7.2 普通方法
下面定义的方式,是最常见的定义方法的方式,应该重点掌握。
class Person:
def showself(self):
print('普通的方法,和我自己有关。self={}'.format(self))
# TypeError: Person.showself() missing 1 required positional argument: 'self'
# Person.showself() # 可以吗?不可以,类直接调用,没有方法绑定,不会注入参数
Person().showself() # 可以吗?可以的
print(Person.__dict__) # 是类属性吗?是类属性
print(Person().showself) # 看看实例绑定
showself 是类属性,也是方法。Person.showself() 即使用类访问该方法,没有实例绑定,那么第一参数 self 缺失而报错。Person().showself() 即使用实例访问该方法,有实例绑定,实参 Person() 是自动注入的。注意普通方法定义中 self,往往提醒这个方法和实例有关。
2.7.3 类方法
类方法,相对普通方法使用较少。classmethod() 函数的语法格式有2种形式。如下:
# 1.第一种
classmethod(function) # function: 方法名
# 2.第二种
@classmethod
def f(cls, arg1, arg2, ...): ...
参数: 无
返回值: 返回函数的类方法
使用 classmethod() 函数设置类方法,代码如下:
class Student:
age = 18
def print_age(cls):
print('The age is:', cls.age)
Student.print_age = classmethod(Student.print_age) # 创建类的方法
Student.print_age() # 调用类的方法 The age is: 18
上述代码中,定义了一个 Student 类,它有一个 age 类属性和 print_age() 方法。值得注意的是 print_age() 函数第一个参数是 cls 而不是 self,它表示类对象不是实例对象。然后调用 classmethod() 函数将 print_age() 方法设置为类方法。使用 @classmethod 将方法设置为类方法,代码如下:
class A(object):
bar = 1
def func1(self):
print('func1')
@classmethod
def func2(cls):
print('func2')
print(cls.bar)
cls().func1() # 调用 foo 方法
'''
func2
1
func1
'''
A.func2() # 不需要实例化
其他示例:
class Person:
@classmethod
def class_method(cls):
print('类方法')
print("{0}'s name = {0.__name__}".format(cls))
cls.HEIGHT = 170
# 调用
Person.class_method() # 可以吗? 可以
Person().class_method() # 可以吗?可以
print(Person.__dict__) # 是类属性吗?是
类方法小结:
- 在类定义中,使用 @classmethod 装饰器修饰的方法
- 必须至少有一个参数,且第一个参数留给了 cls,cls 指代调用者即类对象自身
- cls 这个标识符可以是任意合法名称,但是为了易读,请不要修改
- 通过 cls 可以直接操作类的属性
通过类、实例都可以非常方便地调用类方法。classmethod 装饰器内部将类(或提取实例的类)注入到类方法的第一个参数中。注意:无法通过 cls 操作类的实例。为什么?在 __init__()
初始化方法中,调用类方法为实例属性赋值。代码如下:
class Student:
name = 'student'
class ApplyBatchImpl:
def __init__(self, table, table_args, table_kwargs):
self.table = table # table 对象
self.table_args = table_args
self.table_kwargs = table_kwargs
self.temp_table_name = self._calc_temp_name(table.name)
@classmethod
def _calc_temp_name(cls, table_name):
return ("_alembic_tmp_%s" % table_name)[0:50]
apply = ApplyBatchImpl(Student, [], {})
print(apply.temp_table_name)
2.7.4 静态方法
静态方法,用的极少,偶尔在源码中见到。staticmethod() 函数的语法格式同样有2种形式。
staticmethod(function) # function: 方法名
@staticmethod
def f(cls, arg1, arg2, ...): ...
参数: 无
返回值: 返回函数的静态方法
使用 classmethod() 函数设置静态方法,代码如下:
class Mathematics:
# add_numbers()静态方法的参数没有self或cls
def add_numbers(x, y):
return x + y
# 创建add_numbers静态方法
Mathematics.add_numbers = staticmethod(Mathematics.add_numbers)
# The sum is: 15
print('The sum is:', Mathematics.add_numbers(5, 10))
使用 @classmethod 将方法设置为静态方法,代码如下:
class Dates:
@staticmethod
def to_dash_date(date):
'''用户转换日期的静态方法'''
return date.replace("/", "-") # 将日期中的"/"转化为"-"
dateString = "2024/10/20"
dateWithDash = Dates.to_dash_date(dateString) # 调用静态方法
# 转化后的日期为:2024-10-20
print('转化后的日期为:{}'.format(dateWithDash))
说明: 使用自定义函数的方式同样可以实现日期转换的功能,但是由于日期转换的功能与 Dates 类关联性较大,所以,通常将其作为静态函数使用。其他示例:
class Person:
HEIGHT = 180
@staticmethod
def static_method():
print('静态方法')
print(Person.HEIGHT)
# 调用
Person.static_method() # 可以吗?可以
Person().static_method() # 可以吗?可以
print(Person.__dict__) # 是类属性吗?是
静态方法:
- 在类定义中,使用 @staticmethod 装饰器修饰的方法
- 调用时,不会隐式的传入参数
通过类、实例都可以调用静态方法,不会像普通方法、类方法那样注入参数。静态方法,只是表明这个方法属于这个名词空间。函数归在一起,方便组织管理。
2.7.5 方法的调用
类可以定义这么多种方法,究竟如何调用它们?类几乎可以调用所有内部定义的方法,但是调用普通的方法时会报错,原因是第一参数应该是类的实例。实例也几乎可以调用所有的方法, 普通的函数的调用一般不可能出现,因为原则上不允许这么定义。总结:
- 类除了普通方法都可以调用
- 普通方法需要对象的实例作为第一参数
- 实例可以调用所有类中定义的方法(包括类方法、静态方法),普通方法传入实例自身,静态方法和类方法内部都要使用实例的类
回答下面问题:
class Person:
def method(self):
print(self) # 实例自身
print(__class__) # 当前类
print(__class__.__name__) # 当前类名字
print(__name__) # 当前模块名
tom = Person()
tom.method() # 可以吗 可以
Person.method(1) # 可以吗 可以
Person.method(tom) # 可以吗 可以
tom.__class__.method(tom) # 可以吗 可以
tom.method() 调用的时候,会绑定实例,调用 method 方法时,实例 tom 会注入到 method 中,这样第一参数就满足了。Person.method(),使用类调用,不会有实例绑定,调用 method 方法时,就缺少了第一参数,可以手动的填入。
2.8 访问控制
2.8.1 私有(Private)成员
在 Python 中,在类变量或实例变量前使用两个下划线的变量,称为私有成员,包括私有属性、私有方
法。
# -*- coding: utf-8 -*-
# @Time : 2024-10-20 11:07
# @Author : AmoXiang
# @File: 6.访问控制.py
# @Software: PyCharm
# @Blog: https://blog.csdn.net/xw1680
class Person:
def __init__(self, name, age=18):
self.__name = name
self.__age = age
def __showage(self):
print(self.__age)
print(Person.__name) # 可以吗?
print(Person.__showage) # 可以吗?
tom = Person('Tom')
# AttributeError: 'Person' object has no attribute '__name'
# print(tom.__name) # 可以吗?不可以
# AttributeError: 'Person' object has no attribute '__showage'
print(tom.__showage) # 可以吗?不可以
在类的定义范围内,使用前置双下划线的标识符,在类外部将不能直接访问。
2.8.2 私有成员本质
class Person:
def __init__(self, name, age=18):
self.__name = name
self.__age = age
def __showage(self):
print(self.__age)
print(Person.__dict__)
tom = Person('Tom')
# {'_Person__name': 'Tom', '_Person__age': 18}
print(tom.__dict__)
打开类字典和实例字典,一目了然,都被悄悄的改了名称,所以使用定义的名字就访问不了了。名称都被前置了 _类名
前缀。如果知道了改后的名称,照样可以访问,就绕过了 Python 做的限制。Python 就没有真正的私有成员!但是请遵守这个约定,不要在类外面访问类私有或者实例的私有成员。因为类的作用就是封装,私有成员就是要被隐藏的数据或方法。
2.8.3 保护(protected)成员
在类变量或实例变量前使用一个下划线的变量,称为保护成员。
class Person:
def __init__(self, name, age=18):
self._name = name
self._age = age
def _showage(self):
print(self._age)
print(Person.__dict__)
tom = Person('Tom')
print(tom.__dict__) # {'_name': 'Tom', '_age': 18}
tom._showage() # 可以吗 可以
print(tom._name, tom._age) # 可以吗 可以
保护成员不是 Python 中定义的,是 Python 编程者自我约定俗成的,请遵守这个约定。总结: 在 Python 中使用 _
单下划线 或者 __
双下划线 来标识一个成员被保护或者被私有化隐藏起来。但是,不管使用什么样的访问控制,都不能真正的阻止用户修改类的成员。Python 中没有绝对的安全的保护成员或者私有成员。因此,前导的下划线只是一种警告或者提醒,请遵守这个约定。除非真有必要,不要修改或者使用保护成员或者私有成员,更不要修改它们。在 Pycharm 中,已经对访问私有、保护成员访问的时候不会直接提示,就是一种善意的提醒。如下图所示:
2.9 属性装饰器
本小节介绍的属性与之前小节介绍的类属性和实例属性不同。之前小节介绍的属性将返回所存储的值,而本节要介绍的属性则是一种特殊的属性,访问它时将计算它的值。另外,该属性还可以为属性添加安全保护机制。
2.9.1 创建用于计算的属性
在 Python 中,可以通过 @property(装饰器)将一个方法转换为属性,从而实现用于计算的属性。将方法转换为属性后,可以直接通过方法名来访问方法,而不需要再添加一对小括号 "()"
, 这样可以让代码更加简洁。通过 @property 创建用于计算的属性的语法格式如下:
@property
def methodname(self):
block
参数说明:
1.methodname: 用于指定方法名,一般使用小写字母开头。该名称最后将作为创建的属性名
2.self: 必要参数,表示类的实例
3.block: 方法体,实现的具体功能。在方法体中,通常以return语句结束,用于返回计算结果
例如,定义一个矩形类,在 __init__()
方法中定义两个实例属性,然后再定义一个计算矩形面积的方法,并应用 @property 将其转换为属性,最后创建类的实例,并访问转换后的属性,代码如下:
# -*- coding: utf-8 -*-
# @Time : 2024-10-20 11:16
# @Author : AmoXiang
# @File: 7.属性装饰器.py
# @Software: PyCharm
# @Blog: https://blog.csdn.net/xw1680
class Rect:
def __init__(self, width, height):
self.width = width # 矩形的宽
self.height = height # 矩形的高
@property # 将方法转换为属性
def area(self): # 计算矩形的面积的方法
return self.width * self.height # 返回矩形的面积
rect = Rect(800, 600) # 创建类的实例
# 面积为: 480000
print("面积为: ", rect.area) # 输出属性的值
注意: 通过 @property 转换后的属性不能重新赋值,如果对其重新赋值,将抛出如下图所示的异常信息:
2.9.2 为属性添加安全保护机制
一般好的设计是:把实例的某些属性保护起来,不让外部直接访问,外部使用 getter 读取属性和 setter 方法设置属性。
class Person:
def __init__(self, name):
self._name = name
def name(self):
return self._name
def set_name(self, value):
self._name = value
tom = Person('Tom')
print(tom.name()) # Tom
tom.set_name('Jerry')
print(tom.name()) # Jerry
Python 提供了 property 装饰器,简化调用。
class Person:
def __init__(self, name):
self._name = name
@property
def name(self):
return self._name
@name.setter
def name(self, value):
self._name = value
@name.deleter
def name(self):
# del self._name
print('del name')
tom = Person('Tom')
print(tom.name) # Tom
tom.name = 'Jerry'
print(tom.name) # Jerry
del tom.name # del name
特别注意:使用 property 装饰器的时候这三个方法同名。property 装饰器,后面跟的函数名就是以后的属性名,它就是 getter,这个必须有,有了它至少是只读属性。setter 装饰器与属性名同名,且接收2个参数,第一个是 self,第二个是将要赋值的值。有了它,属性可写。deleter 装饰器,可以控制是否删除属性,很少用。property 装饰器必须在前,setter、deleter 装饰器在后,property 装饰器能通过简单的方式,把对方法的操作变成对属性的访问,并起到了一定隐藏效果。其它写法:
class Person:
def __init__(self, name):
self._name = name
def get_name(self):
return self._name
def set_name(self, value):
self._name = value
def del_name(self):
# del self._name
print('del name')
name = property(get_name, set_name, del_name)
tom = Person('Tom')
print(tom.name) # Tom
tom.name = 'Jerry'
print(tom.name) # Jerry
del tom.name # del name
这种定义方式,适合 get_name、set_name、del_name,还可以单独使用,即可以当方法使用。
2.10 封装总结
面向对象的三要素之一,封装(Encapsulation)。封装,将数据和操作组织到类中,即属性和方法
将数据隐藏起来,给使用者提供操作(方法),使用者通过操作就可以获取或者修改数据,getter 和 setter,通过访问控制,暴露适当的数据和操作给用户,该隐藏的隐藏起来,例如保护成员或私有成员。
三、案例
3.1 跑步案例
# -*- coding: utf-8 -*-
# @Time : 2024-10-20 11:38
# @Author : AmoXiang
# @File: 8.两个简单案例.py
# @Software: PyCharm
# @Blog: https://blog.csdn.net/xw1680
# 封装是面向对象编程的一大特点
# 面向对象编程的第一步:将属性和方法封装到一个抽象的类中
# 外界使用类创建对象,然后让对象调用方法
# 对象方法的细节都被封装在类的内部
"""
需求:amo和jerry都爱跑步
amo体重75.0公斤
jerry体重45.0 公斤
每次跑步都会减少0.5公斤
每次吃东西都会增加1公斤
"""
class Person:
def __init__(self, name, weight):
self.name = name
self.weight = weight
def run(self):
self.weight -= 0.5
print("%s 爱锻炼,跑步锻炼身体" % self.name)
def eat(self):
self.weight += 1
# 注意: 在对象的方法内部,是可以直接访问对象的属性的
print("%s 是吃货,吃完这顿在减肥" % self.name)
def __str__(self):
return "我的名字是%s,体重%.2f公斤" % (self.name, self.weight)
amo = Person("amo", 75.0)
amo.run()
amo.eat()
amo.eat()
print(amo)
print("-" * 50)
jerry = Person("jerry", 45.0) # 同一个类创建的多个对象之间,属性互不干扰
jerry.run()
jerry.eat()
jerry.eat()
print(jerry)
3.2 家具案例
# -*- coding: utf-8 -*-
# @Time : 2024-10-20 11:38
# @Author : AmoXiang
# @File: 8.两个简单案例.py
# @Software: PyCharm
# @Blog: https://blog.csdn.net/xw1680
"""
需求:
1.房子(House)有户型、总面积和家具名称列表
新房子没有任何的家具
2.家具(HouseItem)有名字和占地面积,其中
席梦思(bed)占地4平米
衣柜(chest)占地2平米
餐桌(table)占地1.5平米
3.将以上三件家具添加到房子中
4.打印房子时,要求输出:户型、总面积、剩余面积、家具名称列表
"""
# 分析: 要去添加家具 首先肯定要有一个家具类 被使用的类 通常应该先被开发
class HouseItem:
def __init__(self, name, area):
self.name = name # 家具名称
self.area = area # 占地面积
def __str__(self):
return "[%s] 占地面积 %.2f" % (self.name, self.area)
bed = HouseItem("bed", 4) # 席梦思
chest = HouseItem("chest", 2) # 衣柜
table = HouseItem("table", 1.5) # 餐桌
print(bed)
print(chest)
print(table)
class House:
def __init__(self, apartment, area):
self.apartment = apartment # 户型
self.total_area = area # 总面积
self.free_area = area # 剩余面积开始的时候和总面积是一样的
self.furniture_list = [] # 家具名称列表 默认是没有放置任何家具的
def add_item(self, furniture):
"""添加家具"""
print(f"添加:{furniture.name}")
# 判断家具的面积是否超过剩余面积,如果超过,提示不能添加这件家具
if furniture.area > self.free_area:
print("房间剩余面积不够,不能添置这件家具...")
return
self.furniture_list.append(furniture.name) # 将家具名称追加到家具名称列表中
self.free_area -= furniture.area # 用房子的剩余面积-家具面积
def __str__(self):
return f"房子户型为:{self.apartment},总面积为:{self.total_area}," \
f"剩余面积为{self.free_area},家具列表为:{self.furniture_list}"
if __name__ == "__main__":
my_house1 = House("两室一厅", 60)
my_house1.add_item(bed)
my_house1.add_item(chest)
my_house1.add_item(table)
print(my_house1)
my_house2 = House("一室一厅", 10)
my_house2.add_item(bed)
my_house2.add_item(chest)
my_house2.add_item(table)
my_house2.add_item(bed)
print(my_house2)