1. Posts/

OOP 特征与原则

·3751 字· 32 分钟 草稿
oop java
Ghomist
作者
Ghomist
Life needs patience, and so does code

面向对象是相当常见的编程思想,如今早就遍地开花了,因为其编程风格更能符合生活中的 人的直觉 自然逻辑(大部分情况下),能有效的提高产出以及便于维护(同样也是大部分情况下咳咳),所以还是非常有必要了解的

其实其中的特征和原则之类的,本来我是不太愿意去学,因为不太喜欢这种学究一类的东西,后来了解了之后才发现,其实写代码的时候基本上已经全都涉及到了,为了写一点好看的代码自然而然地就遵守了这些我甚至不知道的原则,所以呢也变相说明了他们没有啥神秘的,只是把一些比较好的编码风格总结了一下而已

近来我女朋友也在问我关于 oop 的一些知识,讲起来略麻烦,觉得不如全部写下来,于是有了这篇文章!

简单概况
#

面向对象顾名思义就是面向一堆的对象(废话)

不同于面向过程,开发者写逻辑之前(或写逻辑的同时)会把整件事的存在主体挑出来,然后写成一个个对象,最后再让这些对象根据他们各自的职能去运转,完成整件事的逻辑

好处是后期需要改故事的时候呢,新加几个角色或者改变哪几个对象的行为就可以了,哪里不行改哪里,哪里不够就扩展哪里,相比于面向过程中经常牵一发而动全身的设计,可谓是方便了不少,坏处呢就是前期需要写许多的规范和接口,前期的架构和框架设计会花掉更多的时间吧,综合来看对于一个中长期的项目显然还是值得的,所以 oop 也就遍地开花了

当然写的垃圾的 oop 还是牵一发动全身,这也就需要我们去了解面向对象的一些基本特征和基本的原则,从而写出更优雅流畅的代码啦

三大特征
#

封装
#

毕竟是面向对象嘛,封装就是把功能放到一个个类型里面去,并通过对象来调用,这样能够控制想要暴露哪些方法、隐藏哪些实现等,在使用时就可以假设对象暴露的方法都是可用的,而不需要担心其实现手段

例如下面的这个 Cat 类,想让它说话时只需要调用 speak() 方法,让猫说话可能有一套复杂的逻辑,但是这个方法的出现意味着让它说话这个过程已经“封装”好了,我们只需要调用,就可以达到想要的效果 当然在人看来猫不会说话只会喵喵叫啦

class Cat {
    public void speak() {
        // complex logic...
        prepareSpeaking();

        System.out.println("喵~");

        // complex logic...
    }

    private void prepareSpeaking() {
        ...
    }
}

继承
#

继承是一种可以从已有的类型进行扩展而得到新类型的技术,一般来说,“类型”表示的是一类对象,而父类表示的对象集合是子类的超集

例如刚刚的 Cat 类,就可以是宠物的一种,我们可以为宠物编写一个 Pet 类。宠物当然可以有很多种,例如 Dog 对吧,每种宠物说话的方式肯定不一样,但对于父类 Pet 来说,每种宠物都一定会叫,这就足够了

由于对于 Pet 来说,我们不一定知道一个宠物怎么叫,所以叫的方法是一个抽象方法(没有具体实现),它只意味着“宠物能叫”,但仅有一个 Pet 对象是不能让宠物叫的,我们必须有 Cat 或者 Dog 对象(把具体如何叫,交给子类实现)

abstract class Pet {
    void speak();
}

class Cat extends Pet {
    void speak() {
        System.out.println("喵~");
    }
}

class Dog extends Pet {
    void speak() {
        System.out.println("汪!");
    }
}

这里会出现一个小问题:既然 Pet 对象不能叫,只有子类对象实现了这个方法才可以叫,那要它有什么用呢?这里就要看到面向对象的第三个特征:多态

多态
#

假设你有一只小猫和一只小狗,也就是一个 Cat 对象和一个 Dog 对象,某一天家里来了客人,你想要他们都给客人打个招呼,代码会是这样

dog.speak();
cat.speak();

看起来很容易,对吧,毕竟我们不需要关心让他们叫的具体逻辑,只是要他们叫就行了

但是假如你养的宠物越来越多,有几百只猫猫和几百只狗狗,这时再来客人,你的代码会变成什么样呢?

cat1.speak();
cat2.speak();
cat3.speak();
...

太多了,根本写不完。那么显然,对于数量较大的对象来说,可以用到数组来储存对吧,但可惜的是,数组只允许储存相同类型的对象,猫和狗是不同的对象,不可以存在一个数组

此时如果你说,可以存在两个数组里,一个存猫,一个存狗,那么假如你又有了新的种类的宠物,小兔子、小鹦鹉、小仓鼠…似乎这是一个永远解决不了的问题

但是只要记住,他们不只是猫、狗本身,他们同时也都是宠物!这里就被我们找到了共性,于是我们把他们一视同仁,看做宠物,然后放到同一个列表里面

Pet[] myPets = ...

// 这里存储的操作仅需做一次
myPets[0] = cat1;
myPets[1] = dog1;
...

for (Pet p : myPets) {
    p.speak();
    // 除了让宠物说话还能做很多别的工作
    // 例如喝水、吃饭、走路…
    // 但再也不需要一个一个的调用了
}

最后我们用一个简单的 for 循环就完成了所有的工作,而在这个循环中,我们取出的对象全部都是 Pet 对象,但同样是 Pet,调用 speak() 的时候却可以有不同的表现(猫猫叫狗狗叫等等),这种特征就叫做多态

六大原则
#

其实编程原则这种东西,你只有主动去遵守他才能发挥效用 (废话!) 他们之所以被推崇,肯定有他们的理由,有他们设计上的先进之处,能够不让你的代码糊成依托💩山,所以建议是理解他们为什么要这样规定,而不是死记硬背和生搬硬套!

面向对象编程目前有公认的六大原则(有些地方又说五大,这些东西总是怪怪的),他们的核心思想是面向接口编程而非面向实现编程,追求的是高内聚低耦合,要达到的效果是代码清晰简洁明了、易于扩展、灵活可变等

记住了这些核心的东西就不难理解为什么需要这些准则来规范你的代码了

单一职责原则
#

每个类应该只有一个职责

职责也可以看成功能。如果一个类有多个功能的话,这些功能就会耦合在一起,如果以后需要其中的一部分进行扩展或者变更,就会很容易影响其它职责,使得代码变得混乱不堪且难以管理,最终越来越臃肿,被迫需要重构整个类,所以最好的方法就是在编写的时候就将职责分离

当然也要适可而止,不要无限地把职责细分下去(俗称钻牛角尖),那样只会造成代码臃肿与不便管理(调用很麻烦),保证最终目的只是便于管理、便于扩展、便于复用即可

迪米特原则
#

有时候也叫做最少知识原则,指的是一个类对于其它类的了解是越少越好,即避免去了解其它类的细节,专注于自己的职责,这样做的好处是,分工进一步明确

单一职责原则使代码变得高度内聚(将功能集中),而迪米特原则使代码降低耦合(减少代码之间的联系),这两个原则共同实现了高内聚低耦合,实现了这两点后,对于任何想要修改、拓展的部分,应当都是很方便的,牵一发很难动全身

开放闭合原则
#

也叫开闭原则,对于封装好的类、模块本身而言,应当仅支持扩展,不应当修改(开放扩展,封闭修改)

一般而言,应该尽量只拓展原有的功能,而不是直接覆盖

例如父类封装的函数不建议完全覆写,子类至少应当保证父类中函数的正常工作,且若需要子类有不同的表现应当将父类设为接口类或者抽象类

abstract class ClassA {
    // 此方法已经实现,尽量不修改
    // 理论上这里应当声明为 private 或 final 防止修改
    void fun1() {
        // ...
    }

    // 此方法支持扩展
    void fun2();
}

class ClassB extends ClassA {
    // bad
    @override
    void fun1() {
        // ...
    }

    // good
    @override
    void fun1() {
        // ...
        // 修改的基础上保留父类的实现逻辑
        super.fun1();
        // ...
    }

    // good (must)
    @override
    void fun2() {
        // ...
    }
}

里式替换原则
#

里式替换是指将代码中的父类替换成其子类,程序依旧可以运行

当然并不是真的要你去替换啦,其真正的含义是,要求在继承时,子类的行为应与父类所规范的行为保持一致,这样也就能满足里式替换的字面意思,使得子类可以在任何地方完全替代父类

class Pet { ... }
class Cat extends Pet { ... }

// 抽象写法(实际不应该这么写)
Pet p = new Pet();
p.move();
p.eat();

// 替换后应该仍然能正常工作(实际也不应该这么写)
Cat p = new Cat();
p.move();
p.eat();

// 实际应该这样写,Cat 在构建时向上转型为 Pet
// 保证了后续的代码面向的是 Pet 这一接口而不是 Cat
// 且保证了这个宠物行为是 Cat 的行为
Pet p = new Cat();
p.move();
p.eat();

这样做的好处就是,当你需要一个新的实现,可以直接继承新的子类,并且在构造或者注入时替换掉即可

// 新需求需要换成狗狗
class Dog extends Pet { ... }

// 上面原封不动的代码,仅改 Cat 为 Dog 即可
Pet p = new Dog();
p.move();
p.eat();

依赖倒置原则
#

这条原则这是面向接口编程的重中之重

如果我说,高层模块应当依赖底层的模块,按照直觉,这显然没有什么问题,但是如果底层模块更改的话,高层模块显然会不可避免地受到牵连,也需要更改

依赖倒置指的就是,高层不依赖于底层,而让高层和底层的模块同时依赖于抽象的接口,这样底层模块就算更改,只要保证它在接口的约束范围内,高层模块也无需进行更改(这其实也是里式替换原则的作用,子类可以直接替换掉父级的接口类,从而让修改实现变得非常容易)

还是之前宠物的例子,假设有一个宠物医院,职责是治疗猫猫狗狗等一系列宠物,医院不应该依赖于 Cat Dog 等底层类,而应该依赖于其接口类 Pet

class PetHospital {
    // 不应该依赖于 Cat Dog 等底层类,这样对于每个动物都要写一遍 cure 方法
    boolean cure(Cat cat) {
        ...
        return true;
    }
    boolean cure(Dog dog) {
        ...
        return true;
    }

    // 治疗宠物的行为应当统一,提供面向 Pet 的函数
    boolean cure(Pet pet) {
        ...
        return true;
    }
}

调用时不论传入 Cat 还是 Dog 都会自动转型为 Pet,而由于 Pet 是父类,子类转为父类在里式替换原则的保证下一定是安全的,这样就可以做到一个函数对应所有宠物的治疗了

就算出现了新的宠物类型,只要它符合 Pet 的规范(是 Pet 的子类),那么他也一定能够被用于这个治疗的函数

Cat cat = new Cat();
Dog dog = new Dog();

PetHospital hospital = new PetHospital();
hospital.cure(cat);
hospital.cure(dog);

class Bird extends Pet { ... }
Bird bird = new Bird();
hospital.cure(bird); // ok!

接口隔离原则
#

要求接口应该大小适中,太大会冗杂,太小调用起来会很复杂

例如下面的例子,小猫和小鸟都可以是宠物,但是小鸟会飞,小猫不会飞,总不能在 Pet 类里面声明一个 fly() 方法吧!这样如果这个 Pet 是小猫的话,你让它怎么飞呢(笑

正确的做法是,将“能够飞”这个属性作为一个新的接口分离,Bird 去实现这个接口,Cat 不实现这个接口即可

interface Flyable {
    void fly();
}

class Cat extends Pet { ... }
class Bird extends Pet implements Flyable {
    ...

    @override
    void fly() {
        ...
    }
}

// 调用时
Pet[] pets = ...
for (Pet pet : pets) {
    if (pet instanceof Flyable) {
        ((Flyable) pet).fly();
    }
}