设计模式之六大原则

Posted by Night Field's Blog on March 14, 2020

1 概述

在面向对象的编程中,要实现一个功能,可以有非常多的方式。在多年的经验积累总结下来,人们发现优秀的的代码总是遵循一定的范式。其中23种设计模式(Design Patterns),就是前人对优秀代码的编程范式的总结,是面向对象编程的最佳实践。合理地运用这些设计模式,已经是写出高质量,高效率,可读性强,易维护的代码的充要条件。 而设计模式六大原则,则是设计模式都会遵守的通用法则。本文将结合简单的例子,介绍这六大原则

2. 六大原则

2.1 依赖倒置原则(Dependency Inversion Principle)

高层模块不应该依赖于底层模块,抽象不应该依赖于细节。因为相对于实现细节的多样性与易变性,抽象类要稳定得多。 换句话说,我们应该针对接口编程。看一个反例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class DIViolation {
    public static void main(String[] args) {
        JavaDeveloper javaDeveloper = new JavaDeveloper();
        PythonDeveloper pythonDeveloper = new PythonDeveloper();
        Team team = new Team(javaDeveloper, pythonDeveloper);
        team.teamWork();
    }
}

class JavaDeveloper {
    public void work() {
        System.out.format("Java developer is working.");
    }
}

class PythonDeveloper {
    public void work() {
        System.out.format("Python developer is working.");
    }
}

class Team {
    JavaDeveloper javaDeveloper;
    PythonDeveloper pythonDeveloper;
    Team(JavaDeveloper javaDeveloper, PythonDeveloper pythonDeveloper) {
        this.javaDeveloper = javaDeveloper;
        this.pythonDeveloper = pythonDeveloper;
    }
    public void teamWork() {
        javaDeveloper.work();
        pythonDeveloper.work();
    }
}

上述例子中,类Team和具体类JavaDeveloperPythonDeveloper强耦合在了一起,扩展性极差。想象一下,某一天,PythonDeveloper离职了,那我们得修改Team类,把PythonDeveloper给删除掉。又有一天,来了一位新同事CPPDeveloper,我们又得修改Team类,增加CPPDeveloper。由此看出,依赖具体类的系统稳定性与扩展性是多不好。如果我们改为依赖接口呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class DIObedience {
    public static void main(String[] args) {
        Collection<Developer> developers = new HashSet<>();
        developers.add(new Javaer());
        developers.add(new Pythoner());
        Team team = new Team(developers);
        team.teamWork();
    }
}

interface Developer {
    void work();
}

class Javaer implements Developer {
    public void work() {
        System.out.println("Java developer is working.");
    }
}

class Pythoner implements Developer {
    public void work() {
        System.out.format("Python developer is working.");
    }
}

class Team {
    Collection<Developer> developers;
    Team(Collection<Developer> developers) {
        this.developers = developers;
    }
    public void teamWork() {
        developers.forEach(Developer::work);
    }
}

上述例子中,Team依赖于顶层接口Developer,无论组内成员怎么变动,Team类本身完全不需要修改,可扩展性很强,稳定性也很高。

2.2 里氏替换原则(Liskov Substitution Principle)

也就是一位姓的女士提出的原则:-)。对象必须保证在不知道基类的具体实现类的情况下可以被使用,简而言之,子类可以替换掉父类出现。对于Java语言来说,这个原则是不言而喻的。想象一下,当我们以Collection对象作为方法参数的时候,无论传ArrayList还是HashSet,方法都应该能正常工作。为了遵循这条简单的原则,我们在编程中需要做到以下几点

2.2.1 子类与父类的关系一定是is-A,而不是like-A

看一个反例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class WrongExtend {
    public static void main(String[] args) {
        Fish fish = new Whale();
        fish.breath();
    }
}

class Fish {
    private String name;
    Fish(String name) {
        this.name = name;
    }
    public void breath() {
        System.out.format("I'm %s, I breath with cheek.");
    }
}

class Whale extends Fish {
    Whale() {
        super("whale");
    }
}

输出:

1
I'm whale, I breath with cheek.

输出了鲸用腮呼吸。原因是鲸不是鱼,但是却错误地继承了Fish这个父类,所以导致了行为breath的错误。所以,当子类并不完全是父类的时候,使用父类的方法,可能会导致错误。

2.2.2 子类应该避免重写父类已定义好的方法

下面是反例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class WrongOverride {
    public static void main(String[] args) {
        Rectangle rectangle = new Square();
        rectangle.setWidth(3);
        rectangle.setLength(5);
        System.out.format("expect rectangle area to be %d, actual is %d", 3 * 5, rectangle.getArea());
    }
}

class Rectangle {
    private int width;
    private int length;
    public void setWidth(int width) {
        this.width = width;
    }
    public void setLength(int length) {
        this.length = length;
    }
    public int getArea() {
        return width * length;
    }
}

class Square extends Rectangle {
    // set length same as width
    @Override
    public void setWidth(int width) {
        super.setLength(width);
        super.setWidth(width);
    }
    // set width same as length
    @Override
    public void setLength(int length) {
        super.setLength(length);
        super.setWidth(length);
    }
}

输出:

1
expect rectangle area to be 15, actual is 25

因为子类Square重写了父类的setWidthsetLength方法,导致了子类与父类的行为不一致,最终输出了与预期不符的结果。所以当子类重写父类方法时,一定不能破坏父类原有的行为。

2.3 接口隔离原则(Interface Segregation Principle)

应该定义多个隔离的接口,而不是一个全面却庞大的接口。子类不应该包含不允许使用的接口。这条规则要求,接口只应包含单一的功能,子类不应包含不必要的功能。 来看一反例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class SIViolation {
    public static void main(String[] args) {
        Person person1 = new Swimmer();
        Person person2 = new Driver();
        person1.drive();
        person2.swim();
    }
}
interface Person {
    void eat();
    void swim();
    void drive();
}
class Swimmer implements Person {
    @Override
    public void eat() {
        System.out.println("Swimmer is eating.");
    }
    @Override
    public void swim() {
        System.out.println("Swimmer is swimming.");
    }
    @Override
    public void drive() {
        System.out.println("Sorry, swimmer can't drive!");
    }
}
class Driver implements Person {
    @Override
    public void eat() {
        System.out.println("Driver is eating.");
    }
    @Override
    public void swim() {
        System.out.println("Sorry, driver can't swim!");
    }
    @Override
    public void drive() {
        System.out.println("Driver is driving.");
    }
}

输出:

1
2
Sorry, swimmer can't drive!
Sorry, driver can't swim!

这个例子中,因为我们定义了一个大接口Person,里面包括了不同的功能,导致子类实现的时候,包含了无法使用的方法。子类不仅徒增了不必要的逻辑,而且导致了最终行为的错误:子类并不能完全替代父类出现(父类Person调用swim方法的时候,子类就不能是Driver而只能是Swimmer),违反了里氏替换原则。正确的做法应该是,把driveswim两个方法分离到不同的接口中,子类只应该包含自己能做到的接口。下面是正例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class ISObedience {
    public static void main(String[] args) {
        swimmable person1 = new Swimmer();
        drivable person2 = new Driver();
        person1.swim();
        person2.drive();
    }
}
interface swimmable {
    void swim();
}
interface eatable {
    void eat();
}
interface drivable {
    void drive();
}

class Swimmer implements swimmable, eatable {
    @Override
    public void eat() {
        System.out.println("Swimmer is eating.");
    }
    @Override
    public void swim() {
        System.out.println("Swimmer is swimming.");
    }
}
class Driver implements drivable, eatable {
    @Override
    public void eat() {
        System.out.println("Driver is eating.");
    }
    @Override
    public void drive() {
        System.out.println("Driver is driving.");
    }
}

2.4 单一职责原则(Single Responsibility Principle)

导致类变化的原因应该只有一个。意思就是,一个类只做一件事。类的职责越简单,代码可读性越高,工程的可维护性也越强,同时也能降低类之间的耦合度,从而降低修改代码带来的风险。 单一职责原则接口隔离原则有些类似。上面的那个例子,也是单一职责原则的很好体现:类/接口的功能应该单一。接口隔离原则更偏向于对抽象与接口的约束,单一职责原则更关注具体实现。

2.5 迪米特法则(Demeter Principle)

又叫最少知道原则。一个类对其他类应该有最少的了解,并尽量少地与其他实体之间发生相互作用,使得系统功能模块相对独立。我们需要做到以下两点:

2.5.1. 类只应该暴露公共的方法,能设成private的方法/属性,就设成private

下面是一个反例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class WrongPrivilege {
    public static void main(String[] args) {
        Cook cook = new Cook();
        // accidentally consumed a tomato
        cook.consumeTomato();
        cook.cookSoup();
    }
}

class Cook {
    private int tomatoNum = 1;
    private int eggNum = 1;
    public boolean consumeTomato() {
        if (--tomatoNum >= 0) {
            System.out.format("Consumed a tomato.");
            return true;
        }
        else {
            System.out.format("Error: No tomato left!");
            return false;
        }
    }
    public boolean consumeEgg() {
        if (--eggNum >= 0) {
            System.out.format("Consumed an egg.");
            return true;
        }
        else {
            System.out.format("Error: No egg left!");
            return false;
        }
    }
    public void cookSoup() {
        if (consumeTomato() && consumeEgg()) {
            System.out.format("Cook soup successfully!");
        }
    }
}

输出:

1
2
Consumed a tomato.
Error: No tomato left!

上述例子中,由于Cook类暴露了不该暴露的方法consumeTomatoconsumeEgg,导致内部的数据一致性遭到破坏,于是cookSoup方法调用失败。而对于调用者(main方法)来说,Cook类过多的public方法也会增加使用难度与使用的错误率,增加学习成本。

2.5.2. 类只应该与直接依赖产生通讯。

只与直接依赖产生关联,可以使类之间的耦合度降到最低。假如有某个模块出现类问题,那么我们只需要修改与之直接相关的模块即可。以下是一个正例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class GoodDependency {
    public static void main(String[] args) {
        Music music = new Music("See you again");
        App app = new App(music);
        Computer computer = new Computer(app);
        computer.openApp();
    }
}

class Computer {
    App app;
    Computer(App app) {
        this.app = app;
    }
    public void openApp() {
        app.open();
    }
}
class App {
    Music music;
    App(Music music) {
        this.music = music;
    }
    public void open() {
        System.out.format("App is playing %s.", music.getName());
    }
}
class Music {
    private String name;
    Music(String name) {
        this.name = name;
    }
    public String getName() {
        return name;
    }
}

输出:

1
App is playing See you again.

上述例子设计良好,模块只依赖于直接相关的模块。

2.1 开闭原则(Open Closed Principle)

系统应该对扩展开放,对修改关闭。 开闭原则可以说是一条总则,是面向对象编程的最高指导法则。上述的所有例子,都可以看到开闭原则的影子。 以上五条原则,目的都是为了提高系统的可扩展性,并极力降低对类原有结构,功能的修改。如果修改了原来的逻辑,那么所有之前正确的功能模块就需要重新测试。而扩展原来的逻辑,则只需要测试新增的逻辑。 开闭原则要求设计者需要有足够的前瞻性。比如考虑把上面例子中App的功能play music改一下,变成read book,那么上面的代码需要有很大的改动。而设计良好的写法,将会是类似如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class Flexibility {
    public static void main(String[] args) {
        AppFunction function = new ReadBook();
        App app = new App(function);
        Computer computer = new Computer(app);
        computer.openApp(function);
    }
}

class Computer {
    Map<AppFunction, App> apps = new HashMap<>();
    Computer(Map<AppFunction, App> apps) {
        this.apps = apps;
    }
    Computer(App app) {
        this.apps.put(app.getFunction(), app);
    }
    public void openApp(AppFunction function) {
        apps.get(function).open();
    }
}
class AppFunction {
    private String name;
    AppFunction(String name) {
        this.name = name;
    }
    public String getName() {
        return name;
    }
}
class PlayMusic extends AppFunction {
    PlayMusic() {
        super("play music");
    }
}
class ReadBook extends AppFunction {
    ReadBook() {
        super("read book");
    }
}
class App<T extends AppFunction> {
    T function;
    App(T function) {
        this.function = function;
    }
    public void open() {
        System.out.format("App function is %s.", function.getName());
    }
    public AppFunction getFunction() {
        return function;
    }
}

输出:

1
App function is read book.

乍一看,代码量增加了,但其实系统的可扩展性非常高。无论是需要删除App还是修改App,唯一需要改动的就是调用者main方法。如果把依赖关系配置在类似Springxml文件中,那么唯一需要改的只是xml配置!如果是新增APP功能如玩游戏,则只需要新增一个类PlayGame继承AppFunction代表功能就可以,避免修改原来的代码。

3 总结

六大原则是所有面向对象编程者的必修课。好好领悟其中的道理,无论对架构设计,还是日常编程,都大有裨益。

文中例子的github地址