Java的构造函数与默认构造函数(深入版)
前言
我们知道在创建对象的时候,一般会通过构造函数来进行初始化。在Java的继承(深入版)有介绍到类加载过程中的验证阶段,会检查这个类的父类数据,但为什么要怎么做?构造函数在类初始化和实例化的过程中发挥什么作用?
(若文章有不正之处,或难以理解的地方,请多多谅解,欢迎指正)
构造函数与默认构造函数
构造函数
构造函数,主要是用来在创建对象时初始化对象,一般会跟new运算符一起使用,给对象成员变量赋初值。
class Cat{
String sound;
public Cat(){
sound = "meow";
}
}
public class Test{
public static void main(String[] args){
System.out.println(new Cat().sound);
}
}
运行结果为:
meow
构造函数的特点
- 构造函数的名称必须与类名相同,而且还对大小写敏感。
- 构造函数没有返回值,也不能用void修饰。如果跟构造函数加上返回值,那这个构造函数就会变成普通方法。
- 一个类可以有多个构造方法,如果在定义类的时候没有定义构造方法,编译器会自动插入一个无参且方法体为空的默认构造函数。
- 构造方法可以重载。
等等,为什么无参构造函数和默认构造函数要分开说?它们有什么不同吗?是的。
默认构造函数
我们创建一个显式声明无参构造函数的类,以及一个没有显式声明构造函数的类:
class Cat{
public Cat(){}
}
class CatAuto{}
然后我们编译一下,得到它们的字节码:
在《Java的多态(深入版)》介绍了invokespecial指令是用于调用实例化方法、私有方法和父类方法。我们可以看到,即使没有显式声明构造函数,在创建CatAuto对象的时候invokespecial指令依然会调用方法。那么是谁创建的无参构造方法呢?是编译器。
从前文我们可以得知,在类加载过程中的验证阶段会调用检查类的父类数据,也就是会先初始化父类。但毕竟验证父类数据跟创建父类数据,从动作的目的上看二者并不相同,所以类会在java文件编译成class文件的过程中,编译器就将自动向无构造函数的类添加无参构造函数,即默认构造函数。
为什么可以编译器要向没有定义构造函数的类,添加默认构造函数?
构造函数的目的就是为了初始化,既然没有显式地声明初始化的内容,则说明没有可以初始化的内容。为了在JVM的类加载过程中顺利地加载父类数据,所以就有默认构造函数这个设定。那么二者的不同之处在哪儿?
二者在创建主体上的不同。无参构造函数是由开发者创建的,而默认构造函数是由编译器生成的。
二者在创建方式上的不同。开发者在类中显式声明无参构造函数时,编译器不会生成默认构造函数;而默认构造函数只能在类中没有显式声明构造函数的情况下,由编译器生成。
二者在创建目的上也不同。开发者在类中声明无参构造函数,是为了对类进行初始化操作;而编译器生成默认构造函数,是为了在JVM进行类加载时,能够顺利验证父类的数据信息。
噢…那我想分情况来初始化对象,可以怎么做?实现构造函数的重载即可。
构造函数的重载
在《Java的多态(深入版)》中介绍到了实现多态的途径之一,重载。所以重载本质上也是
同一个行为具有不同的表现形式或形态能力。
举个栗子,我们在领养猫的时候,一般这只猫是没有名字的,它只有一个名称——猫。当我们领养了之后,就会给猫起名字了:
class Cat{
protected String name;
public Cat(){
name = "Cat";
}
public Cat(String name){
this.name = name;
}
}
在这里,Cat类有两个构造函数,无参构造函数的功能就是给这只猫附上一个统称——猫,而有参构造函数的功能是定义主人给猫起的名字,但因为主人想法比较多,过几天就换个名称,所以猫的名字不能是常量。
当有多个构造函数存在时,需要注意,在创建子类对象、调用构造函数时,如果在构造函数中没有特意声明,调用哪个父类的构造函数,则默认调用父类的无参构造函数(通常编译器会自动在子类构造函数的第一行加上super()方法)。
如果父类没有无参构造函数,或想调用父类的有参构造方法,则需要在子类构造函数的第一行用super()方法,声明调用父类的哪个构造函数。举个栗子:
class Cat{
protected String name;
public Cat(){
name = "Cat";
}
public Cat(String name){
this.name = name;
}
}
class MyCat extends Cat{
public MyCat(String name){
super(name);
}
}
public class Test{
public static void main(String[] args){
MyCat son = new MyCat("Lucy");
System.out.println(son.name);
}
}
运行结果为:
Lucy
总结一下,构造函数的作用是用于创建对象的初始化,所以构造函数的“方法名”与类名相同,且无须返回值,在定义的时候与普通函数稍有不同;且从创建主体、方式、目的三方面可看出,无参构造函数和默认构造函数不是同一个概念;除了Object类,所有类在加载过程中都需要调用父类的构造函数,所以**在子类的构造函数中,**需要使用super()方法隐式或显式地调用父类的构造函数。
构造函数的执行顺序
在介绍构造函数的执行顺序之前,我们来做个题:
public class MyCat extends Cat{
public MyCat(){
System.out.println("MyCat is ready");
}
public static void main(String[] args){
new MyCat();
}
}
class Cat{
public Cat(){
System.out.println("Cat is ready");
}
}
运行结果为:
Cat is ready
MyCat is ready
这个简单嘛,只要知道类加载过程中会对类的父类数据进行验证,并调用父类构造函数就可以知道答案了。
那么下面这个题呢?
public class MyCat{
MyCatPro myCatPro = new MyCatPro();
public MyCat(){
System.out.println("MyCat is ready");
}
public static void main(String[] args){
new MyCat();
}
}
class MyCatPro{
public MyCatPro(){
System.out.println("MyCatPro is ready");
}
}
运行结果为:
MyCatPro is ready
MyCat is ready
嘶…这里就是在创建对象的时候会先实例化成员变量的初始化表达式,然后再调用自己的构造函数。
ok,结合上面的已知项来做做下面这道题:
public class MyCat extends Cat{
MyCatPro myCatPro = new MyCatPro();
public MyCat(){
System.out.println("MyCat is ready");
}
public static void main(String[] args){
new MyCat();
}
}
class MyCatPro{
public MyCatPro(){
System.out.println("MyCatPro is ready");
}
}
class Cat{
CatPro cp = new CatPro();
public Cat(){
System.out.println("Cat is ready");
}
}
class CatPro{
public CatPro(){
System.out.println("CatPro is ready");
}
}
3,2,1,运行结果如下:
CatPro is ready
Cat is ready
MyCatPro is ready
MyCat is ready
通过这个例子我们能看出,类在初始化时构造函数的调用顺序是这样的:
- 按顺序调用父类成员变量和实例成员变量的初始化表达式;
- 调用父类构造函数;
- 按顺序分别调用类成员变量和实例成员变量的初始化表达式;
- 调用类构造函数。
嘶…为什么会是这种顺序呢?
Java对象初始化中的构造函数
我们知道,一个对象在被使用之前必须被正确地初始化。本文采用最常见的创建对象方式:使用new关键字创建对象,来为大家介绍Java对象初始化的顺序。new关键字创建对象这种方法,在Java规范中被称为由执行类实例创建表达式而引起的对象创建。
Java对象的创建过程(详见《深入理解Java虚拟机》)
当虚拟机遇到一条new指令时,首先会去检查这个指令的参数是否能在常量池(JVM运行时数据区域之一)中定位到这个类的符号引用,并且检查这个符号引用是否已被加载、解释和初始化过。如果没有,则必须执行相应的类加载过程(这个过程在Java的继承(深入版)有所介绍)。
类加载过程中,准备阶段中为类变量分配内存并设置类变量初始值,而类初始化阶段则是执行类构造器方法的过程。而**方法是由编译器自动收集类中的类变量赋值表达式和静态代码块**(static{})中的语句合并产生的,其收集顺序是由语句在源文件中出现的顺序所决定。
其实在类加载检查通过后,对象所需要的内存大小已经可以完全确定过了。所以接下来JVM将为新生对象分配内存,之后虚拟机将分配到的内存空间都初始化为零值。接下来虚拟机要对对象进行必要的设置,并这些信息放在对象头。最后,再执行方法,把对象按程序员的意愿进行初始化。
以上就是Java对象的创建过程,那么类构造器方法与实例构造器方法有何不同?
- 类构造器方法不需要程序员显式调用,虚拟机会保证在子类构造器方法执行之前,父类的类构造器方法执行完毕。
- 在一个类的生命周期中,类构造器方法最多会被虚拟机调用一次,而实例构造器方法则会被虚拟机多次调用,只要程序员还在创建对象。
等等,构造函数呢?跑题了?莫急,在了解Java对象创建的过程之后,让我们把镜头聚焦到这里“对象初始化”:
在对象初始化的过程中,涉及到的三个结构,实例变量初始化、实例代码块初始化、构造函数。
我们在定义(声明)实例变量时,还可以直接对实例变量进行赋值或使用实例代码块对其进行赋值,实例变量和实例代码块的运行顺序取决于它们在源码的顺序。
在编译器中,实例变量直接赋值和实例代码块赋值会被放到类的构造函数中,并且这些代码会被放在父类构造函数的调用语句之后,在实例构造函数代码之前。
举个栗子:
class TestPro{
public TestPro(){
System.out.println("TestPro");
}
}
public class Test extends TestPro{
private int a = 1;
private int b = a+1;
public Test(int var){
System.out.println(a);
System.out.println(b);
this.a = var;
System.out.println(a);
System.out.println(b);
}
{
b+=2;
}
public static void main(String[] args){
new Test(10);
}
}
运行结果为:
TestPro
1
4
10
4
总结一下,Java对象创建时有两种类型的构造函数:类构造函数方法、实例构造函数方法,而整个Java对象创建过程是这样:
结语
现在是快阅读流行的时代,短小精悍的文章更受欢迎。但个人认为回顾知识点最重要的是温故知新,所以采用深入版的写法,不过每次写完我都觉得我都不像是一个小甜甜…
如果觉得文章不错,请点一个赞吧,这会是我最大的动力~
参考资料: