本人亲自整理的极客时间设计模式之美的硬核笔记

由于笔记内容过多,我把它放到语雀上了。

点击我

以下内容是为了让搜索引擎,检测到这篇文章。要阅读体验,请点击上面的连接“点击我”,去我的语雀看。对了,我看到语雀那里有投诉的功能,请读者不要去点。程序员不要为难程序员。你去点了,就再也无法看到我的笔记了。

导读

01为什么要学设计模式?

1. 应对面试中的设计模式相关问题

2. 告别写被人吐槽的烂代码

3. 提高复杂代码的设计和开发能力

4. 让读源码、学框架事半功倍

5. 为你的职场发展做铺垫

02从哪些维度评判代码质量的好坏?如何具备写出高质量代码的能力?

最常用的评价标准有可维护性、可读性、可扩展性、灵活性、简洁性(简单、复杂)、可复用性、可测试性。

1. 可维护性(maintainability)

如果 bug 容易修复,修改、添加功能能够轻松完成,那我们就可以主观地认为代码对我们来说易维护。有很强的主观性。

2. 可读性(readability)

“任何傻瓜都会编写计算机能理解的代码。好的程序员能够编写人能够理解的代码。”

我们需要看代码是否符合编码规范、命名是否达意、注释是否详尽、函数是否长短合适、模块划分是否清晰、是否符合高内聚低耦合等等。code review 是一个很好的测验代码可读性的手段。如果你的同事可以轻松地读懂你写的代码,那说明你的代码可读性很好;如果同事在读你的代码时,有很多疑问,那就说明你的代码可读性有待提高了。

3. 可扩展性(extensibility)

代码的可扩展性表示,我们在不修改或少量修改原有代码的情况下,通过扩展的方式添加新的功能代码。代码预留了一些功能扩展点,你可以把新功能代码,直接插到扩展点上,而不需要因为要添加一个功能而大动干戈,改动大量的原始代码。

4. 灵活性(flexibility)

  • 当我们添加一个新的功能代码的时候,原有的代码已经预留好了扩展点,我们不需要修改原有的代码,只要在扩展点上添加新的代码即可。这个时候,我们除了可以说代码易扩展,还可以说代码写得好灵活。
  • 当我们要实现一个功能的时候,发现原有代码中,已经抽象出了很多底层可以复用的模块、类等代码,我们可以拿来直接使用。这个时候,我们除了可以说代码易复用之外,还可以说代码写得好灵活。
  • 当我们使用某组接口的时候,如果这组接口可以应对各种使用场景,满足各种不同的需求,我们除了可以说接口易用之外,还可以说这个接口设计得好灵活或者代码写得好灵活。

5. 简洁性(simplicity)

KISS 原则:“Keep It Simple,Stupid”。尽量保持代码简单。

思从深而行从简,真正的高手能云淡风轻地用最简单的方法解决最复杂的问题。这也是一个编程老手跟编程新手的本质区别之一。

6. 可复用性(reusability)

尽量减少重复代码的编写,复用已有的代码。

当讲到面向对象特性的时候,我们会讲到继承、多态存在的目的之一,就是为了提高代码的可复用性;当讲到设计原则的时候,我们会讲到单一职责原则也跟代码的可复用性相关;当讲到重构技巧的时候,我们会讲到解耦、高内聚、模块化等都能提高代码的可复用性。可见,可复用性也是一个非常重要的代码评价标准,是很多设计原则、思想、模式等所要达到的最终效果。

7. 可测试性(testability)

代码可测试性的好坏,能从侧面上非常准确地反应代码质量的好坏。代码的可测试性差,比较难写单元测试,那基本上就能说明代码设计得有问题。

03 | 面向对象、设计原则、设计模式、编程规范、重构,这五者有何关系?

五者之间的联系

  • 面向对象编程因为其具有丰富的特性(封装、抽象、继承、多态),可以实现很多复杂的设计思路,是很多设计原则、设计模式等编码实现的基础。
  • 设计原则是指导我们代码设计的一些经验总结,对于某些场景下,是否应该应用某种设计模式,具有指导意义。
  • 设计模式是针对软件开发中经常遇到的一些设计问题,总结出来的一套解决方案或者设计思路。应用设计模式的主要目的是提高代码的可扩展性。从抽象程度上来讲,设计原则比设计模式更抽象。设计模式更加具体、更加可执行。
  • 编程规范主要解决的是代码的可读性问题。编码规范相对于设计原则、设计模式,更加具体、更加偏重代码细节、更加能落地。持续的小重构依赖的理论基础主要就是编程规范。
  • 重构作为保持代码质量不下降的有效手段,利用的就是面向对象、设计原则、设计模式、编码规范这些理论。

重构的工具是面向对象设计思想、设计原则、设计模式、编码规范。

虽然使用设计模式可以提高代码的可扩展性,但过度不恰当地使用,也会增加代码的复杂度,影响代码的可读性。在开发初期,除非特别必须,我们一定不要过度设计,应用复杂的设计模式。而是当代码出现问题的时候,我们再针对问题,应用原则和模式进行重构。这样就能有效避免前期的过度设计。

    • 重构的目的(why)、对象(what)、时机(when)、方法(how);
    • 保证重构不出错的技术手段:单元测试和代码的可测试性;
    • 两种不同规模的重构:大重构(大规模高层次)和小重构(小规模低层次)。

面向对象

04 | 理论一:当谈论面向对象的时候,我们到底在谈论什么?

面向对象编程的英文缩写是 OOP,全称是 Object Oriented Programming。

面向对象编程语言的英文缩写是 OOPL,全称是 Object Oriented Programming Language。

面向对象分析英文缩写是 OOA,全称是 Object Oriented Analysis;

面向对象设计的英文缩写是 OOD,全称是 Object Oriented Design。

OOA、OOD、OOP 三个连在一起就是面向对象分析、设计、编程(实现),正好是面向对象软件开发要经历的三个阶段。

UML(Unified Model Language),统一建模语言。

大部分情况下,我们随手画个不那么规范的草图,能够表达意思,方便沟通就够了,而完全按照 UML 规范来将草图标准化,所付出的代价是不值得的。

面向对象编程是一种编程范式或编程风格。它以类或对象作为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基石 。

面向对象编程语言是支持类或对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程四大特性(封装、抽象、继承、多态)的编程语言。

1、什么是面向对象分析和面向对象设计?

面向对象分析就是要搞清楚做什么,面向对象设计就是要搞清楚怎么做。两个阶段最终的产出是类的设计,包括程序被拆解为哪些类,每个类有哪些属性方法、类与类之间如何交互等等。

2、如何判定一个编程语言是否是面向对象编程语言?

如果按照严格的的定义,需要有现成的语法支持类、对象、四大特性才能叫作面向对象编程语言。如果放宽要求的话,只要某种编程语言支持类、对象的语法机制,那基本上就可以说这种编程语言是面向对象编程语言了,不一定非得要求具有所有的四大特性。

3、面向对象编程和面向对象编程语言之间有何关系?

面向对象编程一般使用面向对象编程语言来进行,但是,不用面向对象编程语言,我们照样可以进行面向对象编程。反过来讲,即便我们使用面向对象编程语言,写出来的代码也不一定是面向对象编程风格的,也有可能是面向过程编程风格的。

问题

在文章中,我讲到 UML 的学习成本很高,沟通成本也不低,不推荐在面向对象分析、设计的过程中使用,对此你有何看法?

有关面向对象的概念和知识点,除了我们今天讲到的,你还能想到其他哪些吗?

05 | 理论二:封装、抽象、继承、多态分别可以解决哪些编程问题?

1.关于封装特性(Encapsulation)

封装也叫作信息隐藏或者数据访问保护。面向对象封装的定义是:通过访问权限控制,隐藏内部数据,外部仅能通过类提供的有限的接口访问、修改内部数据。例如 Java 中的 private、protected、public 关键字。private 关键字修饰的属性只能类本身访问,可以保护其不被类之外的代码直接访问。

封装特性存在的意义,

一方面是保护数据不被随意修改,提高代码的可维护性。如果不对属性和方法的访问做限制,那么任何代码都可以去访问它们,这些修改逻辑可能散落在各个角落。势必影响代码的可读性、可维护性。

另一方面是仅暴露有限的必要接口,提高类的易用性。如果暴露了很多属性和方法给调用者,调用者需要去了解这些东西的用法,会增加调用者的使用成本。

小案例:private修饰的属性用public修饰的方法修改

虽然我们将它们定义成 private 私有属性,但是提供了 public 的 getter、setter 方法,这就跟将这两个属性定义为 public 公有属性,没有什么两样了。外部可以通过 setter 方法随意地修改这两个属性的值。这是不对的。暴露不应该暴露的 setter 方法,明显违反了面向对象的封装特性。数据没有访问权限控制,任何代码都可以随意修改它,代码就退化成了面向过程编程风格的了。

当然,我们可以用封装的思想来控制private属性修改的方式。

package test1;
public class Man {
    private int age = 2;
    public int getAge() {
        return age;
    }
    public void setAge(int age) {
        this.age = age;
    }
}
package test2;
import test1.Man;
public class Boy {
    public static void main(String[] args){
        Man man = new Man();
        System.out.println(man.getAge());
        man.setAge(33);
        System.out.println(man.getAge());
    }
}

2. 关于抽象特性

封装主要讲如何隐藏信息、保护数据,那抽象就是讲如何隐藏方法的具体实现,让使用者只需要关心方法提供了哪些功能,不需要知道这些功能是如何实现的。抽象可以通过接口类或者抽象类来实现,但也并不需要特殊的语法机制来支持。

抽象存在的意义,

一方面是提高代码的可扩展性、维护性,修改实现不需要改变定义,减少代码的改动范围;我们在定义(或者叫命名)类的方法的时候,也要有抽象思维,不要在方法定义中,暴露太多的实现细节,以保证在某个时间点需要改变方法的实现逻辑的时候,不用去修改其定义。比如 getAliyunPictureUrl() 就不是一个具有抽象思维的命名,因为某一天如果我们不再把图片存储在阿里云上,而是存储在私有云上,那这个命名也要随之被修改。相反,如果我们定义一个比较抽象的函数,比如叫作 getPictureUrl(),那即便内部存储方式修改了,我们也不需要修改命名。

另一方面,它也是处理复杂系统的有效手段,能有效地过滤掉不必要关注的信息。

public interface IPictureStorage {
  void savePicture(Picture picture);
  Image getPicture(String pictureId);
  void deletePicture(String pictureId);
  void modifyMetaInfo(String pictureId, PictureMetaInfo metaInfo);
}
public class PictureStorage implements IPictureStorage {
  // ...省略其他属性...
  @Override
  public void savePicture(Picture picture) { ... }
  @Override
  public Image getPicture(String pictureId) { ... }
  @Override
  public void deletePicture(String pictureId) { ... }
  @Override
  public void modifyMetaInfo(String pictureId, PictureMetaInfo metaInfo) { ... }
}

在面向对象编程中,我们常借助编程语言提供的接口类(比如 Java 中的 interface 、implements关键字语法)或者抽象类(比如 Java 中的 abstract、extends 关键字语法)这两种语法机制,来实现抽象这一特性。

即便不编写 IPictureStorage 接口类,单纯的 PictureStorage 类本身就满足抽象特性。因为类的方法是通过“函数”这一语法机制来实现的。通过函数包裹具体的实现逻辑,这本身就是一种抽象。调用者在使用函数的时候,并不需要去研究函数内部的实现逻辑,只需要通过函数的命名、注释或者文档,了解其提供了什么功能,就可以直接使用了。比如,我们在使用 C 语言的 malloc() 函数的时候,并不需要了解它的底层代码是怎么实现的。

为什么抽象有时候会被排除在面向对象的四大特性之外?

抽象这个概念是一个非常通用的设计思想,并不单单用在面向对象编程中。只需要编程语言提供“函数”这一基础的语法机制,就可以实现抽象特性。所以,它没有很强的“特异性”,有时候并不被看作面向对象编程的特性之一。

3. 关于继承特性(Inheritance)

继承是用来表示类之间的 is-a 关系,分为两种模式:单继承和多继承。单继承表示一个子类只继承一个父类,多继承表示一个子类可以继承多个父类。为了实现继承这个特性,编程语言需要提供特殊的语法机制来支持。继承主要是用来解决代码复用的问题。

继承的意义:

1、代码复用。假如两个类有一些相同的属性和方法,我们就可以将这些相同的部分,抽取到父类中,让两个子类继承父类。这样,两个子类就可以重用父类中的代码,避免代码重复写多遍。

2、非常符合人类的认知。我们代码中有一个猫类,有一个哺乳动物类。猫属于哺乳动物,从人类认知的角度上来说,是一种 is-a 关系。

缺点:过度使用继承,继承层次过深过复杂,就会导致代码可读性、可维护性变差。

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

4. 关于多态特性(Polymorphism)

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

多态特性的实现方式:

①继承加方法重写。

有三点:支持继承;父类对象可以引用子类对象;子类可以重写(override)父类中的方法。

public class DynamicArray {
  private static final int DEFAULT_CAPACITY = 10;
  protected int size = 0;
  protected int capacity = DEFAULT_CAPACITY;
  protected Integer[] elements = new Integer[DEFAULT_CAPACITY];
  
  public int size() { return this.size; }
  public Integer get(int index) { return elements[index];}
  //...省略n多方法...
  
  public void add(Integer e) {
    ensureCapacity();
    elements[size++] = e;
  }
  
  protected void ensureCapacity() {
    //...如果数组满了就扩容...代码省略...
  }
}

public class SortedDynamicArray extends DynamicArray {
  @Override
  public void add(Integer e) {
    ensureCapacity();
    int i;
    for (i = size-1; i>=0; --i) { //保证数组中的数据有序
      if (elements[i] > e) {
        elements[i+1] = elements[i];
      } else {
        break;
      }
    }
    elements[i+1] = e;
    ++size;
  }
}

public class Example {
  public static void test(DynamicArray dynamicArray) {
    dynamicArray.add(5);
    dynamicArray.add(1);
    dynamicArray.add(3);
    for (int i = 0; i < dynamicArray.size(); ++i) {
      System.out.println(dynamicArray.get(i));
    }
  }
  
  public static void main(String args[]) {
    DynamicArray dynamicArray = new SortedDynamicArray();
    test(dynamicArray); // 打印结果:1、3、5
  }
}

②利用接口类语法。比如 C++ 就不支持接口类语法。

public interface Iterator {
  boolean hasNext();
  String next();
  String remove();
}

public class Array implements Iterator {
  private String[] data;
  
  public boolean hasNext() { ... }
  public String next() { ... }
  public String remove() { ... }
  //...省略其他方法...
}

public class LinkedList implements Iterator {
  private LinkedListNode head;
  
  public boolean hasNext() { ... }
  public String next() { ... }
  public String remove() { ... }
  //...省略其他方法... 
}

public class Demo {
  private static void print(Iterator iterator) {
    while (iterator.hasNext()) {
      System.out.println(iterator.next());
    }
  }
  
  public static void main(String[] args) {
    Iterator arrayIterator = new Array();
    print(arrayIterator);
    
    Iterator linkedListIterator = new LinkedList();
    print(linkedListIterator);
  }
}

③利用 duck-typing 语法。只有一些动态语言才支持,比如 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)

只要两个类具有相同的方法,就可以实现多态,并不要求两个类之间有任何关系,这就是所谓的 duck-typing,是一些动态语言所特有的语法机制。而像 Java 这样的静态语言,通过继承实现多态特性,必须要求两个类之间有继承关系,通过接口实现多态特性,类必须实现对应的接口。

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

扩展性:第二个代码实例(Iterator 的例子)中,利用多态的特性,仅用一个 print() 函数就可以实现遍历打印不同类型(Array、LinkedList)集合的数据。当再增加一种要遍历打印的类型的时候,比如 HashMap,我们只需让 HashMap 实现 Iterator 接口,重新实现自己的 hasNext()、next() 等方法就可以了,完全不需要改动 print() 函数的代码。

复用性:如果我们不使用多态特性,我们就无法将不同的集合类型(Array、LinkedList)传递给相同的函数(print(Iterator iterator) 函数)。我们需要针对每种要遍历打印的集合,分别实现不同的 print() 函数,比如针对 Array,我们要实现 print(Array array) 函数,针对 LinkedList,我们要实现 print(LinkedList linkedList) 函数。而利用多态特性,我们只需要实现一个 print() 函数的打印逻辑,就能应对各种集合数据的打印操作,这显然提高了代码的复用性。

问题

你熟悉的编程语言是否支持多重继承?如果不支持,请说一下为什么不支持。如果支持,请说一下它是如何避免多重继承的副作用的。

你熟悉的编程语言对于四大特性是否都有现成的语法支持?对于支持的特性,是通过什么语法机制实现的?对于不支持的特性,又是基于什么原因做的取舍?

06 | 理论三:面向对象相比面向过程有哪些优势?面向过程真的过时了吗?

1. 什么是面向过程编程?什么是面向过程编程语言?

面向过程编程和面向过程编程语言并没有严格的官方定义。相较于面向对象编程以类为组织代码的基本单元,面向过程编程则是以过程(或方法)作为组织代码的基本单元。它最主要的特点就是数据和方法相分离。相较于面向对象编程语言,面向过程编程语言最大的特点就是不支持丰富的面向对象编程特性,比如继承、多态、封装。

面向过程编程也是一种编程范式或编程风格。它以过程(可以理解为方法、函数、操作)作为组织代码的基本单元,以数据(可以理解为成员变量、属性)与方法相分离为最主要的特点。面向过程风格是一种流程化的编程风格,通过拼接一组顺序执行的方法来操作数据完成一项功能。

面向过程编程语言,它最大的特点是不支持类和对象两个语法概念,不支持丰富的面向对象编程特性(比如继承、多态、封装),仅支持面向过程编程。

面向过程和面向对象最基本的区别就是,代码的组织方式不同。面向过程风格的代码被组织成了一组方法集合及其数据结构(struct User),方法和数据结构的定义是分开的。面向对象风格的代码被组织成一组类,方法和数据结构被绑定一起,定义在类中。

2. 面向对象编程相比面向过程编程有哪些优势?

面向对象编程相比起面向过程编程的优势主要有三个。

对于大规模复杂程序的开发,程序的处理流程并非单一的一条主线,而是错综复杂的网状结构。面向对象编程比起面向过程编程,更能应对这种复杂类型的程序开发。面向对象编程相比面向过程编程,具有更加丰富的特性(封装、抽象、继承、多态)。利用这些特性编写出来的代码,更加易扩展、易复用、易维护。从编程语言跟机器打交道的方式的演进规律中,我们可以总结出:面向对象编程语言比起面向过程编程语言,更加人性化、更加高级、更加智能。

1.OOP 更加能够应对大规模复杂程序的开发

利用面向过程的编程语言照样可以写出面向对象风格的代码,只不过可能代价要高一些。

2.OOP 风格的代码更易复用、易扩展、易维护

封装、抽象、继承、多态。

3.OOP 语言更加人性化、更加高级、更加智能

07 | 理论四:哪些代码设计看似是面向对象,实际是面向过程的?

在平时的开发中,多留心一下自己编写的代码是否满足面向对象风格。违反面向对象编程风格的例子.

1. 滥用 getter、setter 方法

有同事定义完类的属性之后,就顺手把这些属性的 getter、setter 方法都定义上。有些同事更加省事,直接用 IDE 或者 Lombok 插件(如果是 Java 项目的话)自动生成所有属性的 getter、setter 方法。这样的做法我是非常不推荐的。它违反了面向对象编程的封装特性,相当于将面向对象编程风格退化成了面向过程编程风格。

public class ShoppingCart {
  private int itemsCount;
  private double totalPrice;
  private List<ShoppingCartItem> items = new ArrayList<>();
  
  public int getItemsCount() {
    return this.itemsCount;
  }
  
  public void setItemsCount(int itemsCount) {
    this.itemsCount = itemsCount;
  }
  
  public double getTotalPrice() {
    return this.totalPrice;
  }
  
  public void setTotalPrice(double totalPrice) {
    this.totalPrice = totalPrice;
  }

  public List<ShoppingCartItem> getItems() {
    return this.items;
  }
  
  public void addItem(ShoppingCartItem item) {
    items.add(item);
    itemsCount++;
    totalPrice += item.getPrice();
  }
  // ...省略其他方法...
}

itemsCount 和 totalPrice。虽然我们将它们定义成 private 私有属性,但是提供了 public 的 getter、setter 方法。等价于将这两个属性定义为 public 公有属性。

对于 itemsCount 和 totalPrice 这两个属性来说,定义一个 public 的 getter 方法,确实无伤大雅,毕竟 getter 方法不会修改数据。但是,对于 items 属性就不一样了,这是因为 items 属性的 getter 方法,返回的是一个 List集合容器。外部调用者在拿到这个容器之后,是可以操作容器内部数据的,也就是说,外部代码还是能修改 items 中的数据。

但是这样的代码写法,会导致 itemsCount、totalPrice、items 三者数据不一致。我们不应该将清空购物车的业务逻辑暴露给上层代码。正确的做法应该是,在 ShoppingCart 类中定义一个 clear() 方法,将清空购物车的业务逻辑封装在里面,透明地给调用者使用。

ShoppingCart cart = new ShoppCart();
...
cart.getItems().clear(); // 清空购物车
public class ShoppingCart {
  // ...省略其他代码...
  public void clear() {
    items.clear();
    itemsCount = 0;
    totalPrice = 0.0;
  }
}

2. 滥用全局变量和全局方法

在面向对象编程中,常见的全局变量有单例类对象、静态成员变量、常量等,常见的全局方法有静态方法。Constants 类和 Utils 类最常用到。

如果把程序中所有用到的常量,都集中地放到一个Constants 类中。定义一个如此大而全的Constants 类的缺点:

1、会影响代码的可维护性。当常量很多的时候,查找修改某个常量也会变得比较费时,而且还会增加提交代码冲突的概率。

2、增加代码的编译时间。当 Constants 类中包含很多常量定义的时候,依赖这个类的代码就会很多。那每次修改 Constants 类,都会导致依赖它的类文件重新编译。

3、影响代码的复用性。如果我们要在另一个项目中,复用本项目开发的某个类,而这个类又依赖 Constants 类。即便这个类只依赖 Constants 类中的一小部分常量,我们仍然需要把整个 Constants 类也一并引入,也就引入了很多无关的常量到新的项目中。

改进Constants 类的设计思路:

1、将 Constants 类拆解为功能更加单一的多个类,比如跟 MySQL 配置相关的常量,我们放到 MysqlConstants 类中;跟 Redis 配置相关的常量,我们放到 RedisConstants 类中。

2、哪个类用到了某个常量,我们就把这个常量定义到这个类中。比如,RedisConfig 类用到了 Redis 配置相关的常量,那我们就直接将这些常量定义在 RedisConfig 中。

Utils 类的意义:A和B两个类要用到一块相同的功能逻辑。但A和B从业务上来看,不是继承关系,为了避免代码重复,用Utils类。

只包含静态方法不包含任何属性的 Utils 类,是彻彻底底的面向过程的编程风格。并不是说,我们就要杜绝使用 Utils 类了。实际上,从 Utils 类存在的目的来看,它在软件开发中还是挺有用的,能解决代码复用问题。要尽量避免滥用,不要不加思考地随意去定义 Utils 类。

有的时候,从业务含义上,A 类和 B 类并不一定具有继承关系,比如 Crawler 类和 PageAnalyzer 类,它们都用到了 URL 拼接和分割的功能,但并不具有继承关系(既不是父子关系,也不是兄弟关系)。我们可以定义一个新的类,实现 URL 拼接和分割的方法。而拼接和分割两个方法,不需要共享任何数据,所以新的类不需要定义任何属性,这个时候,我们就可以把它定义为只包含静态方法的 Utils 类了。

设计 Utils 类的时候,最好也能细化一下,针对不同的功能,设计不同的 Utils 类,比如 FileUtils、IOUtils、StringUtils、UrlUtils 等,不要设计一个过于大而全的 Utils 类。

3. 定义数据和方法分离的类

在面向对象编程中,为什么容易写出面向过程风格的代码?

在生活中,你去完成一个任务,你一般都会思考,应该先做什么、后做什么,如何一步一步地顺序执行一系列操作,最后完成整个任务。面向过程编程风格恰恰符合人的这种流程化思维方式。而面向对象编程风格正好相反。它是一种自底向上的思考方式。它不是先去按照执行流程来分解任务,而是将任务翻译成一个一个的小的模块(也就是类),设计类之间的交互,最后按照流程将类组装起来,完成整个任务。

面向对象编程要比面向过程编程难一些。要去思考如何封装合适的数据和方法到一个类里,如何设计类之间的关系,如何设计类之间的交互等等诸多设计问题。

不管使用面向过程还是面向对象哪种风格来写代码,我们最终的目的还是写出易维护、易读、易复用、易扩展的高质量代码。只要我们能避免面向过程编程风格的一些弊端,控制好它的副作用,在掌控范围内为我们所用,我们就大可不用避讳在面向对象编程中写面向过程风格的代码。

08 | 理论五:接口vs抽象类的区别?如何用普通的类模拟抽象类和接口?

代码示例:08 | 理论五:接口vs抽象类的区别?如何用普通的类模拟抽象类和接口?-极客时间

Java的变量分为:类变量、实例变量、局部变量。

小案例:构造方法和普通实例方法

public class Boy {
    public Boy() {
        System.out.println("这是Boy的构造方法");
    }
    public void Boy(){
        System.out.println("这是Boy的普通public方法");
    }
    public static void main(String[] args){
        Boy boy = new Boy();
        boy.Boy();
    }
}

抽象类

特性

1、抽象类不允许被实例化,只能被继承。也就是说,不能 new 一个抽象类的对象出来。一个类只能继承一个抽象类。抽象类可以有构造器。抽象类的构造器不能用于创建实例(不能被new调用),主要用于被其子类调用,完成属于抽象类的初始化操作。

但是我们可以用匿名类的方式来new一个抽象类。

public abstract class SimulateInterface {
    protected SimulateInterface() {
    }
    public abstract void test();
}
public class Boy extends SimulateInterface {
    public static void main(String[] args){       
        SimulateInterface i = new SimulateInterface() {
            @Override
            public void test() {
            }
        };
    }
    @Override
    public void test() {
    }
}

反正直接new抽象类是不行的。抽象类的构造方法的修饰符影响的是 通过匿名类的方式new 抽象类。

2、抽象类可以包含属性和方法。方法既可以包含代码实现(比如 Logger 中的 log() 方法),也可以不包含代码实现(抽象方法,比如 Logger 中的 doLog() 方法)。

3、子类继承抽象类,必须实现抽象类中的所有抽象方法。

4、抽象类只不过是一种特殊的类,普通类上加abstract。继承关系是一种 is-a 的关系。

5、抽象类可以有main方法并且可以运行它。

接口

特性

1、接口不能包含实例变量。接口的变量只能是静态常量,总是使用public static final修饰,只能在定义时指定默认值。一个类可以实现多个接口。接口可以继承多个接口。接口只能继承接口,不能继承类。接口没有构造器。

2、接口只能声明方法,方法不能包含代码实现。接口的方法只能是抽象方法、类方法、默认方法。接口的抽象方法都是public abstract修饰,类方法都是public static修饰,默认方法都是default修饰。

3、类实现接口的时候,必须实现接口中声明的所有方法。

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

5、接口没有main方法。

接口不能直接new。


为什么需要抽象类?它能够解决什么编程问题?

继承能解决代码复用的问题。所以,抽象类也是为代码复用而生的。多个子类可以继承抽象类中定义的属性和方法,避免在子类中,重复编写相同的代码。

使用抽象类,编译器会强制要求子类重写抽象类中的抽象方法,否则会报编译错误。这样我们可以更加优雅地使用多态的特性。

为什么需要接口?它能够解决什么编程问题?

抽象类更多的是为了代码复用,而接口就更侧重于解耦。接口是一个比抽象类应用更加广泛,比如,经常提到的“基于接口而非实现编程”。接口是为了解决解耦问题,隔离接口和具体的实现,提高代码的扩展性。

如何模拟抽象类和接口两个语法概念?

接口的定义:接口中没有实例变量,只有方法声明,没有方法实现,实现接口的类必须实现接口中的所有方法。只要满足这样几点,从设计的角度上来说,我们就可以把它叫作接口。

抽象类模拟接口

抽象类 SimulateInterface 没有定义任何属性,并且所有的方法都是抽象方法,这样,所有的方法都不能有代码实现,并且所有继承这个抽象类的子类,都要实现这些方法。从语法特性上来看,这个抽象类就相当于一个接口。

普通类模拟接口

public class MockInteface {
  protected MockInteface() {}
  public void funcA() {
    throw new Exception();
  }
}

类中的方法必须包含实现,这个不符合接口的定义。但是,我们可以让类中的方法抛出 Exception 异常,来模拟不包含实现的接口,并且能强迫子类在继承这个父类的时候,都去主动实现父类的方法,否则就会在运行时强制抛出异常。我们将构造函数设置成 protected 属性的,这样就能避免非同包下的类去实例化 MockInterface。不过,这样还是无法避免同包中的类去实例化 MockInterface。为了解决这个问题,我们可以学习 Google Guava 中 @VisibleForTesting 注解的做法,自定义一个注解,人为表明不可实例化。

为什么模拟接口类的构造器用protected修饰?因为public肯定是不行的。default、private的话,继承,子类在初始化的时候,会调用父类的构造器。

什么时候该用抽象类?什么时候该用接口?

如果我们要表示一种 is-a 的关系,并且是为了解决代码复用的问题,我们就用抽象类;如果我们要表示一种 has-a 关系,并且是为了解决抽象而非代码复用的问题,那我们就可以使用接口。

从类的继承层次上来看,抽象类是一种自下而上的设计思路,先有子类的代码重复,然后再抽象成上层的父类(也就是抽象类)。而接口正好相反,它是一种自上而下的设计思路。我们在编程的时候,一般都是先设计接口,再去考虑具体的实现。

09 | 理论六:为什么基于接口而非实现编程?有必要为每个类都定义接口吗?

代码示例:09 | 理论六:为什么基于接口而非实现编程?有必要为每个类都定义接口吗?-极客时间

“基于接口而非实现编程”这条原则的另一个表述方式,是“基于抽象而非实现编程”。

“基于接口而非实现编程”的设计初衷是

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

重新设计实现一个存储图片到私有云的 PrivateImageStore 类,并用它替换掉项目中所有的 AliyunImageStore 类对象。

为了解决

AliyunImageStore 类中有些函数命名暴露了实现细节,比如,uploadToAliyun() 和 downloadFromAliyun()。

将图片存储到阿里云的流程,跟存储到私有云的流程,可能并不是完全一致的。比如,阿里云的图片上传和下载的过程中,需要生产 access token,而私有云不需要 access token。

的问题。

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

1、函数的命名不能暴露任何实现细节。比如,前面提到的 uploadToAliyun() 就不符合要求,应该改为去掉 aliyun 这样的字眼,改为更加抽象的命名方式,比如:upload()。

2、封装具体的实现细节。比如,跟阿里云相关的特殊上传(或下载)流程不应该暴露给调用者。我们对上传(或下载)流程进行封装,对外提供一个包裹所有上传(或下载)细节的方法,给调用者使用。

3、为实现类定义抽象的接口。具体的实现类都依赖统一的接口定义,遵从一致的上传功能协议。使用者依赖接口,而不是具体的实现类来编程。

如果希望通过实现类来反推接口的定义。将实现类的方法搬移到接口定义中的时候,要有选择性的搬移,不要将跟具体实现相关的方法搬移到接口中,比如 AliyunImageStore 中的 generateAccessToken() 方法。

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

我们在定义接口的时候,一方面,命名要足够通用,不能包含跟具体实现相关的字眼;另一方面,与特定实现有关的方法不要定义在接口中。

什么时候,该为某个类定义接口,实现基于接口的编程,什么时候不需要定义接口,直接使用实现类编程?

我们做权衡的根本依据,还是要回归到设计原则诞生的初衷上来。只要搞清楚了这条原则是为了解决什么样的问题而产生的,你就会发现,很多之前模棱两可的问题,都会变得豁然开朗。

如果在我们的业务场景中,某个功能只有一种实现方式,未来也不可能被其他实现方式替换,那我们就没有必要为其设计接口,也没有必要基于接口编程,直接使用实现类就可以了。除此之外,越是不稳定的系统,我们越是要在代码的扩展性、维护性上下功夫。相反,如果某个系统特别稳定,在开发完之后,基本上不需要做维护,那我们就没有必要为其扩展性,投入不必要的开发时间。

问题

在今天举的代码例子中,尽管我们通过接口来隔离了两个具体的实现。但是,在项目中很多地方,我们都是通过下面第 8 行的方式来使用接口的。这就会产生一个问题,那就是,如果我们要替换图片存储方式,还是需要修改很多类似第 8 行那样的代码。这样的设计还是不够完美,对此,你有更好的实现思路吗?

// ImageStore的使用举例
public class ImageProcessingJob {
  private static final String BUCKET_NAME = "ai_images_bucket";
  //...省略其他无关代码...
  
  public void process() {
    Image image = ...;//处理图片,并封装为Image对象
    ImageStore imageStore = new PrivateImageStore(/*省略构造函数*/);
    imagestore.upload(image, BUCKET_NAME);
  }

10 | 理论七:为何说要多用组合少用继承?如何决定该用组合还是继承?

为什么不推荐使用继承?

继承是面向对象的四大特性之一,用来表示类之间的 is-a 关系,可以解决代码复用的问题。虽然继承有诸多作用,但继承层次过深、过复杂,也会影响到代码的可维护性。在这种情况下,我们应该尽量少用,甚至不用继承。

继承最大的问题就在于:继承层次过深、继承关系过于复杂会影响到代码的可读性和可维护性。

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

继承主要有三个作用:表示 is-a 关系,支持多态特性,代码复用。而这三个作用都可以通过组合、接口、委托三个技术手段来达成。除此之外,利用组合还能解决层次过深、过复杂的继承关系影响代码可维护性的问题。接口表示具有某种行为特性。

is-a 关系,我们可以通过组合和接口的 has-a 关系来替代;

多态特性我们可以利用接口来实现;

代码复用我们可以通过组合和委托来实现。

从理论上讲,通过组合、接口、委托三个技术手段,我们完全可以替换掉继承。

public interface Flyable {
  void fly();
}
public class FlyAbility implements Flyable {
  @Override
  public void fly() { //... }
}
//省略Tweetable/TweetAbility/EggLayable/EggLayAbility

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)使用了继承关系。

举例1:从业务含义上,A 类和 B 类并不一定具有继承关系。比如,Crawler 类和 PageAnalyzer 类,它们都用到了 URL 拼接和分割的功能,但并不具有继承关系(既不是父子关系,也不是兄弟关系)。这时候用组合。

public class Url {
  //...省略属性和方法
}

public class Crawler {
  private Url url; // 组合
  public Crawler() {
    this.url = new Url();
  }
  //...
}

public class PageAnalyzer {
  private Url url; // 组合
  public PageAnalyzer() {
    this.url = new Url();
  }
  //..
}

举例2:特殊的场景要求我们必须使用继承。有一个方法的形参是类,不是接口,我们也不能改变形参类型。为了支持多态的特性,只能使用继承。

比如下面这样一段代码,其中 FeignClient 是一个外部类,我们没有权限去修改这部分代码,但是我们希望能重写这个类在运行时执行的 encode() 函数。这个时候,我们只能采用继承来实现了。

public class FeignClient { // Feign Client框架代码
  //...省略其他代码...
  public void encode(String url) { //... }
}
public void demofunction(FeignClient feignClient) {
  //...
  feignClient.encode(url);
  //...
}
public class CustomizedFeignClient extends FeignClient {
  @Override
  public void encode(String url) { //...重写encode的实现...}
}
// 调用
Feig
  • 4
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

可持续化发展

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值