面向对象三大特性—封装、继承和多态详解

类的抽象与封装

类的抽象是将类的实现和使用分离,而类的封装是将实现的细节封装起来并且对用户隐藏,用户只需会用就行。

以电脑为例,电脑包含了许多组件——显卡、内存、磁盘、CPU等等。每一个组件都可以单独看作是一个对象。要使这些组件一起工作,只需要了解这些对象该怎么用,以及如何和其他组件交互。 至于组件是怎么实现的,无须了解。例如String、Math、System甚至是我们自定义的类,只要了解该类怎么使用,就能将它们应用在我们的类中,而无须关心它们的实现细节。

 

面向对象三大特性

封装(encapsulation)

封装的本质就是通过定义类,并且给类的属性和方法加上访问权限,用于控制在程序中属性的读和修改的访问级别。  

口头理解就是:通过访问权限控制,隐藏重要数据,避免随便修改数据。只对外暴露不会影响类的数据安全的公共方法。

访问权限修饰符的作用域范围从大到小:public --> proetected --> default --> private

public//修饰之后,能被所有类访问,访问权限最宽松。

protected  //作用域在同类、同包和子类中,protected使用最多场景就是父类方法给子类重写。

default    //作用域只在同类和同包中。未使用权限修饰符,默认使用。

private   //作用域只在同类中。常用的就是成员变量私有化,只提供getter和setter方法来访问,保证安全性;将构造器私有化,禁止随意创建对象,工厂模式或者单例模式中有用到。

注意:当父类的默认构造方法设为私有时,会导致子类的构造方法无法调用父类构造器,引发编译错误。这涉及“构造方法调用流程”,会在继承中有所说明。

import 与 package 关键字的区别

package 表明当前类属于那个包空间,永远放在第一行。 import是用于导入其他包的类。 例如日期类Date,Date属于java.util这个包中,使用时需要导入Date类的包目录,编译器才能找到该类所处位置。或者使用全目录名称来创建对象,无需导入包。

        //不导入Date的包目录,采用全缀来创建日期类对象
        java.util.Date d = new java.util.Date();

全名称来创建对象一般用得很少,除非是不同包的同名对象, 为了方便区别。例如java.sql.Date  和 java.util.Date。

注意:java.lang包的类默认导入,无须import显式导入。该包中都是java的核心类,包括但不限于:String、System、Math。

继承(extends)

从已存在的类中定义新的类,实现代码复用,这称为继承。继承在软件重用方面是一个重要且功能强大的特征。 
通过继承可以定义一个通用的类(父类),之后在将其扩展为一个更加特定的类(子类)。  
 
继承使用场景
类与对象的关系:一个类具有同一类型的对象的属性和行为。
类与类的关系:有的时候,不同类之间也有一些共同的特性和行为,将这些共同的特性和行为提取出来,统一放在一个类中,作为一个通用类。这时可以定义特定的类继承该通用类, 这些特定的类继承通用类的可访问数据域和方法。
 
继承语法:使用extends关键字。  public subClass extends SuperClass{}
用下面例子来体现继承的作用。(也可以自行去设计类来体现继承)
//定义一个通用动物类
public class Animal {
    protected String animalName; //动物名称
    protected String kinds; //种类
    protected String food; //食物
    protected boolean sleep = false; //睡眠状态,默认没睡
    public Animal() {}
    public Animal(String animalName, String kinds,
                  String food, boolean sleep) {
        super();
        this.animalName = animalName;
        this.kinds = kinds;
        this.food = food;
        this.sleep = sleep;
    }
    //动物都有进食的行为
    public void eat() {
        System.out.println(animalName + "喜欢吃" + food);
    }
    //获取动物睡眠状态
    public boolean isSleep() {
        return sleep;
    }
    //动物对象的信息
    @Override
    public String toString() {
        return "动物名:" + animalName + ",种类:" + kinds
                + ",食物:" + food;
    }
    //访问器和修改器代码过多,这里省略,测试时请自行添加
}
//定义子类乌龟类,继承Animal类
public class Tortoise extends Animal{
    private double shellSize;//龟壳大小
    //默认无参构造方法
    public Tortoise() {} 
    //使用父类构造器来简化初始化操作
    public Tortoise(String animalName, String kinds, String food, 
                    boolean sleep, double shellSize) {
        super(animalName, kinds, food, sleep);
        this.shellSize = shellSize;
    }
    
    //缩进龟壳
    public void intoTheShell() { /*省略实现细节*/ }
    
    //乌龟类特有属性的访问器和修改器
    public double getShellSize() {
        return shellSize;
    }
    public void setShellSize(double shellSize) {
        this.shellSize = shellSize;
    }
    //继承自Animal类的方法,显式给出和隐藏时的无区别
    @Override
    public String getFood() {
        return super.getFood();
    }
}
//定义子类老虎类,继承Animal类
public class Tiger extends Animal{
    
    public Tiger() {}
    public Tiger(String animalName, String kinds, 
                 String food, boolean sleep) {
        super(animalName, kinds, food, sleep);
    }
    /**除了动物共有行为外,老虎还有自己的行为 */
    //扑咬某个动物
    public void bite(Animal a) { /*省略实现细节*/ }
    
    //爬树
    public void climbTree() { /*省略实现细节*/ }
    
}
 
 
 

上面的例子中,定义一个通用类Animal, 定义了两个子类分别是Tiger和Tortoise。 如果不使用继承,那么就有可能要为Tiger和Tortoise类定义一堆共同的属性和行为,会导致类的设计极为复杂,如果这样的类越来越多,那么会极难维护。但通过继承,我们将不同类中的共同属性和行为统一到一个类,要修改这些属性和行为只需要修改父类即可。 而且子类还能在这基础上扩展自己的特性和行为。

使用继承的注意点

  • 父类的私有数据域和方法无法被继承,因为private修饰的变量和方法仅限于类的内部。 但我们可以通过父类提供的公共访问器/修改器来进行访问和修改。
  • 和传统的理解不同,子类并不是父类的子集。实际上,一个子类通常比父类包含更多的数据和方法。 子类是对父类的扩展,父类仅提供通用数据域和方法。
  • 继承表示“是一种”(is-a)关系,即一个父类与它的子类之间必须存在“是一种”关系。例如老虎是动物,乌龟是动物等等。
  • Java只支持单继承,即一个类只能继承一个父类。然而,要实现多重继承可以通过接口来完成。

super关键字与构造方法链

 super关键字指代父类,可用于调用父类中的普通方法和构造方法。而this则是对调用对象的引用。

首先要知道,父类的构造方法不会被子类继承,它们只能使用关键字super来进行调用。super()调用父类的无参构造方法,super(parameter)调用匹配参数的父类构造方法。super调用的构造方法必须在子类构造方法中第一行,这是显式调用父类构造方法的唯一方式。

构造方法链

构造方法可以调用重载的构造方法或父类的构造方法来简化初始化数据域的操作,若是它们都没有被显式调用,编译器会自动将super()作为构造方法的第一条语句。

    //左边等价于右边
    public Son() {                    public Son() {                          
                                        super();
    }                                }
    
    //有参构造方法
    public Son(int b) {                public Son(int b) {                                        
        System.out.println(b);            super();                                            
    }                                    System.out.println(b);
                                    }

这意味着在任何情况下,创建一个类的实例时,将会调用沿着继承链的所有父类的构造方法。调用流程: 子类创建对象时,子类构造方法在完成自己的任务之前,调用第一行的匹配参数的父类构造方法。若父类还继承了其他类,父类的构造方法在完成自己的任务之前,调用该父类的父类构造方法。这一过程直到整个继承体系结构的最后一个构造方法调用完成,然后向下执行完它们的构造方法。这就是“构造方法链”。

public class Son extends Father{
    public Son() {
        //super(); 调用该构造器时,默认添加super()
        System.out.println("Son-无参构造");
    }
    //重载构造
    public Son(int a) {
        this(); //调用当前类的无参构造
        System.out.println("Son-有参构造");
    }
    
    public static void main(String[] args) {
        Son s = new Son(10);
    }
}
class Father extends GrandFather{
    public Father() {
        System.out.println("Father");
    }
}
class GrandFather{
    public GrandFather() {
        System.out.println("GrandFather");
    }
}
执行上面的代码后,显示结果
GrandFather
Father
Son-无参构造
Son-有参构造

【技巧】: 因为构造方法链的存在,所以设计一个父类时最好显式的提供一个无参构造方法,用于避免子类继承父类时,引发的编译错误。

 关于super调用父类普通方法的问题

super只能调用子类继承的可访问数据域和方法,父类的私有数据域和方法无法被继承,自然不能调用。

方法重写

子类从父类中继承方法,但有时候,需要根据子类的需求来修改父类的方法实现,这称为方法重写,也叫方法覆盖。

要重写一个方法,前提是子类的方法和父类的方法使用同样的方法签名和返回值类型。 而方法重载是关注于方法签名,两者着重点不同。以Object类为例, Object类是基类,Java中所有的类包括自定义类都默认继承Object类。下面重写Object类的toString()方法:

public class Test { //可以看做 public class extends Object
    @Override
    public String toString() {
//        return super.toString();
        return "修改实现,实现重写";
    }
}

在重写一个方法时,最好加上一个注解: @Override,一般的IDE工具都会自动生成。 被标注的方法必须重写父类的一个方法,并检查重写规则。使用该注解可以避免很多错误。

方法重写的注意点

  •  静态方法可以被继承,但不能被覆盖。因为静态方法属于类本身,若子类定义了同名的静态方法,则与父类的静态方法毫无关联。 要调用父类同名静态方法:父类名.静态方法。
  • 只有实例方法是可访问(即非私有),它才能被子类进行方法覆盖。

方法重写是多态实现的重要手段,可以说继承就是为了多态而准备的,继承就是多态实现的前提。

多态(Ploymorphism)

多态就是同一事物的多种不同形态。简单的说就是,做同样的事,但根据实际对象来决定具体的实现。例如,学生去上课,有的学生上高数,有的学生上计算机课,有的上英语课;叫每个学生都看书,有的学生看历史书,有的看文学,有的看技术类书籍等等。

实现多态的前提:类与类之间建立继承关系;子类对父类的方法进行重写;父类变量指向子类对象。

多态的作用:消除了类型之间的耦合关系,允许将多种类型(同一基类)视为同一类型来处理,同一份代码也就可以毫无差别地运行在这些不同类型之上,使得父类型的引用变量可以引用子类型的对象。

首先,理解父类型和子类型的概念。一个类实际上是一种类型,在继承关系中,子类定义的类型称为子类型,父类定义的类型称为父类型。所有子类的实例都是其父类的实例,但反过来则不成立。这就是为什么父类型变量可以指向子类对象。下面使用案例展示多态:

public class Letter {                            class A extends Letter{                            class B extends Letter{
    public void info() {                            @Override                                        @Override    
        System.out.println("show Letter");            public void info() {                            public void info() {
    }                                                    System.out.println("show A");                    System.out.println("show B");
}                                                    }                                                }
                                                 }                                                 }
public class Test {
    public static void main(String[] args) {
     //父类型变量指向子类对象 Letter a = new A(); Letter b = new B(); test(a); test(b); } //接收一个Letter对象参数 public static void test(Letter letter) { letter.info(); } }
show A
show B

test方法接收Letter对象参数,因为多态允许将同一基类的类型进行统一处理,在使用父类对象的地方都可以使用子类对象。这里虽然传递的是Letter类型,但指向的却是该类型的某个子类对象。所以同一个方法却显示不同结果。

多态的实现原理——动态绑定

一个变量必须声明为某种类型,变量的类型称为它的声明类型。如果是一个引用类型变量,那么它可以是一个null值或者是对该声明类型实例的引用,这里的实例可以是该声明类型也可以是它的子类型。但变量的实际类型是引用变量引用的对象的类。所以调用哪一个方法实际上是由实际类型所决定。这就是动态绑定。 以Object为例:

       //声明类型       实际类型
        Object s = new String("ABC");
        System.out.println(s.toString()); //ABC
        Object date = new Date();
        System.out.println(date.toString()); // Sun Mar 11 00:30:57 CST 2018

动态绑定,即运行时才会绑定某个方法。所以编译时,编译器只能匹配声明类型的类所具有的方法(即父类的方法),而无法直接调用子类的特有方法。这也是为什么必须要对父类方法重写,若不重写,则多态实现无意义,因为大家都是一样的。

类型转换问题

        Object s = new String("ABC");
        String str = (String) s; //无法直接赋值,需要强制转换

虽然s变量的实际类型是String,但在编译时,编译器只知道s变量是Object类型,所以需要强制转换。但如果是一个子类实例,则总是可以将它转换成一个父类实例,因为子类实例永远是父类的实例。这称为向上转换。向上转换无须强制指名类型。但如果将父类实例转成子类类型,则需要显式的类型转换。

有时候,要转换的实例并非对应类型的实例,这会导致ClassCastException异常,为了避免这种情况,采用instanceOf运算符来判断一个对象是否是另一个对象的实例。

        Object s = new String("ABC");
        String str;
        //确保指定对象是否是目标类的实例
        if(s instanceof Object) {
            str = (String)s;
        }

附上一张关于面向对象知识总结的思维导图,方便理解。

 
 
原文地址:https://www.cnblogs.com/fwnboke/p/8529437.html