——“面向对象的三大特性是什么?”
——“封装、继承、多态。”

这大概是最常见、也最简单的面试题了,相信人人都知道答案。

但是,封装、继承、多态到底是什么?它们在面向对象中起到了什么样的作用呢?这个问题恐怕就没那么简单了。

封装

“封装”这个词由英文Encapsulate翻译而来。这个翻译颇有“信达雅”之风:“封装”者,“封”与“装”也。

所谓“封”,就是隐藏信息,把需要保密的数据、方法都隐藏在对象内部,不对外公开。

所谓“装”,就是把数据和方法都放到一起,是为“对象”。

图片

琥珀就是一种非常美的“封装”。

以一个简单POJO类为例:

public class Data{
    private String idNo;
    
    public Data(String idNo){
        this.idNo = idNo;
    }
    
    public String getIdNo(){
        return this.idNo;
    }
    
    private Date parseBirthday(String idNumber){
        if(idNumber.length() == 15){
            String birthdayStr = idNumber.substring(5,8);
            return DatUtils.parse(birthdayStr,"yyMMdd");
        }else if(idNumber.length() == 18){
            String birthdayStr = idNumber.substring(5,13);
            return DatUtils.parse(birthdayStr,"yyyyMMdd");
        }
        return null;        
    }

    public Date getBirthday(){
        if(this.idNo != null){
            return parseBirthday(idNo);
        }
        return null;
    }
}

在这段代码中,数据idNo和方法getBirthay()/parseBirtdhay(String)都被“装”在了Data这个对象中。而且,idNoparseBirthday(String)还被冠以private修饰符,由此被“封”了在Data对象中。这样一来,除了这个对象自己之外,其它对象都无法访问它们。

这就是“封装”的最基本的含义:把数据和方法“装”在一起,并且给其中私密的部分贴上“封”条,不容他人染指。 图片

还记得这个吗?装进去之后要及时贴上封条,不然大魔王就要逃出来了。图源网。

封装与面向对象

得益于封装,数据和方法才能够有机结合在一起。二者结合到一起,才有了“对象”和“类”:类是指“一组具有相同属性和行为的对象的抽象”,类中的“属性”就是其中的数据,而“行为”则是指它的方法。

类和对象是面向对象的砖瓦、螺钉。没有它们,面向对象根本无从谈起。以前面那个Data类为例:如果没有数据idNo,它只能算是一堆函数——也许可以叫做头文件;而没有那些方法,它只能算是一个全局变量——或者可以叫做结构体。

我们还可以用职位与员工的例子来理解封装。所谓“装”,就是明明不懂不会还要做出一副很懂很会的样子甚至有意无意地对别人鄙夷嘲讽一番员工为了完成本职工作,学习职业技能、准备办公用品。例如,一名员工需要掌握数据结构、算法、编程语言、系统设计等技能,还需要有一台安装了编程环境并能访问StatckOverflow的电脑,才能满足“开发工程师”的职位要求。

而“封”呢?“封”有一个前提:执行工作任务的人不想让别人干涉自己——开发不会愿意让产品来干涉自己怎么设计系统、怎么编写代码。为了减少这类影响,我们搬进了格子间、贴上了“防窥屏膜”;在工作对接和汇报时,我们也提倡“结果导向”,只接受“明天下班前提测”这类要求,而不会接受“必须由xxx来写这段代码”、“必须用机械键盘来写代码”这种要求。这些做法,其实就是“封”:保证完成工作职责,同时自己安排具体工作。

如果不这样“封装”,恐怕什么工作都做不了。脑子里没有“装”上足够的职业技能,工位上没有“装”上必要的工作设备,谁能开始工作?工作的时候老有人来打扰你,要么说你的代码不够优雅你的显示器不够清晰、要么找你修个打印机修个饮水机,谁能完成工作? 图片

在家办公的各位,相信都体会到工作时没人打扰的重要性了吧!图源网。

所以说,封装是面向对象的基础,是面向对象三大基础特性之一。没有封装,就没有对象,更没有面向对象。

封装与抽象

只要能“装”,就可以组成对象。但是,只“装”不“封”,无法构建良好的抽象。抽象的目的是隐藏底层细节。要做到这一点,就必须要有“封”,必须保证底层细节的私密性。如果谁都能操作对象内部的数据或者方法,谈何“隐藏细节”?

可见,如果说“装”是面向对象的基础,那么“封”就是抽象的基础。 图片

就像插线板一样:不仅能“装”下各种线路、元件,还要能“封”成简单的三脚插座、两脚插座。图源网。

以我们最常见的接口-实现类的结构为例。通常我们认为:接口定义就是“抽象”,而实现类就是“细节”。实际上,只有当接口能够“封”住底层实现,使得我们可以只关注接口,而不用关心接口下到底是哪个实现类、实现类内部到底如何处理时,我们才可以说:这个接口定义了一个良好的抽象。

诚然,这种类结构会给开发带来一点不便:看到了接口声明,却找不到使用了哪个实现类。在看到这一点的同时,我们也应该看到:这个不便利的另一面,是一个良好的业务抽象,以及这个抽象带来的高度可扩展性。

反过来说,如果一个接口不能把实现细节“封”起来,那么它就算不上一个好的“抽象”。例如,我们系统中有这样一个接口:

public interface PayService{
    PayResult pay(PayReq req);
}
public class BuyBizImpl implements BuyBiz{
    private PayService payService;
    public void buy(Order order){
        PayReq req = geneartePayReq(order);
        PayResult result = payService.pay(req);
        // 略
    }
}

这是一个支付接口,通过调用支付系统来支付订单金额。由于某些原因,支付系统切换到了一套新的服务上。而且,这次切换有一个过渡期,在过渡期内有一部分用户仍要使用原支付服务。

结果,这个接口和它的调用代码就变成了这样:

public interface PayService{
    PayResult pay(PayReq req);
    PayResult newPay(NewPayReq req);
    boolean useNewPay(Order order);
}
public class BuyBizImpl implements BuyBiz{
    private PayService payService;
    public void buy(Order order){   
        PayResult result;
        if(payService.useNewPay(order)){
            PayReq req = geneartePayReq(order); 
            result = payService.newPay(req);
        }else{
            NewPayReq req = genearteNewPayReq(order); 
            result = payService.pay(req);
        }
        // 略
    }
}

修改后的PayService接口完全没有“封”住底层实现,反而把实现细节完全暴露在外。显然,这样的接口并不能称之为合格的“抽象”。

封装与高内聚低耦合

从高内聚低耦合的角度来理解,显然,“装”是实现高内聚的手段,而“封”是低耦合的保证。

"高内聚"与"低耦合"是软件设计和开发中经常出现的一对概念。它们既是做好设计的途径,也是评价设计好坏的标准。
"高内聚"是说,一个业务应当尽量把它所涉及的功能和代码放到一个模块中。"低耦合"则是说,一个业务应当尽量减少对其它业务或功能模块的依赖。
——《高内聚与低耦合》

如果代码不能“装”,就不能把“涉及的功能和代码放到一个模块中”,自然就不能实现高内聚。如果代码不做好“封”,就无法阻止不同的业务模块相互依赖,也就不能实现低耦合。 图片

就像面包板互联一样:既不能把相关元件组装在一起、又不能把无关元件隔离开,最后就只能乱成一团。

例如,Java开发规范禁止把对象属性的可见性设置为public,就是通过“封”住对象属性来降低代码间的耦合度。我们可以看一个真实的例子:

public class User{
    public int age;
    public String idNo;
    // 其它,略
}

public class InsuranceService{
    public Insurance apply(User user){
        if(user.age <= 18 or user.age >= 80){
            throw new BizException();
        }
        // 其它,略
    }
}

这是我工作伊始写的一段代码。在这段代码中,User类把内部字段的可见性全部声明成了public。外部调用方也毫不客气地直接使用了这些字段,例如age

初看起来一切安好。直到有一天,我们发现用户会谎报年龄,从而绕开规则限制。为了解决这个问题,我们计划修改用户年龄的取值方式:不再让用户自己填写年龄,改为用户只填写身份证号,然后系统根据身份证号计算出他的年龄。

这个需求其实不大,却影响到了系统中几乎每一个角落,并导致了开发和测试的工作量暴涨。更糟糕的是,由于代码的改动和测试不到位,还引发了若干个线上bug……

这一切的罪魁祸首,就是User中的public int age,以及由这个public带来的模块间高度耦合。如果当初能够把这个字段“封”起来,这个需求只要不到十行代码就能搞定,也许半个小时就能开发完,肯定可以100%测试覆盖,不可能出什么线上bug:

public class User{
    private int age;
    private String idNo;
    public int getAge(){
        // 这是原先的取值方法
        // return this.age;
        // 这是修改后的取值方法,“不到十行”=。=
        return IdCardUtils.parseAge(this.idNo);
    }
}

public class InsuranceService{
    public Insurance apply(User user){
        if(user.getAge() <= 18 or user.getAge() >= 80){
            throw new BizException();
        }
        // 其它,略
    }
}

往期索引

《面向对象是什么》

从具体的语言和实现中抽离出来,面向对象思想究竟是什么?

《抽象》

抽象这个东西,说起来很抽象,其实很简单。

《高内聚与低耦合》

“高内聚”与“低耦合”是软件设计和开发中经常出现的一对概念。它们既是做好设计的方法论,也是评价设计好坏的标准。

《细说几种内聚》

高内聚和低耦合是很原则性、很“务虚”的概念。为了更好地将它们落地实践,我们有必要再多了解一些高内聚低耦合的度量标准。

《细说几种耦合》

高内聚和低耦合是很原则性、很“务虚”的概念。为了更好地将它们落地实践,我们有必要再多了解一些高内聚低耦合的度量标准。