🌈 个人主页:十二月的猫-CSDN博客
🔥 系列专栏: 🏀面向对象修炼手册💪🏻 十二月的寒冬阻挡不了春天的脚步,十二点的黑夜遮蔽不住黎明的曙光
目录
前言
1 多态
1.1 多态的形式(四种)
1.1.1 重载(专用多态):类型签名区分
签名:
基于类型签名的重载:
1.1.2 改写/重写(包含多态):子父类中,相同类型签名,相同函数名
改写和重载的对比:
改写与遮蔽的对比:
改写机制在不同语言中的区别:
两种不同的改写形式:
1.1.3 多态变量(复制多态):声明与包含不同
延迟方法:(抽象方法)
多态变量的四种形式:
1.1.4 泛型(模板) :创建通用工具
概念:
使用:
1.2 多态运行机制
1.2.1 解释
1.2.2 方法动态绑定过程
1.2.3 小练习
2 空间分配
2.1 内存分配方法
2.1.1 静态存储分配
2.1.2 动态存储分配
2.1.3 堆式存储分配
2.2 内存分配策略
2.2.1 最小静态空间分配
2.2.2 最大静态空间分配
2.2.3 动态内存空间分配
总结
前言
前面一讲,我们重点来讲了行为和多态。面向对象修炼手册(三)(行为与多态)(Java宝典)-CSDN博客
在行为中,分为动态行为和静态行为。静态行为和动态行为实施到不同的对象中又构成不同对象,例如静态类型/动态类型;静态类/动态类(静态类是指用于声明变量的类,动态类是指运行时需要动态绑定相关数值的类);静态方法/动态方法。
在多态中,先讲了三种子类父类之间的代码复用情况(三个重),包括重载、重写、重定义。其中重载发生在一个类中,重写发生在父类和子类之间(要求函数名、函数参数都相同),重定义发生在父类和子类之间(要求函数名相同,函数参数不相同)
这一讲,我们从多态的角度再深入分析一下多态,以及内存分配
1 多态
多态、封装和继承是面向对象语言共有的三大特性,其核心思想就是代码复用。封装为了是代码复用下的安全(防止复用时修改了原代码),多态和继承就是在封装的前提下要实现代码复用的两大手段
1.1 多态的形式(四种)
1.1.1 重载(专用多态):类型签名区分
- 重载是在编译时执行的,而改写是在运行时选择的。
- 重载是多态的一种很强大的形式。
- 非面向对象语言也支持。
签名:
函数类型签名是关于函数参数类型、参数顺序、参数数目和返回值类型的描述。
函数签名经常被用在函数重载解析中,因为调用重载的方法从名字上是无法确定你调用的是哪一个方法,而要从你传入的参数和该函数的签名来进行匹配,这样才可以确定你调用的是哪一个函数。
基于类型签名的重载:
多个过程(或函数、方法)允许共享同一名称,且通过该过程所需的参数数目、顺序和类型来对它们进行区分。即使函数处于同一上下文,这也是合法的。
class Example{
//same name,three different methods
int sum(int a){return a;}
int sum(int a,int b){return a+b;}
int sum(int a,int b,int c){return a+b+c;}
}
关于重载的解析,是在编译时基于参数值的静态类型完成的。不涉及运行时机制。
1.1.2 改写/重写(包含多态):子父类中,相同类型签名,相同函数名
如果子类的方法具有与父类的方法相同的名称和类型签名,称子类的方法改写了父类的方法。
- 语法上:子类定义一个与父类有着相同名称且类型签名相同的方法。
- 运行时:变量声明为一个类,它所包含的值来自于子类,与给定消息相对应的方法同时出现于父类和子类。
改写与替换原则同时出现是代码复用的一个重要手段。不论实际对象是父类还是子类都用父类类型,后续可以在父类类型变量中放入子类/父类,从而使得原代码将父类替换为子类时仍然符合要求。
改写可看成是重载的一种特殊情况:
- 接收器搜索并执行相应的方法以响应给定的消息。
- 如果没有找到匹配的方法,搜索就会传导到此类的父类。搜索会在父类链上一直进行下去,直到找到匹配的方法,或者父类链结束。
- 如果能在更高类层次找到相同名称的方法,所执行的方法就称为改写了继承的行为。
改写和重载的对比:
-
继承角度:对于改写来说,方法所在的类之间必须符合父类/子类继承关系,而对于简单的重载来说,并无此要求
-
类型签名角度:如果发生改写,两个方法的类型签名必须匹配
-
方法作用角度: 重载方法总是独立的,而对于改写的两个方法,有时会结合起来一起实现某种行为。
-
编译器角度: 重载通常是在编译时解析的,而改写则是一种运行时机制。对于任何给定的消息,都无法预言将会执行何种行为,而只有到程序实际运行的时候才能对其进行确定。
-
静态动态行为:重载解析是静态的,改写解析是动态的
改写与遮蔽的对比:
遮蔽
是指父类变量接收子类类型,并调用方法或者使用变量时候,使用的父类的方法和变量,而不发生多态的现象。
字段遮蔽:
class Parent {
int x = 10;
}
class Child extends Parent {
int x = 20;
}
public class Main {
public static void main(String[] args) {
Parent parent = new Parent();
Child child = new Child();
Parent parentReferenceToChild = new Child();
System.out.println("parent.x = " + parent.x); // 输出10
System.out.println("child.x = " + child.x); // 输出20
System.out.println("parentReferenceToChild.x = " + parentReferenceToChild.x); // 输出10
}
}
静态方法遮蔽:
class Parent {
static void staticMethod() {
System.out.println("Static method in Parent");
}
}
class Child extends Parent {
static void staticMethod() {
System.out.println("Static method in Child");
}
}
public class Main {
public static void main(String[] args) {
Parent parent = new Parent();
Child child = new Child();
Parent parentReferenceToChild = new Child();
parent.staticMethod(); // 输出 "Static method in Parent"
child.staticMethod(); // 输出 "Static method in Child"
parentReferenceToChild.staticMethod(); // 输出 "Static method in Parent"
}
}
改写机制在不同语言中的区别:
- Java、Smalltalk等面向对象语言,只要子类通过同一类型签名改写父类的方法,自然便会发生所期望的行为。
- C++中,需要父类中使用关键字Virtual来表明这一含义(否则会发生遮蔽)
两种不同的改写形式:
- 代替(replacement):在程序执行时,实现代替的方法完全覆盖父类的方法。即,当操作子类实例时,父类的代码完全不会执行。
- 改进(refinement):实现改进的方法将继承自父类的方法的执行作为其行为的一部分。这样父类的行为得以保留且扩充.
- java中使用super.方法名();
- c++中可以使用 _super::方法名() 或者 使用 父类名::方法名();
建议都是使用改进语义
1.1.3 多态变量(复制多态):声明与包含不同
多态变量是指可以引用多种对象类型的变量。
这种变量在程序执行过程可以包含不同类型的数值。
对于动态类型语言,所有的变量都可能是多态的。例如python中变量可以放任何对象
对于静态类型语言,多态变量则是替换原则的具体表现。使用多态变量出现了自然就有替换原则
例如:Parent variable=new Child();
延迟方法:(抽象方法)
如果方法在父类中定义,但并没有对其进行实现,那么我们称这个方法为延迟方法
优点:
可以使程序员在比实际对象的抽象层次更高的级别上考虑与之相关的活动。
实际意义:
在静态类型面向对象语言中,对于给定对象,只有当编译器可以确认与给定消息选择器相匹配的响应方法时,才允许程序员发送消息给这个对象。
延迟方法有时也称为抽象方法,并且在C++语言中通常称之为纯虚方法。
多态变量的四种形式:
1、简单多态变量(继承+替换原则)(最原始的多态)
Animal pet;
pet = new Dog();
pet.speak();
2、接收器变量(内部接口或者父类指向对象声明但未初始化)
多态变量作为一个数值,表示正在执行的方法内部的接收器。包含接收器的变量没有被正常的声明,通常被称为伪变量。
伪变量:
C++,Java,C#:this
class ThisExample{
public void one(int x){
value=x+4;
two(x+3);
}
private int value;
private void two(int y){
System.out.println(“Value is”+(value+y));
}
}
等价于:
class ThisExample{
public void one(int x){
this.value=x+4;
this.two(x+3);
}
private int value;
private void two(int y){
System.out.println(“Value is”+(this.value+y));
}
}
这个this就是在方法内部指定接收器本身的, 并且任何对象都用这个this指定接收器本身,所以this是多态变量
3、 向下造型(反多态)
向下造型这个变量本质上是取消多态赋值的过程(将父类强制转化为子类,然后赋值给子类)
Father f1 = new Son();
Son s1 = (Son)f1;
但有运行出错的情况:
Father f2 = new Father();
Son s2 = (Son)f2;//编译无错但运行会出现错误
在不确定父类引用是否指向子类对象时,可以用instanceof来判断:
if(f3 instanceof Son){
Son s3 = (Son)f3;
}
4、纯多态(我的多态依靠其他函数的多态实现)
-
多态方法支持可变参数的函数。
-
支持代码只编写一次、高级别的抽象
-
以及针对各种情况所需的代码裁剪。
-
通常是通过给方法的接收器发送延迟消息来实现这种代码裁剪的。
延迟消息:延迟实现的函数,在使用函数中并不实现。因此延迟消息一改变,使用延迟消息的函数也会发生变化。因此这个延迟消息本身就是一种多态方法,即使用延迟消息的函数本身也就实现多态。
Class Stringbuffer{
String append(Object value){
return append(value,toString());}
…
}
方法toString在子类中得以重定义。
toString方法的各种不同版本产生不同的结果。
所以append方法也类似产生了各种不同的结果。
Append:一个定义,多种结果。
1.1.4 泛型(模板) :创建通用工具
概念:
泛型,即“参数化类型”。一提到参数,最熟悉的就是定义方法时有形参,然后调用此方法时传递实参。那么参数化类型怎么理解呢?顾名思义,就是将类型由原来的具体的类型参数化,类似于方法中的变量参数,此时类型也定义成参数形式(可以称之为类型形参),然后在使用/调用时传入具体的类型(类型实参)。
进一步理解,就是我们方法的实现有延迟,这个泛型对方法实现中的参数类型也让它延迟实现。在方法实现中并不直接传入具体的参数,而是在运行时实现。从另一个角度来说,它是动态类型实现的参数。
使用:
泛型类:
//此处T可以随便写为任意标识,常见的如T、E、K、V等形式的参数常用于表示泛型
//在实例化泛型类时,必须指定T的具体类型
public class Generic<T>{
//key这个成员变量的类型为T,T的类型由外部指定
private T key;
public Generic(T key) { //泛型构造方法形参key的类型也为T,T的类型由外部指定
this.key = key;
}
public T getKey(){ //泛型方法getKey的返回值类型为T,T的类型由外部指定
return key;
}
}
//泛型的类型参数只能是类类型(包括自定义类),不能是简单类型
//传入的实参类型需与泛型的类型参数类型相同,即为Integer.
Generic<Integer> genericInteger = new Generic<Integer>(123456);
//传入的实参类型需与泛型的类型参数类型相同,即为String.
Generic<String> genericString = new Generic<String>("key_vlaue");
Log.d("泛型测试","key is " + genericInteger.getKey());
Log.d("泛型测试","key is " + genericString.getKey());
更重要的是,如果不传入泛型类型实参的话,在泛型类中使用泛型的方法或成员变量定义的类型可以为任何的类型(编译器自己根据传入的类型决定泛型类型)
Generic generic = new Generic("111111");
Generic generic1 = new Generic(4444);
Generic generic2 = new Generic(55.55);
Generic generic3 = new Generic(false);
Log.d("泛型测试","key is " + generic.getKey());
Log.d("泛型测试","key is " + generic1.getKey());
Log.d("泛型测试","key is " + generic2.getKey());
Log.d("泛型测试","key is " + generic3.getKey());
泛型接口:
泛型接口与泛型类的定义及使用基本相同。泛型接口常被用在各种类的生产器中,可以看一个例子:
//定义一个泛型接口
public interface Generator<T> {
public T next();
}
/**
* 传入泛型实参时:
* 定义一个生产器实现这个接口,虽然我们只创建了一个泛型接口Generator<T>
* 但是我们可以为T传入无数个实参,形成无数种类型的Generator接口。
* 在实现类实现泛型接口时,如已将泛型类型传入实参类型,则所有使用泛型的地方都要替换成传入的实参类型
* 即:Generator<T>,public T next();中的的T都要替换成传入的String类型。
*/
public class FruitGenerator implements Generator<String> {
private String[] fruits = new String[]{"Apple", "Banana", "Pear"};
@Override
public String next() {
Random rand = new Random();
return fruits[rand.nextInt(3)];
}
}
泛型方法:
在java中,泛型类的定义非常简单,但是泛型方法就比较复杂了。
尤其是我们见到的大多数泛型类中的成员方法也都使用了泛型,有的甚至泛型类中也包含着泛型方法,这样在初学者中非常容易将泛型方法理解错了。
泛型类,是在实例化类的时候指明泛型的具体类型;泛型方法,是在调用方法的时候指明泛型的具体类型 。(重点区分:泛型类中的普通方法和泛型类中的泛型方法)
public class GenericTest {
//这个类是个泛型类,在上面已经介绍过
public class Generic<T>{
private T key;
public Generic(T key) {
this.key = key;
}
//我想说的其实是这个,虽然在方法中使用了泛型,但是这并不是一个泛型方法。
//这只是类中一个普通的成员方法,只不过他的返回值是在声明泛型类已经声明过的泛型。
//所以在这个方法中才可以继续使用 T 这个泛型。
public T getKey(){
return key;
}
/**
* 这个方法显然是有问题的,在编译器会给我们提示这样的错误信息"cannot reslove symbol E"
* 因为在类的声明中并未声明泛型E,所以在使用E做形参和返回值类型时,编译器会无法识别。
public E setKey(E key){
this.key = keu
}
*/
}
/**
* 这才是一个真正的泛型方法。
* 首先在public与返回值之间的<T>必不可少,这表明这是一个泛型方法,并且声明了一个泛型T
* 这个T可以出现在这个泛型方法的任意位置.
* 泛型的数量也可以为任意多个
* 如:public <T,K> K showKeyName(Generic<T> container){
* ...
* }
*/
public <T> T showKeyName(Generic<T> container){
System.out.println("container key :" + container.getKey());
//当然这个例子举的不太合适,只是为了说明泛型方法的特性。
T test = container.getKey();
return test;
}
//这也不是一个泛型方法,这就是一个普通的方法,只是使用了Generic<Number>这个泛型类做形参而已。
public void showKeyValue1(Generic<Number> obj){
Log.d("泛型测试","key value is " + obj.getKey());
}
//这也不是一个泛型方法,这也是一个普通的方法,只不过使用了泛型通配符?
//同时这也印证了泛型通配符章节所描述的,?是一种类型实参,可以看做为Number等所有类的父类
public void showKeyValue2(Generic<?> obj){
Log.d("泛型测试","key value is " + obj.getKey());
}
/**
* 这个方法是有问题的,编译器会为我们提示错误信息:"UnKnown class 'E' "
* 虽然我们声明了<T>,也表明了这是一个可以处理泛型的类型的泛型方法。
* 但是只声明了泛型类型T,并未声明泛型类型E,因此编译器并不知道该如何处理E这个类型。
public <T> T showKeyName(Generic<E> container){
...
}
*/
/**
* 这个方法也是有问题的,编译器会为我们提示错误信息:"UnKnown class 'T' "
* 对于编译器来说T这个类型并未项目中声明过,因此编译也不知道该如何编译这个类。
* 所以这也不是一个正确的泛型方法声明。
public void showkey(T genericObj){
}
*/
public static void main(String[] args) {
}
}
总之就是,不是方法中出现T就是泛型方法。必须要在public和返回值之间增加一个<T>
本质来说,泛型方法的作用就是让方法和类的返回值/参数类型独立化了,可以不同
具体使用如下:
public class GenericFruit {
class Fruit{
@Override
public String toString() {
return "fruit";
}
}
class Apple extends Fruit{
@Override
public String toString() {
return "apple";
}
}
class Person{
@Override
public String toString() {
return "Person";
}
}
class GenerateTest<T>{
public void show_1(T t){
System.out.println(t.toString());
}
//在泛型类中声明了一个泛型方法,使用泛型E,这种泛型E可以为任意类型。可以类型与T相同,也可以不同。
//由于泛型方法在声明的时候会声明泛型<E>,因此即使在泛型类中并未声明泛型,编译器也能够正确识别泛型方法中识别的泛型。
public <E> void show_3(E t){
System.out.println(t.toString());
}
//在泛型类中声明了一个泛型方法,使用泛型T,注意这个T是一种全新的类型,可以与泛型类中声明的T不是同一种类型。
public <T> void show_2(T t){
System.out.println(t.toString());
}
}
public static void main(String[] args) {
Apple apple = new Apple();
Person person = new Person();
GenerateTest<Fruit> generateTest = new GenerateTest<Fruit>();
//apple是Fruit的子类,所以这里可以
generateTest.show_1(apple);
//编译器会报错,因为泛型类型实参指定的是Fruit,而传入的实参类是Person
//generateTest.show_1(person);
//使用这两个方法都可以成功
generateTest.show_2(apple);
generateTest.show_2(person);
//使用这两个方法也都可以成功
generateTest.show_3(apple);
generateTest.show_3(person);
}
}
泛型方法能使方法独立于类而产生变化,以下是一个基本的指导原则:
无论何时,如果你能做到,你就该尽量使用泛型方法。也就是说,如果使用泛型方法将整个类泛型化,那么就应该使用泛型方法。另外对于一个static的方法而已,无法访问泛型类型的参数。所以如果static方法要使用泛型能力,就必须使其成为泛型方法。
泛型数组:
List<String>[] ls = new ArrayList<String>[10]; // x
List<?>[] ls = new ArrayList<?>[10]; //√
List<String>[] ls = new ArrayList[10];//√
1.2 多态运行机制
多态机制的运行是基于”方法绑定“ 。由于多态中的重写、重载、多态变量、泛型的存在,很多方法/变量需要延迟绑定来实现。想要实现延迟绑定就需要”方法绑定“机制来决定和哪个方法绑定(不能考编译器根据函数签名直接决定)
1.2.1 解释
- Java多态机制是基于“方法绑定(binding)”,就是建立method call(方法调用)和method body(方法本体)的关联。如果绑定动作发生于程序执行前(由编译器和连接器完成),称为“先期绑定”。对于面向过程的语言它们没有其他选择,一定是先期绑定。比如C编译器只有一种method call,就是先期绑定。(C++有先期联编和后期联编)
- 当有多态的情况时,解决方案便是所谓的后期绑定(late binding):绑定动作将在执行期才根据对象型别而进行。后期绑定也被称为执行期绑定(run-time binding)或动态绑定(dynamic binding)。
- Java的所有方法,只有final,static,private和构造方法是前期绑定,其他都使用后期绑定。 将方法声明为final型可以有效防止他人覆写该函数。但是或许更重要的是,这么做可以“关闭”动态绑定。或者说,这么做便是告诉编译器:动态绑定是不需要的。于是编译器可以产生效率较佳的程序代码。
1.2.2 方法动态绑定过程
- 编译器检查对象的声明类型和方法名。假设我们调用x.f(args)方法,并且x已经被声明为C类的变量,那么编译器会列举出C类中所有的名称为f的方法和从C类的超类继承过来的f方法
- 接下来编译器检查方法调用中提供的参数类型。如果在所有名称为f 的方法中有一个参数类型和调用提供的参数类型最为匹配,那么就确定调用这个方法( 重载解析)
- 当程序运行并且使用动态绑定调用方法时,虚拟机必须调用同x所指向的对象的实际类型相匹配的方法版本。假设实际类型为D(C的子类),如果D类定义了f(args)那么该方法被调用,否则就在D的超类中搜寻方法f(args),依次类推
1.2.3 小练习
代码如下:
public class Bird{
public void fly(Bird p)
{System.out.println(“Bird fly with Bird”);}
}
public class Eagle extends Bird {
public void fly(Bird p)
{System.out.println(“Eagle fly with Bird!”);}
public void fly(Eagle e)
{System.out.println(“Eagle fly with Eagle!”);}
}
Bird p1 = new Bird () ;
Bird p2 = new Eagle () ;
Eagle p3 = new Eagle () ;
p1.fly( p1 ) ;
p1.fly( p2 ) ;
p1.fly( p3 ) ;
p2.fly( p1 ) ;
p2.fly( p2 ) ;
p2.fly( p3 ) ;
p3.fly( p1 ) ;
p3.fly( p2 ) ;
p3.fly( p3 ) ;
运行结果为:
关键点:
1、编译阶段根据调用函数的对象类型进行静态绑定——确定函数名字及函数签名,但是不确定函数是否在子类中被重写(也就说并没有真正绑定一个函数)(检查语法正确性也在这一阶段)
2、运行阶段动态绑定方法,这个动态绑定是基于第一步中确定的函数名和函数签名进行的。只是根据调用函数的对象实际类型(子类还是父类),来实际选择和父类还是子类绑定
2 空间分配
2.1 内存分配方法
内存分配方法是指程序是用什么方法去请求内存分配的
2.1.1 静态存储分配
静态存储分配是指在编译时就能确定每个数据目标在运行时刻的存储空间需求,因而在编译时就可以给他们分配固定的内存空间。
这种分配策略要求程序代码中不允许有可变数据结构(比如可变数组)的存在,也不允许有嵌套或者递归的结构出现,因为它们都会导致编译程序无法计算准确的存储空间需求。
2.1.2 动态存储分配
也被称为栈式存储分配,它是由一个类似于堆栈的运行栈来实现的。
和静态存储分配相反,在栈式存储方案中,程序对数据区的需求在编译时是完全未知的,只有到运行的时候才能够知道,但是规定在运行中进入一个程序模块时,必须知道该程序模块所需的数据区大小才能够为其分配内存。
栈式存储分配按照先进后出的原则进行分配。
2.1.3 堆式存储分配
堆式存储分配专门负责在编译时或运行时模块入口处都无法确定存储要求的数据结构的内存分配,比如可变长度串和对象实例。
堆由大片的可利用块或空闲块组成,堆中的内存可以按照任意顺序分配和释放。
2.2 内存分配策略(考试重点)
研究的是这门语言遇到类/方法等内存分配时采用的是什么策略
2.2.1 最小静态空间分配
-
C++使用最小静态空间分配策略,运行高效。
-
只分配基类所需的存储空间。
为了防止采用这种策略时因为多态而引发的程序错误(具体参照P179),C++改变了虚拟方法的调用规则:
-
对于指针 / 引用变量:当信息调用可能被改写的成员函数时,选择哪个函数取决于接收器的动态数值。
-
对于其他变量:调用虚拟成员函数的方式取决于静态类,而不取决于动态类
2.2.2 最大静态空间分配
无论基类还是派生类,都分配可用于所有合法的数值的最大的存储空间。
这一方案不合适,因为需要找到最大的对象,就需要对继承树上的所有对象都进行扫描,然后找到需要分配最大内存的对象才能
2.2.3 动态内存空间分配
- 堆栈中不保存对象值。
- 堆栈通过指针大小空间来保存标识变量,数据值保存在堆中。
- 指针变量都具有恒定不变的大小,变量赋值时,不会有任何问题。
只分配用于保存一个指针所需的存储空间。在运行时通过对来分配指针对应对象所需的存储空间,同时将指针设为相应的合适值。
总结
本系列内容均来自:山东大学-潘丽老师-面向对象开发技术-课程ppt、《设计模式》、《大话设计模式》