代码该怎么写——设计原则
初学者学会编程语言后开始写代码,当我们实现一个功能后会有成就感,但是看了别人写的代码又大感困惑,他为什么把代码写得那么复杂?明明一个简单的功能,为什么要这样做?
还有人即使学会了编程语言,仍然不知道怎么下手写代码,哪里该创建一个类,哪里又该创建一个方法?
现代社会,文盲率很低,人人识字,但为什么不是人人都能当作家呢?
因为,我们只学识字是不够的,我们还得学习写作的技巧和套路,并且还要有一定的人生经历,这样才能成长为一个作家。简而言之,会写代码,和写好代码,是两个层面!
面向对象编程语言的设计原则和设计模式,就是程序员需要学习的写作套路。这是很多前辈采坑总结的血泪教训,学习这些套路,能避免后来者踩同样的坑,犯相同的错。
现在,就让我们来用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判断那样去修改原逻辑。
总结:
-
降低类的复杂度,一个类只负责一项职责。
-
提高类的可读性,可维护性
-
降低修改引发的风险
开闭原则(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 超清视频");
}
}
首先作为功能模块的提供者,我们编写了三个类提供视频服务,功能的使用者调用这三个类实现需求。也许很多时候,功能提供者和使用者都是我们自己,但是在大团队开发时,可能我们只是编写接口给别人用的,因此在开发时,大脑中需要具有提供者、使用者的思维区分。
对于使用者,不需要知道功能的具体细节,只需要面向抽象接口编程。因此使用者只需要调用抽象接口VideoService
的play
方法。注意,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
函数中调用Student
的study
方法传递参数时,就是所谓的依赖注入!
如何理解依赖?一般说的依赖某个类,就是指需要使用某个类。
依赖注入的方式有三种:
- 通过类的构造方法将需要用到的类传入
- 通过类的Setter方法,将需要用到的类传入
- 通过具体使用的接口,将依赖的类传入
上例中显然使用的是第三种方式注入依赖。我们在具体调用study
接口时,才将依赖的类传入进去。
总结:
- 抽象不应该依赖细节,细节应该依赖抽象
- 依赖倒置的中心思想是面向接口编程
- 相对于细节的多变性,抽象的东西要稳定得多。以抽象为基础搭建的架构比以细节为基础的架构要稳定得多
- 使用接口或抽象类的目的是制定好规范,而不涉及任何具体的操作,把展现细节的任务交给它们的实现类去完成
- 变量的声明类型尽量是抽象类或接口, 这样变量引用和实际对象间,就存在一个缓冲层,利于程序扩展和优化
里氏替换原则(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
中,然后再让这些业务类去继承抽象基类。这样,具体子类只需要创建自己特有的方法,而不需要去重写父类的方法来达到需求。既复用了代码,也遵循了里氏替换原则。
总结:
- 约束继承泛滥,同时也是开闭原则的一种体现。
- 加强程序的健壮性,同时变更时可以做到非常好的兼容性,提高程序的维护性、可扩展性,降低需求变更时引入的风险。
举个生活中的例子,我们经常与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
接口,这是所有动物都具有的行为,然后让其他接口继承它。
总结:
- 一个类对另一个类的依赖应该建立在最小接口上。
- 建立单一接口,不要建立庞大臃肿的接口
- 尽量细化接口,接口中的方法尽量少(当然不是越少越好,要适度)
另外需要注意,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();
}
这里我们运用了组合来描述计算机与鼠标、显示器的关系。我们认为计算机与鼠标、显示器是不可分离的,因此mouse
、monitor
成员不能由外部传入,只能在内部创建实例。
设计的核心思想
- 找出应用中需要变化之处,把它们独立出来,不要和那些不需要变化的代码混在一起。
- 针对接口编程,而不是针对实现编程。
- 为了交互对象之间的松耦合设计而努力
关注公众号:编程之路从0到1
今天的文章代码设计的7个原则_代码命名的基本原则分享到此就结束了,感谢您的阅读。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/67387.html