软件越来越复杂已经成为了一种趋势,传统的面向过程方法已经无法满足软件开发的需求了,为了软件的更好维护,人们开始考虑将现实世界的实体与软件中的模块对应起来,软件中的模块不仅有着现实世界实体的属性,还有着现实世界实体的行为。
面向对象技术就是做着这样的一件事,它的目的就是将合适的属性和行为放入合适的类中。
面向对象是一门实践中产生的技术,要理解面向对象的思想,离不开实际应用。所以博文会以实际的例子来说明每个面向对象技术。
面向对象的四大特性
封装
首先介绍第一特性。
先看下面一段代码有什么问题?
public class Wallet{
private string ID;
private long createTime;
private BigDecimal balance;
private long balanceLastModifiedTime;
public Wallet(){
//initialize
...
}
public string getID();
public long getCreateTime();
public BigDecimal getBalance():
public long getBalanceLastModifiedTime();
public void setID(string ID);
public void setCreateTime(long createTime);
public void setBalance(BigDecimal balance):
public void setBalanceLastModifiedTime(long balanceLastModifiedTime);
}
这段代码看似是面向对象,实则为面向过程,因为Wallet类中每一个属性都有getter和setter方法,试想一下,这样的代码可以在项目中被使用去修改Wallet类的每一个属性,但是实际上有些类的属性是不能被随意修改的。
这段代码其实就违背了面向对象的第一个特性,封装。封装其实就是对类的访问权限做控制,不该被访问的应该封装起来,避免被访问。
下面来完成正确的代码。
分析以后,可以看出
1.Wallet类中实际能被我们改变的属性只有balance,而且balance不能被随意修改,只能增加一个数或减少一个数。
2.类中的属性balanceLastModifiedTime也应该能被修改,不过要在我们调用修改balance的方法的内部进行改动。
最终代码如下
public class Wallet{
private string ID;
private long createTime;
private BigDecimal balance;
private long balanceLastModifiedTime;
public Wallet(){
//initialize
...
}
public string getID();
public long getCreateTime();
public BigDecimal getBalance():
public long getBalanceLastModifiedTime();
public void increaseBalance(BigDecimal increasedAmount);//change balance and change balanceLastModifiedTime
public void decreaseBalance(BigDecimal decreasedAmount);//change balance and change balanceLastModifiedTime
}
抽象
面向对象的第二个特性是抽象。
在java中,主要有两种实现抽象的方法。第一是接口,第二是抽象类。
首先思考这样一种情况。
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) { ... }
void func1();
void func2();
void func3();
void func4();
}
可以看到,我们PictureStorage类中有很多种方法,哪些方法是给其他系统调用的比较难看出来,而用接口就很好的解决了这个问题。
接口抽象出了四种方法,需要使用到这个类的人一看接口就知道该类哪些方法是能调用的,哪些方法是不能调用的,这给使用者过滤掉了很多不必要的信息,使用更为简单。这就是一种抽象。
下面是另一种情况
public class FileLogger{
// 抽象类的子类:输出日志到文件
private Writer fileWriter;
public FileLogger(String name, boolean enabled, Level minPermittedLevel, String filepath)
@Override public void doLog(Level level, String mesage);
// 格式化 level 和 message, 输出到日志文件}
}
public class MessageQueueLogger{
// 抽象类的子类: 输出日志到消息中间件 (比如 kafka)
private MessageQueueClient msgQueueClient;
public MessageQueueLogger(String name, boolean enabled,
Level minPermittedLevel, MessageQueueClient msgQueueClient);
@Override protected void doLog(Level level, String mesage);
// 格式化 level 和 message, 输出到消息中间件
}
我们有两个子类FileLogger和MessageQueueLogger,作用为将日志输出到文件和消息队列。
我们客户端在使用这些类的时候需要创建这些类的对象,但是这样的设计是非常不好的,如果需要再增加一个输出日志信息的类,我们需要做的改动就很大。这时我们最好的办法就是增加一层抽象来统一管理这些特殊的输出日志信息的类,客户端使用这些类的时候使用抽象就行了,这样就能实现客户端与具体的输出日志信息的类的解耦。代码如下。
// 抽象类
public abstract class Logger { private String name;
private boolean enabled;
private Level minPermittedLevel;
public Logger(String name, boolean enabled, Level minPermittedLevel)
public void log(Level level, String message){}
public class FileLogger extends Logger {
// 抽象类的子类:输出日志到文件
private Writer fileWriter;
public FileLogger(String name, boolean enabled, Level minPermittedLevel, String filepath)
@Override public void doLog(Level level, String mesage);
// 格式化 level 和 message, 输出到日志文件}
}
public class MessageQueueLogger extends Logger {
// 抽象类的子类: 输出日志到消息中间件 (比如 kafka)
private MessageQueueClient msgQueueClient;
public MessageQueueLogger(String name, boolean enabled,
Level minPermittedLevel, MessageQueueClient msgQueueClient);
@Override protected void doLog(Level level, String mesage);
// 格式化 level 和 message, 输出到消息中间件
}
继承
在软件开发中,有一个很出名的原则。DRY(don’t repeat yourself)。这个原则说的就是不要重复做已经做过的事,而继承的一个最大的好处就是实现软件复用。
假如两个类非常相似,我们就可以将这两个类相似的部分抽取出来,做成父类,这样两个子类就能重用父类的代码,避免重复的代码写很多次。
但是继承的使用也是有讲究的,我们应该避免使用层次太深,或者太过复杂的继承,否则会导致代码的可读性变差。
多态
多态的用法是使得父类对象引用子类,同时,如果子类重写了父类的某个方法的话,那么调用这个方法时实际调用的是子类的方法。
代码示例如下
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();
for (int 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[i]);
}
}
public static void main(String args[]) {
DynamicArray dynamicArray = new SortedDynamicArray();
test(dynamicArray); // 打印结果:1、3、5
}
}
面向对象和面向过程的区分
有一些方法看似是面向,实际上是面向过程。
1.滥用getter和setter方法
给类的每个属性都加一个getter和setter方法,破坏了类的封装特性,看似面向对象,实则面向过程。
2.滥用全局变量和全局方法
将所有全局变量和全局方法全部放到一个类中,这样的方法就是典型的面向过程的方法,最终很容易导致所有的全局变量和方法混乱,难以维护。
我们可以按全局变量的用途对全局变量进行分类,以方便日后的维护。
3.将类的方法与属性分离
传统的MVC结构一般会采用这种方法,将系统分为接口层,服务层,数据访问层,每层的变量和方法都是划分开的,这就是典型的面向对象风格。这样的做法在构建简单系统时会比较有效,但在构建复杂系统时就容易被复杂度搞得力不从心了。所以这种做法也叫作贫血模型。
面向抽象而非实现编程
在软件设计中有这么一句话,软件系统的架构不应该依赖于复杂多变的具体实现,而应该依赖于相对不变的抽象。利用抽象的架构才是稳定的架构。
抽象的方法主要有两种,抽象类和抽象接口。
抽象类和接口
抽象类和抽象接口的使用在上文面向对象4大特性中的抽象已经有过介绍,不再重复。
抽象类和接口的区别
那么我们应该什么时候用抽象类,什么时候用抽象接口呢?
这两者的区别主要在于接口是自上而下的,先定义好了所要接口,再去定义类。
而抽象类是自下而上的,我们先有了一些类,之后发现这些类的代码可以复用,就考虑做一个抽象类来实现代码的复用,以及应付未来的变化。
当然,好的设计一般都是演进来的,没有一开始就完美的设计,我们在实践中的做法一般都是凭借自己经验设计一个比较粗略的系统模型,然后再实践中发现问题,一步步将我们的系统演进。
多用组合少用继承
在面向对象中有一个原则:组合优于继承,多用组合少用继承。
这是因为继承很容易就导致层次关系爆炸的情况。
假设我们要设计一个关于鸟的类。我们将“鸟类”这样一个抽象的事物概念,定义为一个抽象类 AbstractBird。所有更细分的鸟,比如麻雀、鸽子、乌鸦等,都继承这个抽象类。
但是这样就有一个问题,有些鸟会飞,有些年不会飞,我们应该给AbstractBird添加一个fly()方法吗?
这是一个很难抉择问题:
因为有些鸟不会飞,父类就不应该有这样一种方法,但是父类中没有这样一种方法的话会飞的鸟也将无法在父类的引用下调用用fly()方法了,这显然也不合理。
一种解决思路是将AbstractBird细分为会飞的鸟和不会飞的鸟,会飞的年和不会飞的年都继承自AbstractBird。这样貌似解决了这一个问题,但是此时又有了另一个疑问,有些年会叫,有些鸟不会叫,这个时候该怎么办呢?
也在原来的基础上继承一个会叫,不会叫的鸟类吗?这样的话我们就有4个抽象类了。AbstractFlyableTweetableBird、AbstractFlyableUnTweetableBird、
AbstractUnFlyableTweetableBird、AbstractUnFlyableUnTweetableBird
要是再去细分的话,比如是否会下蛋,就是八个,再细分就是十六个…最终导致组合爆炸…
这就是继承层次太深导致的问题。
遇到这种情况,我们用接口来解决该问题。
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() { //...
}
}
这里有一个问题,如果每个接口中鸟类实现飞和叫和下蛋的代码是一样的话,这样就会导致重复的代码出现多次。
我们可以这样解决,让一个类去实现接口,之后实际的鸟类利用这个接口去完成这个动作。
比如,让一个Flyability类实现Fiyable接口,再将这个类组合在实际的会飞的鸟类中,鸟类在飞方法中调用这个Flyability类中的飞方法,这样就实现了代码的复用。
这叫做接口,组合,加委托的方法。
代码如下
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(); // 委托
}
}
源示例来自《设计模式之美》极客时间王争 博文对源程序进行了删减