本着什么原则,才能写出优秀的代码? (二)

简介: 本着什么原则,才能写出优秀的代码? (二)

高级阶段


最近刚读完一本书,Bob 大叔的**《架构整洁之道》**,感觉还是不错的,收获很多。


176ac71c91e847a9ad59d5e4fe61c664~tplv-k3u1fbpfcp-zoom-in-crop-mark 1304 0 0 0.png


全书基本上是在描述软件设计的一些理论知识。大体分成三个部分:编程范式(结构化编程、面向对象编程和函数式编程),设计原则(主要是 SOLID),以及软件架构(其中讲了很多高屋建翎的内容)。


总体来说,这本书中的内容可以让你从微观(代码层面)和宏观(架构层面)两个层面对整个软件设计有一个全面的了解。


其中 SOLID 就是指面向对象编程和面向对象设计的五个基本原则,在开发过程中适当应用这五个原则,可以使软件维护和系统扩展都变得更容易。


五个基本原则分别是:


  1. 单一职责原则(SRP)
  2. 开放封闭原则(OCP)
  3. 里氏替换原则(LSP)
  4. 接口隔离原则(ISP)
  5. 依赖倒置原则(DIP)


单一职责原则(SRP)


A class should have one, and only one, reason to change. – Robert C Martin


一个软件系统的最佳结构高度依赖于这个系统的组织的内部结构,因此每个软件模块都有且只有一个需要被改变的理由。


这个原则非常容易被误解,很多程序员会认为是每个模块只能做一件事,其实不是这样。

举个例子:


假如有一个类 T,包含两个函数,分别是 A()B(),当有需求需要修改 A() 的时候,但却可能会影响 B() 的功能。


这就不是一个好的设计,说明 A()B() 耦合在一起了。


开放封闭原则(OCP)


Software entities should be open for extension, but closed for modification. – Bertrand Meyer, Object-Oriented Software Construction


如果软件系统想要更容易被改变,那么其设计就必须允许新增代码来修改系统行为,而非只能靠修改原来的代码。


通俗点解释就是设计的类对扩展是开放的,对修改是封闭的,即可扩展,不可修改。

看下面的代码示例,可以简单清晰地解释这个原则。


void DrawAllShape(ShapePointer list[], int n)
{
    int i;
    for (i = 0; i < n; i++)
    {
        struct Shape* s = list[i];
        switch (s->itsType)
        {
            case square:
                DrawSquare((struct Square*)s);
                break;
            case circle:
                DrawSquare((struct Circle*)s);
                break;
            default:
                break;
        }
    }
}
复制代码


上面这段代码就没有遵守 OCP 原则。


假如我们想要增加一个三角形,那么就必须在 switch 下面新增一个 case。这样就修改了源代码,违反了 OCP 的封闭原则。


缺点也很明显,每次新增一种形状都需要修改源代码,如果代码逻辑复杂的话,发生问题的概率是相当高的。


class Shape
{
    public:
        virtual void Draw() const = 0;
}
class Square: public Shape
{
    public:
        virtual void Draw() const;
}
class Circle: public Shape
{
    public:
        virtual void Draw() const;
}
void DrawAllShapes(vector<Shape*>& list)
{
    vector<Shape*>::iterator I;
    for (i = list.begin(): i != list.end(); i++)
    {
        (*i)->Draw();
    }
}
复制代码


通过这样修改,代码就优雅了很多。这个时候如果需要新增一种类型,只需要增加一个继承 Shape 的新类就可以了。完全不需要修改源代码,可以放心扩展。


里氏替换原则(LSP)


Require no more, promise no less.– Jim Weirich


这项原则的意思是如果想用可替换的组件来构建软件系统,那么这些组件就必须遵守同一个约定,以便让这些组件可以相互替换。


里氏替换原则可以从两方面来理解:


第一个是继承。如果继承是为了实现代码重用,也就是为了共享方法,那么共享的父类方法就应该保持不变,不能被子类重新定义。


子类只能通过新添加方法来扩展功能,父类和子类都可以实例化,而子类继承的方法和父类是一样的,父类调用方法的地方,子类也可以调用同一个继承得来的,逻辑和父类一致的方法,这时用子类对象将父类对象替换掉时,当然逻辑一致,相安无事。


第二个是多态,而多态的前提就是子类覆盖并重新定义父类的方法。


为了符合 LSP,应该将父类定义为抽象类,并定义抽象方法,让子类重新定义这些方法。当父类是抽象类时,父类就是不能实例化,所以也不存在可实例化的父类对象在程序里,也就不存在子类替换父类实例(根本不存在父类实例了)时逻辑不一致的可能。


举个例子:


看下面这段代码:


class A{
  public int func1(int a, int b){
    return a - b;
  }
}
public class Client{
  public static void main(String[] args){
    A a = new A();
    System.out.println("100-50=" + a.func1(100, 50));
    System.out.println("100-80=" + a.func1(100, 80));
  }
}
复制代码


输出;


100-50=50
100-80=20
复制代码


现在,我们新增一个功能:完成两数相加,然后再与 100 求和,由类 B 来负责。即类 B 需要完成两个功能:


  1. 两数相减
  2. 两数相加,然后再加 100


现在代码变成了这样:


class B extends A{
  public int func1(int a, int b){
    return a + b;
  }
  public int func2(int a, int b){
    return func1(a,b) + 100;
  }
}
public class Client{
  public static void main(String[] args){
    B b = new B();
    System.out.println("100-50=" + b.func1(100, 50));
    System.out.println("100-80=" + b.func1(100, 80));
    System.out.println("100+20+100=" + b.func2(100, 20));
  }
}
复制代码


输出;


100-50=150
100-80=180
100+20+100=220
复制代码


可以看到,原本正常的减法运算发生了错误。原因就是类 B 在给方法起名时重写了父类的方法,造成所有运行相减功能的代码全部调用了类 B 重写后的方法,造成原本运行正常的功能出现了错误。


这样做就违反了 LSP,使程序不够健壮。更通用的做法是:原来的父类和子类都继承一个更通俗的基类,原有的继承关系去掉,采用依赖、聚合,组合等关系代替。


接口隔离原则(ISP)


Clients should not be forced to depend on methods they do not use. –Robert C. Martin


软件设计师应该在设计中避免不必要的依赖。


ISP 的原则是建立单一接口,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法要尽量少。


也就是说,我们要为各个类建立专用的接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。


在程序设计中,依赖几个专用的接口要比依赖一个综合的接口更灵活。


单一职责与接口隔离的区别:


  1. 单一职责原则注重的是职责;而接口隔离原则注重对接口依赖的隔离。
  2. 单一职责原则主要是约束类,其次才是接口和方法,它针对的是程序中的实现和细节; 而接口隔离原则主要约束接口。


举个例子:


818cc8ef0ee547a19522ae0a531ef7cd~tplv-k3u1fbpfcp-zoom-in-crop-mark 1304 0 0 0.png


首先解释一下这个图的意思:


「犬科」类依赖「接口 I」中的方法:「捕食」,「行走」,「奔跑」; 「鸟类」类依赖「接口 I」中的方法「捕食」,「滑翔」,「飞翔」。


「宠物狗」类与「鸽子」类分别是对「犬科」类与「鸟类」类依赖的实现。


对于具体的类:「宠物狗」与「鸽子」来说,虽然他们都存在用不到的方法,但由于实现了「接口 I」,所以也 必须要实现这些用不到的方法,这显然是不好的设计。


如果将这个设计修改为符合接口隔离原则的话,就必须对「接口 I」进拆分。


5ad1a17412b140809575c15f476c3505~tplv-k3u1fbpfcp-zoom-in-crop-mark 1304 0 0 0.png

在这里,我们将原有的「接口 I」拆分为三个接口,拆分之后,每个类只需实现自己需要的接口即可。


依赖倒置原则(DIP)


High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.– Robert C. Martin


高层策略性的代码不应该依赖实现底层细节的代码。


这话听起来就让人听不明白,我来翻译一下。大概就是说在写代码的时候,应该多使用稳定的抽象接口,少依赖多变的具体实现。


举个例子:


看下面这段代码:


public class Test {
    public void studyJavaCourse() {
        System.out.println("张三正在学习 Java 课程");
    }
    public void studyDesignPatternCourse() {
        System.out.println("张三正在学习设计模式课程");
    }
}
复制代码


上层直接调用:


public static void main(String[] args) {
    Test test = new Test();
    test.studyJavaCourse();
    test.studyDesignPatternCourse();
}
复制代码


这样写乍一看并没有什么问题,功能也实现的好好的,但仔细分析,却并不简单。


第一个问题:


如果张三又新学习了一门课程,那么就需要在 Test() 类中增加新的方法。随着需求增多,Test() 类会变得非常庞大,不好维护。


而且,最理想的情况是,新增代码并不会影响原有的代码,这样才能保证系统的稳定性,降低风险。


第二个问题:


Test() 类中方法实现的功能本质上都是一样的,但是却定义了三个不同名字的方法。那么有没有可能把这三个方法抽象出来,如果可以的话,代码的可读性和可维护性都会增加。


第三个问题:


业务层代码直接调用了底层类的实现细节,造成了严重的耦合,要改全改,牵一发而动全身。


基于 DIP 来解决这个问题,势必就要把底层抽象出来,避免上层直接调用底层。


dfa67fcc5b3f42efa4879d6cb5146c67~tplv-k3u1fbpfcp-zoom-in-crop-mark 1304 0 0 0.png


抽象接口:


public interface ICourse {
    void study();
}
复制代码


然后分别为 JavaCourseDesignPatternCourse 编写一个类:


public class JavaCourse implements ICourse {
    @Override
    public void study() {
        System.out.println("张三正在学习 Java 课程");
    }
}
public class DesignPatternCourse implements ICourse {
    @Override
    public void study() {
        System.out.println("张三正在学习设计模式课程");
    }
}
复制代码


最后修改 Test() 类:


public class Test {
    public void study(ICourse course) {
        course.study();
    }
}
复制代码


现在,调用方式就变成了这样:


public static void main(String[] args) {
    Test test = new Test();
    test.study(new JavaCourse());
    test.study(new DesignPatternCourse());
}
复制代码


通过这样开发,上面提到的三个问题得到了完美解决。


其实,写代码并不难,通过什么设计模式来设计架构才是最难的,也是最重要的。

所以,下次有需求的时候,不要着急写代码,先想清楚了再动手也不迟。


这篇文章写的特别辛苦,主要是后半部分理解起来有些困难。而且有一些原则也确实没有使用经验,单靠文字理解还是差点意思,体会不到精髓。


其实,文章中的很多要求我都做不到,总结出来也相当于是对自己的一个激励。以后对代码要更加敬畏,而不是为了实现功能草草了事。写出健壮,优雅的代码应该是每个程序员的目标,与大家共勉。



目录
相关文章
|
2月前
|
设计模式 Java 测试技术
优雅代码,建议掌握这 11个编程原则!
高质量的代码不仅让人信服,还能提升开发效率。本文总结了多位高手的经验,提炼出11条编码基本原则:DRY(避免重复)、KISS(简洁至上)、重构(优化代码)、SOLID(设计原则)、文档编写、创建优于继承、YAGNI(避免过度设计)、委托原则、始终保持代码清洁、封装变化以及优先使用组合而非继承。遵循这些原则,你的代码将更加优雅和高效。
51 3
|
4月前
|
设计模式 算法 开发者
设计模式问题之最小知识原则(迪米特法则)对代码设计有何影响,如何解决
设计模式问题之最小知识原则(迪米特法则)对代码设计有何影响,如何解决
|
7月前
|
缓存 Java 编译器
什么是happens-before原则?
什么是happens-before原则?
124 0
|
测试技术
软件设计原则-单一置原则讲解以及代码示例
单一职责原则(Single Responsibility Principle,SRP)是面向对象设计中的一个重要原则,提倡将一个类或模块只负责一个职责或功能。它最早由Robert C. Martin在其《敏捷软件开发:原则、模式与实践》一书中提出。 单一职责原则的核心思想是:一个类或模块应该只有一个引起它变化的原因。也就是说,每个类或模块都应该只有一个职责或功能,并且该职责或功能应该在该类或模块内部封装起来,而不是分散到多个类或模块中。
107 0
|
设计模式 测试技术 程序员
代码的简单设计五原则
代码的简单设计五原则
33092 1
|
设计模式
设计模式(7) -- 合成复用原则和七大原则总结
设计模式(7) -- 合成复用原则和七大原则总结
140 0
设计模式(7) -- 合成复用原则和七大原则总结
|
设计模式 Java 程序员
代码设计原则
代码设计原则
398 0
代码设计原则
|
消息中间件 SQL 缓存
程序命名的原则与重构
命名是对事物本质的一种认知探索,是给读者一份宝贵的承诺。糟糕的命名会像迷雾,引领读者走进深渊;而好的命名会像灯塔,照亮读者前进的路。命名如此美妙,本文将一步步揭开它的神秘面纱!
程序命名的原则与重构
|
设计模式 缓存 NoSQL
设计模式六大原则(二)----里式替换原则
设计模式六大原则(二)----里式替换原则
221 0
设计模式六大原则(二)----里式替换原则
|
SQL 程序员 测试技术
本着什么原则,才能写出优秀的代码? (一)
本着什么原则,才能写出优秀的代码? (一)
163 0
本着什么原则,才能写出优秀的代码? (一)