目录
前言
包
包访问权限
继承(extends)
protected关键字
final
组合
多态
向上转型
方法传参
方法返回
动态绑定
方法重写
重载和重写的区别:
在构造方法中调用重写的方法(一个坑)
理解多态
向下转型
抽象类
接口
总结
我们都知道java是一门面向对象的语言,但它具体又是通过怎样实现的呢?
本文将详细介绍java语言面向对象编程的实现。
包(package)是组织类的一种方式,使用包的主要目的是保证类的唯一性.
我们知道如果在一个java文件下写了两个同名的类,编译器会报错。
但如果我们在不同的包下面写入相同的类名却不会报错。
java已经提供了许多现成的类供我们使用。如下图所示,util是一个包,Arrays是包中的一个类,tostring是类下面的一个静态方法,因此可以直接通过类名调用。
要注意的是导入只能导入包中一个具体的类,而不能直接导入一个包。
我们有时也看到过这种写法java.util.*,这种 “ * ”叫做通配符,当你调用该包中的类的方法时,他才会导入类(很nice),与c语言的include不同,include直接导入该文件下的所有方法。
但是我们一般建议直接写出调用的类。因为通配符容易犯错。
java.sql 中的类 java.sql.Date 和 java.util 中的类 java.util.Date 都匹配 ,编译器不知道你要调用哪个,因此报错了。 正确的写法是导入java.sql.Date或java.util.Date。
但如果你导入两个类名中都有的date,编译器同样会报错哦。
当我们创建一个包时,会在对应的文件夹中产生一个包的文件夹,而在该包中创建的类也会出现在该文件夹下。
常见的系统包1. java.lang: 系统常用基础类 (String 、 Object), 此包从 JDK1.1 后自动导入。2. java.lang.reflflect:java 反射编程包 ;3. java.net: 进行网络编程开发包。4. java.sql: 进行数据库开发的支持包。5. java.util: 是 java 提供的工具程序包。 ( 集合类等 ) 非常重要
总结:包是我们面向对象编程的一个重要方式,可以实现分工合作及简化我们的操作。
因为我们还没给name赋予初值,所以String类型默认为null。
但当我们为父类构造方法后,发现子类报错了,这又是为啥呢?
原来,子类在继承父类时,会默认调用父类的无参数的构造方法,而当父类没有写自己的构造方法时,编译器会默认生成一个不带参数的构造方法,如下图:
而父类写了一个构造方法后,编译器便不会再生成一个默认的无参构造方法(也就是父类里没有了无参的构造方法),而子类默认调用的又是父类的无参构造方法,于是编译器就报错了。于是我们得出了一个重要结论:子类构造的同时,要先帮助父类进行构造。
那子类什么时候构造,在构造方法之后就构造完成。
也就是说,如果在子类构造方法里,必须将super放在第一行,突出"先".
说了这么多,那么super是干什么的呢。
super其实就是让编译器确定你要用的是父类的哪个构造方法,而且调用父类的构造方法要与子类的形参类型对应上。 下图这样就不行。
如果子类里又重新定义了一个与父类重名的字段或方法又会怎么样呢?
我们发现当子类与父类有重名时,会优先调用自己类中的字段或方法。
当我们在name前加入super修饰时,他就会调用父类的name。
super修饰:
前文说了非private修饰的字段或方法在子类中是可以使用的,但将字段或方法设置为public或友好型(包访问权限)都不符合我们“封装(private修饰)”的初衷,因为如果子类不在同一个包下,那么子类的继承限制就太多。此时protect关键字就应运而生。
protect:①对于类的调用者来说, protected 修饰的字段和方法是不能访问的
小结:
什么时候下用哪一种呢?
我们希望类要尽量做到 "封装", 即隐藏内部实现细节, 只暴露出 必要 的信息给类的调用者.
因此我们在使用的时候应该尽可能的使用 比较严格 的访问权限. 例如如果一个方法能用 private, 就尽量不要用 public.
另外, 还有一种 简单粗暴 的做法: 将所有的字段设为 private, 将所有的方法设为 public. 不过这种方式属于是对访问权限的滥用, 还是更希望我们能写代码的时候认真思考, 该类提供的字段方法到底给 "谁" 使用(是类内部自己用, 还是类的调用者使用, 还是子类使用).
更复杂的继承关系
public animal{
}
public Vegetarian animal extends animal{
}
public cattle extends Vegetarian animal{
}
...........
//等等
如刚才这样的继承方式称为多层继承, 即子类还可以进一步的再派生出新的子类.
时刻牢记, 我们写的类是现实事物的抽象. 而我们真正在公司中所遇到的项目往往业务比较复杂, 可能会涉及到一系列复杂的概念, 都需要我们使用代码来表示, 所以我们真实项目中所写的类也会有很多. 类之间的关系也会更加复杂.
但是即使如此, 我们并不希望类之间的继承层次太复杂. 一般我们不希望出现超过三层的继承关系. 如果继承层次太多, 就需要考虑对代码进行重构了.
如果想从语法上进行限制继承, 就可以使用 final 关键字。
我们曾经学过final修饰字段时,表示常量不能被修改。
final int a = 10;
a = 20; // 编译出错
final 关键字也能修饰类, 此时表示被修饰的类就不能被继承.
final public class Animal {
...
}
public class Bird extends Animal {
...
}
// 编译出错
final 关键字的功能是 限制 类被继承
"限制" 这件事情意味着 "不灵活". 在编程中, 灵活往往不见得是一件好事. 灵活可能意味着更容易出错.
是用 final 修饰的类被继承的时候, 就会编译报错, 此时就可以提示我们这样的继承是有悖这个类设计的初衷的.
我们定义一个String类型,将鼠标放到String上,按住ctrl,在点击一下String,就能看到如下画面
这说明我们平时是用的 String 字符串类, 就是用 final 修饰的, 不能被继承.
和继承类似, 组合也是一种表达类之间关系的方式, 也是能够达到代码重用的效果.
例如表示一个学校:
public class Student {
...
}
public class Teacher {
...
}
public class School {
public Student[] students;
public Teacher[] teachers;
}
组合并没有涉及到特殊的语法(诸如 extends 这样的关键字), 仅仅是将一个类的实例作为另外一个类的字段.
这是我们设计类的一种常用方式之一.
组合表示 “ has - a”的关系,在刚才的例子中, 我们可以理解成一个学校中 "包含" 若干学生和教师.
继承表示“ is - a”的关系,比如之前的狗是一个动物。
在刚才的例子中, 我们写了形如下面的代码
Bird bird = new Bird("hehe");
这个代码也可以写成这个样子
Bird bird = new Bird("hehe");
Animal bird2 = bird;
// 或者写成下面的方式
Animal bird2 = new Bird("hehe");
此时 bird2 是一个父类 (Animal) 的引用, 指向一个子类 (Bird) 的实例. 这种写法称为 向上转型.
至于为啥叫 "向上转型"?
通过查阅资料得知:在面向对象程序设计中, 针对一些复杂的场景(很多类, 很复杂的继承关系), 程序猿会画一种 UML 图的方式来表示类之间的关系. 此时父类通常画在子类的上方. 所以我们就称为 "向上转型" , 表示往父类的方向转.
UML图:
向上转型发生的时机:
直接赋值
方法传参
方法返回
public class Test {
public static void feed(animal animal) {
animal.eat();
}
public static void main(String[] args) {
bird bird = new bird("haha",19);
feed(bird);
}
}
此时形参 animal 的类型是 Animal (基类), 实际上对应到 Bird (父类) 的实例.
public static animal findMyAnimal() {
bird bird = new bird("haha",19);
return bird;
}
animal animal = findMyAnimal();
System.out.println(animal.name);
System.out.println(animal.age);
此时方法 findMyAnimal 返回的是一个 Animal 类型的引用, 但是实际上对应到 Bird 的实例.
当我们写入了如下一段代码,并且在bird类之中添加一个eat方法,结果会怎么样呢?
animal animal1 = new animal("圆圆",19);
animal animal2 = new bird("团团",20);
animal1.eat();
animal2.eat();
此时, 我们发现:animal1 和 animal2 虽然都是 animal 类型的引用, 但是 animal1 指向 animal 类型的实例, animal2 指向bird 类型的实例.
针对 animal1 和 animal2 分别调用 eat 方法, 发现 animal1.eat() 实际调用了父类的方法, 而
animal2.eat() 实际调用了子类的方法.
因此, 在 Java 中, 调用某个类的方法, 究竟执行了哪段代码 (是父类方法的代码还是子类方法的代码) , 要看究竟这个引用指向的是父类对象还是子类对象. 这个过程是程序运行时决定的(而不是编译期), 因此称为 动态绑定.
我们再通过反汇编理解为啥叫动态绑定:
在反编译代码的主函数中我们发现两个调用的还都是父类的eat方法,但运行后,却有一个调用的是子类的eat,也就是说编译器在编译时还不能完全确定调用的是哪种方法,而是在运行时完全确定的。
针对刚才的 eat 方法来说:子类实现父类的同名方法, 并且参数的类型和个数完全相同, 这种情况称为 覆写/重写/覆盖(Override).关于重写的注意事项
1.重写和重载完全不一样. 不要混淆(思考一下, 重载的规则是啥?)
2.普通方法可以重写, static 修饰的静态方法不能重写.
3.重写中子类的方法的访问权限不能低于父类的方法访问权限.
4.重写的方法返回值类型不一定和父类的方法相同(但是建议最好写成相同, 特殊情况除外).
//父类eat方法为public
public void eat(){
System.out.println(name+"正在吃饭");
}
//子类eat方法为private
private void eat(){
System.out.println("我是一只小鸟"+super.name+"正在吃饭");
}
当父类得到方法为public时,子类重写的方法也只能是public
另外, 针对重写的方法, 可以使用 @Override 注解来显式指定
public class Bird extends Animal {
@Override
private void eat(String food) {
...
}
}
有了这个注解能帮我们进行一些合法性校验 . 例如不小心将方法名字拼写错了 ( 比如写成 aet),此时编译器就会发现父类中没有 aet 方法 , 就会编译报错 , 提示无法构成重写 .
事实上, 方法重写是 Java 语法层次上的规则, 而动态绑定是方法重写这个语法规则的底层实现. 两者本质上描述的是相同的事情, 只是侧重点不同.
当我们写了如下一段代码后
class B {
public B() {
func();
}
public void func() {
System.out.println("B.func()");
}
}
class D extends B {
private int num = 1;
@Override
public void func() {
System.out.println("D.func() " + num);
}
}
public class test4 {
public static void main(String[] args) {
D d = new D();
}
}
D.func()结果应该很容易想出来,但后面的0 又是怎么回事呢?
构造 D 对象的同时, 会调用 B 的构造方法.B 的构造方法中调用了 func 方法, 此时又会触发动态绑定, 会调用到 D 中的 func ,而又因为此时 D 对象自身还没有构造(还没有完全实例化), 此时 num 处在未初始化的状态, 值为 0.
只有当完全实例化之后,也就是说生成了对象后,num的值才会初始化。
结论: "用尽量简单的方式使对象进入可工作状态", 尽量不要在构造器中调用方法(如果这个方法被子类重写, 就会触发动态绑定, 但是此时子类对象还没构造完成), 可能会出现一些隐藏的但是又极难发现的问题.
有了上面的向上转型, 动态绑定, 方法重写之后, 我们就可以使用 多态(polypeptide) 的形式来设计程序了.我们可以写一些只关注父类的代码, 就能够同时兼容各种子类的情况.
代码示例: 打印多种形状
class Shape {
public void draw() {
// 啥都不用干
}
}
class Cycle extends Shape {
@Override
public void draw() {
System.out.println("○");
}
}
class Rect extends Shape {
@Override
public void draw() {
System.out.println("□");
}
}
class Flower extends Shape {
@Override
public void draw() {
System.out.println("♣");
}
}
public class Test {
public static void main(String[] args) {
Shape shape1 = new Flower();
Shape shape2 = new Cycle();
Shape shape3 = new Rect();
drawMap(shape1);
drawMap(shape2);
drawMap(shape3);
}
// 打印单个图形
public static void drawMap(Shape shape) {
shape.draw();
}
}
对于上述代码,Test类以上的代码是类的实现者编写的,而Test是类的调用者编写的。
当类的调用者在编写 drawMap 这个方法的时候, 参数类型为 Shape (父类), 此时在该方法内部并不知道, 也不关注当前的 shape 引用指向的是哪个类型(哪个子类)的实例. 此时 shape 这个引用调用 draw 方法可能会有多种不同的表现(和 shape 对应的实例相关), 这种行为就称为 多态.
也就是说,类的调用者不必注重方法或属性的实现,只需知道这个事物具有这一属性或方法,就能通过该事物的父类直接调用该方法或属性。
多态顾名思义, 就是 "一个引用, 能表现出多种不同形态"
举个具体的例子. 小明家里养了两只鹦鹉(圆圆和扁扁)和一个小孩(核弹). 小明妈妈管他们都叫 "儿子". 这时候小明爸爸对小明妈妈说, "你去喂喂你儿子去". 那么如果这里的 "儿子" 指的是鹦鹉, 小明妈妈就要喂鸟粮; 如果这里的 "儿子" 指的是核弹, 小明妈妈就要喂馒头.
那么如何确定这里的 "儿子" 具体指的是啥? 那就是根据小明爸爸和小明妈妈对话之间的 "上下文".
代码中的多态也是如此. 一个引用到底是指向父类对象, 还是某个子类对象(可能有多个), 也是要根据上下文的代码来确定.
使用多态的好处是什么?
- 类调用者对类的使用成本进一步降低.
封装是让类的调用者不需要知道类的实现细节.
多态能让类的调用者连这个类的类型是什么都不必知道, 只需要知道这个对象具有某个方法即可.
因此, 多态可以理解成是封装的更进一步, 让类调用者对类的使用成本进一步降低.
2.可扩展能力更强.
如果要新增一种新的形状, 使用多态的方式代码改动成本也比较低.
class Triangle extends Shape { @Override public void draw() { System.out.println("△"); } }
对于类的调用者来说(drawShapes方法), 只要创建一个新类的实例就可以了, 改动成本很低.
而对于不用多态的情况, 就要把 drawShapes 中的 if - else 进行一定的修改, 改动成本更高.
向上转型是子类对象转成父类对象, 向下转型就是父类对象转成子类对象. 相比于向上转型来说, 向下转型没那么常见,但是也有一定的用途.
当我们写了这么一串代码时,eat方法发生了动态绑定,fly方法直接找不到了?
编译过程中, animal 的类型是 Animal, 此时编译器只知道这个类中有一个 eat 方法, 没有 fly 方法.
虽然 animal 实际引用的是一个 Bird 对象, 但是编译器是以 animal 的类型来查看有哪些方法的.
那么想实现fly的方法就需要向下转型:
bird bird = (bird)animal;
bird.fly();
但是这样的向下转型有时是不太可靠的. 例如
animal animal = new Dog("haha",10);
animal.eat();
bird bird = (bird) animal;
bird.fly();
animal 本质上引用的是一个 Dog 对象, 是不能转成 Bird 对象的. 运行时就会抛出异常.
所以, 为了让向下转型更安全, 我们可以先判定一下看看 animal 本质上是不是一个 Bird 实例, 再来转换。
Animal animal = new Cat("小猫");
if (animal instanceof Bird) {
Bird bird = (Bird)animal;
bird.fly();
}
instanceof 可以判定一个引用是否是某个类的实例. 如果是, 则返回 true. 这时再进行向下转型就比较安全了.
在刚才的打印图形例子中, 我们发现, 父类 Shape 中的 draw 方法好像并没有什么实际工作, 主要的绘制图形都是由 Shape 的各种子类的 draw 方法来完成的. 像这种没有实际工作的方法, 我们可以把它设计成一个 抽象方法(abstract method), 包含抽象方法的类我们称为 抽象类(abstract class).
我们将其draw设置为抽象方法,抽象方法在抽象类中可以不用实现,而只要一个类中有了抽象方法,他就必须是一个抽象类 。
那么抽象类有什么作用呢?
抽象类存在的最大意义就是为了被继承.
抽象类本身不能被实例化, 要想使用, 只能创建该抽象类的子类. 然后让子类重写抽象类中的抽象方法.
一个普通子类继承了一个抽象类为父类时,必须重写父类的抽象方法。
一旦普通子类继承了抽象父类不重写抽象方法就会报错:
说到这,肯定很多人会想,普通的类也可以被继承呀, 普通的方法也可以被重写呀, 为啥非得用抽象类和抽象方法呢?这抽象类不是没有存在的意义了吗?
答案是:不是的,虽然普通类也能继承重写,但使用抽象类相当于多了一重编译器的校验.使用抽象类的场景就如上面的代码, 实际工作不应该由父类完成, 而应由子类完成. 那么此时如果不小心误用成父类了,使用普通类编译器是不会报错的. 但是父类是抽象类就会在实例化的时候提示错误, 让我们尽早发现问题.
很多语法存在的意义都是为了 "预防出错", 例如我们曾经用过的 final 也是类似. 创建的变量用户不去修改, 不就相当于常量嘛? 但是加上 final 能够在不小心误修改的时候, 让编译器及时提醒我们.
在刚才的抽象类中,若我们只定义一个抽象方法,那么这个抽象类是否是多余的呢,或者说是否有更简易的方法了呢?
这就引到了我们的接口。接口是抽象类的更进一步. 抽象类中还可以包含非抽象方法, 和字段. 而接口中包含的方法都是抽象方法, 字段只能包含静态常量.接口既然是抽象类的更进一步,那么它肯定也不能被实例化。
我们简单实现了一个接口,发现它也能进行向下转型和动态绑定。
接口中也能写普通方法,但需要通过关键字default修饰,default是默认的意思
在编译器中我们发现public abstract是灰色的,这代表默认类型,也就是说我们不写也是默认这个类型。
但是这又很容易犯一个错了,我们子类重写这个draw方法时也忘记加上public,默认是包访问权限,低于public级,这就不符合重写的规则了。
当然了,接口中也可以实现静态方法:
一个类与接口是通过implements关键字连接的, implements可以看做是继承也可以叫实现。
当我们在一个接口中定义一个字段时,发现它报错了,原因是接口中的所有字段时默认被public static final修饰的,final意味着不可被修改,所以它在一开始就需要进行赋值。
一个类可以实现许多接口,但是需要重写接口里的所有抽象方法,接口与接口之间用逗号分开。
还可以在继承的基础上在实现接口:但是顺序不能改变,继承在前,接口在后。
那么接口与接口之间又会怎样呢?
我们发现接口与接口之间可以通过extends连接,意味“扩展”,一个接口也只能扩展一个接口。
一个类继承了一个有扩展的接口后不但要实现该接口类的抽象方法,还要实现扩展接口类的抽象方法(无限套娃)。
例子
当一个类实现了这个接口后(现实生活是具有该接口的能力),只要实现了接口,这个方法的参数都能接收该事物 。
有了接口之后, 类的使用者就不必关注具体类型, 而只关注某个类是否具备某种能力.与抽象类有点相似又在它之上。
总结:
正是因为java中各种各样的包、接口,多种继承、多态,才造就了java面向对象的三大特性:封装性,继承性,多态性。用户只需使用该功能而无需考虑如何实现它,这才是Java令人着迷的地方啊!