前言
之前的例子中,我们已经编写了一些简单的类。但是,那些类都只包含一个简单的main方法。现在来学习如何编写复杂应用程序所需要的那种主力类。通常这些类没有main方法,却有自己的实例字段和实例方法。要想构建一个完整的程序,会结合使用多个类,其中只有一个类有main方法。
自定义简单的类
在Java中,最简单的类定义形式为:
class ClassName { // 字段 field1 field2 ... // 构造方法 constructor1 constructor2 ... // 普通方法 method1 method2 ... }
接下来将上面的伪代码填充完整
class Employee { private String name; private double salary; private LocalDate hireDay; // constructor public Emploee(String n, double s, int year, int month, int day) { name = n; salary = s; hireDay = LocalDate.of(year, month, day); } public String getName() { return name; } }
上面就是我们定义的一个普通的类,分为3个部分,变量 + 构造器 + 方法,下面我们编写一个完整的程序,最后输出员工的名字、薪水和出生日期
文件:EmployeeTest/EmployeeTest.java import java.time.LocalDate; public class EmployeeTest { public static void main(String[] args) { Employee[] staff = new Employee[3]; staff[0] = new Employee("jkc1", 75000, 1987, 12, 15); staff[1] = new Employee("jkc2", 50000, 1987, 10, 1); staff[2] = new Employee("jkc3", 40000, 1990, 3, 15); for (Employee e: staff) { e.raiseSalary(5); } for (Employee e: staff) { System.out.println("name=" + e.getName() + ", salary=" + e.getSalary() + ", hireDay=" + e.getHireDay()); } } } class Employee { private String name; private double salary; private LocalDate hireDay; public Employee(String n, double s, int year, int month, int day) { name = n; salary = s; hireDay = LocalDate.of(year, month, day); } public String getName() { return name; } public double getSalary() { return salary; } public LocalDate getHireDay() { return hireDay; } public void raiseSalary(double byPercent) { double raise = salary * byPercent / 100; salary += raise; } }
在这个程序中,我们构造了一个Employee
数组,并填入了3个Employee对象:
Employee[] staff = new Employee[3]; staff[0] = new Employee("jkc1", 75000, 1987, 12, 15); staff[1] = new Employee("jkc2", 50000, 1987, 10, 1); staff[2] = new Employee("jkc3", 40000, 1990, 3, 15);
接下里,使用Employee
类的raiseSalary
方法将每个员工的薪水提高5%:
for (Employee e: staff) { e.raiseSalary(5); }
最后调用getName
方法、getSalary
方法和getHireDay
方法打印各个员工的信息:
for (Employee e: staff) { System.out.println("name=" + e.getName() + ", salary=" + e.getSalary() + ", hireDay=" + e.getHireDay()); }
注意,在这个示例程序中包含两个类:Employee
类和带有public
访问修饰符的EmployeeTest
类。EmployeeTest
类包含了main方法,其中使用了前面介绍的指令。
源文件名是EmployeeTest.java
,这是因为文件名必须与public
类的名字相匹配。在一个源文件中,只能有一个公共类,但可以有任意数目的非公共类。
接下来,当编译这段源代码的时候,编译器将在目录下创建两个类文件:EmployeeTest.class
和Employee.class
将程序中包含main方法的类名提供给字节码解释器,以启动这个程序:
java EmployeeTest
字节码解释器开始运行EmployeeTest
类的main
方法中的代码。在这段代码中,先后构造了3个新的Employee
对象,并显示它们的状态。
多个源文件的使用
上面那个程序包含了两个类。我们通常习惯于将每一个类存放在一个单独的源文件中。例如:将Employee
类存放在文件Employee.java
中,将EmployeeTest
类存放在文件EmployeeTest.java
中。
如果喜欢这样组织文件,可以有两种编译源程序的方法。一种是使用通配符调用Java编译器:
javac Employee*.java
这样一来,所有与通配符匹配的源文件都将被编译成类文件。或者写以下命令:
javac EmployeeTest.java
虽然我们第二种方式并没有显示地编译Employee.java
,但当Java编译器发现EmployeeTest.java
使用了Employee
类时,它会查找名为Employee.class
的文件。如果没有找到这个文件,就会自动搜索Employee.java
,然后对它进行编译。更重要的是:如果Employee.java
版本较已有的Employee.class
文件版本更新,Java编译器就会自动地重新编译这个文件。
剖析Employee类
Employee类包含一个构造器和4个方法:
public Employee(String n, double s, int year, int month, int day) public String getName() public double getSalary() public LocalDate getHireDay() public void raiseSalary(double byPercent)
这个类的所有方法都被标记为public
。关键字public意味着任何类的任何方法都可以调用这些方法。
接下来,需要注意在Employee
类的实例中有3个实例字段用来存放将要操作的数据:
private String name; private double salary; private LocalDate hireDay;
关键字private
确保只有Employee类自身的方法能够访问这些实例字段,而其他类的方法不能够读写这些字段。
构造器解析
我们先看看Employee类的构造器:
public Employee(String n, double s, int year, int month, int day) { name = n; salary = s; hireDay = LocalDate.of(year, month, day); }
可以看到,构造器与类同名。在构造Employee类的对象时,构造器会运行,从而将实例字段初始化为所希望的初始状态。
例如,当使用下面这条代码创建Employee类的实例时:
new Employee("James Bond", 100000, 1950, 1, 1)
将会把实例字段设置为:
name = "James Bond"; salary = 100000; hireDay = LocalDate.of(1950, 1, 1);
构造器与其他方法有一个重要的不同。构造器总是结合new
运算符来调用。不能对一个已存在的对象调用构造器来达到重新设置实例字段的目的。例如:
james.Employee("James Bond", 280000, 1950, 1, 1)
将产生编译错误
构造器注意点
- 构造器与类必须同名
- 每个类可以有一个以上的构造器。
- 构造器可以有0个、1个或多个参数。
- 构造器没有返回值。
- 构造器总是伴随着new操作符一起调用。
用var变量声明局部变量
在Java10中,如果可以从变量的初始值推导出它们的类型,那么可以用var
关键字声明局部变量,而无须指定类型。例如,可以不这样声明:
Employee harry = new Employee("jkc", 50000, 1989, 10, 1);
只需写以下代码:
var harry = new Employee("jkc", 50000, 1989, 10, 1);
这一点很好,因为可以避免重复写类型名Employee
。
使用null引用
我们之前了解到一个对象变量包含一个对象的引用,或者包含一个特殊值null
,后者表示没有引用任何对象。
听上去这是一种处理特殊情况的便捷机制,如未知的名字或雇用日期。不过使用null
值时要非常小心。
如果对null
值应用一个方法,会产生一个NullPointerException
异常。
LocalDate birthday = null; String s = birthday.toString(); // NullPointerExcetion
这是一个很严重的错误,类似于索引越界
异常。如果你的程序没有"捕获"异常,程序就会终止。正常情况下,程序并不捕获这些异常,而是依赖于我们从一开始就不要带来异常。
定义一个类时,最好清楚地知道哪些字段可能为null
。在我们例子中,我们不希望name
或hireDay
字段为null
。(不用担心salary字段。这个字段是基本类型,所以不可能是null
)。
hireDay
字段肯定是非null
的,因为它初始化一个新的LocalDate
对象。但是name可能为null,如果调用构造器时为n提供的实参是null,name就会是null.
对此有两种解决办法。"宽容型"办法是把null
参数转换为一个适当的非null值
:
if (n == null) name = "unknown"; else name = n;
隐式参数与显式参数
方法用于操作对象以及存取它们的实例字段。例如,以下方法:
public void raiseSalary(double byPercent) { double raise = salary * byPercent / 100; salary += raise; }
上面的方式是调用这个方法的对象的sarlary
实例字段设置为一个新值。现在我们考虑下面这个调用:
number001.raiseSsalary(5);
它的结果是将number001.salary
字段的值增加5%。具体的说,这个调用将执行下列指定。
double raise = number001.salary * 5 / 100; number001.salary += raise;
raiseSalary
方法有两个参数。第一个参数称为隐式参数,是出现在方法名前的Employee
类型的对象。第二个参数是位于方法名后面括号中的数值,这是一个显式参数。(有人把隐式参数称为方法调用的目标
或者接受者
)
可以看到,显式参数显式地列在方法声明中,例如double byPercent
。隐式参数没有出现在方法声明中。
在每一个方法中,关键字this
指示隐式参数。我们可以改写raiseSalary
方法:
public void raiseSalary(double byPercent) { double raise = this.salary * byPercent / 100; salary += raise; }
封装的优点
最后我们再仔细看一下非常简单的getName
方法、getSalary
方法和getHireDay
方法。
public String getName() { return name; } public double getSalary() { return salary; } public LocalDate getHireDay() { return hireDay; }
这些都是典型的访问器方法。由于它们只返回实例字段值,因此又称为字段访问器
。
如果将name
、salary
和hireDay
字段标记为公共,而不是编写单独的访问器方法,难道不是更容易一些吗?
不过,name
是一个只读字段。一旦在构造器中设置,就没有任何办法可以对它进行修改,这样我们可以确保name字段不受外界的破坏。
虽然salary
不是只读字段,但是它只能用raiseSalary
方法修改。特别是一旦这个值出现了错误,只需要调试这个方法就可以了。如果salary
字段是公共的,破坏这个字段值的捣乱者有可能会出没在任何地方。
有些时候,可能想要获得或设置实例字段的值。那么你需要提供下面三项内容:
- 一个私有的数据字段;
- 一个公共的字段访问器方法;
- 一个公共的字段更改器方法。
这样做比提供一个简单的公共数据字段复杂些,但却有着下列明显的好处:
首先,可以改变内部实现,而除了该类的方法之外,这不会影响其他代码。例如,如果将存储名字的字段改为:
String firstName; String lastName;
那么getName
方法可以改为返回
firstName + " " + lastName
这个改变对于程序的其他部分是完全不可见的。
当然,为了进行新旧数据表示之间的转换,访问器方法和更改器方法可能需要做许多工作。但是,这将为我们带来第二点好处:更改器方法可以完成错误检查,而只对字段赋值的代码可能没有这个麻烦。例如,setSalary
方法可以检查工资是否小于0。
注意:不要编写返回可变对象引用的访问器方法,如果你需要返回一个可变对象的引用,那么应该对它进行克隆。
基于类的访问权限
从前面已经知道,方法可以访问调用这个方法的对象的私有数据。一个方法可以访问所属类的所有对象的私有数据,这令很多人感到奇怪!例如,下面看一下用来比较两个员工的equals
方法。
class Employee{ ... public boolean equals(Employee other) { return name.euqals(other.name) } }
典型的调用方式是
if (harry.euqals(boss))...
这个方法访问harry
的私有字段,这点并不会让人奇怪,不过, 它还访问了boss
的私有字段。这是合法的,其原因是boss
是Employee
类型的对象,而Employee
类的方法可以访问任何Employee
类型对象的私有字段。
私有方法
在实现一个类时,由于公共数据非常危险,所以应该将所有的数据字段都设置为私有的。然而,方法又应该如何设计呢?尽管绝大多数方法都被设计为公共的,但在某些特殊情况下,将方法设计为私有可能很有用。有时,你可能希望将一个计算代码分解成若干个独立的辅助方法,通常,这些辅助方法不应该成为公共接口的一部分,这是由于它们往往与当前实现关系非常紧密,或者需要一个特殊协议或者调用次序。最好将这样的方法设计为私有方法。
在Java中,要实现私有方法,只需将关键字public
改成private
即可。
通常将方法设计为私有,如果你改变了方法的实现方式,将没有义务保证这个方法依然可用。如果数据的表示发生了变化,这个方法可能会变得难以实现,或者不再需要;这并不重要。重点在于,只要方法是私有的,类的设计者就可以确信它不会在别处使用,所以可以将其山区。如果一个方法是公共的,就不能简单地将其删除,因为可能会有其他代码依赖这个方法。
final实例字段
可以将实例字段定义为final
。这样的自动断必须在构造对象时初始化。也就是说,必须确保在每一个构造器执行之后,这个字段的值已经设置,并且以后不能再修改这个字段。例如,可以将Employee
类中的name
字段声明为final
,因此在对象构造之后,这个值不会再改变,即没有setName
方法。
class Employee { private final String name; }
final修饰符对于类型为基本类型或者不可变类的字段尤其有用(如果类中的所有方法都不会改变其对象,这样的类就是不可变的类。例如,String
类就是不可变的)
对于可变的类,使用final
修饰符可能会造成混乱。例如,考虑以下字段:
private final StringBuilder evaluations;
它在Employee
构造器中初始化为
evaluations = new StringBuilder();
final
关键字只是表示存储在evaluations
变量中的对象引用不会再指示另一个不同的StringBuilder
对象。不过这个对象可以更改:
public void giveGoldStar() { evaluations.append(LocalDate.now() + ":Gold star!n") }