面向对象设计

面向对象设计是一种新的软件设计思想,意在模拟真实的世界🌏运作。通过面向对象设计衍生出来的面向对象程序设计能让我们尽可能模拟人类👶的思维方式,使得软件的开发方法与过程尽可能接近人类认识世界。

下面是百度百科关于面向对象程序设计的介绍:

面向对象程序设计(Object Oriented Programming)作为一种新方法,其本质是以建立模型体现出来的抽象思维过程和面向对象的方法。模型是用来反映现实世界中事物特征的。任何一个模型都不可能反映客观事物的一切具体特征,只能对事物特征和变化规律的一种抽象,且在它所涉及的范围内更普遍、更集中、更深刻地描述客体的特征。通过建立模型而达到的抽象是人们对客体认识的深化。

  1. 面向对象程序设计(Object Oriented Programming,OOP)是一种计算机编程架构。OOP 的一条基本原则是计算机程序由单个能够起到子程序作用的单元或对象组合而成。OOP 达到了软件工程的三个主要目标:重用性、灵活性和扩展性。OOP = 对象 + 类 + 继承 + 多态 + 消息,其中核心概念是类和对象。
  2. 面向对象程序设计方法是尽可能模拟人类的思维方式,使得软件的开发方法与过程尽可能接近人类认识世界、解决现实问题的方法和过程,也即使得描述问题的问题空间与问题的解决方案空间在结构上尽可能一致,把客观世界中的实体抽象为问题域中的对象。
  3. 面向对象程序设计以对象为核心,该方法认为程序由一系列对象组成。类是对现实世界的抽象,包括表示静态属性的数据和对数据的操作,对象是类的实例化。对象间通过消息传递相互通信,来模拟现实世界中不同实体间的联系。在面向对象的程序设计中,对象是组成程序的基本模块。

对于面向对象程序设计,百度百科的介绍还是不够具体。如果我们想要使用面向对象的思维进行程序设计,还是会无从下手。所以在开始学习面向对象设计之前,我们还需要了解以下概念:软件要求、概念和技术设计用户故事耦合和内聚关注点分离信息隐藏概念完整性模型检查。如果你了解这些概念,也可以直接从面向对象建模开始。


软件要求、概念和技术设计

🔖 我们可以将开发软件视为一个处理问题并产生涉及软件的解决方案的过程。通常,这是一个迭代的过程,每次迭代都将一组需求带到工作和测试的实现中,并最终构建一个完整的解决方案。在每一次迭代中,我们需要完成以下步骤:

👉引出需求:当我们有了一组初始需求时,需要将它概述为解决方案。就是将一组初始需求变成一个解决整体问题的方案,确保能够加以快速有效的执行。在此活动中需要生成“概念设计”,然后再生成“详细设计”。

👉概念设计:概念设计是我们对如何满足需求的初步想法,不涉及任何技术细节。

👉详细设计:在详细设计中,我们需要开始考虑每个组件的技术细节,可以通过将组件拆分为越来越小的组件来完成。如果这些组件不够具体、有结构需要调整、不能够进行详细设计,则需要重新回到“概念设计”进行调整。

开发软件的步骤分为需求分析、概念设计、详细设计。


用户故事

🔖 用户故事是从用户的角度来描述用户渴望得到的功能,我们可以利用它去更好的表述我们的需求。

作为一个______,我想______,以便______。

作为 在线购物者我想 将商品添加到我的购物车中以便 我可以购买它。

🔖 通常,名词对应软件中的对象。因此,在此示例中,我们标识了三个对象:

  1. 用户角色与软件中的对象(在线购物者)相关联。
  2. 商品对象可以是商店中的任何产品。
  3. 购物车对象是用于跟踪要购买的商品的。

🔖 谓词可以帮助我们确定对象可能具有的责任或者识别对象之间的联系。因此,在此示例中,我们可以得出:

  1. 添加和购买可能是购物车的职责。
  2. 一个在线购物者通常与一个购物车相关联,同时购物车应该能够容纳多个商品。

❗用户故事是可用于表达软件系统需求的众多技术之一。它们易于使用,可以让我们在使用面向对象的思维时发现对象和进一步的需求。


耦合和内聚

乔治·米勒(George Miller)在一篇心理学论文中观察到了这一点,其中受试者必须回忆 1 到 14 个随机的声音和图像。当人数达到 7 左右时,受试者开始回忆失败。在编程时,保持模块简单至关重要。一旦你的设计复杂性超过了开发人员的心理处理能力,错误就会更频繁地发生。我们将用耦合和内聚评估设计的复杂性。耦合侧重于模块与其他模块之间的复杂性。内聚侧重于模块内的复杂性。

在设计系统时,我们需要将各种模块组合在一起。

❎ 把一个糟糕的设计想象成拼图,你的模块就是拼图。你只能将一个拼图连接到另一个特定的拼图,而不能连接其他拼图。

✅ 一个设计良好的系统就像乐高积木。你可以毫不费力地连接任意两个乐高积木,并且所有乐高积木都相互兼容。

🔖 在设计系统时,我们应该让它像乐高一样。这样就可以轻松地将模块连接在一起并重复使用。

❗模块的耦合表示模块连接到其他模块的复杂性。

❎ 如果一个模块高度依赖其他模块,那么就说明这个模块与其他模块紧密耦合,就像拼图一样;

✅ 如果一个模块很容易连接到其他模块,那么就说明这个模块与其他模块松散耦合。就像乐高。

❗内聚力表示模块职责的明确性,它是描述模块内部的。

❎ 如果一个模块试图封装多个任务或目的不明确,则该模块的内聚力较低。

✅ 如果一个模块只执行一项任务而不执行其他任务或有明确的目的,则该模块具有高内聚力。

🔖 我们不仅需要考虑模块与模块间的耦合度,还需要注意模块内的内聚力。如果你发现你的模块中有多个职责,那么你就需要拆分你的模块。

例如,现在有个传感器类,有个 getter 方法,1️⃣标志位代表获取温度,0️⃣标志位代表获取湿度。那么从内聚角度看,这个类低内聚,因为它没有指出明确的用途。从耦合角度看,这个类高耦合,因为我们如果不阅读它的接口文档,就不知道如何获取温度湿度。

🎈 接下来我们来重新设计这个类。

传感器类现在被湿度传感器类和温度传感器类所取代。这两个类都有一个明确定义的用途。由于每个类都有明确的目的,因此可以说这些类具有高度的凝聚力。湿度传感器的 getter 方法返回湿度,温度传感器的 getter 方法返回温度。这使得其他类使用它们其中的任何一个类时都是松散耦合的。

❗一般来说,在设计中,需要在低耦合和高内聚之间取得平衡。对于复杂的系统,复杂性可以分布在模块之间或者模块内部。随着模块的简化以实现高内聚,那么它们就需要依赖更多其他的模块,从而变得高耦合。如果模块之间的连接被简化以实现低耦合,那么模块内部就需要承担更多的责任,从而变得低内聚。


关注点分离

关注点分离可以帮助我们创建一个灵活、可重用和可维护的系统。关注点分离是应用于整个面向对象建模和编程的关键思想。

举个例子,我们都知道狗的一些基本行为,比如走路、跑步、说话和吃饭。虽然这些行为很容易识别和抽象,但我们还需要思考以下两点:

❓ 狗可以自己做哪些行为?

❓ 哪些需要其他事或其他人的帮助?

如果我们仔细研究过狗的饮食行为,我们可能会得出这样的结论。“狗有食物,它知道如何吃,主人可以通过给狗食物来告诉它吃食物。”但这是模拟情况的正确方法吗?

❓ 谁在给狗喂食?

❓ 狗总是有食物吃,还是需要主人给狗食物才有得吃?

实际上,狗需要主人来喂养它。狗知道如何吃食物,但在主人喂它之前,它对它将要吃的食物一无所知。这就出现了两个问题:“饮食的行动”和“提供食物的行动”。这可以通过引入狗主人类来完成。

🎈 在我们的新设计中,狗类只知道如何吃食物。狗主人类是知道如何获取狗粮以及如何给狗食物的类。我们已经消除了狗是如何获取食物的问题,让狗主人来处理这个问题。

🔖 使用关注点分离时,我们应该只将行为和属性封装在与所述行为和属性相关的类中。这有助于我们创建一个模块化的系统,其中各个类可以轻松地交换,而无需重写大部分代码。

再比如说,智能手机有许多功能,包括照相、录音、通信等等。要解决这些问题就要把问题抛给指定对象,让相机对象来满足智能手机能照相的功能。让录音机对象满足智能手机能录音的功能。而智能手机只需要关注如何组合它们,并不要求知道它们内部是如何实现的。

🔖 我们的目标是创建灵活的可重用且可维护的代码。关注点分离使用抽象、封装、分解和泛化创建更具凝聚力的类。这将创建一个更易于维护的系统,因为每个类都经过组织,因此它只包含完成其工作所需的代码。这使得开发人员能够重用和构建单个类而不影响其他类。

❗关注点分离要求我们在建模时,将问题分解,让每个对象专注于每个问题。


信息隐藏

信息隐藏允许我们的系统模型为其他人提供正确使用它们所需的最少信息量并隐藏其他所有内容。信息隐藏允许开发人员与需要了解该模块的实现细节的其他开发人员单独处理模块。他们只能通过其接口使用此模块。通常,可能会更改的内容(例如实现细节)应隐藏。而不应该改变的事情,都用接口暴露出来。

例如,我们都在同一个系统上,但在不同的模块上工作。如果我的模块需要来自你的模块的信息,则信息隐藏要求你仅向我提供模块工作所需的信息。不必让我访问模块中的所有内容,你也不需要知道我的模块是如何工作的。

🔖 信息隐藏通常与封装相关联。我们使用封装将属性和行为捆绑到它们的相应类中,并公开一个接口来提供访问权限。

🔖 封装有效地隐藏了行为的实现,因为唯一的访问是通过特定方法的接口。其他类只能依赖于这些方法接口中的信息,而不依赖于具体的方法实现。通过封装隐藏的信息使我们能够在不改变预期结果的情况下更改实现。

❗信息隐藏能够让我们控制要共享的信息以及要让其他人看到的行为。外部知道的信息越少,才能更有利于构建灵活、可重用和可维护的系统。


概念完整性

概念完整性通常被认为是系统设计中最重要的考虑因素。著名的计算机架构师弗雷德·布鲁克斯(Fred Brooks)在他的书《神话人月》(The Mythical Man-Month)中指出,最好让一个系统省略某些异常功能和改进,去反映一组设计思想,而不是拥有一个包含许多良好却独立和不协调的想法的系统。简而言之,概念完整性是关于以一致的方式设计和实现软件,就好像它是由一个人编写的一样。在软件中实践概念完整性有助于指导团队编写软件。如果每个团队成员都看到软件的设计和逻辑是一致且易于遵循的,这将帮助他们知道如何以及在何处更改软件以满足新的要求。

例如,在Unix操作系统中,每个资源都可以像文件一样被查看和操作。同一组操作可用于不同类型的资源。这简化了事情,使任何资源都可以以相同的方式处理。统一概念可以避免出现特殊情况,能让软件具有一致性。

例如,团队成员都可以遵循特定的命名约定。

例如,在前后端分离项目中,封装后端返回给前端的结果集。

❗概念完整性可以帮助我们创建一致且设计良好的软件。


模型检查

模型检查是对系统状态模型所有可能发生的事情进行的系统检查,我们需要检查软件的所有状态,并通过模拟会更改软件状态和变量的不同事件来发现存在的任何错误。以此来确保软件的状态模型是否包含一些缺陷。或者说我们需要对我们的软件进行软件测试。

模型检查是一种进行状态探索的技术。从当前状态开始,探索所有可能出现的情况,并推理这些可能出现的情况会导致状态发生怎样的变化。

❗执行模型检查有三个不同的阶段:建模阶段、运行阶段和分析阶段。

🔖 模型检查让我们不仅能够开发出设计良好的软件,而且能够开发出满足所需属性和行为的软件。


面向对象建模

面向对象建模是将现实世界的任何实体抽象为对象的建模思想。如果能够学会使用面向对象建模的方式设计软件,会让你的代码会更加灵活、更易重用。

实体是什么?对象又是什么?

🎗️ 我们身边的许多物品,比如杯子、笔记本等,这些都是现实世界的实体。

🎗️ 对象则是这一个个实体的抽象,根据实体的特性和功能抽象出对象的属性和方法。

在面向对象建模中,我们将一个个实体看做一个个有自我意识的对象,它们知道自己是怎么样的,能干什么事。

例如,对于杯子自身来说,它知道自己的形状大小、能容纳多少毫升的液体、能用来干嘛。

❗对象可以分为三种:实体对象边界对象控制对象

❗面向对象建模有四个原则:抽象化封装分解泛化

对象类别

🔖 实体对象是最容易理解的,因为它们对应问题空间中的某个实际实体。软件中表示椅子的对象、表示建筑物或客户的对象,这些都是实体对象。通常,这些对象需要知道有关自身的属性,同时他们还可以修改自己,并使用一些方法来改变自己。

🔖 边界对象是位于系统边界之间的对象。可能是处理另一个软件系统的对象,就像从互联网获取信息的对象一样。也可能是负责向用户显示信息并获取其输入的对象。如果你需要编写用户界面,那一定离不开边界对象。任何处理另一个系统的对象、用户、另一个软件系统、互联网等都可以被视为边界对象。

🔖 控制对象是负责协调的对象。当我们尝试去分解大型对象时,拥有一个控制其他对象的对象会很有用,这就是控制对象。调解器是个很好的例子:它能够协调许多不同对象的活动,以便它们可以保持松散耦合。

抽象化

抽象是人类处理复杂事物时的主要方式之一 。抽象是在某些上下文中,将问题域中的概念简化为基本概念的想法。抽象能忽略不重要的细节,将一个概念分解为简化的描述,让我们可以更好地理解概念。

❗抽象化让我们在某些上下文中,抽取实体必要的属性和行为。

例如抽象化“食物”,在健康层面下,我们往往关注它的营养价值而并非成本。

❗一个好的抽象体应该强调它概念所需的要点,而不在意它那些不必要的细节。它应该具备必要的属性和行为,除此之外的一切都与它不相关。

例如“人类”,但是人类是一个庞大的概念,我们无法抽象出一个完整的人类,所以我们要结合所处环境和其所对应的身份。

假设现在是在学校学习的学生,他们有一些基本的属性“学号”、“成绩”等。这些属性实际的数值会改变,比如学生的成绩会发生变化。但是属性本身不会,因为属性是一个抽象体最基础的描述。除此之外还有行为,例如学生的行为包含上课和写作业,这些是属于学生的责任,反映他是一个学生。哪些与他学生身份不相关的细节,我们不会去考虑。例如在学校学习的学生,我们不会去关注他养了几只小狗、他家有多有钱之类的事情。

封装

封装是指隐藏对象内部实现的细节,仅对外提供公共访问的接口。

❗封装是一个在面对对象建模和编程中最基础的设计原则,它涉及三个方面:

👉 你需要将属性数值(或数据)和会改变这些数值的行为(或函数),封装在一起成为一个独立的对象。

👉 你可以显示该对象的某些数据或函数,这些数据或函数可以让其他对象访问。

👉 你可以将对某些数据或函数的访问限制为仅用于该对象内部。

🔖 简单来说就是封装把对象所需的数据和函数打包成一个独立的对象,显示能让其他对象访问的部分,用来防止外部访问对象内部的细节。

❗一个对象应该只包含与该对象相关的属性和方法,这些方法可以是让外部获取内部属性数据的接口,也可以是和该对象有关的一些行为。

例如,一个学生对象只含有与自己相关的课程成绩。

例如,教师对象可以通过询问学生对象(调用学生对象提供的方法)去得知该学生对象的课程成绩。

❗封装有利于数据的完整性。

在 Java 中,你可以定义一些不让外部直接访问的数据,然后提供指定的方法让外部安全访问。这样对象的属性值就无法通过变量赋值的方式直接改变。

例如,现在有一个汽车对象,它有一个里程数,如果直接通过赋值改变,那么它可能为负数。但是如果提供指定方法让它改变,我们就能在方法中限制它不为负数。

❗封装可以保护敏感的信息。

假设让学生对象去存储一个课程分数,学生对象本身可以提供课程分数的查询,但不需要去透露课程分数的实际数值。

例如,学生对象提供一个查询方法,辨别该学生是否在该课程的学习中表现良好,其方法实现就是使用自己存储的课程分数计算。当分数在 90 ~ 100 时输出“优秀”,在 80 ~ 90 时输出“良”依此类推…

❗封装有助于更改软件,提高代码复用性。类向外部提供的访问接口可以保持不变,但属性和方法的实现逻辑可以发生改变。外部类在使用接口时,不需要关心其内部是如何运作的。

例如,学生对象可以在教师对象中知道自己的成绩,但是教师对象不会告知学生对象“他是如何批卷的?”、“是机改还是手批?”

🔖 在编程中,我们可以采用黑盒思考法去封装。把类想象成一个无法看清内部细节的盒子。只要根据规范提供输入值,就会得到相应的预期值,与盒子内部如何运作无关。我们不需要去思考太多,只需要关注“输入值后得到的输出值能帮我们实现什么”。

分解

分解是把完整的物体分成不同的部分,或者从另一个角度来看,把一堆功能不同的个体组合在一起变成完整对象。

🔖 分解让你能进一步地把问题分解成更容易理解和解决的部分,这与关注点分离类似。

❗分解的普遍原则是去分析一个完整对象的不同职责,并思考如何把它们分解成不同的部分。每个部分都有自己独特的责任,我们需要把一个整体和它的每个部分联系在一起。

例如,把一个完整的车分解,它可能有的零件是变速箱、引擎、车轮、轮胎、车门、窗户、车椅和燃料。这些部分都有它们自己的责任。

❗一个整体可能会有固定或者动态数量的部件。

例如,冰箱的冷冻库是固定的,但是冰箱内的食物不固定。这也对应了 UML 中的组合和聚合关系。

❗整体对象的生命周期和分解出来的部分对象的生命周期可能紧密相关也可能不相关。

例如,冰箱和它的冷冻库有同样的生命周期,但是冰箱和冰箱内的食物生命周期却不同。冷冻库故障,那么也说明这个冰箱需要去修理;但冰箱里的食物过期和冰箱本身却没有什么关联。

❗多数整体是可以共享内部的部分的。但是有些时候是不可能或无法共享的。

例如,一个女生可能是一个家庭的子女也可能是另一个家庭的父母。

例如,冰箱中的食物不能同时放在烤箱里。

🔖 总而言之,分解能帮助我们将问题分解成较小的部分。一个复杂的整体对象可以由独立及简单的部分对象所构成。了解部分跟整体的联系很重要,像是固定或动态数量,它们的生命周期或是否有共享的情况。在 UML 建模中,组合关系、聚合关系所对应的都是整体与部分的关系。利用好它们,不会让我们在面对一个复杂的整体对象时无从下手。

泛化

通过泛化,可以帮助我们在解决问题时减少代码冗余。我们可以泛化行为,这样就不需要在整个程序中编写相同的代码。

🔖 在 Java 中我们用继承基类和实现接口的方式来实现泛化。

在 Java 的继承中,你可以有一个“父类”和多个“子类”。当子类继承自父类时,子类将具有父类的属性和行为。我们可以将共通的属性和行为放在父类中。每个继承自该父类的子类,都将具备这些共通的属性和行为。此外,子类还可以拥有一些属于自己的属性和行为,这让它们可以更加专注于自己可以做什么。

例如,狗类和猫类都继承与动物类,它们都具有动物共通的吃和睡。但是狗有属于它自己的忠诚性,猫有属于它自己的好奇心。

❗不过,泛化不能滥用。有些时候,分解的效果比泛化更好,分解对于模块间的耦合度较低。

🔖 泛化能帮我们减少代码重复,但它也有着高耦合的特性。当我们使用泛化时,需要思考一下以下问题。

❓ “我是否使用泛化来简单地共享属性或行为,而无需在我的子类中进一步添加任何特殊内容?”

🎗️ 如果“是”,那么你滥用了继承。这是误用的迹象,子类没有存在的意义,因为拥有父类就已经足够了。

❓ “是否违反了里氏替换原则(子类型必须能够替换掉他们的基类型)?”

🎗️ 例如,动物类有跑和跳的行为,现在有一个鱼类,用游泳行为替代了这个跑和跳。那么这就违背了里氏替换原则。因为在动物类中,我们期望子类去形容如何跑和跳,而不是像鱼类一样,使用游泳替代它们。鱼的行为不再像我们期望的那样,它违背了父类的期望,也就违反了里氏替换原则。


总结

  • 开发软件的步骤分为需求分析概念设计详细设计
  • 用户故事可以让我们在使用面向对象的思维时发现对象和进一步的需求。
  • 模块与模块之间的复杂度用耦合度表示,模块内部职责的明确性用内聚力表示。一般来说,在设计中,需要在低耦合高内聚之间取得平衡。
  • 关注点分离要求我们在建模时,将问题分解,让每个对象专注于每个问题。
  • 信息隐藏能够让我们控制要共享的信息以及要让其他人看到的行为。外部知道的信息越少,才能更有利于构建灵活、可重用和可维护的系统。
  • 概念完整性可以帮助我们创建一致且设计良好的软件。
  • 执行模型检查有三个不同的阶段:建模阶段运行阶段分析阶段
  • 对象可以分为三种:实体对象边界对象控制对象
  • 面向对象建模有四个原则:抽象化封装分解泛化
  • 抽象化让我们在某些上下文中,抽取实体必要的属性和行为。一个好的抽象体应该强调它概念所需的要点,而不在意它那些不必要的细节。它应该具备必要的属性和行为,除此之外的一切都与它不相关。
  • 封装是一个在面对对象建模和编程中最基础的设计原则,它涉及三个方面:
    1. 你需要将属性数值(或数据)和会改变这些数值的行为(或函数),封装在一起成为一个独立的对象。
    2. 你可以显示该对象的某些数据或函数,这些数据或函数可以让其他对象访问。
    3. 你可以将对某些数据或函数的访问限制为仅用于该对象内部。
  • 封装要求一个对象应该只包含与该对象相关的属性和方法,这些方法可以是让外部获取内部属性数据的接口,也可以是和该对象有关的一些行为。
  • 封装的好处:
    1. 有利于数据的完整性。
    2. 可以保护敏感的信息。
    3. 有助于更改软件,提高代码复用性。
  • 分解让我们将一个完整的对象分解为不同职责的部分。整体和部分的关系可以从数量、生命周期和共享三方面去考虑。
  • 泛化可以让我们减少编写重复的代码,但是泛化不能滥用。有些时候,分解的效果比泛化更好,分解对于模块间的耦合度较低。


📌最后:希望本文能够给您提供帮助,文章中有不懂或不正确的地方,请在下方评论区💬留言!

🔗参考文献:

🌐 Object Oriented Design –coursera

🌐 面向对象程序设计 –百度百科