代码该怎么写——设计原则

代码该怎么写——设计原则

初学者学会编程语言后开始写代码,当我们实现一个功能后会有成就感,但是看了别人写的代码又大感困惑,他为什么把代码写得那么复杂?明明一个简单的功能,为什么要这样做?

还有人即使学会了编程语言,仍然不知道怎么下手写代码,哪里该创建一个类,哪里又该创建一个方法?

现代社会,文盲率很低,人人识字,但为什么不是人人都能当作家呢?

因为,我们只学识字是不够的,我们还得学习写作的技巧和套路,并且还要有一定的人生经历,这样才能成长为一个作家。简而言之,会写代码,和写好代码,是两个层面!

面向对象编程语言的设计原则和设计模式,就是程序员需要学习的写作套路。这是很多前辈采坑总结的血泪教训,学习这些套路,能避免后来者踩同样的坑,犯相同的错。

现在,就让我们来用Dart 语言来学习这些设计原则和设计模式。

六大设计原则

这些是程序员编程时应当遵守的原则,它们也是设计模式的基础(依据)

单一职责原则(SRP)

单一职责原则(Single Responsibility Principle,SRP)又称单一功能原则。

对类来说,即一个类应该只负责一项职责。假如有类A负责了两个不同职责:职责a1和职责a2。当职责a1因需求变更而改变了A类时,就可能造成职责a2发生错误, 所以需要将类A的粒度拆分为两个类:A1和 A2。

示例

假如某公司开发一视频网站,现在实现视频服务,模拟代码如下:

void main(List<String> arguments) {
  VideoService().play('1');
}

// 创建视频服务类
class VideoService{
  // 播放资源
  void play(String resId){
    print("开始播放ID为: $resId 的视频");
  }
}

刚开始网站的视频都是随便播放,后来随着公司商业化发展,视频网站实现用户分级制,用户被分为三个级别,普通用户(免费),普通会员,超级会员。不同级别,播放的视频的清晰度、网络带宽是不一样的,这时候,如何在代码中增加该功能,支持业务的发展呢?

在没有学习设计原则和设计模式之前,我们首先想到的可能是在play方法中写大量if-else判断:

  void play(String resId,int userType){
    if(userType == 1){ // 普通用户
      print("开始播放ID为: $resId 的 480P 视频,");
    }else if(userType == 2){ // vip会员
      print("开始加速播放ID为: $resId 的 1080P 高清视频,");
    }else{  // 超级会员
      print("开始加速播放ID为: $resId 的 2K 超清视频");
    }
  }

这是一种非常糟糕的代码设计,随着业务发展,功能增多,最后大量的if判断会变成屎山代码。并且这种代码违背了单一职责原则,因为类负责了多个职责,而且我们修改了原有逻辑,这种修改,甚至可能导致以前正常的功能(普通用户播放免费视频)发生错误,

现在按照单一职责原则,拆分类重构代码:

void main(List<String> arguments) {
  OrdinaryVideoService().play('1');
}

// 普通用户视频服务
class OrdinaryVideoService{
  void play(String resId){
    print("开始播放ID为: $resId 的 480P 视频,");
  }
}
// VIP用户视频服务
class VIPVideoService{
  void play(String resId){
    print("开始加速播放ID为: $resId 的 1080P 高清视频,");
  }
}
// 超级VIP用户视频服务
class SuperVIPVideoService{
  void play(String resId){
    print("开始加速播放ID为: $resId 的 2K 超清视频");
  }
}

我们拆分了三个类来分别为不同用户提供视频服务。重构后,我们的逻辑更加清晰易懂,可维护性,可扩展性更高。以后如果出现了在超级VIP之上的新用户级别,我们也可以在不修改已有代码情况下扩展,因为我们只需要创建一个新的类即可,而不是像if判断那样去修改原逻辑。

总结:

  1. 降低类的复杂度,一个类只负责一项职责。

  2. 提高类的可读性,可维护性

  3. 降低修改引发的风险

开闭原则(OCP)

开闭原则(Open-Close Principle,OCP)规定软件中的对象、类、模块和函数对于扩展(提供者)应该是开放的,但对于修改(使用者)是封闭的。这意味着应该用抽象定义结构,用具体实现扩展细节,以此确保软件系统开发和维护过程的可靠性。

对于外部的调用者来说,体现开闭原则需要面向抽象编程。

示例

上面单一职责原则讲的示例看起来仍然比较粗糙,因为我们仅运用了单一职责原则,这还不够,现在我们再结合开闭原则继续重构上例:

void main(List<String> arguments) {
  VideoService service = OrdinaryVideoService();
  // VideoService service = VIPVideoService();
  service.play('1');
}

// Dart中的抽象接口
abstract class VideoService{
  void play(String resId);
}

// 普通用户视频服务,实现抽象接口
class OrdinaryVideoService implements VideoService{
  @override
  void play(String resId){
    print("开始播放ID为: $resId 的 480P 视频,");
  }
}
// VIP用户视频服务,实现抽象接口
class VIPVideoService implements VideoService{
  @override
  void play(String resId){
    print("开始加速播放ID为: $resId 的 1080P 高清视频,");
  }
}
// 超级VIP用户视频服务,实现抽象接口
class SuperVIPVideoService implements VideoService{
  @override
  void play(String resId){
    print("开始加速播放ID为: $resId 的 2K 超清视频");
  }
}

首先作为功能模块的提供者,我们编写了三个类提供视频服务,功能的使用者调用这三个类实现需求。也许很多时候,功能提供者和使用者都是我们自己,但是在大团队开发时,可能我们只是编写接口给别人用的,因此在开发时,大脑中需要具有提供者、使用者的思维区分。

对于使用者,不需要知道功能的具体细节,只需要面向抽象接口编程。因此使用者只需要调用抽象接口VideoServiceplay方法。注意,Dart中没有提供专门声明接口的关键字,其抽象类就相当于Java的接口。对于不同的用户级别,接口只需要切换不同的实现类即可。

经过我们上面的重构,就实现了开闭原则。对于新增功能,提供者只需要创建新的类来实现VideoService接口,这就是所谓对扩展开放。使用者则面向接口编程,它并不知道具体实现细节,也无从修改,这就是对修改封闭。

总结:

当软件需要变化时,尽量通过扩展软件的行为来实现变化,而不是通过修改已有的代码来实现变化 。需要注意的是,开闭原则是编程中最基础、最重要的设计原则 。

依赖倒置原则(DIP)

依赖倒置原则(Dependence Inversion Principle,DIP)是指在设计代码架构时,高层模块不应该依赖于底层模块,二者都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。

依赖倒置原则是实现开闭原则的重要途径之一,它降低了类之间的耦合,提高了系统的稳定性和可维护性,同时这样的代码一般更易读,且便于传承。

示例

现在有一个学生,他正在学习Dart和Java编程,代码实现如下:

void main(List<String> arguments) {
  var student = Student();
  student.studyDart();
}

class Student{

  void studyDart(){
    print('我在学习Dart编程');
  }

  void studyJava(){
    print('我在学习Java编程');
  }
}

后面随着该学生的发展,他又想学习Go语言,怎么添加功能呢?难道继续修改Student类,添加一个studyGo方法吗?显然已经不符合开闭原则,对扩展开放,对修改关闭。同时,代码也违背了依赖倒置原则!

依据依赖倒置原则重构代码:

void main(List<String> arguments) {
  var student = Student();
  // 依赖注入
  student.study(DartCourse());
  student.study(JavaCourse());
  student.study(GoCourse());
}

class Student{
  // 依赖抽象接口Course,而不是具体实现
  void study(Course course){
    course.study();
  }
}

// 课程接口
abstract class Course{
  void study();
}

class DartCourse implements Course{
  @override
  void study() {
    print('我在学习Dart编程');
  }

}

class JavaCourse implements Course{
  @override
  void study() {
    print('我在学习Java编程');
  }
}

class GoCourse implements Course{
  @override
  void study() {
    print('我在学习Go编程');
  }
}

现在,不论该学生后续想学习多少新课程,都能在不修改原有代码的情况下很简便的扩展。

需要注意的是,当我们在main函数中调用Studentstudy方法传递参数时,就是所谓的依赖注入!

如何理解依赖?一般说的依赖某个类,就是指需要使用某个类。

依赖注入的方式有三种:

  • 通过类的构造方法将需要用到的类传入
  • 通过类的Setter方法,将需要用到的类传入
  • 通过具体使用的接口,将依赖的类传入

上例中显然使用的是第三种方式注入依赖。我们在具体调用study接口时,才将依赖的类传入进去。

总结:

  1. 抽象不应该依赖细节,细节应该依赖抽象
  2. 依赖倒置的中心思想是面向接口编程
  3. 相对于细节的多变性,抽象的东西要稳定得多。以抽象为基础搭建的架构比以细节为基础的架构要稳定得多
  4. 使用接口或抽象类的目的是制定好规范,而不涉及任何具体的操作,把展现细节的任务交给它们的实现类去完成
  5. 变量的声明类型尽量是抽象类或接口, 这样变量引用和实际对象间,就存在一个缓冲层,利于程序扩展和优化

里氏替换原则(LSP)

里氏替换原则(Liskov Substitution Principle,LSP)是由麻省理工学院计算机科学系教授Barbara Liskov 女士于 1987 年提出。她提出:继承必须确保超类所拥有的性质在子类中仍然成立。

原则:如果S是T的子类型,那么所有T类型的对象都可以在不破坏程序的情况下被S类型的对象替换。

简单来说,子类可以扩展父类的功能,但不能改变父类原有的功能。也就是说:当子类继承父类时,除添加新的方法且完成新增功能外,尽量不要重写父类的方法。

概括为4点:

  • 子类可以实现父类的抽象方法,但不应该覆盖父类的非抽象方法。
  • 子类可以增加自己特有的方法。
  • 当子类重载父类的方法时,方法的前置条件(即方法的输入参数)要比父类的方法更宽松。
  • 当子类实现父类的方法(重写、重载或实现抽象方法)时,方法的后置条件(即方法的输出或返回值)要比父类的方法更严格或与父类的方法相等。

示例

void main(List<String> arguments) {
  var eagle = Eagle();
  eagle.fly();

  var ostrich = Ostrich();
  ostrich.feature();
  ostrich.fly();
}

// 老鹰
class Eagle{

  void feature(){
    print('体覆羽毛,有双翼');
  }

  void fly(){
    print('翱翔天空!');
  }
}

// 鸵鸟
class Ostrich extends Eagle{

  @override
  void fly(){
    print('不会飞!');
  }

  void run(){
    print('奔跑如飞!');
  }
}

如上例,我们先有老鹰这个类,后面需要鸵鸟类。程序员考虑到动物界,老鹰和鸵鸟同属于鸟类,有很多共性,为了复用代码,少写代码,直接让鸵鸟类继承老鹰类。继承完了,发现鸵鸟不会飞,于是重写父类的fly方法,屏蔽了鸵鸟的飞这个功能。以上代码确实做到了复用,复用了feature方法。但是这样的代码设计是糟糕的,不符合里氏替换原则!

在后续的业务扩展中,我们会逐渐发现,有一部分鸟类是不会飞的,譬如鸡,企鹅,同属鸟类但都不会飞。如果都这样继承,代码只会越写越蹩脚。

为了符合里氏替换原则,我们需要重构代码。一般的解决方法,是抽象出一个共同基类,而不是直接去继承业务类:

void main(List<String> arguments) {
  var eagle = Eagle();
  eagle.feature();
  eagle.fly();
  
  var ostrich = Ostrich();
  eagle.feature();
  ostrich.run();
}

// 抽象基类:鸟类
abstract class Birds{
  void feature(){
    print('体覆羽毛,有双翼');
  }
}

// 老鹰
class Eagle extends Birds{
  void fly(){
    print('翱翔天空!');
  }
}

// 鸵鸟
class Ostrich extends Birds{
  void run(){
    print('奔跑如飞!');
  }
}

我们将这些类的共同特性抽象到一个单独的基类——Birds中,然后再让这些业务类去继承抽象基类。这样,具体子类只需要创建自己特有的方法,而不需要去重写父类的方法来达到需求。既复用了代码,也遵循了里氏替换原则。

总结:

  1. 约束继承泛滥,同时也是开闭原则的一种体现。
  2. 加强程序的健壮性,同时变更时可以做到非常好的兼容性,提高程序的维护性、可扩展性,降低需求变更时引入的风险。

举个生活中的例子,我们经常与USB插口打交道,计算机依赖抽象USB插口去读取数据,至于具体接入什么设备,计算机不必关心,可以是键盘,也可以是扫描仪,只要是兼容USB接口的设备就可以对接。这便实现了多种USB设备的里氏替换,让系统功能模块可以灵活替换,功能无限扩展,这种可替换、可延伸的软件系统才是有灵魂的设计。

迪米特法则(LOD)

迪米特法则(Law of Demeter,LOD)又称为最少知道原则(Least KnowledgePrinciple,LKP),是指一个对象类对于其他对象类来说,知道得越少越好。也就是说,两个类之间不要有过多的耦合关系,保持最少关联性。

迪米特法则还有个更简单的定义:只与直接的朋友通信

所谓直接的朋友:每个对象都会与其他对象有耦合关系,只要两个对象之间有耦合关系,我们就说这两个对象之间是朋友关系。耦合的方式很多,依赖,关联,组合,聚合等。其中,我们称出现在成员变量,方法参数,方法返回值中的类为直接的朋友,而出现在局部变量中的类则不是直接的朋友。也就是说,陌生的类最好不要以局部变量的形式出现在类的内部。

示例

现在有三个类,商品、员工和老板。需求是让老板调度员工去统计商品的数量,代码实现如下:

void main(List<String> arguments) {
  var boss = Boss();
  var employee = Employee();
  boss.scheduleEmployee(employee);
}

// 商品
class Goods{}

// 员工
class Employee{

  // 检查商品数量
  void checkNumber(List<Goods> goodsList){
    print('检查到的商品数量是:${goodsList.length}');
  }
}

// 老板
class Boss{

  // 调度员工做事
  void scheduleEmployee(Employee employee){
    var goodsList = List.filled(10, Goods());
    employee.checkNumber(goodsList);
  }
}

上例显然没有遵循迪米特法则!老板调度员工干活,只需要知道结果,而不关心过程。也就是说,在老板类的scheduleEmployee方法中不应该出现Goods类。Goods类不是Boss类的直接朋友。

按照迪米特法则重构代码:

// 员工
class Employee{

  List<Goods> _getAllGoods(){
    return List.filled(10, Goods());
  }

  // 检查商品数量
  void checkNumber(){
    var goodsList = _getAllGoods();
    print('检查到的商品数量是:${goodsList.length}');
  }
}

// 老板
class Boss{
  // 调度员工做事
  void scheduleEmployee(Employee employee){
    employee.checkNumber();
  }
}

我们在Employee的内部封装一个方法获取所有商品,然后在checkNumber方法内部去调用。Boss类中,只需调用雇员的checkNumber功能即可,Boss类是不需要和Goods类耦合的。

总结:

迪米特法则要求,一个类对自己依赖(使用)的类知道得越少越好。也就是说,对于被依赖(使用)的类不管多么复杂,都尽量将逻辑封装在类的内部。对外除了提供公共方法,不对外泄露任何信息。 就像我们上面的Employee类,不管其功能多复杂,都应该封装在类的内部,而不应该让Boss类知道。

举个生活中的例子,假如我们买了一台游戏机,其内部集成了非常复杂的电子元件,这些对外部来说完全是不可见的,就像一个黑盒子。虽然我们看不到黑盒子的内部构造与工作原理,但它向外部开放了控制接口,让我们可以接上手柄对其进行访问,这便构成了一个完美的封装。除了封装起来的黑盒子游戏主机,手柄是另一个封装好的模块,它们之间只是通过一根线来传递信号,至于主机内部的各种复杂逻辑,手柄一无所知。

接口隔离原则(ISP)

接口隔离原则(Interface Segregation Principle,ISP)要求程序员尽量将臃肿庞大的接口拆分成更小的和更具体的接口,让接口中只包含客户感兴趣的方法。

因为客户不应该依赖它不需要的接口,即一个类对另一个类的依赖应该建立在最小的接口之上!

示例

现在有人抽象了一个动物接口:

// 动物接口
abstract class Animal{
  void eat();
  void fly();
  void run();
  void swim();
}

// 狗
class Dog implements Animal{
  @override
  void eat() {}

  @override
  void fly() {}

  @override
  void run() {}

  @override
  void swim() {}
}

// 金鱼
class Goldfish  implements Animal{
  @override
  void eat() {}

  @override
  void fly() {}

  @override
  void run() {}

  @override
  void swim() {}
}

如上例,当我们用狗类去实现动物接口时,因为狗不会飞,因此只能空实现一个它不需要的方法。用金鱼去实现动物接口时,金鱼不会跑,也不会飞,得空实现两个不需要的方法。这显然违背了接口隔离原则!

这说明我们在抽象Animal接口时存在问题,抽象的接口太多了,没有建立在最小的接口之上。

根据接口隔离原则重构代码:

// 动物接口
abstract class Animal{
  void eat();
}

// 飞行动物接口
abstract class FlyAnimal extends Animal{
  void fly();
}

// 陆地动物接口
abstract class TerrestrialAnimal extends Animal{
  void run();
}

// 水生动物接口
abstract class WaterAnimal extends Animal{
  void swim();
}

// 狗
class Dog implements TerrestrialAnimal{

  @override
  void eat() {}

  @override
  void run() {}
}

// 金鱼
class Goldfish  implements WaterAnimal{

  @override
  void eat() {}
  
  @override
  void swim() {}
}

如上例,我们将之前很大的接口Animal拆分成颗粒度更低的众多小接口,然后让狗类去实现陆地动物接口,让金鱼实现水生动物接口,这样,它们就不需要空实现根本不需要的方法。需要注意,接口之间也是可以继承的,以达到复用代码的目的。我们在Animal中抽象出了eat接口,这是所有动物都具有的行为,然后让其他接口继承它。

总结:

  1. 一个类对另一个类的依赖应该建立在最小接口上。
  2. 建立单一接口,不要建立庞大臃肿的接口
  3. 尽量细化接口,接口中的方法尽量少(当然不是越少越好,要适度)

另外需要注意,Dart语法中提出了一个新的概念 mixin,它在功能上有些类似于接口,使用mixin在一定程度上就遵循了接口隔离原则。Dart不是很强调使用接口这个概念,但它实际上就是将一个大的接口拆分成多个小的 mixin混入,达到依赖最小接口的目的。

其他

以上六大设计原则,也被称为SOLID 原则,这是根据它们英文的首字母缩写而来。其实在这六大原则之外,一些其他书籍资料中,还有一种原则被称为合成复用原则。

合成复用原则(Composite/Aggregate Reuse Principle,CARP)指尽量使用对象组合(has-a)或对象聚合(contanis-a)的方式实现代码复用,而不是用继承关系达到代码复用的目的。合成复用原则可以使系统更加灵活,降低类与类之间的耦合度,一个类的变化对其他类造成的影响相对较小。继承,又被称为白箱复用,相当于把所有实现细节暴露给子类。组合/聚合又被称为黑箱复用,对类以外的对象是无法获取实现细节的。

那么什么是组合关系,什么又是聚合关系呢?这里我们有必要对依赖、组合、聚合等概念做一个总结。

依赖关系

  • 在类中使用到了对方

  • 是类的成员属性

  • 是方法的返回类型

  • 是方法接收的参数类型

  • 在方法中使用到

关联关系

是类与类之间的联系,他是依赖关系的特例。关联具有导航性:即双向关系或单向关系

聚合关系

表示的是整体和部分的关系, 整体与部分可以分开。 聚合关系是关联关系的特例,所以他具有关联的导航性与多重性。

例如,一台电脑由键盘、显示器,鼠标等组成;组成电脑的各个配件是可以从计算机上分离出来的 :

// 鼠标
class Mouse{}

// 显示器
class Monitor{}

// 计算机
class Computer{
  Mouse? _mouse;
  Monitor? _monitor;

  set mouse(Mouse m){
    _mouse = m;
  }

  set monitor(Monitor m){
    _monitor = m;
  }
}

如上例,显示器,鼠标是可以从计算机上分离的,即可以从外部传进来。

组合关系

也是整体与部分的关系,但是整体与部分不可以分开。

我们仍然用上面的例子来描述:

// 鼠标
class Mouse{}

// 显示器
class Monitor{}

// 计算机
class Computer{
  Mouse mouse = Mouse();
  Monitor monitor = Monitor();
}

这里我们运用了组合来描述计算机与鼠标、显示器的关系。我们认为计算机与鼠标、显示器是不可分离的,因此mousemonitor成员不能由外部传入,只能在内部创建实例。

设计的核心思想

  • 找出应用中需要变化之处,把它们独立出来,不要和那些不需要变化的代码混在一起。
  • 针对接口编程,而不是针对实现编程。
  • 为了交互对象之间的松耦合设计而努力

关注公众号:编程之路从0到1

编程之路从0到1

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码

)">
< <上一篇
下一篇>>