面向对象的七大设计原则
一、单一职责原则
介绍
一个对象应该只包含单一的职责,并且该职责被完整地封装在一个类中。
就一个类而言,应该仅有一个引起它变化的原因。
分析
一个类(或者大到模块,小到方法)承担的职责越多,它被复用的可能性越小,而且如果一个类承担的职责过多,就相当于将这些职责耦合在一起,当其中一个职责变化时,可能会影响其他职责的运作。
类的职责主要包括两个方面:数据职责和行为职责,数据职责通过其属性来体现,而行为职责通过其方法来体现。
单一职责原则是实现高内聚、低耦合的指导方针,在很多代码重构手法中都能找到它的存在,它是最简单但又最难运用的原则,需要设计人员发现类的不同职责并将其分离,而发现类的多重职责需要设计人员具有较强的分析设计能力和相关重构经验。
比如我们现在有一个People类:
//一个人类
public class People {
/**
* 人类会编程
*/
public void coding(){
System.out.println("int mian() {");
System.out.println(" printf(\"Holle Wrold!\");");
System.out.println("}");
System.out.println("啊嘞,怎么运行不起?明明照着老师敲的啊");
}
/**
* 工厂打螺丝也会
*/
public void work(){
System.out.println("真开心,能进到富土康打螺丝");
System.out.println("诶,怎么工友都提桶跑路了");
}
/**
* 送外卖也会
*/
public void ride(){
System.out.println("今天终于通过美团最终面,加入了梦寐以求的大厂了");
System.out.println("感觉面试挺简单的,就是不知道为啥我同学是现场做一道力扣接雨水,而我是现场问会不会骑车");
System.out.println("(迫不及待穿上外卖服装)");
}
}
我们可以看到,这个People类可以说是十八般武艺样样精通了,啥都会,但是实际上,我们每个人最终都是在自己所擅长的领域工作,所谓闻道有先后,术业有专攻,会编程的就应该是程序员,会打螺丝的就应该是工人,会送外卖的应该是骑手,显然这个People太过臃肿(我们需要修改任意一种行为都需要修改People类,它拥有不止一个引起它变化的原因),所以根据单一职责原则,我们下需要进行更明确的划分,同种类型的操作我们一般才放在一起:
class Coder{
/**
* 程序员会编程
*/
public void coding(){
System.out.println("int mian() {");
System.out.println(" printf(\"Hello World!\")");
System.out.println("}");
System.out.println("啊嘞,怎么运行不起?明明照着老师敲的啊");
}
}
class Worker{
/**
* 工人会打螺丝
*/
public void work(){
System.out.println("真开心,能进到富土康打螺丝");
System.out.println("诶,怎么工友都提桶跑路了");
}
}
class Rider {
/**
* 骑手会送外卖
*/
public void ride(){
System.out.println("今天终于通过美团最终面,加入了梦寐以求的大厂");
System.out.println("感觉面试挺简单的,就是不知道为啥我同学是现场做一道力扣接雨水,我是现场问会不会骑车");
System.out.println("(迫不及待穿上外卖服装)");
}
}
我们将类的粒度进行更近一步的划分,这样就很清晰了,包括我们以后在设计Mapper、Service、Controller等等,根据不同的业务进行划分,都可以采用单一职责原则,以它作为我们实现高内聚低耦合的指导方针。实际上我们的微服务也是参考了单一职责原则,每个微服务只应担负一个职责。
二、开闭原则
介绍
一个软件实体应当对扩展开放,对修改关闭。也就是说在设计一个模块的时候,应当使这个模块可以在不被修改的前提下被扩展,即实现在不修改源代码的情况下改变这个模块的行为。
分析
开闭原则由 Bertrand Meyer 于1988年提出,它是面向对象设计中最重要的原则之一。
在开闭原则的定义中,软件实体可以指一个软件模块、一个由多个类组成的局部结构或一个独立的类。
比如我们的程序员分为Java程序员、C#程序员、C++程序员、PHP程序员、前端程序员等,而他们要做的都是去打代码,而具体如何打代码是根据不同语言的程序员来决定的,我们可以将程序员打代码这一个行为抽象成一个统一的接口或是抽象类,这样我们就满足了开闭原则的第一个要求:对扩展开放,不同的程序员可以自由地决定他们该如何进行编程。而具体哪个程序员使用什么语言怎么编程,是自己在负责,不需要其他程序员干涉,所以满足第二个要求:对修改关闭,比如:
public abstract class Coder {
public abstract void coding();
class JavaCoder extends Coder{
@Override
public void coding() {
System.out.println("Java太卷了T_T,快去学Go吧!");
}
}
class PHPCoder extends Coder{
@Override
public void coding() {
System.out.println("PHP是世界上最好的语言");
}
}
class C++Coder extends Coder{
@Override
public void coding() {
System.out.println("笑死,Java再牛逼底层不还得找我?");
}
}
}
通过提供一个Coder抽象类,定义出编程的行为,但是不进行实现,而是开放给其他具体类型的程序员来实现,这样就可以根据不同的业务进行灵活扩展了,具有较好的延续性。
抽象化是开闭原则的关键。
开闭原则还可以通过一个更加具体的 “对可变性封装原则” 来描述,对可变性封装原则(Principle of Encapsulation of Variation, EVP)要求找到系统的可变因素并将其封装起来。
三、里氏代换原则
介绍
如果对每一个类型为 S 的对象 o1 ,都有类型为 T 的对象 o2 ,使得以 T 定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有变化,那么类型 S 是类型 T 的子类型。
所有引用基类(父类)的地方必须能透明地使用其子类的对象。
分析
里氏代换原则由2008年图灵奖得主、美国第一位计算机科学女博士、麻省理工学院教授 Barbara Liskov 和卡内基.梅隆大学 Jeannette Wing 教授于1994年提出。
里氏代换原则可以通俗表述为:在软件中如果能够使用基类对象,那么一定能够使用其子类对象。把基类都替换成它的子类,程序将不会产生任何错误和异常,反过来则不成立,如果一个软件实体使用的是一个子类的话,那么它不一定能够使用基类。
里氏代换原则是实现开闭原则的重要方式之一,由于使用基类对象的地方都可以使用子类对象,因此在程序中尽量使用基类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换父类对象。
比如我们下面的例子:
public abstract class Coder {
public void coding() {
System.out.println("我会打代码");
}
class JavaCoder extends Coder{
/**
* 子类除了会打代码之外,还会打游戏
*/
public void game(){
System.out.println("艾欧尼亚最强王者已上号");
}
}
}
可以看到JavaCoder虽然继承自Coder,但是并没有对父类方法进行重写,并且还在父类的基础上进行额外扩展,符合里氏替换原则。但是我们再来看下面的这个例子:
```java
public abstract class Coder {
public void coding() {
System.out.println("我会打代码");
}
class JavaCoder extends Coder{
public void game(){
System.out.println("艾欧尼亚最强王者已上号");
}
/**
* 这里我们对父类的行为进行了重写,现在它不再具备父类原本的能力了
*/
public void coding() {
System.out.println("我寒窗苦读十六年,到最后还不如培训班三个月出来的程序员");
System.out.println("想来想去,房子车子结婚彩礼,为什么这辈子要活的这么累呢?");
System.out.println("难道来到这世间走这一遭就为了花一辈子时间买个房子吗?一个人不是也能活的轻松快乐吗?");
System.out.println("摆烂了,啊对对对");
//好了,emo结束,继续卷吧,人生因奋斗而美丽,这个世界虽然满目疮痍,但是还是有很多美好值得期待
}
}
}
可以看到,现在我们对父类的方法进行了重写,显然,父类的行为已经被我们给覆盖了,这个子类已经不具备父类的原本的行为,很显然违背了里氏替换原则。
要是程序员连敲代码都不会了,还能叫做程序员吗?
所以,对于这种情况,我们不需要再继承自Coder了,我们可以提升一下,将此行为定义到People中:
public abstract class People {
public abstract void coding(); //这个行为还是定义出来,但是不实现
class Coder extends People{
@Override
public void coding() {
System.out.println("我会打代码");
}
}
class JavaCoder extends People{
public void game(){
System.out.println("艾欧尼亚最强王者已上号");
}
public void coding() {
System.out.println("摆烂了,啊对对对");
}
}
}
四、依赖倒转原则
介绍
高层模块不应该依赖低层模块,它们都应该依赖抽象。抽象不应该依赖于细节,细节应该依赖于抽象。
要针对接口编程,不要针对实现编程。
分析
依赖倒转原则是 Robert C. Martin 在1996年为《C++ Reporter》所写的专栏 Engineering Notebook 的第三篇,后来加入到他在2002年出版的经典著作《Agile Software Development, Principles, Patterns, and Practices》中。
简单来说,依赖倒转原则就是指:代码要依赖于抽象的类,而不要依赖于具体的类;要针对接口或抽象类编程,而不是针对具体类编程。
实现开闭原则的关键是抽象化,并且从抽象化导出具体化实现,如果说开闭原则是面向对象设计的目标的话,那么依赖倒转原则就是面向对象设计的主要手段。
依赖倒转原则的常用实现方式之一是在代码中使用抽象类,而将具体类放在配置文件中。
类之间的耦合有零耦合关系、具体耦合关系和抽象耦合关系。依赖倒转原则要求客户端依赖于抽象耦合,以抽象方式耦合是依赖倒转原则的关键。
依赖注入:
-
构造注入(Constructor Injection):通过构造函数注入实例变量。
-
设值注入(Setter Injection):通过Setter方法注入实例变量。
-
接口注入(Interface Injection):通过接口方法注入实例变量。
还记得我们在我们之前的学习中为什么要一直使用接口来进行功能定义,然后再去实现吗?我们回顾一下在使用Spring框架之前的情况:
public class Main {
public static void main(String[] args) {
UserController controller = new UserController();
//该怎么用就这么用
}
static class UserMapper {
//CRUD...
}
static class UserService {
UserMapper mapper = new UserMapper();
//业务代码....
}
static class UserController {
UserService service = new UserService();
//业务代码....
}
}
但是突然有一天,公司业务需求变化,现在用户相关的业务操作需要使用新的实现:
public class Main {
public static void main(String[] args) {
UserController controller = new UserController();
}
static class UserMapper {
//CRUD...
}
static class UserServiceNew { //由于UserServiceNew发生变化,会直接影响到其他高层模块
UserMapper mapper = new UserMapper();
//业务代码....
}
static class UserController { //焯,干嘛改底层啊,我这又得重写了
UserService service = new UserService(); //哦豁,原来的不能用了
UserServiceNew serviceNew = new UserServiceNew(); //只能修改成新的了
//业务代码....
}
}
我们发现,我们的各个模块之间实际上是具有强关联的,一个模块是直接指定依赖于另一个模块,虽然这样结构清晰,但是底层模块的变动,会直接影响到其他依赖于它的高层模块,如果我们的项目变得很庞大,那么这样的修改将是一场灾难。
而有了Spring框架之后,我们的开发模式就发生了变化:
public class Main {
public static void main(String[] args) {
UserController controller = new UserController();
}
interface UserMapper {
//接口中只做CRUD方法定义
}
static class UserMapperImpl implements UserMapper {
//实现类完成CRUD具体实现
}
interface UserService {
//业务代码定义....
}
static class UserServiceImpl implements UserService {
@Resource //现在由Spring来为我们选择一个指定的实现类,然后注入,而不是由我们在类中硬编码进行指定
UserMapper mapper;
//业务代码具体实现
}
static class UserController {
@Resource
UserService service; //直接使用接口,就算你改实现,我也不需要再修改代码了
//业务代码....
}
}
可以看到,通过使用接口,我们就可以将原有的强关联给弱化,我们只需要知道接口中定义了什么方法然后去使用即可,而具体的操作由接口的实现类来完成,并由Spring来为我们注入,而不是我们通过硬编码的方式去指定。
五、接口隔离原则
介绍
客户端不应该依赖那些它不需要的接口。注意,在该定义中的接口指的是所定义的方法。
一旦一个接口太大,则需要将它分割成一些更细小的接*,使用该接口的客户端仅需知道与之相关的方法即可。
分析
接口隔离原则是指使用多个专门的接口,而不使用单一的总接口。每一个接口应该承担一种相对独立的角色,不多不少,不干不该干的事,该干的事都要干。
一个接口就只代表一个角色,每个角色都有它特定的一个接口,此时这个原则可以叫做 “角色隔离原则”。
接口仅仅提供客户端需要的行为,即所需的方法,客户端不需要的行为则隐藏起来,应当为客户端提供尽可能小的单独的接口,而不要提供大的总接口。
使用接口隔离原则拆分接口时,首先必须满足单一职责原则,将一组相关的操作定义在一个接口中,且在满足高内聚的前提下,接口中的方法越少越好。
可以在进行系统设计时采用定制服务的方式,即为不同的客户端提供宽窄不同的接口,只提供用户需要的行为,而隐藏用户不需要的行为。
我们在定义接口的时候,一定要注意控制接口的粒度,比如下面的例子:
interface Device {
String getCpu();
String getType();
String getMemory();
}
//电脑就是一种电子设备,那么我们就实现此接口
class Computer implements Device {
@Override
public String getCpu() {
return "i9-12900K";
}
@Override
public String getType() {
return "电脑";
}
@Override
public String getMemory() {
return "32G DDR5";
}
}
//电风扇也算是一种电子设备
class Fan implements Device {
@Override
public String getCpu() {
return null; //就一个破风扇,还需要CPU?
}
@Override
public String getType() {
return "风扇";
}
@Override
public String getMemory() {
return null; //风扇也不需要内存吧
}
}
虽然我们定义了一个Device接口,但是由于此接口的粒度不够细,虽然比较契合电脑这种设备,但是不适合风扇这种设备,因为风扇压根就不需要CPU和内存,所以风扇完全不需要这些方法。这时我们就必须要对其进行更细粒度的划分:
interface SmartDevice { //智能设备才有getCpu和getMemory
String getCpu();
String getType();
String getMemory();
}
interface NormalDevice { //普通设备只有getType
String getType();
}
//电脑就是一种电子设备,那么我们就继承此接口
class Computer implements SmartDevice {
@Override
public String getCpu() {
return "i9-12900K";
}
@Override
public String getType() {
return "电脑";
}
@Override
public String getMemory() {
return "32G DDR5";
}
}
//电风扇也算是一种电子设备
class Fan implements NormalDevice {
@Override
public String getType() {
return "风扇";
}
}
这样,我们就将接口进行了细粒度的划分,不同类型的电子设备就可以根据划分去实现不同的接口了。当然,也不能划分得太小,还是要根据实际情况来进行决定。
六、合成复用原则
介绍
尽量使用对象组合,而不是继承来达到复用的目的。
分析
合成复用原则就是指在一个新的对象里通过关联关系(包括组合关系和聚合关系)来使用一些已有的对象,使之成为新对象的一部分;新对象通过委派调用已有对象的方法达到复用其已有功能的目的。简言之:要尽量使用组合/聚合关系,少用继承。
在面向对象设计中,可以通过两种基本方法在不同的环境中复用已有的设计和实现,即通过组合/聚合关系或通过继承。
-
继承复用:**实现简单,易于扩展。破坏系统的封装性;**从基类继承而来的实现是静态的,不可能在运行时发生改变,没有足够的灵活性;只能在有限的环境中使用。
-
组合/聚合复用:耦合度相对较低,选择性地调用成员对象的操作;可以在运行时动态进行。
组合/聚合可以使系统更加灵活,类与类之间的耦合度降低,一个类的变化对其他类造成的影响相对较少,因此一般首选使用组合/聚合来实现复用;其次才考虑继承。
在使用继承时,需要严格遵循里氏代换原则,有效使用继承会有助于对问题的理解,降低复杂度,而滥用继承反而会增加系统构建和维护的难度以及系统的复杂度,因此需要慎重使用继承复用。
比如下面这个例子:
class A {
public void connectDatabase(){
System.out.println("我是连接数据库操作!");
}
}
class B extends A{ //直接通过继承的方式,得到A的数据库连接逻辑
public void test(){
System.out.println("我是B的方法,我也需要连接数据库!");
connectDatabase(); //直接调用父类方法就行
}
}
虽然这样看起来没啥毛病,但是还是存在我们之前说的那个问题,耦合度太高了。
可以看到通过继承的方式实现复用,我们是将类B直接指定继承自类A的,那么如果有一天,由于业务的更改,我们的数据库连接操作,不再由A来负责,而是由新来的C去负责,那么这个时候,我们就不得不将需要复用A中方法的子类全部进行修改,很显然这样是费时费力的。
并且还有一个问题就是,通过继承子类会得到一些父类中的实现细节,比如某些字段或是方法,这样直接暴露给子类,并不安全。
所以,当我们需要实现复用时,可以优先考虑以下操作:
class A {
public void connectDatabase(){
System.out.println("我是连接数据库操作!");
}
}
class B { //不进行继承,而是在用的时候给我一个A,当然也可以抽象成一个接口,更加灵活
public void test(A a){
System.out.println("我是B的方法,我也需要连接数据库!");
a.connectDatabase(); //在通过传入的对象A去执行
}
}
或是:
class A {
public void connectDatabase(){
System.out.println("我是连接数据库操作!");
}
}
class B {
A a;
public B(A a){ //在构造时就指定好
this.a = a;
}
public void test(){
System.out.println("我是B的方法,我也需要连接数据库!");
a.connectDatabase(); //也是通过对象A去执行
}
}
通过对象之间的组合,我们就大大降低了类之间的耦合度,并且A的实现细节我们也不会直接得到了。
七、迪米特法则
介绍
不要和“陌生人”说话。
只与你的直接朋友通信。
每一个软件单位对其他的单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位。
分析
迪米特法则来自于1987年秋美国东北大学(Northeastern University)一个名为 “Demeter” 的研究项目。
简单地说,迪米特法则就是指一个软件实体应当尽可能少的与其他实体发生相互作用。这样,当一个模块修改时,就会尽量少的影响其他的模块,扩展会相对容易,这是对软件实体之间通信的限制,它要求限制软件实体之间通信的宽度和深度。
在迪米特法则中,对于一个对象,其朋友包括以下几类:
-
当前对象本身(this);
-
以参数形式传入到当前对象方法中的对象;
-
当前对象的成员对象;
-
如果当前对象的成员对象是一个集合,那么集合中的元素也都是朋友;
-
当前对象所创建的对象。
任何一个对象,如果满足上面的条件之一,就是当前对象的“朋友”,否则就是“陌生人”。
迪米特法则可分为狭义法则和广义法则。在狭义的迪米特法则中,如果两个类之间不必彼此直接通信,那么这两个类就不应当发生直接的相互作用,如果其中的一个类需要调用另一个类的某一个方法的话,可以通过第三者转发这个调用。
**狭义的迪米特法则:**可以降低类之间的耦合,但是会在系统中增加大量的小方法并散落在系统的各个角落,它可以使一个系统的局部设计简化,因为每一个局部都不会和远距离的对象有直接的关联,但是也会造成系统的不同模块之间的通信效率降低,使得系统的不同模块之间不容易协调。
**广义的迪米特法则:**指对对象之间的信息流量、流向以及信息的影响的控制,主要是对信息隐藏的控制。信息的隐藏可以使各个子系统之间脱耦,从而允许它们独立地被开发、优化、使用和修改,同时可以促进软件的复用,由于每一个模块都不依赖于其他模块而存在,因此每一个模块都可以独立地在其他的地方使用。一个系统的规模越大,信息的隐藏就越重要,而信息隐藏的重要性也就越明显。
迪米特法则的主要用途在于控制信息的过载:
-
在类的划分上,应当尽量创建松耦合的类,类之间的耦合度越低,就越有利于复用,一个处在松耦合中的类一旦被修改,不会对关联的类造成太大波及。
-
在类的结构设计上,每一个类都应当尽量降低其成员变量和成员函数的访问权限。
-
在类的设计上,只要有可能,一个类型应当设计成不变类;
-
在对其他类的引用上,一个对象对其他对象的引用应当降到最低。
其实说白了,还是降低耦合度,我们还是来看一个例子:
public class Main {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("localhost", 8080); //假设我们当前的程序需要进行网络通信
Test test = new Test();
test.test(socket); //现在需要执行test方法来做一些事情
}
static class Test {
/**
* 比如test方法需要得到我们当前Socket连接的本地地址
*/
public void test(Socket socket){
System.out.println("IP地址:"+socket.getLocalAddress());
}
}
}
可以看到,虽然上面这种写法没有问题,我们提供直接提供一个Socket对象,然后再由test方法来取出IP地址,但是这样显然违背了迪米特法则,实际上这里的test方法只需要一个IP地址即可,我们完全可以直接传入一个字符串,而不是整个Socket对象,我们需要保证与其他类的交互尽可能的少。
就像我们在餐厅吃完了饭,应该是我们自己扫码付款,而不是直接把手机交给老板来帮你操作付款。
要是某一天,Socket类中的这些方法发生修改了,那我们就得连带着去修改这些类,很麻烦。
所以,我们来改进改进:
public class Main {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("localhost", 8080);
Test test = new Test();
test.test(socket.getLocalAddress().getHostAddress()); //在外面解析好就行了
}
static class Test {
public void test(String str){ //一个字符串就能搞定,就没必要丢整个对象进来
System.out.println("IP地址:"+str);
}
}
}
这样,类与类之间的耦合度再次降低。
参考:https://www.cnblogs.com/distance66/p/15009036.html
案例来源:https://blog.csdn.net/qq_25928447/article/details/124884700?spm=1001.2014.3001.5502