corejava11(4.3 定义你自己的类)

本文围绕Java自定义类展开,以Employee类为例,介绍了类的定义、使用多个源文件编译的方法,剖析了类的构造函数、实例字段等。还阐述了用var声明局部变量、处理null引用、隐式和显式参数等内容,强调了封装的好处、基于类的访问权限等知识。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

4.3 定义你自己的类

在第3章中,您开始编写简单的类。然而,所有这些类只有一个main方法。现在是时候向您展示如何编写更复杂应用程序所需的“workhorse类”了。这些类通常没有main方法。相反,它们有自己的实例字段和方法。要构建一个完整的程序,需要组合几个类,其中一个类有一个main方法。

4.3.1 一个Employee类

Java中类定义最简单的形式是

class ClassName
{
    field1
    field2
    . . .
    constructor1
    constructor2
    . . .
    method1
    method2
    . . .
}

考虑以下非常简单的Employee类版本,企业在编写工资单系统时可能会使用这些版本:

class Employee
{
    // instance fields
    private String name;
    private double salary;
    private LocalDate hireDay;
    // constructor
    public Employee(String n, double s, int year, int month, int day)
    {
        name = n;
        salary = s;
        hireDay = LocalDate.of(year, month, day);
    }
    // a method
    public String getName()
    {
    	return name;
    }
    // more methods
    . . .
}

我们在下面的小节中详细地分解了这个类的实现。不过,首先,清单4.2是一个显示员工类实际操作的程序。

在程序中,我们构造一个Employee数组,并用三个Employee对象填充它:

Employee[] staff = new Employee[3];
staff[0] = new Employee("Carl Cracker", . . .);
staff[1] = new Employee("Harry Hacker", . . .);
staff[2] = new Employee("Tony Tester", . . .);

接下来,我们使用Employee类的raiseSalary方法将每个员工的工资提高5%:

for (Employee e : staff)
	e.raiseSalary(5);

最后,我们通过调用getName打印出每个员工的信息,

for (Employee e : staff)
	System.out.println("name=" + e.getName()
		+ ",salary=" + e.getSalary()
		+ ",hireDay=" + e.getHireDay());

注意,示例程序由两个类组成:Employee类和带有public访问说明符的EmployeeTest类。带有我们刚才描述的指令的主要方法包含在EmployeeTest类中。

源文件的名称为EmployeeTest.java,因为该文件的名称必须与public类的名称匹配。源文件中只能有一个公共类,但可以有任意数量的非公共类。

接下来,在编译这个源代码时,编译器会在目录中创建两个类文件:EmployeeTest.class和Employee.class。

然后,通过向字节码解释器提供包含程序main方法的类的名称来启动程序:

java EmployeeTest

字节码解释器开始在EmployeeTest类的main方法中运行代码。此代码依次构造三个新的Employee对象并向您显示其状态。

清单4.2 EmployeeTest/EmployeeTest.java

package com.hellozjf.learn.corejava11.v1ch04.EmployeeTest;

import java.time.*;

/**
 * This program tests the Employee class.
 * @version 1.13 2018-04-10
 * @author Cay Horstmann
 */
public class EmployeeTest
{
   public static void main(String[] args)
   {
      // fill the staff array with three Employee objects
      Employee[] staff = new Employee[3];

      staff[0] = new Employee("Carl Cracker", 75000, 1987, 12, 15);
      staff[1] = new Employee("Harry Hacker", 50000, 1989, 10, 1);
      staff[2] = new Employee("Tony Tester", 40000, 1990, 3, 15);

      // raise everyone's salary by 5%
      for (Employee e : staff)
         e.raiseSalary(5);

      // print out information about all Employee objects
      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;
   }
}

4.3.2 使用多个源文件

清单4.2中的程序在一个源文件中有两个类。许多程序员喜欢将每个类放入自己的源文件中。例如,可以将Employee类放在文件Employee.java中,将EmployeeTest类放在EmployeeTest.java中。

如果您喜欢这种安排,您有两个选择来编译程序。可以用通配符调用Java编译器:

javac Employee*.java

然后,所有与通配符匹配的源文件将被编译成类文件。或者,您可以简单地键入

javac EmployeeTest.java

即使Employee.java文件从未显式编译,第二个选项仍然有效,这可能会让您感到惊讶。但是,当Java编译器看到Employee类在EmployeeTest.java中使用时,它会寻找名为Employee.class的文件。

如果找不到该文件,它会自动搜索Employee.java并编译它。此外,如果发现的Employee.java版本的时间戳比现有Employee.class文件有更新的时间戳,Java编译器将自动重新编译该文件。

注意

如果您熟悉UNIX的make工具(或者它的一个Windows表兄弟,比如nmake),您可以认为Java编译器具有已经内置的make功能。

4.3.3 剖析Employee类

在下面的部分中,我们将分析Employee类。让我们从这个类中的方法开始。通过检查源代码可以看到,这个类有一个构造函数和四个方法:

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类的实例中操作的数据。

private String name;
private double salary;
private LocalDate hireDay;

private关键字确保可以访问这些实例字段的唯一方法是Employee类本身的方法。外部方法无法读取或写入这些字段。

注意

您可以在实例字段中使用public关键字,但这将是一个非常糟糕的主意。拥有public数据字段将允许程序的任何部分读取和修改实例字段,从而彻底破坏封装。任何类的任何方法都可以修改公共字段,根据我们的经验,某些代码将在您最不期望的时候利用该访问特权。强烈建议将所有实例字段设置为private。

最后,注意两个实例字段本身就是对象:name和hireDay字段是对String和LocalDate对象的引用。这很常见:类通常包含类类型的实例字段。

4.3.4 第一步构造函数

让我们来看一下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); // January 1, 1950

构造器和其他方法之间有一个重要的区别。只能与new运算符一起调用构造函数。不能将构造函数应用于现有对象以重置实例字段。例如,

james.Employee("James Bond", 250000, 1950, 1, 1) // ERROR

是一个编译时错误。

在本章后面,我们将对构造函数有更多的讨论。现在,请记住以下几点:

  • 构造函数与类同名。
  • 类可以有多个构造函数。
  • 构造函数可以接受零个、一个或多个参数。
  • 构造函数没有返回值。
  • 始终使用new的运算符调用构造函数。

C++注意

在Java中,构造函数的工作方式与C++相同。但是,请记住,所有Java对象都是在堆上构建的,并且构造函数必须与new组合使用。C++程序员忘记一个new的操作符是一个常见的错误:

Employee number007("James Bond", 100000, 1950, 1, 1); // C++, not Java

它在C++中工作,而不是在Java中工作。

小心

注意不要引入与实例字段同名的局部变量。例如,以下构造函数将不设置工资:

public Employee(String n, double s, . . .)
{
    String name = n; // ERROR
    double salary = s; // ERROR
    . . .
}

构造函数声明局部变量name和salary。这些变量只能在构造函数内部访问。它们用相同的名称隐藏实例字段。有些程序员在输入的速度比他们想象的要快时,意外地编写了这种代码,因为他们的手指习惯于添加数据类型。这是一个很难追踪的严重错误。您只需在所有方法中小心,不要使用与实例字段名相同的变量名。

4.3.5 用var声明局部变量

对于Java 10,可以用var关键字来声明局部变量,而不是指定它们的类型,只要它们的类型可以从初始值推断出来。例如,取代声明

Employee harry = new Employee("Harry Hacker", 50000, 1989, 10, 1);

你能够简单写

var harry = new Employee("Harry Hacker", 50000, 1989, 10, 1);

这很好,因为它避免了类型名Employee的重复。

从现在开始,我们将使用var表示法,在这种情况下,该类型从右侧能够明显推测出,而不需要任何Java API的知识。但我们不会将var与int、long或double等数字类型一起使用,这样您就不必考虑0、0L和0.0之间的差异。一旦您对Java API更有经验,就可能更频繁地使用var关键字。

注意var关键字只能用于方法内部的局部变量。必须始终声明参数和字段的类型。

4.3.6 使用null引用

在第132页的第4.2.1节“对象和对象变量”中,您看到了一个对象变量持有对对象的引用,或者特殊值为空,表示没有对象。

这听起来像是处理特殊情况的一种方便机制,例如未知姓名或雇用日期。但是您需要非常小心地使用空值。

如果将方法应用于null值,则会发生NullPointerException。

LocalDate birthday = null;
String s = birthday.toString(); // NullPointerException

这是一个严重错误,类似于“索引越界”异常。如果您的程序没有“捕获”异常,它将被终止。通常情况下,程序不会捕获这些异常,但首先依赖于程序员而不会引发这些异常。

定义类时,最好清楚哪些字段可以为空。在我们的示例中,我们不希望name或hireDay字段为空。(我们不必担心salary字段。它有基元类型,不能为null。)

hireDay字段保证为非null,因为它是用新的LocalDate对象初始化的。但如果n参数为null,然后调用构造函数,则name将为null。

有两种解决方案。“permitive”方法是将null参数转换为适当的非null值:

if (n == null) name = "unknown"; else name = n;

对于Java 9,对象类有一个方便的方法用于此目的:

public Employee(String n, double s, int year, int month, int day)
{
    name = Objects.requireNonNullElse(n, "unknown");
    . . .
}

“tough love”方法拒绝一个null参数:

public Employee(String n, double s, int year, int month, int day)
{
    Objects.requireNonNull(n, "The name cannot be null");
    name = n;
    . . .
}

如果有人使用null名称构造Employee对象,则会发生NullPointerException。乍一看,这似乎不是一个有用的补救办法。但有两个优势:

  1. 异常报告包含问题的描述。
  2. 异常报告指出了问题的位置。否则,NullPointerException会在其他地方发生,而无法轻松地将其跟踪回错误的构造函数参数。
    无论何时接受对象引用作为构造参数,都要问问自己是否真的打算对可能存在或不存在的值进行建模。如果不是的话,最好采用“tough love”的方式。

4.3.7 隐式和显式参数

方法对对象进行操作并访问其实例字段。例如,方法

public void raiseSalary(double byPercent)
{
    double raise = salary * byPercent / 100;
    salary += raise;
}

为调用此方法的对象中的salary实例字段设置新值。考虑一下这个调用

number007.raiseSalary(5);

其效果是将number007.salary字段的值增加5%。更具体地说,调用执行以下指令:

double raise = number007.salary * 5 / 100;
number007.salary += raise;

raiseSalary方法有两个参数。第一个参数称为隐式参数,是出现在方法名称之前的Employee类型的对象。第二个参数,方法名后面括号内的数字,是一个显式参数。(有些人将隐式参数称为方法调用的目标或接收器。)

如您所见,显式参数在方法声明中显式列出,例如double byPercent。隐式参数未出现在方法声明中。

在每个方法中,关键字this都引用隐式参数。如果您愿意,可以编写raiseSalary方法,如下所示:

public void raiseSalary(double byPercent)
{
    double raise = this.salary * byPercent / 100;
    this.salary += raise;
}

有些程序员喜欢这种风格,因为它清楚地区分实例字段和局部变量。

C++注意

在C++中,通常定义类外的方法:

void Employee::raiseSalary(double byPercent) // C++, not Java
{
	. . .
}

如果在类中定义一个方法,那么它将自动成为内联方法。

class Employee
{
    . . .
    int getName() { return name; } // inline in C++
}

在Java中,所有方法都在类本身中定义。这不会使它们内联。寻找内联替换的机会是Java虚拟机的工作。实时编译器监视对方法的调用,这些方法是短的、通常被调用的、不被重写的,并对它们进行优化。

4.3.8 封装的好处

最后,让我们更仔细地研究一下比较简单的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字段是public的,那么造成值混乱的罪魁祸首可能就在其它地方。

有时,您可能希望获取和设置实例字段的值。然后您需要提供三个对象:

  • 一个私有数据字段;
  • 一个公共字段访问器方法;以及
  • 一种公共变换器方法。

这比提供单个公共数据字段要繁琐得多,但有相当大的好处。

首先,您可以在不影响类方法以外的任何代码的情况下更改内部实现。例如,如果name的存储更改为

String firstName;
String lastName;

然后可以将getName方法更改为返回

firstName + " " + lastName

此更改对程序的其余部分完全不可见。

当然,访问器和赋值器方法可能需要做大量的工作来在新的和旧的数据表示之间进行转换。这就给我们带来了第二个好处:mutator方法可以执行错误检查,而只分配给字段的代码可能不会陷入麻烦。例如,setSalary方法可能会检查薪资是否不低于0。

小心

注意不要编写返回可变对象引用的访问器方法。在本书的前一版本中,我们违反了Employee类中的规则,其中getHireDay方法返回了Date类的对象:

class Employee
{
    private Date hireDay;
    . . .
    public Date getHireDay()
    {
    	return hireDay; // BAD
    }.
    . .
}

与没有mutator方法的LocalDate类不同,Date类有一个mutator方法setTime,您可以在其中设置毫秒数。

Date对象是可变的事实打破了封装!考虑以下流氓代码:

Employee harry = . . .;
Date d = harry.getHireDay();
double tenYearsInMilliSeconds = 10 * 365.25 * 24 * 60 * 60 * 10
d.setTime(d.getTime() - (long) tenYearsInMilliSeconds);
// let's give Harry ten years of added seniority

原因很微妙。d和harry.hireDay都指同一个对象(见图4.5)。将mutator方法应用于d会自动更改Employee对象的私有状态!

图4.5返回可变数据字段的引用

如果需要返回对可变对象的引用,应首先克隆它。克隆是存储在新位置的对象的精确副本。我们将在第6章中详细讨论克隆。以下是正确的代码:

class Employee
{
    . . .
    public Date getHireDay()
    {
    	return (Date) hireDay.clone(); // OK
    } 
    . . .
}

根据经验,每当需要返回可变字段的副本时,总是使用clone。

4.3.9 基于类的访问权限

您知道方法可以访问调用它的对象的私有数据。人们经常感到惊讶的是,一个方法可以访问其类中所有对象的私有数据。例如,考虑一个方法equals比较两个雇员。

class Employee
{
    . . .
    public boolean equals(Employee other)
    {
    	return name.equals(other.name);
    }
}

一个典型的调用是

if (harry.equals(boss)) . . .

这种方法访问harry的私有字段,这并不奇怪。它还可以访问boss的私有字段。这是合法的,因为boss是Employee类型的对象,并且Employee类的方法允许访问Employee类型的任何对象的私有字段。

C++注意

C++有相同的规则。方法可以访问其类中任何对象的私有特性,而不仅仅是隐式参数。

4.3.10 私有方法

在实现类时,我们将所有数据字段设置为私有,因为公共数据是危险的。但是方法呢?虽然大多数方法是公共的,但是私有方法在某些情况下是有用的。有时,您可能希望将计算的代码分解为单独的助手方法。通常,这些助手方法不应该是公共接口的一部分,它们可能太接近当前的实现,或者需要特殊的协议或调用顺序。这些方法最好作为私有方法来实现。

要在Java中实现私有方法,只需将public关键字更改为private。

通过使一个方法私有化,如果您更改了实现,那么您没有义务使它保持可用。如果数据表示形式发生变化,该方法可能很难实现或不必要;这是不相关的。重点是,只要方法是私有的,类的设计者就可以确信它从未在其他地方使用过,所以他们可以简单地删除它。如果一个方法是公共的,则不能简单地删除它,因为其他代码可能依赖它。

4.3.11 Final实例字段

可以将实例字段定义为final。在构造对象时,必须初始化此类字段。也就是说,必须确保字段值是在每个构造函数结束后设置的。之后,不能再次修改该字段。例如,Employee类的name字段可以声明为final,因为在构造对象之后它永远不会更改

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");
} 
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值