日拱一卒 | 设计模式之美 | 02 面向对象 理论篇

2023-02-25 18:53:05 浏览数 (1)

日拱一卒 | 设计模式之美 | 02 面向对象 理论篇

日拱一卒(2/100)

今天学习分享的是王争的《设计模式之美》之《面向对象》理论篇

封装、抽象、继承、多态

封装(Encapsulation)

封装可以提高代码可维护性;降低接口复杂度,提高类的易用性。

封装也叫作 信息隐藏数据访问保护,类通过暴露有限的访问接口,授权外部仅能通过类提供的方式来访问内部信息或者数据。

例如有个钱包类:

代码语言:javascript复制
public class 钱包{
    private String id;
    private long createTime;
    private BigDecimal 余额;    
    private long 上次余额变更时间;
    private ...
}

如果你全部都开放 get 和 set,那就有问题了,从业务角度来讲:

  • id 和创建时间在创建时应该就已经赋值了,不可以改变。
  • 余额从业务角度来讲只能增加或者减少,而不能重新设置。

应该这样做:

  • 去掉余额和变更时间的 set 方法。
  • 添加余额增加方法和减少方法。
  • 并且在这俩个方法里同步更新变更时间。

这样也可以保证数据的一致性,同时也能确保业务代码不会散落在各处。

抽象(Abstraction)

抽象可以提高代码的扩展性、维护性;降低复杂度,减少细节负担。

隐藏方法的具体实现,让调用者只需要关心方法提供了哪些功能,并不需要知道这些功能是如何实现的。

如:定义类方法时,不要在定义中暴露太多实现细节,比如 getAliyunPictureUrl () 改成 getPictureUrl () ,这样后期如果变更实现的话,也不需要去修改方法命名。

继承(Inheritance)

继承最大的一个好处就是代码复用(不止继承,组合关系也可以)

但过度继承、继承层次过深过复杂也会导致可读性维护性变差

继承用来表示类之间的 is-a 关系,分为单继承和多继承。

(多重继承增加了程序的复杂性和含糊性,例如容易导致菱形缺陷,Java 用 interface 更优雅的实现了多继承的功能)

一般建议多用组合少用继承

多态(Polymorphism)

多态特性能提高代码的可扩展性和复用性,同时也是很多设计模式、设计原则、编程技巧的代码实现基础。

多态是指子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。

例子:

代码语言:javascript复制
//... 简化版代码 ...
public class Array{
    protected Integer[] elements = new Integer[10];
    protected int size = 0;
    ... 

    public add(Integer e){
        // 添加
    }
    public size()...       
    public toString()...                   
    private ensureCapacity()...  // 扩容
}

public class SortedArray extends Array{
    @Override
    add(Integer e){
        // 排序并添加
    }
}

public class Example {
    public static void test(Array array) {
        array.add(4);
        array.add(6);
        array.add(2);
        System.out.println(array.toString());
    }

    public static void main(String args[]) {
        Array array = new SortedArray(); 
        test(array);  // 打印输出 246
    }
}

以上是用继承实现了在 test 方法中用子类 SortedArray 替换父类 Array,并执行子类的 add 方法

使用接口类也能实现多态特性,例如 Iterator 迭代器,实现了这个接口的子类可以动态的调用不同的 next () 和 hasNext () 实现

还有 duck-typing ,这是一些动态语言特有的语法机制,如 Python、JavaScript 等。他们可以不需要继承也不需要接口,只要方法名相同就可以实现多态的特性。

这里直接贴王争老师的 Python 代码示例:

代码语言:javascript复制
class Logger: 
    def record(self): 
        print(“I write a log into file.”) 

class DB: 
    def record(self): 
        print(“I insert data into db. ”) 

def test(recorder): 
    recorder.record()

def demo(): 
    logger = Logger() 
    db = DB() 
    test(logger) 
    test(db)

哪些代码实际是面向过程?

滥用 getter、setter 方法

例如:

代码语言:javascript复制
public class 购物车{
    private List 商品列表;
    private int 总数;
    private double 总价;
}

如果此时都给定义了 get、set 方法暴露给外部使用, 外部是有可能直接修改 List 内部导致商品列表与其他字段数据不一致的。

总结:如果你把某个属性设置为 private ,但与此同时你又都给他提供了 public 的 get 和 set 方法,那跟直接把属性设置为 public 又有什么区别呢?

滥用全局变量和全局方法

对于这两种类的设计,我们尽量能做到职责单一,定义一些细化的小类

比如 RedisConstants、FileUtils,而不是定义一个大而全的 Constants 类、Utils 类。

除此之外,如果能将这些类中的属性和方法,划分归并到其他业务类中,那是最好不过的了,能极大地提高类的内聚性和代码的可复用性。

实际上,只包含静态方法不包含任何属性的 Utils 类,是面向过程的编程风格。但是在实际开发中它能解决代码复用的问题,尽量避免滥用就可以了。

定义数据和方法分离的类

一般基于贫血开发模型的开发模式中,VO、BO、Entity 中只会定义数据,不会定义方法,所有操作这些数据的业务逻辑都定义在对应的 Controller 类、Service 类、Repository 类中。

这就是典型的面向过程的编程风格。

后面再详细解释贫血模型和充血模型。

面向对象编程与面向过程编程

我们人的逻辑一般是按流程往下走的,写代码也容易按照这种思路写成面向过程风格。

面向对象编程正好相反,是一种自底向上的思考方式,将任务分解成一个个小的模块,设计类之间的交互,最后按照流程组装起来,适合复杂程序的开发

如果开发微小程序或者数据处理相关的,以算法为主,数据为辅,那脚本式的面向过程编程风格就比较合适一些。

不管使用面向过程还是面向对象哪种风格来写代码,我们最终的目的还是写出易维护、易读、易复用、易扩展的高质量代码。

接口与抽象类?

抽象类是为了解决代码复用问题。

抽象类实际上就是类,只不过是一种特殊的类,这种类不能被实例化为对象,只能被子类继承。

接口是为了解决解耦问题,隔离接口和具体的实现,提高代码的扩展性。

相对于抽象类的 is-a 关系来说,接口表示一种 has-a 关系,表示具有某些功能。对于接口,有一个更加形象的叫法,那就是协议(contract)。

基于接口而非实现编程?

软件开发中唯一不变的就是变化

“基于接口而非实现编程” 这条原则的英文描述是:“Program to an interface, not an implementation”。

这里的 “接口” 非特指 Java 里的 interface 接口语法,可以理解为抽象类和接口,也可以称之为 “基于抽象而非实现编程”。

这条原则的设计初衷是,将接口和实现相分离,封装不稳定的实现,暴露稳定的接口

上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低代码间的耦合性,提高代码的扩展性。

例子:

代码语言:javascript复制
public class 阿里图片存储{
    public 创建桶()...
    public 生成token()...
    public 上传到阿里云()...
    public 从阿里云下载()...
}

在编写代码的时候,要遵从 “基于接口而非实现编程” 的原则,具体来讲,我们需要做到下面这 3 点。

  • 函数的命名不能暴露任何实现细节。比如例子里的 “阿里云”。
  • 封装具体的实现细节。比如关于阿里云上传下载的流程不应该暴露给调用者。
  • 为实现类定义抽象的接口。具体的实现类都依赖统一的接口定义,遵从一致的上传功能协议。使用者依赖接口,而不是具体的实现类来编程。

重构后的例子:

代码语言:javascript复制
public interface 图片存储{
    public 上传()...
    public 下载()...
}

public class 阿里图片存储 implements 图片存储{
    public 上传(){
        创建桶();
        生成token();
        ...
    }
    public 下载(){
        生成token();
        ...
    }
    private 创建桶()...
    // 这里注意不要把具体的实现搬到接口里,因为可能别的图片存储不需要生成token,所以他是属于阿里独有的实现。
    private 生成token()...
}

public class 图像处理任务 { 
    public void process() { 
        图片存储 imageStore = new 阿里图片存储(); 
        imagestore.上传(image, BUCKET_NAME); 
    }
}

多用组合少用继承?

为什么不推荐使用继承?

继承层次过深、过复杂,也会影响到代码的可维护性。

举个例子,定义个鸟的抽象类,然后在里面定义个 fly () 方法,没毛病吧?麻雀、鸽子、乌鸦都继承这个鸟类,也没毛病吧?这时突然来了个企鹅和鸵鸟?他们都不会飞,那可咋整?

重写 fly () 方法?显然不太 OK,违背了迪米特法则,暴露不该暴露的接口给外部,增加了类使用过程中被误用的概率。

把抽象类分为会飞的鸟和不会飞的鸟?那后面还得考虑鸟会不会叫、是否会下蛋等等。继承层次过深、继承关系过于复杂也会影响到代码的可读性和可维护性。

组合相比继承有哪些优势?

继承主要有三个作用:表示 is-a 关系,支持多态特性,代码复用。

而这三个作用都可以通过组合(composition)接口委托(delegation) 三个技术手段来达成。

除此之外,利用组合还能解决层次过深、过复杂的继承关系影响代码可维护性的问题。

这里直接贴王争老师的代码示例:

代码语言:javascript复制
public interface Flyable { 
    void fly();
}
public class FlyAbility implements Flyable { 
    @Override 
    public void fly() { 
        //... 
    }
}
public class Ostrich implements Tweetable, EggLayable {  // 鸵鸟 
    private TweetAbility tweetAbility = new TweetAbility(); // 组合 
    private EggLayAbility eggLayAbility = new EggLayAbility(); // 组合 
    //... 省略其他属性和方法... 

    @Override 
    public void tweet() { 
        tweetAbility.tweet(); // 委托 
    } 

    @Override 
    public void layEgg() { 
        eggLayAbility.layEgg(); // 委托 
    }
}

如何判断该用组合还是继承?

如果类之间的继承结构稳定(不会轻易改变),继承层次比较浅(比如,最多有两层继承关系),继承关系不复杂,我们就可以大胆地使用继承

反之,系统越不稳定,继承层次很深,继承关系复杂,我们就尽量使用组合来替代继承

除此之外,还有一些设计模式会固定使用继承或者组合。比如,装饰者模式(decorator pattern)、策略模式(strategy pattern)、组合模式(composite pattern)等都使用了组合关系,而模板模式(template pattern)使用了继承关系。

还有一些特殊的场景要求我们必须使用继承。如果你不能改变一个函数的入参类型,而入参又非接口,为了支持多态,只能采用继承来实现。

今天就到这,下一篇是实战篇。

0 人点赞