4.6 对象构造
您已经了解了如何编写定义对象初始状态的简单构造函数。但是,由于对象构造非常重要,Java提供了很多用于编写构造函数的机制。我们将在下面的章节中介绍这些机制。
4.6.1 重载
有些类有多个构造函数。例如,可以将空的StringBuilder对象构造为
var messages = new StringBuilder();
或者,可以指定初始字符串:
var todoList = new StringBuilder("To do:\n");
此功能称为重载。如果多个方法具有相同的名称(在本例中是StringBuilder构造函数方法),但参数不同,则会发生重载。编译器必须找出要调用的方法。它通过将各种方法头中的参数类型与特定方法调用中使用的值类型相匹配来选择正确的方法。如果编译器无法匹配参数,或者因为根本不匹配,或者因为没有一个参数比所有其他参数更好,则会发生编译时错误。(查找匹配的过程称为重载解决方案。)
注意
Java允许您重载不只是构造函数方法的任何方法。因此,要完全描述一个方法,需要指定它的名称及其参数类型。这称为方法的签名。例如,string类有四个名为indexOf的公共方法。他们有签名
indexOf(int) indexOf(int, int) indexOf(String) indexOf(String, int)
返回类型不是方法签名的一部分。也就是说,不能有两个方法具有相同的名称和参数类型,但返回类型不同。
4.6.2 默认字段初始化
如果没有在构造函数中显式设置字段,则会自动将其设置为默认值:数字为0,布尔值为假,对象引用为空。有些人认为依赖默认值是糟糕的编程实践。当然,如果字段被无形地初始化,那么就很难让人理解您的代码。
注意
这是字段和局部变量之间的重要区别。必须始终在方法中显式初始化局部变量。但在类中,如果不初始化字段,则会自动将其初始化为默认值(0、false或null)。
例如,考虑Employee类。假设您没有指定如何初始化构造函数中的某些字段。默认情况下,salary字段将初始化为0,name和hireDay字段将初始化为空。
不过,这不是个好主意。如果有人调用getName或getHireDay方法,他们可能会得到一个他们可能不期望的空引用:
LocalDate h = harry.getHireDay();
int year = h.getYear(); // throws exception if h is null
4.6.3 没有参数的构造函数
许多类都包含一个不带参数的构造函数,该构造函数创建的对象的状态设置为适当的默认值。例如,这里是Employee类的无参构造函数:
public Employee()
{
name = "";
salary = 0;
hireDay = LocalDate.now();
}
如果编写的类没有任何构造函数,则会为您提供无参数构造函数。此构造函数将所有实例字段设置为其默认值。因此,实例字段中包含的所有数值数据都将为0,所有布尔值都将为假,所有对象变量都将为空。
如果类至少提供一个构造函数,但不提供无参数构造函数,则在不提供参数的情况下构造对象是非法的。例如,清单4.2中的原始Employee类提供了一个构造函数:
public Employee(String n, double s, int year, int month, int day)
使用这个类,构建默认的员工对象是不合法的。也就是说,调用
e = new Employee();
可能是个错误。
小心
请记住,只有当您的类没有其他构造函数时,才能获得一个免费的无参数构造函数。如果用自己的一个构造函数编写类,并且希望类的用户能够通过调用
new ClassName()
然后必须提供无参数构造函数。当然,如果您对所有字段的默认值满意,您可以简单地提供
public ClassName() { }
4.6.4 显式字段初始化
通过重载类中的构造函数方法,可以构建多种方法来设置类的实例字段的初始状态。无论构造函数调用如何,确保每个实例字段都设置为有意义的内容总是一个好主意。
您可以简单地为类定义中的任何字段赋值。例如:
class Employee
{
private String name = "";
. . .
}
此分配在构造函数执行之前执行。如果类的所有构造函数都需要将特定实例字段设置为相同的值,则此语法特别有用。
初始化值不必是常量值。下面是一个用方法调用初始化字段的示例。考虑一个Employee类,其中每个员工都有一个id字段。您可以如下初始化它:
class Employee
{
private static int nextId;
private int id = assignId();
. . .
private static int assignId()
{
int r = nextId;
nextId++;
return r;
}
. . .
}
C++注意
在C++中,不能直接初始化类的实例字段。必须在构造函数中设置所有字段。但是,C++有一个特殊的初始化列表语法,比如
Employee::Employee(String n, double s, int y, int m, int d) // : name(n), salary(s), hireDay(y, m, d) { }
C++使用这个特殊的语法来调用字段构造函数。在Java,不需要这样做,因为对象没有子对象,只指向其他对象的指针。
4.6.5 参数名称
当您编写非常普通的构造器(并且您将编写大量构造器)时,起一个参数名可能会有点令人沮丧。
我们通常选择单字母参数名称:
public Employee(String n, double s)
{
name = n;
salary = s;
}
但是,缺点是您需要读取代码来了解n和s参数的含义。
有些程序员在每个参数前面加上“a”前缀:
public Employee(String aName, double aSalary)
{
name = aName;
salary = aSalary;
}
那很干净。任何读者都可以立即了解参数的含义。
另一个常用的技巧依赖于这样一个事实:当参数变量和实例字段的名称相同时,会隐藏实例字段。例如,如果调用参数salary,那么salary引用的是参数,而不是实例字段。但您仍然可以使用this.salary访问实例字段。回想一下,这表示隐式参数,即被构造的对象。下面是一个例子:
public Employee(String name, double salary)
{
this.name = name;
this.salary = salary;
}
C++注意
在C++中,用下划线或固定字母对实例字段进行前缀是常见的。(字母m和x是常用的选项。)例如,salary字段可以叫做_salary、mSalary或xSalary。Java程序员通常不这样做。
4.6.6 调用另一个构造函数
关键字this指方法的隐式参数。但是,这个关键字有第二个含义。
如果构造函数的第一条语句的格式为this(…),然后该构造函数调用同一类的另一个构造函数。下面是一个典型的例子:
public Employee(double s)
{
// calls Employee(String, double)
this("Employee #" + nextId, s);
nextId++;
}
当您调用new Employee(60000)时,Employee(double)构造函数调用Employee(string,double)构造函数。
以这种方式使用this关键字非常有用,您只需要编写一次公共构造代码。
C++注意
Java中的this引用与C++中的this指针是相同的。但是,在C++中,一个构造函数调用另一个构造函数是不可能的。如果你想在C++中分解常见的初始化代码,你必须编写一个单独的方法。
4.6.7 初始化块
您已经看到两种初始化数据字段的方法:
- 通过在构造函数中设置值
- 通过在声明中赋值
Java中有第三种机制,称为初始化块。类声明可以包含任意代码块。每当构造该类的对象时,都会执行这些块。例如:
class Employee
{
private static int nextId;
private int id;
private String name;
private double salary;
// object initialization block
{
id = nextId;
nextId++;
}
public Employee(String n, double s)
{
name = n;
salary = s;
}
public Employee()
{
name = "";
salary = 0;
}
. . .
}
在本例中,id字段在对象初始化块中初始化,无论使用哪个构造函数来构造对象。初始化块首先运行,然后执行构造函数的主体。
这种机制既不必要,也不常见。通常将初始化代码放在构造函数中更简单。
注意
在初始化块中设置字段是合法的,即使它们只是在类的后面定义的。但是,为了避免循环定义,从稍后才初始化的字段中读取是不合法的。Java语言规范(http://docs.oracle.com/javase/specs)的第8.3.2.3节详细说明了这些规则。这些规则是复杂的,足以阻碍编译器实现者-早期版本的Java实现它们的时候,会有微妙的错误。因此,我们建议您总是将初始化块放在字段定义之后。
由于有许多初始化数据字段的方法,因此为构建过程提供所有可能的路径可能会非常令人困惑。以下是调用构造函数时的详细情况:
- 如果构造函数的第一行调用第二个构造函数,则第二个构造函数使用提供的参数执行。
- 否则
- 所有数据字段都初始化为其默认值(0、false或null)。
- 所有字段初始值设定项和初始化块都按照它们在类声明中出现的顺序执行。
- 将执行构造函数的主体。
当然,组织初始化代码是一个好主意,这样其他程序员就可以轻松地理解它,而不必成为语言律师。例如,如果有一个类的构造函数依赖于数据字段的声明顺序,那么这个类将非常奇怪,并且有点容易出错。
要初始化静态字段,请提供初始值或使用静态初始化块。您已经看到了第一种机制:
private static int nextId = 1;
如果类的静态字段需要复杂的初始化代码,请使用静态初始化块。
将代码放在一个块中,并用关键字static标记它。下面是一个例子。我们希望员工ID号以小于10000的随机整数开始。
// static initialization block
static
{
var generator = new Random();
nextId = generator.nextInt(10000);
}
静态初始化在首次加载类时发生。与实例字段一样,静态字段为0、false或null,除非您将其显式设置为其他值。所有静态字段初始值设定项和静态初始化块都按照它们在类声明中出现的顺序执行。
注意
令人惊讶的是,到JDK 6为止,可以用Java编写一个没有main 方法的“Hello, Word”程序。
public class Hello { static { System.out.println("Hello, World"); } }
当用java Hello调用类时,类被加载,静态初始化块打印“Hello, Word”,并且只有一个消息显示main未定义。从Java 7开始,Java程序首先检查是否有一个main方法。
清单4.5中的程序显示了我们在本节中讨论的许多特性:
- 重载构造函数
- 使用this(…)调用另一个构造函数
- 无参构造函数
- 对象初始化块
- 静态初始化块
- 实例字段初始化
清单4.5 ConstructorTest/ConstructorTest.java
import java.util.*;
/**
* This program demonstrates object construction.
* @version 1.02 2018-04-10
* @author Cay Horstmann
*/
public class ConstructorTest
{
public static void main(String[] args)
{
// fill the staff array with three Employee objects
var staff = new Employee[3];
staff[0] = new Employee("Harry", 40000);
staff[1] = new Employee(60000);
staff[2] = new Employee();
// print out information about all Employee objects
for (Employee e : staff)
System.out.println("name=" + e.getName() + ",id=" + e.getId() + ",salary="
+ e.getSalary());
}
}
class Employee
{
private static int nextId;
private int id;
private String name = ""; // instance field initialization
private double salary;
// static initialization block
static
{
var generator = new Random();
// set nextId to a random number between 0 and 9999
nextId = generator.nextInt(10000);
}
// object initialization block
{
id = nextId;
nextId++;
}
// three overloaded constructors
public Employee(String n, double s)
{
name = n;
salary = s;
}
public Employee(double s)
{
// calls the Employee(String, double) constructor
this("Employee #" + nextId, s);
}
// the default constructor
public Employee()
{
// name initialized to ""--see above
// salary not explicitly set--initialized to 0
// id initialized in initialization block
}
public String getName()
{
return name;
}
public double getSalary()
{
return salary;
}
public int getId()
{
return id;
}
}
java.util.Random 1.0
- Random()
构造一个新的随机数生成器 - int nextInt(int n) 1.2
返回一个在0到n-1之间的随机数
4.6.8 对象销毁和finalize方法
一些面向对象的编程语言,特别是C++,对于任何不再使用对象时可能需要的清理代码,都有明确的析构函数方法。析构函数中最常见的活动是回收为对象预留的内存。由于Java执行自动垃圾回收,因此不需要手动内存回收,因此Java不支持析构函数。
当然,有些对象使用内存以外的资源,例如使用系统资源的另一个对象的文件或句柄。在这种情况下,当不再需要资源时,回收资源是很重要的。
如果一个资源需要在使用完后立即关闭,请提供一个关闭方法来进行必要的清理。处理完对象后,可以调用close方法。在第7章中,您将看到如何确保自动调用此方法。
如果您可以等待虚拟机退出,请使用Runtime.addShutdownHook方法添加“shutdown hook”。对于Java 9,可以使用Cleaner类来注册当对象不再可访问时执行的操作(而不是由cleaner执行)。这在实践中是不常见的情况。有关这两种方法的详细信息,请参阅API文档。
小心
不要使用finalize方法进行清理。该方法是在垃圾收集器清除对象之前调用的。但是,您只是不知道何时调用此方法,现在已弃用它。