今天看啥  ›  专栏  ›  coder-pig

把书读薄 | 《设计模式之美》学习导读 & 面向对象

coder-pig  · 掘金  · android  · 2021-06-02 17:14
阅读 86

把书读薄 | 《设计模式之美》学习导读 & 面向对象

0x0、引言

上周说过肝不动了,休息下,找点轻松点的东西学学,看到 Bezier大佬 架构相关的文章,心血来潮,想系统地学下设计模式相关的东西,So,六月就学这个了,学习资料主要是《设计模式之美》和一些技术博客。

设计模式某些概念比较抽象,认真看完有时似懂非懂,往往没过多久就忘了,在实际设计与编码中,也不知道如何下手,所以需要落地,想办法加深理解,阅读开源项目,应用到项目中等等。

纸上得来终觉浅,绝知此事要躬行

当然还得把握好一个"",不要滥用设计模式,为了用而去用,而是根据具体的需求场景去决策。

本文是 学习导读(3讲)和面向对象(11讲) 的浓缩总结,二手知识加工难免有所纰漏,感兴趣有时间的可自行查阅原文,谢谢。


0x1、学习导读

  • 学习算法 → 是为了写出 高效 的代码;
  • 学习设计模式 → 是为了写出 高质量 (可扩展、可读、可维护)的代码;

很多开发仔写了很多年代码,Coding水平却没啥长进,原因是日常工作都是CV、修修补补的重复劳动。编写的代码大都止步于能用就好、能跑就行,能力自然停留在"会干活"的层面,只能算一个代码搬运的 熟练工

① 学习设计模式的理由

  • 应付面试
  • 少写烂代码 (写的代码维护费劲,增删功能,常常牵一发而动全身);
  • 提高复杂代码的设计和开发能力 (开发一个与业务无关的通用功能模块,力不从心,不止从何入手);
  • 读源码、学框架事半功倍 (琢磨不透作者的设计思路,一些明显的设计思路要花费很多时间才能参悟);
  • 职场发展做铺垫 (成为技术大牛的基本功,成为Leader指导培训新人,code review,招聘等);

② 如何判断代码质量的好坏

对一段代码的质量评价,常常具有很强的主观性,每个人的评判标准不一,这跟工程师自身经验有极大关系。

闷头写代码,在没 有人指导和阅读借鉴优秀源码 的情况下,很容易有种 自己的代码已经写得足够好 的错觉。

代码质量常用的几个评价标准

  • 可维护性 (Maintainability) → 较直观角度:Bug容易修复、修改添加功能轻松,则主观认为是易维护的;
  • 可读性 (Readability) → 好的验证手段:code review,别人可以轻松读懂你写的代码,说明代码可读性好;
  • 扩展性 (Extensibility) → 代码预留扩展点,添加功能直接插,无需大动干戈改动大量原始代码;
  • 灵活性 (Flexibility) → 一段代码易扩展、易复用或易用,可以称这段代码写得比较灵活;
  • 简洁性 (Simplicity) → 代码尽量写得简洁,逻辑清晰,符合KISS原则;
  • 可复用性 (Reusability) → 尽量减少重复代码的编写,复用已有代码;
  • 可测试性 (Testability) → 代码比较难写单元测试,基本上能说明代码设计得有问题;

如何才能写出搞质量代码

  • 面向对象设计思想 → 因其具有丰富的特性(封装、抽象、继承、多态),可实现很多复杂的设计思路,基础;
  • 设计原则 → 代码设计的经验总结,对某些场景下应用何种设计模式,有指导意义;
  • 设计模式 → 针对软件开发中常见的设计问题,总结出来的一套解决方案或设计思路;
  • 编码规范 → 主要解决代码可读性问题,更偏重代码细节,持续小重构依赖的理论基础;
  • 重构技巧 → 利用前面这四种理论,作为保持代码质量不下降的有效手段;

0x2、面向对象(OOP)

① 概念相关

面向过程编程 (OPP,Procedure Oriented Programming)

过程 为基础的编程范式/风格,主要关注 怎么做,即完成任务的具体细节,主要特点是数据与方法相互分离,流程化拼接一组顺序执行的方法,来操作数据完成某项功能。

面向对象编程 (OOP,Object Oriented Programming)

类或对象 为基础的编程范式/风格,主要关注 "谁来做",即完成任务的对象,将封装、抽象、继承、多态四个特性,作为代码设计与实现的基石。

面向过程编程语言

不支持类与对象语法概念,不支持丰富的面向对象特性,仅支持面向过程编程。

面向对象编程语言

支持类与对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程四大特性(封装、抽象、继承、多态)的编程语言。另外,用面向对象语言编写的代码不一定就是面向对象编程风格的,也可能是面向过程的编程风格。

面向对象分析 (OOA,Object Oriented Analysis) 与 面向对象设计 (OOD,Object Oriented Design)

围绕着对象或类做需求分析与设计,前者搞清楚 做什么,后者搞清楚 怎么做,两个阶段的最终产出是 类的设计,包括程序被拆解成哪些类、每个类有哪些属性方法、类与类间如何交互等。而OOP就是将这两者的产出翻译成代码的过程。

OOP对比OPP编程有什么优势

  • 大规模复杂程序开发,程序处理流程并非单一主线,而是错综复杂的网状结果,OOP更易应对;
  • OOP相比OPP,具有更多丰富特性,利用这些特性写出来的代码,更加易扩展、易复用、易维护;
  • OOP语言比起OPP语言,更加人性化、更加高级、更加智能 。

② 封装 (Encapsulation)

信息隐藏或数据访问保护,表现为:类暴露有限的访问接口(函数),授权外部仅能通过这些方式访问/修改内部信息或数据。

如Java中:使用 private 关键字设置访问限制,提供 gettersetter 供外部对数据仅限有限的操作和访问。

封装的意义

对类中属性的访问不加限制,可在任何代码中随意访问篡改,看似很灵活,却带来了 不可控问题。属性的修改逻辑可能散落在代码的各个角落,势必影响代码的可读性、可维护性。

一个封装的简单例子:

public class UserCredential {
    private String id;  // 用户ID
    private String key; // 用户Key
    private long lastVerifyTime;    // 上次校验时间
    private long verifyCount;    // 校验次数

    public UserCredential(String id, String key) {
        this.id = id;
        this.key = key;
    }

    public Long getLastVerifyTime() {
        return lastVerifyTime;
    }

    public void setLastVerifyTime(long lastVerifyTime) {
        this.lastVerifyTime = lastVerifyTime;
    }

    public void increaseVerifyCount() {
        verifyCount++;
    }

    public long getVerifyCount() {
        return verifyCount;
    }
}
复制代码

代码解析:(对上面四个属性的访问进行了限制)

  • id、key → 创建用户凭证实例时就确定好,不该改动,所以不暴露访问或修改的方法;
  • lastVerifyTime → 每次验证凭证都更新这个值,有时也需要这个值,所以暴露getter和setter方法;
  • verifyCount → 每次校验都更新这个值,只会增且是+1,有时也需要这个值,所以暴露increase和getter方法;

Tips:设计实现类时,除非真的需要,否则,尽量不要给属性定义setter方法,除此之外,getter方法虽然相对setter安全写,但如果返回的是集合容器(如List),要注意防范集合内部数据被修改的危险。

封装带来的好处

减轻代码调用者对该类的学习负担(背后的业务细节),不必了解每个属性,可以放心地调用暴露的方法。


② 抽象 (Abstraction)

如何隐藏方法的具体实现,表现为:调用者只需关心方法提供的功能,而不需要知道功能是如何实现的。

在面向对象编程中,常利用编程语言提供的 接口类 (如Java中的Interface)或 抽象类 (如Java中的abstract) 这两种语法机制来实现抽象。

一个抽象的简单例子:

public interface IImageLoader {
    public void loadImage(String url);
}

public class MemoryImageLoader implements IImageLoader {
    @Override
    public void loadImage(String url) { ... }
}

public class DiskImageLoader implements IImageLoader {
    @Override
    public void loadImage(String url) { ... }
}
复制代码

代码解析:

调用者在加载图片时,只需了解IImageLoader接口类暴露了什么方法,而不需要查看MemoryImageLoader和DiskImageLoader中的具体实现细节。

另外,抽象有时会被排除在面向对象的四大特性之外,原因是:

抽象这个特性,其实可以不借助接口类或抽象类这类特殊语法机制实现,类的方法通过编程语言中的 "函数" 语法实现。通过函数包裹具体实现逻辑,调用者无需研究具体的实现逻辑,通过函数名、注释或文档了解到函数功能,即可直接使用,这本身就是一种 抽象

抽象的意义

在面对复杂系统时,人脑能承受的信息复杂度是有限的,抽象这种只关注功能点不关注实现的设计思路,可以帮我们过滤掉很多非必要的信息。另外,很多设计原则也体现了抽象这种设计思想。


④ 继承 (Inheritance)

用来表示类之间的is-a关系,比如:猫是一种哺乳动物。根据遗传关系划分可以划分为:单继承和多继承。

单继承只能继承一个父类,多继承可以继承多个父类,比如猫既是哺乳动物,又是爬行动物。

为了实现继承这个特性,编程语言需要提供特殊的语法机制来支持,比如Java用extends,C++使用冒号(:)等。

有些编程语言只支持单继承,不支持多继承,比如Java,而有些编程语言都支持,比如C++。

继承的意义

代码复用,两个类具有相同属性或方法,将这部分代码抽取到父类中,让两个类继承父类,子类重用父类代码,避免代码重复。另外,应 避免过度使用继承,继承的层次过深过复杂,就会导致代码可读性、可维护性变差。


⑤ 多态 (Polymorphism)

子类可以替换父类,一个多态的简单例子:

public class IBrother {
    void doSomething() {
        System.out.println("搞事");
    }
}

public class TeaBrother extends IBrother {
    @Override
    void doSomething() {
        System.out.println("给大佬递茶");
    }
}

public class Test {
    public static void main(String[] args) {
        IBrother brother = new TeaBrother();
        brother.doSomething();  // 输出结果:给大佬递茶
    }
}
复制代码

从上面这个例子,可以看到多态这种需要编程语言支持下述三个语法机制:

父类可以引用子类对象 → 支持继承 → 子类可以重写(Override)父类中的方法。

上述代码是Java中的 运行时多态,还有一个编译时多态,通过 方法重载(Overload) 实现。

多态还有两种较常见的实现方式:接口类语法(注入) 和 duck-typing语法(Python中类具有相同的方法即可实现多态)。

多态的意义

提高代码的扩展性和复用性,很多设计原则、设计模式、编程技巧的代码实现基础。


⑥ 接口与抽象类的区别

  • 抽象类(is-a):不允许被实例化,只能被继承,可以包含属性和方法,子类继承需实现所有抽象方法;
  • 接口(has-a):不能包含属性(成员变量),只能声明方法,不能含代码实现,实现接口时需实现声明的所有方法;

使用抽象类更多是为了 代码复用,强制子类实现所有抽象方法,减少类误用导致报错。

而接口侧重于 解耦,对行为的一种抽象(协议/契约),调用者只需关注抽象接口,无需了解具体实现,约定与实现分离,降低代码间的耦合,提高代码的扩展性。


⑦ 为何基于接口而非实现编程

接口和实现分离,封装不稳定的实现,暴露稳定的接口,上游系统面向接口而非面向实现编程,不依赖不稳定的实现细节,当实现发生改变时,上游代码不许改动,降低耦合,提高扩展性。

如何将原则应用到实践中

  • 函数命名不暴露任何实现细节 (如:uploadToQiniuYun(×) → upload(√) )
  • 封装具体的实现细节 (如:上传流程不该暴露给调用者,应在类内部分封装)
  • 为实现类定义抽象的接口 (依赖统一的接口定义,使用者依赖接口,而不是具体实现类来编程)

做软件开发时,一定要有抽象、封装和接口意识,接口的定义只表明做什么,而不是怎么做。设计接口时多思考这样的接口设计是否足够通用,是否能够做到在替换具体接口实现时,不需要任何接口定义的改动。

是否需要为每个类都定义接口

凡事都讲究一个 ,滥用这条原则非得给每个类都定义接口,接口满天飞会导致不必要的开发负担。回归设计初衷,如果在业务场景中,某个功能只有一种实现方式,未来也不可能被其他方式替代,就没必要为其设置接口,直接用实现类就好了。

⑧ 为何说要多用组合少用继承

继承层次过审、过复杂,会影响到代码的可读性和可维护性,看一个子类跳一堆父类,父类修改影响所有子类逻辑。

举个例子,设计一个奶茶店茶的类,先定义一个抽象类 AbstractTea,然后是各种奶茶,珍珠奶茶,椰果奶茶,波霸奶茶等

AbstractTea → 珍珠奶茶
            → 椰果奶茶
            → 波霸奶茶
复制代码

但,有些店除了卖奶茶还卖纯茶,为了区分需要在AbstractTea的基础上派生出两个更加细分的抽象类:

AbstractTea → AbstractMilkTea → 珍珠奶茶
                              → 椰果奶茶
                              → 波霸奶茶
            → AbstractPureTea → 纯四季春
                              → 纯绿妍
                              → 纯金凤茶王
复制代码

继承关系从两层变成三层,层次还算浅,如果再加一个条件呢,冷热:

AbstractTea → AbstractMilkTea → AbstractMilkColdTea → 冰珍珠奶茶
                                                    → 冰椰果奶茶
                                                    → 冰波霸奶茶
                              → AbstractMilkHotTea  → 热珍珠奶茶
                                                    → 热椰果奶茶
                                                    → 热波霸奶茶
            → AbstractPureTea → AbstractPureColdTea → 冰纯四季春
                                                    → 冰纯绿妍
                                                    → 冰纯金凤茶王
                              → AbstractPureHotTea  → 热纯四季春
                                                    → 热纯绿妍
                                                    → 热纯金凤茶王
复制代码

如果再加一个条件,是否加糖,2333,五层直接原地裂开。

上述的问题其实可以通过 组合(Composition)、接口和委托(Delegation) 这三种技术手段来解决。

public interface IProduct {
    void product();
}

public interface ITemperature {
    void temperature();
}

public interface ISweet {
    void sweet();
}

// 实现上述接口
public class ColdSugarPearlMilkTea implements IProduct, ITemperature, ISweet {
    @Override
    public void product() {
        System.out.println("原料是:牛奶+茶");
    }

    @Override
    public void sweet() {
        System.out.println("加糖");
    }

    @Override
    public void temperature() {
        System.out.println("冷");
    }
}
复制代码

接口只声明方法,不定义实现,每种茶都要实现接口中的方法,有些实现逻辑是一样的,代码重复,引入组合委托来消除此问题:

public class MilkTea implements IProduct {
    @Override
    public void product() {
        System.out.println("原料是:牛奶+茶");
    }
}

public class Sugar implements ISweet{
    @Override
    public void sweet() {
        System.out.println("加糖");
    }
}

public class Cold implements ITemperature{
    @Override
    public void temperature() {
        System.out.println("冷");
    }
}

public class ColdSugarPearlMilkTea implements IProduct, ITemperature, ISweet {
    // 组合
    private MilkTea milkTea = new MilkTea();
    private Cold cold = new Cold();
    private Sugar sugar = new Sugar();

    @Override
    public void product() {
        milkTea.product();  // 委托
    }

    @Override
    public void sweet() {
        sugar.sweet();
    }

    @Override
    public void temperature() {
        cold.temperature();
    }
}
复制代码

实际开发中要根据具体情况选择继承还是组合,类间继承结构稳定、层次较浅,关系不复杂,可以大胆地使用继承。反制,就尽量使用组合替代继承。除此之外一些设计模式、特殊应用场景,会固定使用继承或组合。


⑨ 贫血模型和充血模型

MVC → Model(数据层)、View(展示层)、Controller(逻辑层),很多项目并不会100%遵从这种固定的分层方式。

现在很多Web或App项目都是前后端分离的,一般将后端项目分为下面几层:

  • Repository层 → 负责数据访问;
  • Service层 → 负责业务逻辑;
  • Controller层 → 负责暴露接口;

一个充血模型的示例如下:

////////// Controller+VO(View Object) //////////
public class UserController {
  private UserService userService; //通过构造函数或者IOC框架注入
  
  public UserVo getUserById(Long userId) {
    UserBo userBo = userService.getUserById(userId);
    UserVo userVo = [...convert userBo to userVo...];
    return userVo;
  }
}

public class UserVo {//省略其他属性、get/set/construct方法
  private Long id;
  private String name;
  private String cellphone;
}

////////// Service+BO(Business Object) //////////
public class UserService {
  private UserRepository userRepository; //通过构造函数或者IOC框架注入
  
  public UserBo getUserById(Long userId) {
    UserEntity userEntity = userRepository.getUserById(userId);
    UserBo userBo = [...convert userEntity to userBo...];
    return userBo;
  }
}

public class UserBo {//省略其他属性、get/set/construct方法
  private Long id;
  private String name;
  private String cellphone;
}

////////// Repository+Entity //////////
public class UserRepository {
  public UserEntity getUserById(Long userId) { //... }
}

public class UserEntity {//省略其他属性、get/set/construct方法
  private Long id;
  private String name;
  private String cellphone;
}
复制代码

像UserBo这样,只包含数据,不包含业务逻辑的类,就叫做贫血模型 (Anemic Domain Model),同理UserVo 和UserEntity也是贫血模型。而 充血模型 (Rich Domain Model) 正好相反,类中 既包含数据,又包含业务逻辑

贫血模型的Service层包含Service类和BO类(贫血模型),业务逻辑集中在Service类中; 重学模型的Service层包含Service类和Domain类(充血模型,包含数据和业务),Service类较单薄。

Tips:这部分只是看是明白了,先暂时理解成:贫血模型 → 只有属性的类,好处是容易看懂,充血模型 → 有属性也有业务逻辑的类,好处是代码复用,坏处是成本较高。


⑩ 如何对一个功能做面向对象分析

面向对象主要分析对象是 "需求",因此,面向对象可以粗略地看成 "需求分析",给到的需求一般都是不明确的,首先要做的都是将笼统的需求细化到足够清晰、可执行。我们需要通过沟通、挖掘、分析、假设、梳理搞清楚具体的需求有哪些,哪些现在要做的,哪些是未来可能要做的,哪些是不用考虑做的,将抽象问题具象化,最终产生清晰的、可落地的需求定义。

接口鉴权功能分析例子演进(从最简单的方案想起,得出可行解,然后再优化引出更优解):

  • 用户名+密码认证,给允许访问服务的调用方派发一个(AppID和Key),每次请求带上,微服务接收到请求,解析出AppID和Key,与存储在微服务端的数据比对,一致说明认证成功,允许接口调用请求,否则,拒绝接口调用请求。
  • 明文传输 容易被拦截,对密码加密(如SHA),一样会被截获,不发分子可以携带拦截的加密key和AppID访问我们的接口(重放攻击);
  • OAuth验证思路,调用方将请求接口URL、AppID、key拼接在一起,然后加密算法,生成一个token,调用接口时带上。服务端根据AppID从数据库取出对应key,通过同样的token生成算法,生成token,然后与传进来的比对;
  • token固定,拦截到了还是可以进行重放攻击,可以对 token生成算法进行优化,引入随机变量,让生成的Token都不一样,比如加上时间戳参数加密。将token、AppID和时间戳传到后台,后台验证时间戳是否在一定时间内(如一分钟),小于一分钟,说明token没过期,走一遍token生成,跟传入的token比对;

需求分析过程是一个不断迭代优化的过程,不要试图一下子就给出完美的解决方案,而是先给出一个粗糙的、基础的方案,有一个迭代的基础,再慢慢优化,这样一个思考过程能让我们摆脱无从下手的窘境。


⑪ 如何做面向对象设计

  • 划分职责进而识别出有哪些类;
  • 定义类及属性和方法;
  • 定义类与类间的交互关系;
  • 将类封装起来并提供执行入口;

⑫ 类与类间的相互关系

UML统一建模语言中定义了六种,我的记忆口诀:鸡湿衣冠剧组 (也可以自己编),继承、实现都基本知道,后面四个只是 语义层次 的区别,两个类的相关程度:依赖 < 关联 < 聚合 < 组合

  • 继承/泛化 (Generalization)

子类指向父类,或子接口指向父接口,用空心三角箭头实线表示;

  • 实现 (Realization)

类实现接口,重写相关方法,用 空心三角箭头虚线 表示;

  • 依赖 (Dependency)

具体表现:局部变量、函数参数、返回值,用 实心三角箭头虚线 表示,从使用类指向依赖类,示例如下:

  • 关联 (Association)

具体表现:成员变量,用 实心三角箭头实线 表示,箭头指向被关联类,可以双向,一对多或多对多,示例如下:

  • 聚合 (Aggregation)

具体表现:成员变量,不过关联是处于 同一层次 的,而聚合则是 整体和局部,比如社团与小弟,社团没了,小弟还能去别的地方搞事,用 空心菱形箭头实线 表示。

  • 组合 (Composition)

和聚合类似,只是程度更强烈,共生死,组合类负责被组合类的生命周期,比如社团和大佬,社团没了,大佬也就不复存在了,用 实心菱形箭头实线 表示。

根绝上述关系列一个简单的UML类图:




原文地址:访问原文地址
快照地址: 访问文章快照