深入理解Java面向对象三大特征【封装,继承,多态】

1.封装

1.1 概念

      封装,则是面向对象的根基。它把紧密相关的信息放在一起,形成一个单元。如果这个单元是稳定的,我们就可以把这个单元和其他单元继续组合,构成更大的单元。然后,我们再用这个组合出来的新单元继续构建更大的单元。由此,一层一层地逐步向上。
      封装也叫作信息隐藏或者数据访问保护类通过暴露有限的访问接口,授权外部仅能通过类提供的方式(或者叫函数)来访问内部信息或者数据

1.2 如何实现高内聚的封装

将意图与实现分离开来

      对象之间只能通过消息来通信。如果按今天程序设计语言的通常做法,发消息就是方法调用,对象之间就是靠方法调用来通信的。但这个方法调用并不是简单地把对象内部的数据通过方法暴露
      因为,封装的重点在于对象提供了哪些行为,而不是有哪些数据。也就是说,即便我们把对象理解成数据加函数,数据和函数也不是对等的地位。函数是接口,而数据是内部的实现,正如我们一直说的那样,接口是稳定的,实现是易变的。
      理解了这一点,我们来看一个很多人都有的日常编程习惯。他们编写一个类的方法是,把这个类有哪些字段写出来,然后,生成一大堆 getter 和 setter,将这些字段的访问暴露出去。这种做法的错误就在于把数据当成了设计的核心,这一堆的 getter 和 setter,就等于把实现细节暴露了出去。
      一个正确的做法应该是,我们设计一个类,先要考虑其对象应该提供哪些行为。然后,我们根据这些行为提供对应的方法,最后才是考虑实现这些方法要有哪些字段。
      请注意,方法的命名,体现的是你的意图,而不是具体怎么做。所以,getXXX 和 setXXX绝对不是一个好的命名。举个例子,设计一个让用户修改密码的功能,有些人直觉的做法可能是这样:

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;
	}
}

      这两段代码相比,只是修改密码的方法名变了,但二者更重要的差异是,一个在说做什么,一个在说怎么做。将意图与实现分离开来,这是一个优秀设计必须要考虑的问题。
      不过,在真实的项目中,有时确实需要暴露一些数据,所以,等到你确实需要暴露的时候,再去写 getter 也不迟,你一定要问问自己为什么要加 getter。至于 setter,首先,大概率是你用错了名字,应该用一个表示意图的名字;其次,setter 通常意味着修改,这是我们不鼓励的

减少暴露的接口

      之所以我们需要封装,就是要构建一个内聚的单元。所以,我们要减少这个单元对外的暴露。
      这句话的第一层含义是减少内部实现细节的暴露,它还有第二层含义,减少对外暴露的接口
      一般面向对象程序设计语言都支持 public、private 这样的修饰符。程序员在日常开发中,经常会很草率地给一个方法加上 public,从而不经意间将一些本来应该是内部实现的部分暴露出去。
      举个例子,一个服务要停下来的时候,你可能要把一些任务都停下来,代码可能会这样
写:

class Service {
	public void shutdownTimerTask() {
	// 停止定时器任务
	}
	
	 public void shutdownPollTask() {
	 // 停止轮询服务
	}
}

别人调用时,可能会这样调用这段代码:

class Application {
	 private Service service;
	
	 public void onShutdown() {
		 service.shutdownTimerTask();
		 service.shutdownPollTask();
	 }
 }

突然有一天,你发现,停止轮询任务必须在停止定时器任务之前,你就不得不要求别人改代码。而这一切就是因为我们很草率地给那两个方法加上了 public,让别人有机会看到了这两个方法。
从设计的角度来说,我们必须谨慎地问一下,这个方法真的有必要暴露出去吗?
就这个例子而言,我们可以仅仅暴露一个方法:

class Service {
	 private void shutdownTimerTask() {
	 // 停止定时器任务
	 }

	 private void shutdownPollTask() {
	 // 停止轮询服务
	 }

	 public void shutdown() {
		 this.shutdownTimerTask();
		 this.shutdownPollTask();
	 }
}

我们调用代码也会简单很多:

class Application {
	 private Service service;
	
	 public void onShutdown() {
	 	service.shutdown();
	 }
 }

      尽可能减少接口暴露,这个原则不仅仅适用于类的设计,同样适用于系统设计。在我的职业生涯中,看到了很多团队非常随意地在系统里面添加接口,一个看似不那么复杂的系统里,随随
便便就有成百上千个接口。如果你想改造系统去掉一些接口时,很有可能会造成线上故障,因为你根本不知道哪个团队在什么时候用到了它。所以,在软件设计中,暴露接口需要非常谨慎

      关于这一点,你可以有一个统一的原则:最小化接口暴露。也就是,每增加一个接口,你都要找到一个合适的理由。

总结

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

1.3 意义/解决了什么编程

      如果我们对类中属性的访问不做限制,那任何代码都可以访问、修改类中的属性,虽然这样看起来更加灵活,但从另一方面来说,过度灵活也意味着不可控,属性可以随意被以各种奇葩的方式修改,而且修改逻辑可能散落在代码中的各个角落,势必影响代码的可读性、可维护性。
      比如某个同事在不了解业务逻辑的情况下,在某段代码中“偷偷地”重设了 wallet 中的balanceLastModifiedTime 属性,这就会导致 balance 和 balanceLastModifiedTime 两个数据不一致
      除此之外,类仅仅通过有限的方法暴露必要的操作,也能提高类的易用性。如果我们把类属性都暴露给类的调用者,调用者想要正确地操作这些属性,就势必要对业务细节有足够的了解。这对于调用者来说也是一种负担。相反,如果我们将属性封装起来,暴露少许的几个必要的方法给调用者使用,调用者就不需要了解太多背后的业务细节,用错的概率就减少很多。这就好比,如果一个冰箱有很多按钮,你就要研究很长时间,还不一定能操作正确。相反,如果只有几个必要的按钮,比如开、停、调节温度,你一眼就能知道该如何来操作,而且操作出错的
概率也会降低很多

继承

2.1 概念

      继承是用来表示类之间的 is-a关系,比如猫是一种哺乳动物。从继承关系上来讲,继承可以分为两种模式,单继承和多继承。单继承表示一个子类只继承一个父类,多继承表示一个子类可以继承多个父类,比如猫既是哺乳动物,又是爬行动物。

2.2 继承与组合

继承例子 && 为什么不推荐继承

      继承是面向对象的四大特性之一,用来表示类之间的 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() 方法,抛出异常。这样的设计,一方面,徒增了编码的工作量;另一方面,也违背了我们之后要讲的最小知识原则(LeastKnowledge Principle,也叫最少知识原则或者迪米特法则),暴露不该暴露的接口给外部,增加了类使用过程中被误用的概率

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

      从图中我们可以看出,继承关系变成了三层。不过,整体上来讲,目前的继承关系还比较简单,层次比较浅,也算是一种可以接受的设计思路。我们再继续加点难度。在刚刚这个场景中,我们只关注“鸟会不会飞”,但如果我们还关注“鸟会不会叫”,那这个时候,我们又该如何设计类之间的继承关系呢?
      是否会飞?是否会叫?两个行为搭配起来会产生四种情况:会飞会叫、不会飞会叫、会飞不会叫、不会飞不会叫。
      如果我们继续沿用刚才的设计思路,那就需要再定义四个抽象类
(AbstractFlyableTweetableBird、AbstractFlyableUnTweetableBird、
AbstractUnFlyableTweetableBird、AbstractUnFlyableUnTweetableBird)。

在这里插入图片描述

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

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

组合的例子 && 组合相比继承有哪些优势

      实际上,我们可以利用组合(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() { //... }
}

      不过,我们知道,接口只声明方法,不定义实现。也就是说,每个会下蛋的鸟都要实现一遍
layEgg() 方法,并且实现逻辑是一样的,这就会导致代码重复的问题
。那这个问题又该如何解决呢?
      我们可以针对三个接口再定义三个实现类,它们分别是:实现了 fly() 方法的 FlyAbility 类、
实现了 tweet() 方法的 TweetAbility 类、实现了 layEgg() 方法的 EggLayAbility 类。然后,通过组合和委托技术来消除代码重复。具体的代码实现如下所示:

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

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 关系来替代
      多态特性我们可以利用接口来实现;
      代码复用我们可以通过组合和委托来实现。
所以,从理论上讲,通过组合、接口、委托三个技术手段,我们完全可以替换掉继承,在项目中不用或者少用继承关系,特别是一些复杂的继承关系。

用继承还是组合

      尽管我们鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。
      在实际的项目开发中,我们还是要根据具体的情况,来选择该用继承还是组合。如果类之间的继承结构稳定,层次比较浅,关系不复杂,我们就可以大胆地使用继承。
      反之,我们就尽量使用组合来替代继承。除此之外,还有一些设计模式、特殊的应用场景,会固定使用继承或者组合。

2.3 继承的意义

      继承最大的一个好处就是代码复用。假如两个类有一些相同的属性和方法,我们就可以将这些相同的部分,抽取到父类中,让两个子类继承父类。这样,两个子类就可以重用父类中的代
码,避免代码重复写多遍。不过,这一点也并不是继承所独有的,我们也可以通过其他方式来解决这个代码复用的问题比如利用组合关系而不是继承关系
      如果我们再上升一个思维层面,去思考继承这一特性,可以这么理解:我们代码中有一个猫类,有一个哺乳动物类。猫属于哺乳动物,从人类认知的角度上来说,是一种 is-a 关系。我
们通过继承来关联两个类,反应真实世界中的这种关系,非常符合人类的认知,而且,从设计的角度来说,也有一种结构美感。
      继承的概念很好理解,也很容易使用。不过,过度使用继承,继承层次过深过复杂,就会导致代码可读性、可维护性变差。为了了解一个类的功能,我们不仅需要查看这个类的代码,还需
要按照继承关系一层一层地往上查看“父类、父类的父类……”的代码。还有,子类和父类高度耦合修改父类的代码,会直接影响到子类
      所以,继承这个特性也是一个非常有争议的特性。很多人觉得继承是一种反模式。我们应该尽量少用,甚至不用。

多态

3.1 概念

      多态。多态是指,子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。对于多态这种特性,纯文字解释不好理解,我们还是看一个具体的例子。

3.2 多态例子

继承加方法重写实现多态

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
	}
}

      多态这种特性也需要编程语言提供特殊的语法机制来实现。在上面的例子中,我们用到了三个语法机制来实现多态。
      第一个语法机制是编程语言要支持父类对象可以引用子类对象,也就是可以将SortedDynamicArray 传递给 DynamicArray
      第二个语法机制是编程语言要支持继承,也就是 SortedDynamicArray 继承了DynamicArray,才能将 SortedDyamicArray 传递给 DynamicArray。
      第三个语法机制是编程语言要支持子类可以重写(override)父类中的方法,也就是SortedDyamicArray 重写了 DynamicArray 中的 add() 方法。通过这三种语法机制配合在一起,我们就实现了在 test() 方法中,子类 SortedDyamicArray替换父类 DynamicArray,执行子类 SortedDyamicArray 的 add() 方法,也就是实现了多态特性。

利用接口类语法实现多态

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

在这段代码中,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() 的实现逻辑。

利用 duck-typing 语法实现【略】

3.3 多态的意义

      多态特性能提高代码的可扩展性复用性
      为什么这么说呢?我们回过头去看讲解多态特性的时候,举的第二个代码实例(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 语句等等。关于这点,在学习后面的章节中,你慢慢会有更深的体会

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

冲鸭的猪

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

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

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

打赏作者

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

抵扣说明:

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

余额充值