设计思想:封装、抽象、继承、多态

1,765 阅读9分钟

面对对象编程与面对对象语言的四大特性:封装、抽象、继承、多态。一般来说,我们知道这些特性,但是却不知道怎么使用,为什么要使用。接下来,我们一起去解决这些问题。

封装

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

那究竟什么是封装呢?我们通过简单的例子来描述一下封装特性。

下面是一段游戏装备强化的代码实现,在玩家获得装备时,我们为每个装备对象创建 Equip 对象,以此保存每个装备的数据。


/**
* 装备
*/
public class Equip {
    // 装备流水id
    private int id;
    // 装备配置id
    private int configId;
    // 强化时刻
    private long upgradeLevelTime;
    // 强化等级
    private int level;
    // 更多属性
    ...
    
    private Equip() {}
    
    public Equip(int configId) {
        this.id = IdGenerator.obtainId();
        this.configId = configId;
        this.level = 1;
    }
    
    /**
    * 装备流水id
    */
    public int getId() { 
        return id; 
    }
    
    /**
    * 装备配置id
    */
    public int getConfigId() { 
        return configId; 
    }
    
    /**
    * 强化时刻
    */
    public long getUpgradeLevelTime() { 
        return upgradeLevelTime; 
    }
    
    /**
    * 获得强化等级
    */
    public int getLevel() { 
        return level; 
    }
    
    /**
    * 强化
    * @return 强化是否成功
    */
    public boolean upgradeLevel() {
        // 判断是否能强化到下一个等级
        ...
        
        // 强化成功
        this.level ++;
        this.upgradeLevelTime = System.currentTimeMillis();
        return true;
    }
    
    ...
}

从代码中,我们可以看到装备存在属性:

  • id 装备流水id
  • configId 装备配置id
  • upgradeLevelTime 强化时刻
  • level 强化等级

存在方法:

  • getId() 装备流水id
  • getConfigId() 装备配置id
  • getReceiveLevelTime() 强化时刻
  • getLevel() 获得强化等级
  • upgradeLevel() 强化

装备流水id、装备配置id,这些属性为什么就只有 get 方法呢?因为这些方法都在对象初始化构造时,在构造函数中进行赋值,固化数值,后续不允许修改,因此不应提供修改方法。

upgradeLevelTime 强化时刻、强化等级level 这俩个属性,皆只提供了 get 方法来获取数据,只通过强化方法 upgradeLevel 去修改强化等级,这又为什么呢?之所有这么设计,强化时刻和强化等级这俩个属性的变化,都在会发生在强化操作,因此不应该提供 set 方法修改方式。

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

什么是封装?如何进行封装?我们在上面都已经解决了。那封装到底解决了什么编程痛点呢?

如果类中属性的访问不做任何限制,任意代码皆可访问或者修改类中属性,虽然使用此类时更灵活了,但是这种“灵活”却要付出代价的,造成类的状态不可控,严重影响代码的 可读性、可维护性 。像上述的例子中,若upgradeLevelTime、level 都提供 set 方法,某个小伙伴在不了解的情况下,setUpgradeLevelTime(0L) ,手动设置了强化时刻,就造成强化时刻与强化等级数据不一致的情况。因强化时刻这个属性的设置,本身的修改就依赖于强化操作,此番操作势必为非法。

除此之外,类仅仅通过有限的方法暴露必要的操作,也能提高类的 易用性 。如果将所有的属性接口都暴露,除非调用者对此类的业务充分了解,否则很容易出错。这对调用者极不友好,为了降低出错的概率,应该只暴露真正需要的接口。假如我们现在拥有一个电水壶,水壶上有很多按钮开关,用户需要花很长的时间去研究说明书,最终也不一定能看懂,这样太闹腾了。对于用户,拿到一个电水壶,我只想插上电,开始煮水,能尽快喝到热水。简单的开、关按钮足以应对用户场景,效率高,用户体验好,出错率低。

抽象

封装解决的是隐藏信息和数据保护的问题,而抽象解决的是隐藏方法实现的问题。

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

我们可以通过下面的例子来看一下抽象:

public interface IUserService {

    /**
    * 登录
    */
    void login(User user);
    
    /**
    * 登出
    */
    void logout(User user);
    
    /**
    * 改名
    */
    void rename(User user);
}

public class UserService implement IUserService {

    @Override
    public void login(User user){
        //...
    }
    
    @Override
    public void logout(User user){
        //...
    }
    
    @Override
    public void rename(User user){
        //...
    }
}

抽象这一特性,并不一定要通过接口或者抽象类来实现。 即使不编写 IUserService 接口类,UserService 类本身也能满足抽象特性。因为,类的方法是通过编程语言中的“函数”这一语法机制来实现的。通过函数包裹具体的实现逻辑,这本身就是一种抽象。我们使用函数时,并不需要知道方法内部怎么实现,直接调用即可。

什么是抽象?如何进行抽象?我们在上面都已经解决了。那抽象到底解决了什么编程痛点呢?

我们去处理复杂的问题时,问题的处理实现细节没必要暴露出来给调用者,这时候我们需要做上层抽象,把非必要性的实现细节抽象出来,这样我们可以更好地关注功能,而非具体的实现的设计思路。我们在命名类的方法时,需要抽象思维,不能暴露太多的实现细节,以此确保后续变更内部逻辑时,不用再次修改方法定义。比如 getMySqlDBUrl() 就暴露了实现细节,后续假如将数据库改成 MongoDB ,方法定义与实现就不一致了,需要重新定义方法了,这里应该使用 getDBUrl() 更为合适。

在代码设计中,起到非常重要的指导作用。很多设计原则都体现了抽象这种设计思想,比如基于接口而非实现编程、开闭原则(对扩展开放、对修改关闭)、代码解耦(降低代码的耦合性)等。

继承

如果你熟悉的是类似 Java、C++ 这样的面向对象的编程语言,那你对继承这一特性,应该不陌生了。继承是用来表示类之间的 is-a 关系。

为了实现继承这个特性,编程语言需要提供特殊的语法机制来支持,比如 Java 使用 extends 关键字来实现继承,C++ 使用冒号(class B : public A),Python 使用 parentheses (),Ruby 使用 <。不过,有些编程语言只支持单继承,不支持多重继承,比如 Java、PHP、C#、Ruby 等,而有些编程语言既支持单重继承,也支持多重继承,比如 C++、Python、Perl 等。

什么是继承?如何进行继承?我们在上面都已经解决了。那继承到底解决了什么编程痛点呢?

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

继承很好理解,也很容易使用。不过,过度使用继承,继承层次过深过复杂,就会导致代码可读性、可维护性变差。部分人会认为继承是一种反模式,觉得应该少用,甚至不用。在继承层次过深的情况下,接口-组合-委托的方式会比继承更优,这就是“多用组合少用继承”的思想。

多态

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

我们通过下面的例子可以更好体会多态:

public interface Animal {
    
    void eat();
    
    void sleep();
}

public class Dog implement Animal {

    @Override
    public void eat(){
        //...
    }
    
    @Override
    public void sleep(){
        //...
    }
}

public class Bird implement Animal {

    @Override
    public void eat(){
        //...
    }
    
    @Override
    public void sleep(){
        //...
    }
}

public class Demo {
    public static void main(String[] args){
        Dog dog = new Dog();
        Bird bird = new Bird();
        
        eat(dog);
        sleep(bird);
    }
    
    private static void eat(Animal a) {
        a.eat();
        // ...
    }
    
    private static void sleep(Animal a) {
        a.sleep();
        // ...
    }
}

多态有3个机制:

  • 父类对象可以引用子类对象。Dog、Bird 皆可传递给 Animal;
  • 对象间为继承关系。Dog、Bird 皆继承 Animal;
  • 子类可以重写父类的方法。在本例中 Animal 为接口,也可以为抽象类。

我们通过不同的实现类型对象传递给 Animal ,在调用 Animal 的方法时,实质上是调用传递对象的方法实现逻辑。具体点,我们在调用 Animal#eat 时,方法内就会调用 Dog#eat 的实现逻辑。

什么是多态?如何进行多态?我们在上面都已经解决了。那多态到底解决了什么编程痛点呢?

多态提高了代码的可扩展性和复用性。

为什么可以提高可扩展性呢?我们看下上面的例子,Demo#eat 、Demo#sleep 都使用了一个方法,就可以处理来自 Dog 和 Bird 的调用,而且在后续增加 Animal 的继承类时,Demo#eat 、Demo#sleep 的实现逻辑皆不用改动,因此提高了代码的可扩展性。

为什么可以提高复用性呢?我们在执行不同 Animal 的 eat 方法时,不需要实现不同接收类型的函数(Demo#eat(Dog)、Demo#eat(Bird)),我们只需要一个函数处理即可(Demo#eat(Animal)),显然提高了代码的复用性。

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

回顾

  • 封装
    • what? 信息隐藏或者数据访问保护。
    • how? 通过语法机制就是访问权限控制,暴露有限的接口。
    • why? 可读性、可维护性、易用性。
  • 抽象
    • what? 隐藏方法的实现。
    • how? 通过接口类或者抽象类,非必须。
    • why? 隐藏实现细节,降低复杂度。
  • 继承
    • what? 表示类之间的 is-a 关系。
    • how? 通过特殊的语法机制来支持,比如 Java 使用 extends。
    • why? 代码复用。
  • 多态
    • what? 通过子类绑定父类,父类可以调用子类实现逻辑。
    • how? 提供特殊的语法机制。比如继承、接口类。
    • why? 提高了代码的可扩展性和复用性。