三种主流的编程范式

354 阅读57分钟

1. 编程范式

编程范式(Programming paradigm),指的是程序的编写模式。

现在主流的编程范式主要有三种:

  • 结构化编程(structured programming);
  • 面向对象编程(object-oriented programming);
  • 函数式编程(functional programming)。

2 面向过程编程

2.1 什么面向过程编程

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

过程式就是以一条条命令的方式,让计算机按我们的意愿来执行。今天计算机的机器语言本身就是一条条指令构成,本身也是过程式的。所以过程式最为常见,每个语言都有一定过程式的影子。

过程式编程中最核心的两个概念是结构体(自定义的类型)和过程(也叫函数)。通过结构体对数据进行组合,可以构建出任意复杂的自定义数据结构。通过过程可以抽象出任意复杂的自定义指令,复用以前的成果,简化意图的表达。

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

2.2 一个demo

假设我们有一个记录了用户信息的文本文件 users.txt,每行文本的格式是 name&age&gender(比如,小王 &28& 男)。我们希望写一个程序,从 users.txt 文件中逐行读取用户信息,然后格式化成 name\tage\tgender(其中,\t 是分隔符)这种文本格式,并且按照 age 从小到达排序之后,重新写入到另一个文本文件 formatted_users.txt 中。

struct User {
  char name[64];
  int age;
  char gender[16];
};
struct User parse_to_user(char* text) {
  // 将 text(“小王 &28& 男”) 解析成结构体 struct User
}
char* format_to_text(struct User user) {
  // 将结构体 struct User 格式化成文本(" 小王\t28\t 男 ")
}
void sort_users_by_age(struct User users[]) {
  // 按照年龄从小到大排序 users
}
void format_user_file(char* origin_file_path, char* new_file_path) {
  // open files...
  struct User users[1024]; // 假设最大 1024 个用户
  int count = 0;
  while(1) { // read until the file is empty
    struct User user = parse_to_user(line);
    users[count++] = user;
  }
  
  sort_users_by_age(users);
  
  for (int i = 0; i < count; ++i) {
    char* formatted_user_text = format_to_text(users[i]);
    // write to new file...
  }
  // close files...
}
int main(char** args, int argv) {
  format_user_file("/home/zheng/user.txt", "/home/zheng/formatted_users.txt");
}

然后,我们再来看,用面向对象这种编程风格写出来的代码是什么样子的。注意,下面的代码是用 Java 这种面向对象的编程语言来编写的。

public class User {
  private String name;
  private int age;
  private String gender;
  
  public User(String name, int age, String gender) {
    this.name = name;
    this.age = age;
    this.gender = gender;
  }
  
  public static User praseFrom(String userInfoText) {
    // 将 text(“小王 &28& 男”) 解析成类 User
  }
  
  public String formatToText() {
    // 将类 User 格式化成文本(" 小王\t28\t 男 ")
  }
}
public class UserFileFormatter {
  public void format(String userFile, String formattedUserFile) {
    // Open files...
    List users = new ArrayList<>();
    while (1) { // read until file is empty 
      // read from file into userText...
      User user = User.parseFrom(userText);
      users.add(user);
    }
    // sort users by age...
    for (int i = 0; i < users.size(); ++i) {
      String formattedUserText = user.formatToText();
      // write to new file...
    }
    // close files...
  }
}
public class MainApplication {
  public static void main(Sring[] args) {
    UserFileFormatter userFileFormatter = new UserFileFormatter();
    userFileFormatter.format("/home/zheng/users.txt", "/home/zheng/formatted_users.txt");
  }
}

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

2.3 面向过程编程的优缺点

自 C 语言问世 40 多年以来,其影响了太多太多的编程语言,到现在还一直被广泛使用。C 语言的伟大之处在于——使用 C 语言的程序员在高级语言的特性之上还能简单地做任何底层上的微观控制。这是 C 语言的强大和优雅之处。也有人说,C 语言是高级语言中的汇编语言。

可以操作计算机底层

不过,这只是在针对底层指令控制和过程式的编程方式。而对于更高阶更为抽象的编程模型来说,C 语言这种基于过程和底层的初衷设计方式就会成为它的短板。因为,在编程这个世界中,更多的编程工作是解决业务上的问题,而不是计算机的问题,所以,我们需要更为贴近业务更为抽象的语言。如典型的面向对象语言C++和Java等。

3 函数式编程

函数编程的核心思想是将运算过程尽量写成一系列嵌套的函数调用,关注的是做什么而不是怎么做,因而被称为声明式编程。以Stateless(无状态)和Immutable(不可变)为主要特点,代码简洁,易于理解,能便于进行并行执行,易于做代码重构,函数执行没有顺序上的问题,支持惰性求值,具有函数的确定性——无论在什么场景下都会得到同样的结果。

函数式本质上是过程程式编程的的一种约束,它最核心的主张就就是变量不可变,函数尽可能没有副作用(对于通用语言来说,所有函数都没副作用是不可能的,内部有 IO 行为的函数就有副作用)。

既然变量不可变,函数没有副作用,自然人们犯错的机会也就更少,代码质量就会更高。函数式语言的代表是 Haskell、Erlang 等等。大部分语言会比较难以彻底实施函数式的编程思想,但在思想上会有所借鉴。

函数式编程,它的理念就来自于数学中的代数。

f(x)=5x^2+4x+3
g(x)=2f(x)+5=10x^2+8x+11
h(x)=f(x)+g(x)=15x^2+12x+14

假设f(x)是一个函数,g(x)是第二个函数,把f(x)这个函数套下来,并展开。然后还可以定义一个由两个一元函数组合成的二元函数,还可以做递归,下面这个函数定义就是斐波那契数列。

f(x)=f(x-1)+f(x-2)

对于函数式编程来说,它只关心定义输入数据和输出数据相关的关系,数学表达式里面其实是在做一种映射(mapping),输入的数据和输出的数据关系是什么样的,是用函数来定义的。

3.1 特征

  • stateless:函数不维护任何状态。函数式编程的核心精神是stateless,简而言之就是它不能存在状态,打个比方,你给我数据我处理完扔出来。里面的数据是不变的。
  • immutable:输入数据是不能动的,动了输入数据就有危险,所以要返回新的数据集。

3.2 函数式编程的优缺点

优点

  • 没有状态就没有伤害。

  • 并行执行无伤害。

  • Copy-Paste重构代码无伤害。

  • 函数的执行没有顺序上的问题。

函数式编程还带来了以下一些好处。

  • 惰性求值。这需要编译器的支持,表达式不在它被绑定到变量之后就立即求值,而是在该值被取用的时候求值。也就是说,语句如 x:=expression; (把一个表达式的结果赋值给一个变量)显式地调用这个表达式被计算并把结果放置到 x 中,但是先不管实际在 x 中的是什么,直到通过后面的表达式中到 x 的引用而有了对它的值的需求的时候,而后面表达式自身的求值也可以被延迟,最终为了生成让外界看到的某个符号而计算这个快速增长的依赖树。

  • 确定性。所谓确定性,就是像在数学中那样,f(x) = y 这个函数无论在什么场景下,都会得到同样的结果,而不是像程序中的很多函数那样。同一个参数,在不同的场景下会计算出不同的结果,这个我们称之为函数的确定性。所谓不同的场景,就是我们的函数会根据运行中的状态信息的不同而发生变化。

我们知道,因为状态,在并行执行和copy-paste时引发bug的概率是非常高的,所以没有状态就没有伤害,就像没有依赖就没有伤害一样,并行执行无伤害,copy代码无伤害,因为没有状态,代码怎样拷都行。

缺点

  • 数据复制比较严重。

注:有一些人可能会觉得这会对性能造成影响。其实,这个劣势不见得会导致性能不好。因为没有状态,所以代码在并行上根本不需要锁(不需要对状态修改的锁),所以可以拼命地并发,反而可以让性能很不错。比如:Erlang就是其中的代表。

对于纯函数式(也就是完全没有状态的函数)的编程来说,各个语言支持的程度如下:

  • 完全纯函数式 Haskell
  • 容易写纯函数 F#, Ocaml, Clojure, Scala
  • 纯函数需要花点精力 C#, Java, JavaScript

完全纯函数的语言,很容易写成函数,纯函数需要花精力。只要所谓的纯函数的问题,传进来的数据不改,改完的东西复制一份拷出去,然后没有状态显示。

但是很多人并不习惯函数式编程,因为函数式编程和过程式编程的思维方式完全不一样。过程式编程是在把具体的流程描述出来,所以可以不假思索,而函数式编程的抽象度更大,在实现方式上,有函数套函数、函数返回函数、函数里定义函数……把人搞得很糊涂。

3.3 函数式编程的思维方式

函数式编程关注的是:what to do, rather than how to do it。于是,我们把过程式编程范式叫做 Imperative Programming – 指令式编程,而把函数式编程范式叫做 Declarative Programming – 声明式编程。

3.3.1 面向过程方式的写法

下面我们看一下相关的示例。比如,我们有3辆车比赛,简单起见,我们分别给这3辆车70%的概率让它们可以往前走一步,一共有5次机会,然后打出每一次这3辆车的前行状态。

对于Imperative Programming来说,代码如下(Python):

from random import random
 
time = 5
car_positions = [1, 1, 1]
 
while time:
    # decrease time
    time -= 1
 
    print ''
    for i in range(len(car_positions)):
        # move car
        if random() > 0.3:
            car_positions[i] += 1
 
        # draw car
        print '-' * car_positions[i]

我们可以把这两重循环变成一些函数模块,这样有利于更容易地阅读代码:

from random import random
 
def move_cars():
    for i, _ in enumerate(car_positions):
        if random() > 0.3:
            car_positions[i] += 1
 
def draw_car(car_position):
    print '-' * car_position
 
def run_step_of_race():
    global time
    time -= 1
    move_cars()
 
def draw():
    print ''
    for car_position in car_positions:
        draw_car(car_position)
 
time = 5
car_positions = [1, 1, 1]
 
while time:
    run_step_of_race()
    draw()

上面的代码,从主循环开始,我们可以很清楚地看到程序的主干,因为我们把程序的逻辑分成了几个函数。这样一来,代码逻辑就会变成几个小碎片,于是我们读代码时要考虑的上下文就少了很多,阅读代码也会更容易。不像第一个示例,如果没有注释和说明,你还是需要花些时间理解一下。而将代码逻辑封装成了函数后,我们就相当于给每个相对独立的程序逻辑取了个名字,于是代码成了自解释的。

但是,你会发现,封装成函数后,这些函数都会依赖于共享的变量来同步其状态。于是,在读代码的过程中,每当我们进入到函数里,读到访问了一个外部的变量时,我们马上要去查看这个变量的上下文,然后还要在大脑里推演这个变量的状态, 才能知道程序的真正逻辑。也就是说,这些函数必须知道其它函数是怎么修改它们之间的共享变量的,所以,这些函数是有状态的。

3.3.2 函数式的写法

我们知道,有状态并不是一件很好的事情,无论是对代码重用,还是对代码的并行来说,都是有副作用的。因此,要想个方法把这些状态搞掉,于是出现了函数式编程的编程范式。下面,我们来看看函数式的方式应该怎么写?

from random import random
 
def move_cars(car_positions):
    return map(lambda x: x + 1 if random() > 0.3 else x,
               car_positions)
 
def output_car(car_position):
    return '-' * car_position
 
def run_step_of_race(state):
    return {'time': state['time'] - 1,
            'car_positions': move_cars(state['car_positions'])}
 
def draw(state):
    print ''
    print '\n'.join(map(output_car, state['car_positions']))
 
def race(state):
    draw(state)
    if state['time']:
        race(run_step_of_race(state))
 
race({'time': 5,
      'car_positions': [1, 1, 1]})

上面的代码依然把程序的逻辑分成了函数。不过这些函数都是函数式的,它们有三个特点:它们之间没有共享的变量;函数间通过参数和返回值来传递数据;在函数里没有临时变量。

对于函数式编程的思路,下图是一个比较形象的例子,面包和蔬菜map到切碎的操作上,再把结果给reduce成汉堡。

image.png

在这个图中,我们可以看到map和reduce不关心源输入数据,它们只是控制,并不是业务。控制是描述怎么干,而业务是描述要干什么。

4 面向对象编程

面向过程编程的思想是以一条条命令的方式,让计算机按我们的意愿来执行。今天计算机的机器语言本身就是一条条指令构成,本身也是过程式的。所以面向过程编程最为常见,每个语言都有一定过程式的影子。

函数式本质上是过程程式编程的的一种约束,它最核心的主张就就是变量不可变,函数尽可能没有副作用(对于通用语言来说,所有函数都没副作用是不可能的,内部有 IO 行为的函数就有副作用)。

面向对象在过程式的基础上, 引入了对象( 类) 和对象方法( 类成员函数) , 它主张尽可能把方法( 其实就是过程) 归纳到合适的对象( 类) 上, 不主张全局函数( 过程)

函数式编程,主要讲的是把一些功能或逻辑代码通过函数的拼装方式来组织的玩法。其中涉及最多的是函数,也就是编程中的代码逻辑。但我们知道,代码中还是需要处理数据的,这些就是所谓的“状态”,函数式编程需要我们写出无状态的代码。

而这天下并不存在没有状态没有数据的代码,如果函数式编程不处理状态这些东西,那么,状态会放在什么地方呢?总是需要一个地方放这些数据的。

对于状态和数据的处理,我们有必要提一下“面向对象编程”(Object-oriented programming,缩写为 OOP)这个编程范式了。

面向对象编程是种具有对象概念的程序编程范型,同时也是一种程序开发的抽象方针。它可能包含数据、属性、代码与方法。对象则指的是类的实例。它将对象作为程序的基本单元,将程序和数据封装其中,以提高软件的可重用性、灵活性和可扩展性,对象里的程序可以访问及修改对象相关联的数据。在面向对象编程里,计算机程序会被设计成彼此相关的对象。

面向对象程序设计可以看作一种在程序中包含各种独立而又互相调用的对象的思想,这与传统的思想刚好相反:传统的程序设计主张将程序看作一系列函数的集合,或者直接就是一系列对计算机下达的指令。面向对象程序设计中的每一个对象都应该能够接受数据、处理数据并将数据传达给其它对象,因此它们都可以被看作一个小型的“机器”,即对象。

目前已经被证实的是,面向对象程序设计推广了程序的灵活性和可维护性,并且在大型项目设计中广为应用。此外,支持者声称面向对象程序设计要比以往的做法更加便于学习,因为它能够让人们更简单地设计并维护程序,使得程序更加便于分析、设计、理解。

4.1 面向对象编程的定义

如果非得给出一个定义的话,可以用下面两句话来概括。

  • 面向对象编程是一种编程范式或编程风格。它以类或对象作为组织代码的基本单元,并将封装、继承、多态 三个特性,作为代码设计和实现的基石 。
  • 面向对象编程语言是支持类或对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程三大特性(封装、继承、多态)的编程语言。

4.1.1 什么是面向对象

“面向对象”这个词是由Alan Kay创造的,他是2003年图灵奖的获得者。在他最初的构想中,对象就是一个细胞。当细胞一点一点组织起来,就可以组成身体的各个器官,再一点一点组织起来,就构成了人体。而当你去观察人的时候,就不用再去考虑每个细胞是怎样的。所以,面向对象给了我们一个更宏观的思考方式。

但是,这一切的前提是,每个对象都要构建好,也就是封装要做好,这就像每个细胞都有细胞壁将它与外界隔离开来,形成了一个完整的个体。

在Alan Kay关于面向对象的描述中,他强调对象之间只能通过消息来通信。如果按今天程序设计语言的通常做法,发消息就是方法调用,对象之间就是靠方法调用来通信的。但这个方法调用并不是简单地把对象内部的数据通过方法暴露。在Alan Kay的构想中,他甚至想把数据去掉。

因为,封装的重点在于对象提供了哪些行为,而不是有哪些数据。也就是说,即便我们把对象理解成数据加函数,数据和函数也不是对等的地位。函数是接口,而数据是内部的实现,正如我们一直说的那样,接口是稳定的,实现是易变的。

理解了这一点,我们来看一个很多人都有的日常编程习惯。他们编写一个类的方法是,把这个类有哪些字段写出来,然后,生成一大堆getter和setter,将这些字段的访问暴露出去。这种做法的错误就在于把数据当成了设计的核心,这一堆的getter和setter,就等于把实现细节暴露了出去。

一个正确的做法应该是,我们设计一个类,先要考虑其对象应该提供哪些行为。然后,我们根据这些行为提供对应的方法,最后才是考虑实现这些方法要有哪些字段。

举个例子,设计一个让用户修改密码的功能,有些人直觉的做法可能是这样:

class User {
  private String username;
  private String password;
  
  ...
  
  // 修改密码
  public void setPassword(final String password) {
    this.password = password;
  }
}

但推荐的做法是,把意图表现出来:

class User {
  private String username;
  private String password;
  
  ...
  
  // 修改密码
  public void changePassword(final String password) {
    this.password = password;
  }
}

这两段代码相比,只是修改密码的方法名变了,但二者更重要的差异是,一个在说做什么,一个在说怎么做。将意图与实现分离开来,这是一个优秀设计必须要考虑的问题。

分离关注点

4.1.2 什么是面向对象编程

面向对象编程从字面上,按照最简单、最原始的方式来理解,就是将对象或类作为代码组织的基本单元,来进行编程的一种编程范式或者编程风格,并不一定需要封装、继承、多态这三大特性的支持。但是,在进行面向对象编程的过程中,人们不停地总结发现,有了这三大特性,我们就能更容易地实现各种面向对象的代码设计思路。

比如,我们在面向对象编程的过程中,经常会遇到 is-a 这种类关系(比如狗是一种动物),而继承这个特性就能很好地支持这种 is-a 的代码设计思路,并且解决代码复用的问题,所以,继承就成了面向对象编程的四大特性之一。但是随着编程语言的不断迭代、演化,人们发现继承这种特性容易造成层次不清、代码混乱,所以,很多编程语言在设计的时候就开始摒弃继承特性,比如 Go 语言。但是,我们并不能因为它摒弃了继承特性,就一刀切地认为它不是面向对象编程语言了。

实际上,只要某种编程语言支持类或对象的语法概念,并且以此作为组织代码的基本单元,那就可以被粗略地认为它就是面向对象编程语言了。至于是否有现成的语法机制,完全地支持了面向对象编程的四大特性、是否对三大特性有所取舍和优化,可以不作为判定的标准。基于此,按照严格的定义,很多语言都不能算得上面向对象编程语言,但按照不严格的定义来讲,现在流行的大部分编程语言都是面向对象编程语言。

只要某种编程语言支持类或对象的语法概念,并且以此作为组织代码的基本单元,那就可以被粗略地认为它就是面向对象编程语言了

4.2 封装、继承、多态分别解决哪些编程问题

我们知道,面向对象的编程有三大特性:封装、继承和多态。

4.2.1 封装

4.2.1.1 封装特性的定义

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

4.2.1.2 一个demo

在金融系统中,我们会给每个用户创建一个虚拟钱包,用来记录用户在我们的系统中的虚拟货币量。

import java.math.BigDecimal;

public class Wallet {
    private String id;
    private long createTime;
    private BigDecimal balance;
    private long balanceLastModifiedTime;

    // ... 省略其他属性...
    public Wallet() {
        this.id = IdGenerator.getInstance().generate();
        this.createTime = System.currentTimeMillis();
        this.balance = BigDecimal.ZERO;
        this.balanceLastModifiedTime = System.currentTimeMillis();
    }

    // 注意:下面对 get 方法做了代码折叠,是为了减少代码所占文章的篇幅
    public String getId() {
        returnthis.id;
    }

    public long getCreateTime() {
        returnthis.createTime;
    }

    public BigDecimal getBalance() {
        returnthis.balance;
    }

    public long getBalanceLastModifiedTime() {
        returnthis.balanceLastModifiedTime;
    }

    public void increaseBalance(BigDecimal increasedAmount) {
        if (increasedAmount.compareTo(BigDecimal.ZERO) < 0) {
            thrownew InvalidAmountException ("...");
        }
        this.balance.add(increasedAmount);
        this.balanceLastModifiedTime = System.currentTimeMillis();
    }

    public void decreaseBalance(BigDecimal decreasedAmount) {
        if (decreasedAmount.compareTo(BigDecimal.ZERO) < 0) {
            thrownew InvalidAmountException ("...");
        }
        if (decreasedAmount.compareTo(this.balance) > 0) {
            thrownew InsufficientAmountException ("...");
        }
        this.balance.subtract(decreasedAmount);
        this.balanceLastModifiedTime = System.currentTimeMillis();
    }
}

从代码中,我们可以发现,Wallet 类主要有四个属性(也可以叫作成员变量),也就是我们前面定义中提到的信息或者数据。其中,id 表示钱包的唯一编号,createTime 表示钱包创建的时间,balance 表示钱包中的余额,balanceLastModifiedTime 表示上次钱包余额变更的时间。

我们参照封装特性,对钱包的这四个属性的访问方式进行了限制。调用者只允许通过下面这六个方法来访问或者修改钱包里的数据。

String getId()
long getCreateTime()
BigDecimal getBalance()
long getBalanceLastModifiedTime()
void increaseBalance(BigDecimal increasedAmount)
void decreaseBalance(BigDecimal decreasedAmount)

之所以这样设计,是因为从业务的角度来说,id、createTime 在创建钱包的时候就确定好了,之后不应该再被改动,所以,我们并没有在 Wallet 类中,暴露 id、createTime 这两个属性的任何修改方法,比如 set 方法。而且,这两个属性的初始化设置,对于 Wallet 类的调用者来说,也应该是透明的,所以,我们在 Wallet 类的构造函数内部将其初始化设置好,而不是通过构造函数的参数来外部赋值。

对于钱包余额 balance 这个属性,从业务的角度来说,只能增或者减,不会被重新设置。所以,我们在 Wallet 类中,只暴露了 increaseBalance() 和 decreaseBalance() 方法,并没有暴露 set 方法。对于 balanceLastModifiedTime 这个属性,它完全是跟 balance 这个属性的修改操作绑定在一起的。只有在 balance 修改的时候,这个属性才会被修改。所以,我们把 balanceLastModifiedTime 这个属性的修改操作完全封装在了 increaseBalance() 和 decreaseBalance() 两个方法中,不对外暴露任何修改这个属性的方法和业务细节。这样也可以保证 balance 和 balanceLastModifiedTime 两个数据的一致性。

对于封装这个特性,我们需要编程语言本身提供一定的语法机制来支持。这个语法机制就是访问权限控制。例子中的 private、public 等关键字就是 Java 语言中的访问权限控制语法。private 关键字修饰的属性只能类本身访问,可以保护其不被类之外的代码直接访问。如果 Java 语言没有提供访问权限控制语法,所有的属性默认都是 public 的,那任意外部代码都可以通过类似 wallet.id=123; 这样的方式直接访问、修改属性,也就没办法达到隐藏信息和保护数据的目的了,也就无法支持封装特性了。

访问权限修饰符

Java语言提供了很多修饰符,修饰符用来定义类、方法或者变量,通常放在语句的最前端。主要分为以下两类:

  • 访问修饰符
  • 非访问修饰符

访问修饰符有public,protected,default,private四种访问控制修饰符。非访问修饰符有static修饰符,final修饰符和abstract修饰符。

Java中关于访问权限的四个修饰符,表格如下

privatedefaultprotectedpublic
当前类访问权限
包访问权限×
子类访问权限××
其他类访问权限×××

default:(也有称friendly)即不加任何访问修饰符,通常称为“默认访问权限“或者“包访问权限”。该模式下,只允许在同一个包中进行访问。

4.2.1.3 封装的意义是什么?它能解决什么编程问题?(保护数据,提升代码可维护性,易用性)

如果我们对类中属性的访问不做限制,那任何代码都可以访问、修改类中的属性,虽然这样看起来更加灵活,但从另一方面来说,过度灵活也意味着不可控,属性可以随意被以各种奇葩的方式修改,而且修改逻辑可能散落在代码中的各个角落,势必影响代码的可读性、可维护性。比如某个同事在不了解业务逻辑的情况下,在某段代码中“偷偷地”重设了 wallet 中的 balanceLastModifiedTime 属性,这就会导致 balance 和 balanceLastModifiedTime 两个数据不一致。

除此之外,类仅仅通过有限的方法暴露必要的操作,也能提高类的易用性。如果我们把类属性都暴露给类的调用者,调用者想要正确地操作这些属性,就势必要对业务细节有足够的了解。而这对于调用者来说也是一种负担。相反,如果我们将属性封装起来,暴露少许的几个必要的方法给调用者使用,调用者就不需要了解太多背后的业务细节,用错的概率就减少很多。这就好比,如果一个冰箱有很多按钮,你就要研究很长时间,还不一定能操作正确。相反,如果只有几个必要的按钮,比如开、停、调节温度,你一眼就能知道该如何来操作,而且操作出错的概率也会降低很多。

封装的目的:

  1. 封装可以隐藏实现的细节,这让使用者只能通过写好的访问方法来访问这些字段,
  2. 限制对数据的不合理访问、方便数据检查,就可以保护对象信息的完整性
  3. 便于修改,提高代码的可维护性
  4. 降低了建构大型系统的风险
  5. 提高代码的复用性

4.2.1.4 小结

  1. 封装,是面向对象的根基。面向对象编程就是要设计出一个一个可以组合,可以复用的单元。然后,组合这些单元完成不同的功能。如果这个单元是稳定的,我们就可以把这个单元和其他单元继续组合,构成更大的单元。然后,我们再用这个组合出来的新单元继续构建更大的单元。由此,一层一层地逐步向上。
  2. 封装的重点在于对象提供了哪些行为,而不是有哪些数据。即便我们把对象理解成数据加函数,数据和函数也不是对等的地位。函数是接口,应该是稳定的;数据是实现,是易变的,应该隐藏起来。
  3. 设计一个类的方法,先要考虑其对象应该提供哪些行为,然后,根据这些行为提供对应的方法,最后才是考虑实现这些方法要有哪些字段。getter和setter是暴露实现细节的,尽可能不提供,尤其是setter。
  4. 封装,除了要减少内部实现细节的暴露,还要减少对外接口的暴露。一个原则是最小化接口暴露。有了对封装的理解,即便我们用的是C语言这样非面向对象的语言,也可以按照这个思路把程序写得更具模块性。

4.2.2 继承

继承就如字面意思那样,将多个相同属性和方法提取出来,新建一个父类,然后子类继承父类的特征和行为。

关于继承有如下 3 点:

  • 子类拥有父类非 private 的属性和方法。

  • 子类可以拥有自己属性和方法,即子类可以对父类进行扩展。

  • 子类可以用自己的方式实现父类的方法。

如果你熟悉的是类似 Java、C++ 这样的面向对象的编程语言,那你对继承这一特性,应该不陌生了。继承是用来表示类之间的 is-a 关系,比如猫是一种哺乳动物。从继承关系上来讲,继承可以分为两种模式,单继承和多继承。单继承表示一个子类只继承一个父类,多继承表示一个子类可以继承多个父类,比如猫既是哺乳动物,又是爬行动物。Java虽然不支持多继承,但是Java有三种实现多继承效果的方式,分别是内部类、多层继承和实现接口。

4.2.2.1 继承的实现方式

extends关键字

在Java中,类的继承是单一继承,也就是说一个子类只能拥有一个父类,所以extends只能继承一个类。其使用语法为:

class 子类名 extends 父类名{} 

如果我们把继承理解成一种代码复用方式,更多地是站在子类的角度向上看。在客户端代码使用的时候,面对的是子类,这种继承叫实现继承。其实,还有一种看待继承的角度,就是从父类的角度往下看,客户端使用的时候,面对的是父类,这种继承叫接口继承

  • 实现继承:感觉就是创建出的对象实例用相对应的类类型来接收
  • 接口继承:感觉就是创建出的对象实例用相对应的父类型来接收
// 实现继承
Child object = new Child();
// 接口继承
Parent object = new Child();

把实现继承当作一种代码复用的方式,并不是一种值得鼓励的做法。一方面,继承是很宝贵的,尤其是Java这种单继承的程序设计语言。每个类只能有一个父类,一旦继承的位置被实现继承占据了,再想做接口继承就很难了。

另一方面,实现继承通常也是一种受程序设计语言局限的思维方式,有很多程序设计语言,即使不使用继承,也有自己的代码复用方式。

假设,我要做一个产品报表服务,其中有个服务是要查询产品信息,这个查询过程是通用的,别的服务也可以用,所以,我把它放到父类里面。这就是代码复用的做法,代码用Java写出来是这样的:

class BaseService {
  // 获取相应的产品信息
  protected List<Product> getProducts(List<String> product) {
    ...
  }
}

// 生成报表服务
class ReportService extends BaseService {
  public void report() {
    List<Product> product = getProduct(...);
    // 生成报表
    ...
  }
}

从前面的分析中,我们也不难看出,获取产品信息和生成报表其实是两件事,只是因为在生成报表的过程中,需要获取产品信息,所以,它有了一个基类。

其实,在Java里面,我们不用继承的方式也能实现,也许你已经想到了,代码可以写成这样:

class ProductFetcher {
  // 获取相应的产品信息
  public List<Product> getProducts(List<String> product) {
    ...
  }
}

// 生成报表服务
class ReportService {
  private ProductFetcher fetcher;
  
  public void report() {
    List<Product> product = fetcher.getProducts(...);
    // 生成报表
    ...
  }
}

这种实现方案叫作组合,也就是说ReportService里组合进一个ProductFetcher。在设计上,有一个通用的原则叫做:组合优于继承。也就是说,如果一个方案既能用组合实现,也能用继承实现,那就选择用组合实现。

4.2.2.2 继承存在的意义是什么?它能解决什么编程问题?

继承最大的一个好处就是代码复用。假如两个类有一些相同的属性和方法,我们就可以将这些相同的部分,抽取到父类中,让两个子类继承父类。这样,两个子类就可以重用父类中的代码,避免代码重复写多遍。不过,这一点也并不是继承所独有的,我们也可以通过其他方式来解决这个代码复用的问题,比如利用组合关系而不是继承关系。

如果我们再上升一个思维层面,去思考继承这一特性,可以这么理解:我们代码中有一个猫类,有一个哺乳动物类。猫属于哺乳动物,从人类认知的角度上来说,是一种 is-a 关系。我们通过继承来关联两个类,反应真实世界中的这种关系,非常符合人类的认知,而且,从设计的角度来说,也有一种结构美感。

继承的概念很好理解,也很容易使用。不过,过度使用继承,继承层次过深过复杂,就会导致代码可读性、可维护性变差。为了了解一个类的功能,我们不仅需要查看这个类的代码,还需要按照继承关系一层一层地往上查看“父类、父类的父类……”的代码。还有,子类和父类高度耦合,修改父类的代码,会直接影响到子类。

所以,继承这个特性也是一个非常有争议的特性。很多人觉得继承是一种反模式。我们应该尽量少用,甚至不用。

4.2.2.3 方法重写

方法重写(Override)和方法重载(Overload)都是面向对象编程中,多态特性的不同体现,但二者本身并无关联。但是二者对比着学习,可以加深印象

4.2.2.3.1 方法重写

方法重写(Override)是一种语言特性,它是多态的具体表现,它允许子类重新定义父类中已有的方法,且子类中的方法名和参数类型及个数都必须与父类保持一致,这就是方法重写。

对于重写,需要注意以下几点:

  • 父类中被static、private、final修饰的方法、构造方法不能被重写;
  • 重写的方法,可以使用 @override 注解来显示指定(帮助我们进行一些合法性的检验)。
  • 重写的返回值类型可以不同,但是必须具有父子关系。
  • 被final修饰的方法,叫做密封方法,该方法不能被重写。
  • 外部类只能是public或者默认权限

从重写的要求上看:

  • 重写的方法和父类的要一致(包括返回值类型、方法名、参数列表)
  • 方法重写只存在于子类和父类之间,同一个类中只能重载

从访问权限上看:

  • 重写的方法访问权限不能比父类中原方法的的权限低;
  • 父类的私有方法不能被子类重写
  • 子类方法返回的类型只能变小,也就是说如果父类方法返回的是 Number 类型,那么子类方法只能返回 Number 类型或 Number 类的子类 Long 类型,而不能返回 Number 类型的父类类型 Object;
  • 子类方法名必须和父类方法名保持一致
  • 子类方法的参数类型和个数必须和父类保持一致。
  • 子类方法不能抛出比父类方法范围更广的异常(uncheck 异常不适用,适用于checked exception)

Java 子类重写继承的方法时,不可以降低方法的访问权限,子类继承父类的访问修饰符作用域不能比父类小,也就是更加开放,假如父类是protected修饰的,其子类只能是protected或者public,绝对不能是default(默认的访问范围)或者private。所以在继承中需要重写的方法不能使用private修饰词修饰。

修改成private后报错

从静态和非静态上看:

  • 父类的静态方法不能被子类重写为非静态方法
  • 子类可以定义于父类的静态方法同名的静态方法,以便在子类中隐藏父类的静态方法(满足重写约束)
  • 父类的非静态方法不能被子类重写为静态方法

从抽象和非抽象来看:

  • 父类的抽象方法可以被子类通过两种途径重写(即实现和重写)
  • 父类的非抽象方法可以被重写为抽象方法
class Father {
    String getAge() {
        System.out.println("father");
        return "100";
    }
}

abstract class Son extends Father {
    @Override
    abstract String getAge();
}
4.2.2.3.2 方法重载(Overload)

方法重载是指在同一个类中,定义了多个同名方法,但同名方法的参数类型或参数个数不同就是方法重载。

如果有两个方法的方法名相同,但参数不一致,那么可以说一个方法是另一个方法的重载。方法重载规则如下:

  • 被重载的方法必须改变参数列表(参数个数或类型或顺序不一样)

  • 被重载的方法可以改变返回类型

  • 被重载的方法可以改变访问修饰符

  • 被重载的方法可以声明新的或更广的检查异常

  • 方法能够在同一个类中或者在一个子类中被重载

  • 无法以返回值类型作为重载函数的区分标准

方法重载的优先匹配原则有以下 5 个:

  1. 优先匹配相同数据类型,方法重载会优先调用和方法参数类型一模一样的方法,比如只有一个 String 类型的参数调用,会优先匹配只有一个 String 参数类型的重载方法;
  2. 如果是基本数据类型,会自动转换成更大的基本数据类型进行匹配,比如调用的参数是 int 类型,那么会优先调用基本类型 long,而非包装类型 Integer。
  3. 自动装箱和自动拆箱匹配,参数调用也会进行自动拆箱和自动装箱的方法匹配,比如调用参数传递的是 int 类型,那么它可以匹配到 Integer 类型的重载方法;
  4. 会按照继承路线依次向上匹配父类,如果匹配不到当前类,会尝试匹配它的父类,或者是父类的父类,依次往上匹配;
  5. 可变参数匹配,如果方法是可选参数方法,那么它的调用优先级是最低的,在最后阶段才会匹配可选参数方法。

方法重载会按照以上的 5 个原则依次进行匹配,符合规则的方法会被优先调用。除了以上匹配原则之外,还需要特殊注意一点,不同的返回类型不能作为方法重载的依据,也就是不同的返回值类型不算方法重载。

4.2.2.4 为什么不推荐使用继承?

在面向对象编程中,有一条非常经典的设计原则,那就是:组合优于继承,多用组合少用继承。

继承是面向对象的三大特性之一,用来表示类之间的 is-a 关系,可以解决代码复用的问题。虽然继承有诸多作用,但继承层次过深、过复杂,也会影响到代码的可维护性。所以,对于是否应该在项目中使用继承,网上有很多争议。很多人觉得继承是一种反模式,应该尽量少用,甚至不用。为什么会有这样的争议?我们通过一个例子来解释一下。

假设我们要设计一个关于鸟的类。我们将“鸟类”这样一个抽象的事物概念,定义为一个抽象类 AbstractBird。所有更细分的鸟,比如麻雀、鸽子、乌鸦等,都继承这个抽象类。

我们知道,大部分鸟都会飞,那我们可不可以在 AbstractBird 抽象类中,定义一个 fly() 方法呢?答案是否定的。尽管大部分鸟都会飞,但也有特例,比如鸵鸟就不会飞。鸵鸟继承具有 fly() 方法的父类,那鸵鸟就具有“飞”这样的行为,这显然不符合我们对现实世界中事物的认识。当然,你可能会说,我在鸵鸟这个子类中重写(override)fly() 方法,让它抛出 UnSupportedMethodException 异常不就可以了吗?具体的代码实现如下所示:

public class AbstractBird {
  //... 省略其他属性和方法...
  public void fly() { //... }
}
    
public class Ostrich extends AbstractBird { // 鸵鸟
  //... 省略其他属性和方法...
  public void fly() {
    throw new UnSupportedMethodException("I can't fly.'");
  }
}

这种设计思路虽然可以解决问题,但不够优美。因为除了鸵鸟之外,不会飞的鸟还有很多,比如企鹅。对于这些不会飞的鸟来说,我们都需要重写 fly() 方法,抛出异常。这样的设计,一方面,徒增了编码的工作量;另一方面,也违背了我们之后要讲的最小知识原则(Least Knowledge Principle,也叫最少知识原则或者迪米特法则),暴露不该暴露的接口给外部,增加了类使用过程中被误用的概率。

你可能又会说,那我们再通过 AbstractBird 类派生出两个更加细分的抽象类:会飞的鸟类 AbstractFlyableBird 和不会飞的鸟类 AbstractUnFlyableBird,让麻雀、乌鸦这些会飞的鸟都继承 AbstractFlyableBird,让鸵鸟、企鹅这些不会飞的鸟,都继承 AbstractUnFlyableBird 类,不就可以了吗?具体的继承关系如下图所示:

从图中我们可以看出,继承关系变成了三层。不过,整体上来讲,目前的继承关系还比较简单,层次比较浅,也算是一种可以接受的设计思路。我们再继续加点难度。在刚刚这个场景中,我们只关注“鸟会不会飞”,但如果我们还关注“鸟会不会叫”,那这个时候,我们又该如何设计类之间的继承关系呢?

是否会飞?是否会叫?两个行为搭配起来会产生四种情况:会飞会叫、不会飞会叫、会飞不会叫、不会飞不会叫。如果我们继续沿用刚才的设计思路,那就需要再定义四个抽象类(AbstractFlyableTweetableBird、AbstractFlyableUnTweetableBird、AbstractUnFlyableTweetableBird、AbstractUnFlyableUnTweetableBird)。

如果我们还需要考虑“是否会下蛋”这样一个行为,那估计就要组合爆炸了。类的继承层次会越来越深、继承关系会越来越复杂。而这种层次很深、很复杂的继承关系,一方面,会导致代码的可读性变差。因为我们要搞清楚某个类具有哪些方法、属性,必须阅读父类的代码、父类的父类的代码……一直追溯到最顶层父类的代码。另一方面,这也破坏了类的封装特性,将父类的实现细节暴露给了子类。子类的实现依赖父类的实现,两者高度耦合,一旦父类代码修改,就会影响所有子类的逻辑。

总之,继承最大的问题就在于:继承层次过深、继承关系过于复杂会影响到代码的可读性和可维护性。这也是为什么我们不推荐使用继承。那刚刚例子中继承存在的问题,我们又该如何来解决呢?

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

实际上,我们可以利用组合(composition)、接口、委托(delegation)三个技术手段,一块儿来解决刚刚继承存在的问题。

我们前面讲到接口的时候说过,接口表示具有某种行为特性。针对“会飞”这样一个行为特性,我们可以定义一个 Flyable 接口,只让会飞的鸟去实现这个接口。对于会叫、会下蛋这些行为特性,我们可以类似地定义 Tweetable 接口、EggLayable 接口。我们将这个设计思路翻译成 Java 代码的话,就是下面这个样子:

public interface Flyable {
  void fly();
}
public interface Tweetable {
  void tweet();
}
public interface EggLayable {
  void layEgg();
}
public class Ostrich implements Tweetable, EggLayable {// 鸵鸟
  //... 省略其他属性和方法...
  @Override
  public void tweet() { //... }
  @Override
  public void layEgg() { //... }
}
public class Sparrow impelents Flayable, Tweetable, EggLayable {// 麻雀
  //... 省略其他属性和方法...
  @Override
  public void fly() { //... }
  @Override
  public void tweet() { //... }
  @Override
  public void layEgg() { //... }
}

不过,我们知道,接口只声明方法,不定义实现。也就是说,每个会下蛋的鸟都要实现一遍 layEgg() 方法,并且实现逻辑是一样的,这就会导致代码重复的问题。那这个问题又该如何解决呢?

我们可以针对三个接口再定义三个实现类,它们分别是:实现了 fly() 方法的 FlyAbility 类、实现了 tweet() 方法的 TweetAbility 类、实现了 layEgg() 方法的 EggLayAbility 类。然后,通过组合和委托技术来消除代码重复。具体的代码实现如下所示:

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(); // 委托
  }
}

我们知道继承主要有三个作用:表示 is-a 关系,支持多态特性,代码复用。而这三个作用都可以通过其他技术手段来达成。比如 is-a 关系,我们可以通过组合和接口的 has-a 关系来替代;多态特性我们可以利用接口来实现;代码复用我们可以通过组合和委托来实现。所以,从理论上讲,通过组合、接口、委托三个技术手段,我们完全可以替换掉继承,在项目中不用或者少用继承关系,特别是一些复杂的继承关系。

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

尽管我们鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。从上面的例子来看,继承改写成组合意味着要做更细粒度的类的拆分。这也就意味着,我们要定义更多的类和接口。类和接口的增多也就或多或少地增加代码的复杂程度和维护成本。所以,在实际的项目开发中,我们还是要根据具体的情况,来具体选择该用继承还是组合。

如果类之间的继承结构稳定(不会轻易改变),继承层次比较浅(比如,最多有两层继承关系),继承关系不复杂,我们就可以大胆地使用继承。反之,系统越不稳定,继承层次很深,继承关系复杂,我们就尽量使用组合来替代继承。

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

前面我们讲到继承可以实现代码复用。利用继承特性,我们把相同的属性和方法,抽取出来,定义到父类中。子类复用父类中的属性和方法,达到代码复用的目的。但是,有的时候,从业务含义上,A 类和 B 类并不一定具有继承关系。比如,Crawler 类和 PageAnalyzer 类,它们都用到了 URL 拼接和分割的功能,但并不具有继承关系(既不是父子关系,也不是兄弟关系)。仅仅为了代码复用,生硬地抽象出一个父类出来,会影响到代码的可读性。如果不熟悉背后设计思路的同事,发现 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();
  }
  //..
}

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

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

public class FeignClient { // feighn 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 的实现...}
}
// 调用
FeignClient client = new CustomizedFeignClient();
demofunction(client);

之所以“多用组合少用继承”这个口号喊得这么响,只是因为,长期以来,我们过度使用继承。还是那句话,组合并不完美,继承也不是一无是处。只要我们控制好它们的副作用、发挥它们各自的优势,在不同的场合下,恰当地选择使用继承还是组合,这才是我们所追求的境界。

4.2.3 多态

面向对象编程,是现在最主流的编程范式,它的核心概念就是对象。用面向对象风格写出的程序,本质上就是一堆对象之间的交互。面向对象编程给我们提供了一种管理程序复杂性的方式,其中最重要的概念就是多态(polymorphism)。

只使用封装和继承的编程方式,我们称之为基于对象(Object Based)编程,而只有把多态加进来,才能称之为面向对象(Object Oriented)编程。也就是说,多态是一个分水岭,将基于对象与面向对象区分开来,可以说,没写过多态的代码,就是没写过面向对象的代码。

对于面向对象而言,多态至关重要,正是因为多态的存在,软件设计才有了更大的弹性,能够更好地适应未来的变化。我们说,软件设计是一门关注长期变化的学问,只有当你开始理解了多态,你才真正踏入应对长期变化的大门。

4.2.3.1 什么是多态

  • 多态(Polymorphism),顾名思义,一个接口,多种形态。同样是一个绘图(draw)的方法,如果以正方形调用,则绘制出一个正方形;如果以圆形调用,则画出的是圆形:
  • 多态是指,子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。
  • 所谓多态,就是指一个引用(类型)在不同的情况下的多种状态。也可以理解为,多态是指通过指向父类的指针,来调用在不同子类中实现的方法。
package com.core.extend;

public class Test1 {
    public static void main(String[] args) {
        father people = new son();
        people.speak(); //i am son !
        System.out.println(people.name); //father
        people.show(); //father show

    }
}

class father {
    String name = "father";

    static void show() {
        System.out.println("father show");
    }

    public void speak() {
        System.out.println("i am father !");
    }
}

class son extends father {

    String name = "son";

    static void show() {
        System.out.println("son show");
    }

    public void speak() {
        System.out.println("i am son !");
    }
}

多态的条件

  • 继承
  • 子类重写父类方法
  • 父类对象引用子类

由运行结果我们可以分析得出:

1.调用成员变量的特点:
  • 编译时,参考引用型变量所属的类中的是否有调用的成员变量,有,编译通过,没有,编译失败。
  • 运行时,参考引用型变量所属的类中的是否有调用的成员变量,并运行该所属类中的成员变量。
  • 简单说,编译和运行都参考等号的左边。
2 调用成员方法的特点:
  • 编译时,参考引用型变量所属的类中的是否有调用的成员变量,有,编译通过,没有,编译失败。
  • 运行时,参考的是对象所属的类中是否有调用的函数。
  • 简单说:编译看左边,运行看右边。
3 调用静态函数的特点:
  • 编译时,参考引用型变量所属的类中的是否有调用的成员变量
  • 运行时,参考引用型变量所属的类中的是否有调用的成员变量
  • 简单说:编译和运行都看左边。

其实对于静态方法,是不需要对象的,直接用类名调用即可。

静态方法和静态变量发生在前期绑定,成员方法发生在后期绑定,因为子类可能会重写父类中的方法,那么成员变量为什么没有被重写呢

Within a class, a field that has the same name as a field in the superclass hides the superclass’s field, even if their types are different. Within the subclass, the field in the superclass cannot be referenced by its simple name. Instead, the field must be accessed through super. Generally speaking, we don’t recommend hiding fields as it makes code difficult to read.

意思就是:

在一个类中,子类中的成员变量如果和父类中的成员变量同名,那么即使他们类型不一样,只要名字一样。父类中的成员变量都会被隐藏。在子类中,父类的成员变量不能被简单的用引用来访问。而是,必须从父类的引用获得父类被隐藏的成员变量,一般来说,我们不推荐隐藏成员变量,因为这样会使代码变得难以阅读。

其实,简单来说,就是子类不会去重写覆盖父类的成员变量,所以成员变量的访问不能像方法一样使用多态去访问。

4.2.3.2 向上转型

通过父类类型的引用调用子类对象,向上转型是安全的

package com.core.extend;

public class Test3 {
    public static void main(String[] args) {
        father2 people = new son2();
        people.speak();
        System.out.println("i am " + people.getAge());
        //people.play(); //father中缺少play()方法,所以不能执行。
    }
}

class father2 {
    private int age;

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public void speak() {
        System.out.println("i am father !");
    }
}

class son2 extends father2 {
    public son2() {
        setAge(17);
    }

    public void speak() {
        System.out.println("i am son !");
    }

    public void play() {
        System.out.println("i am son , i want to play!");
    }
}
优缺点:
  • 优点:让代码更加灵活
  • 缺点:不能访问到子类特有的方法

4.2.3.3 通过“继承加方法重写”实现多态

package com.core.extend;

public class Test {

    public static void main(String[] args) {
        Master master = new Master();
        master.feed(new Dog(), new Bone());

        // hin方便,可以再试试
        master.feed(new Cat(), new Fish());

    }
}


// 动物类
class Animal {
    int age;
    String name;

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    // 动物类里面有叫和吃两个方法
    public void cry() {
        System.out.println("我不知道叫什么");
    }

    public void eat() {
        System.out.println("我不知道吃什么");
    }
}


// 狗类继承于动物类
class Dog extends Animal {
    public Dog() {
        setName("dog");
        setAge(2);
    }

    // 覆盖(重写)方法
    public void cry() {
        System.out.println("旺旺");
    }

    public void eat() {
        System.out.println("我是狗,我爱吃骨头");
    }
}

// 猫类继承于动物类
class Cat extends Animal {
    public Cat() {
        setName("cat");
        setAge(1);
    }

    // 覆盖(重写)方法
    public void cry() {
        System.out.println("喵喵");
    }

    public void eat() {
        System.out.println("我是猫,我爱吃鱼");
    }
}

// 食物类
class Food {

    String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    // 食物类里面让它有一个方法
    public void showName() {

    }
}

// 鱼(食物的一种)继承于食物
class Fish extends Food {
    public void showName() {
        System.out.println("食物:鱼");
    }
}

// 骨头(食物的一种)继承于食物
class Bone extends Food {
    public void showName() {
        System.out.println("食物:骨头");
    }
}

// 主人类 存在一个投食方法
class Master {
    // 给动物喂食物,如果没有多态,他要写给猫喂食和给狗喂食两个方法
    // 有了多态,以后即使再来好多动物,用这一个函数就可以了
    public void feed(Animal an, Food f) {
        an.eat();
        f.showName();
    }
}

这种做法的好处就在于,一旦有了新的变化,比如,需要添加新的动物和食物,除了新增相对应的实现类,其他的代码并不需要修改,更让人兴奋的是不需要更改业务逻辑。

4.2.3.4 通过“接口”实现多态

public interface Iterator {
    String hasNext();
    String next();
    String remove();
}
public class Array implements Iterator {
    private String[] data;

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

    public String 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);
    }
}

在这段代码中,Iterator 是一个接口类,定义了一个可以遍历集合数据的迭代器。Array 和 LinkedList 都实现了接口类 Iterator。我们通过传递不同类型的实现类(Array、LinkedList)到 print(Iterator iterator) 函数中,支持动态的调用不同的 next()、hasNext() 实现。

具体点讲就是

  • 当我们往 print(Iterator iterator) 函数传递 Array 类型的对象的时候,print(Iterator iterator) 函数就会调用 Array 的 next()、hasNext() 的实现逻辑;

  • 当我们往 print(Iterator iterator) 函数传递 LinkedList 类型的对象的时候,print(Iterator iterator) 函数就会调用 LinkedList 的 next()、hasNext() 的实现逻辑。

4.2.3.5 多态特性存在的意义是什么?它能解决什么编程问题?

多态特性能提高代码的可扩展性和复用性。为什么这么说呢?我们回过头去看讲解多态特性的时候,举的第二个代码实例(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() 函数的打印逻辑,就能应对各种集合数据的打印操作,这显然提高了代码的复用性。

除此之外,多态也是很多设计模式、设计原则、编程技巧的代码实现基础,比如策略模式、基于接口而非实现编程、依赖倒置原则、里式替换原则、利用多态去掉冗长的 if-else 语句等等。

4.2.3.6 为什么很多程序员不能在自己的代码中很好地运用多态呢

既然多态这么好,为什么很多程序员不能在自己的代码中很好地运用多态呢?因为多态需要构建出一个抽象。

构建抽象,需要找出不同事物的共同点,而这是最有挑战的部分。而遮住程序员们双眼的,往往就是他们眼里的不同之处。在他们眼中,鸡就是鸡,鸭就是鸭。

寻找共同点这件事,地基还是在分离关注点上。只有你能看出来,鸡和鸭都有羽毛,都养在家里,你才有机会识别出一个叫做“家禽”的概念。

我们构建出来的抽象会以接口的方式体现出来,这里的接口不一定是一个语法,而是一个类型的约束。在构建抽象上,接口扮演着重要的角色。首先,接口将变的部分和不变的部分隔离开来。不变的部分就是接口的约定,而变的部分就是子类各自的实现。

在软件开发中,对系统影响最大的就是变化。有时候需求一来,你的代码就要跟着改,一个可能的原因就是各种代码混在了一起。比如,一个通信协议的调整需要你改业务逻辑,这明显就是不合理的。对程序员来说,识别出变与不变,是一种很重要的能力。

其次,接口是一个边界。无论是什么样的系统,清晰界定不同模块的职责是很关键的,而模块之间彼此通信最重要的就是通信协议。这种通信协议对应到代码层面上,就是接口。

很多程序员在接口中添加方法显得很随意,因为在他们心目中,并不存在实现者和使用者之间的角色差异。这也就造成了边界意识的欠缺,没有一个清晰的边界,其结果就是模块定义的随意,彼此之间互相影响也就在所难免。后面谈到Liskov替换法则的时候,我们还会再谈到这一点。

所以,要想理解多态,首先要理解接口的价值,而理解接口,最关键的就是在于谨慎地选择接口中的方法。

至此,你已经对多态和接口有了一个基本的认识。你就能很好地理解一个编程原则了:面向接口编程。面向接口编程的价值就根植于多态,也正是因为有了多态,一些设计原则,比如,开闭原则、接口隔离原则才得以成立,相应地,设计模式才有了立足之本。

这些原则你可能都听说过,但在编码的细节上,你可能会有一些忽略的细节,比如,下面这段代码是很多人经常写的:

ArrayList<> list = new ArrayList<String>();

这么简单的代码也有问题,是的,因为它没有面向接口编程,一个更好的写法应该是这样:

List<> list = new ArrayList<String>();

二者之间的差别就在于变量的类型,是面向一个接口,还是面向一个具体的实现类。

4.3 面向对象的优缺点

  • 优点

    • 能和真实的世界交相辉映,符合人的直觉。
    • 面向对象和数据库模型设计类型,更多地关注对象间的模型设计。
    • 强调于“名词”而不是“动词”,更多地关注对象和对象间的接口。
    • 根据业务的特征形成一个个高内聚的对象,有效地分离了抽象和具体实现,增强了可重用性和可扩展性。
    • 拥有大量非常优秀的设计原则和设计模式。
    • S.O.L.I.D(单一功能、开闭原则、里氏替换、接口隔离以及依赖反转,是面向对象设计的五个基本原则)
  • 缺点

    • 代码都需要附着在一个类上,从一侧面上说,其鼓励了类型。
    • 代码需要通过对象来达到抽象的效果,导致了相当厚重的“代码粘合层”。
    • 因为太多的封装以及对状态的鼓励,导致了大量不透明并在并发下出现很多问题。

还是好多人并不是喜欢面向对象,尤其是喜欢函数式和泛型那些人,似乎都是非常讨厌面向对象的。

通过对象来达到抽象结果,把代码分散在不同的类里面,然后,要让它们执行起来,就需要把这些类粘合起来。所以,它另外一方面鼓励相当厚重的代码黏合层(代码黏合层就是把代码黏合到这里面)。

在 Java 里有很多注入方式,像 Spring 那些注入,鼓励黏合,导致了大量的封装,完全不知道里面在干什么事情。而且封装屏蔽了细节,具体发生啥事你还不知道。这些都是面向对象不太好的地方。

5 一些问题

5.1 我们为什么容易写出面向过程风格的代码?

我们在进行面向对象编程的时候,很容易不由自主地就写出面向过程风格的代码,或者说感觉面向过程风格的代码更容易写。这是为什么呢?

你可以联想一下,在生活中,你去完成一个任务,你一般都会思考,应该先做什么、后做什么,如何一步一步地顺序执行一系列操作,最后完成整个任务。面向过程编程风格恰恰符合人的这种流程化思维方式。

而面向对象编程风格正好相反。它是一种自底向上的思考方式。它不是先去按照执行流程来分解任务,而是将任务翻译成一个一个的小的模块(也就是类),设计类之间的交互,最后按照流程将类组装起来,完成整个任务。这样的思考路径比较适合复杂程序的开发,但并不是特别符合人类的思考习惯。

除此之外,面向对象编程要比面向过程编程难一些。在面向对象编程中,类的设计还是挺需要技巧,挺需要一定设计经验的。你要去思考如何封装合适的数据和方法到一个类里,如何设计类之间的关系,如何设计类之间的交互等等诸多设计问题。

所以,基于这两点原因,很多工程师在开发的过程,更倾向于用不太需要动脑子的方式去实现需求,也就不由自主地就将代码写成面向过程风格的了。

5.2 如何选择面向过程 与 面向对象

在日常开发过程中,对一个软件的开发我们是使用面向过程风格还是面向对象风格。该如何抉择?

面向过程编程是面向对象编程的基础,面向对象编程离不开基础的面向过程编程。为什么这么说?我们仔细想想,类中每个方法的实现逻辑,不就是面向过程风格的代码吗?

除此之外,面向对象和面向过程两种编程风格,也并不是非黑即白、完全对立的。在用面向对象编程语言开发的软件中,面向过程风格的代码并不少见,甚至在一些标准的开发库(比如 JDK、Apache Commons、Google Guava)中,也有很多面向过程风格的代码。

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

面向对象是解决更大规模应用开发的一种尝试,它提升了程序员管理程序的尺度。