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
关键字设置访问限制,提供 getter
和 setter
供外部对数据仅限有限的操作和访问。
封装的意义:
对类中属性的访问不加限制,可在任何代码中随意访问篡改,看似很灵活,却带来了 不可控问题。属性的修改逻辑可能散落在代码的各个角落,势必影响代码的可读性、可维护性。
一个封装的简单例子:
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类图: